mirror of
https://github.com/stablyai/orca.git
synced 2026-10-24 08:03:58 +00:00
merge origin/main
This commit is contained in:
@@ -134,7 +134,8 @@ 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 fonts-noto-cjk python3 zsh
|
||||
# Why: paired Quick Open coverage exercises the resource-bounded host search.
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk python3 ripgrep zsh
|
||||
|
||||
# Why: Electron on Linux needs an X display even when the app
|
||||
# suppresses mainWindow.show() via ORCA_E2E_HEADLESS. xvfb provides a
|
||||
|
||||
@@ -74,6 +74,7 @@ jobs:
|
||||
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:tab-bar-agent-launch-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
|
||||
@@ -87,6 +88,7 @@ jobs:
|
||||
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:tab-bar-agent-launch-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
|
||||
@@ -97,6 +99,7 @@ jobs:
|
||||
$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:tab-bar-agent-launch-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
|
||||
}
|
||||
|
||||
+51
-32
@@ -475,6 +475,44 @@ jobs:
|
||||
--exclude=tests/e2e/cross-version-wire/** \
|
||||
--shard=${{ matrix.shard }}/${{ matrix.shard_total }}
|
||||
|
||||
# Why a separate job: the test needs a real Chrome, and the 32-way `test` matrix
|
||||
# would pay for it 32 times to run one file in whichever shard it landed in.
|
||||
orcad_browser:
|
||||
name: orcad browser provider
|
||||
needs: [code_paths]
|
||||
if: needs.code_paths.outputs.should_run == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# Why no native-runtime: the provider drives the prebuilt agent-browser binary
|
||||
# shipped in node_modules and never touches node-pty.
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
|
||||
# Why the runner's Google Chrome and not its chromium: Ubuntu 24.04 only ships an
|
||||
# AppArmor userns profile for the Chrome .deb, so chromium dies with "No usable
|
||||
# sandbox" and the provider passes no --no-sandbox. Why fail instead of skip: an
|
||||
# unset ORCA_BROWSER_EXECUTABLE is exactly how this test went uncovered for so long.
|
||||
- name: Resolve Chrome for the browser provider
|
||||
run: |
|
||||
set -euo pipefail
|
||||
chrome="$(command -v google-chrome || command -v google-chrome-stable || true)"
|
||||
if [ -z "$chrome" ]; then
|
||||
echo "::error::No Google Chrome on the runner; the browser provider test would silently skip."
|
||||
exit 1
|
||||
fi
|
||||
"$chrome" --version
|
||||
echo "ORCA_BROWSER_EXECUTABLE=$chrome" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Test external Chromium browser provider
|
||||
run: |
|
||||
pnpm exec vitest run --config config/vitest.config.ts \
|
||||
src/main/orcad/external-chromium-browser-process.integration.test.ts
|
||||
|
||||
cross-version-wire:
|
||||
name: cross-version wire compatibility
|
||||
needs: [code_paths]
|
||||
@@ -642,6 +680,7 @@ jobs:
|
||||
- name: Test Windows-specific boundaries
|
||||
run: >-
|
||||
pnpm exec vitest run --config config/vitest.config.ts
|
||||
config/scripts/rebuild-native-deps.test.mjs
|
||||
src/main/providers/windows-conpty-wide-char-duplication.node-pty.test.ts
|
||||
src/main/providers/pty-repaint-wide-char-buffer.node-pty.test.ts
|
||||
src/shared/child-process/windows-command-line.win32.test.ts
|
||||
@@ -658,6 +697,8 @@ jobs:
|
||||
src/main/runtime/repo-worktree-admin-fingerprint.test.ts
|
||||
src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts
|
||||
src/shared/secure-file-fsync-flags.test.ts
|
||||
src/main/ipc/pty-codex-account-attribution.test.ts
|
||||
src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts
|
||||
|
||||
# Why the :parallel variant: identical to build:release except the three
|
||||
# electron-vite targets overlap instead of running back to back. The Linux package
|
||||
@@ -673,6 +714,9 @@ jobs:
|
||||
ORCA_REUSE_PREPARED_NATIVE_RUNTIME: '1'
|
||||
run: pnpm exec electron-builder --config config/electron-builder.config.cjs --dir
|
||||
|
||||
- name: Smoke packaged Windows PTY native capability
|
||||
run: pnpm run smoke:windows-pty-native-capability -- --exe=dist/win-unpacked/Orca.exe
|
||||
|
||||
- name: Smoke packaged CLI
|
||||
run: node config/scripts/smoke-packaged-cli.mjs --app-dir=dist/win-unpacked
|
||||
|
||||
@@ -707,38 +751,9 @@ jobs:
|
||||
BASE="${{ github.event.pull_request.base.sha }}"
|
||||
HEAD="${{ github.event.pull_request.head.sha }}"
|
||||
CHANGED="$(git diff --name-only --diff-filter=AMCR --merge-base "$BASE" "$HEAD")"
|
||||
TEST_FILES="$(printf '%s\n' "$CHANGED" | grep -E '^tests/e2e/.*\.spec\.ts$' || true)"
|
||||
if printf '%s\n' "$CHANGED" | grep -Eq '^src/(main/ephemeral-vm-(runtime-(service|provisioning-persistence)|failed-start-cleanup)|shared/(ephemeral-vm-runtime-(store|feature-store|rollback-projection|runtimes)|ephemeral-vm-recipes|orca-yaml-hook-types))\.ts$'; then
|
||||
TEST_FILES="$(printf '%s\n%s\n' "$TEST_FILES" 'tests/e2e/ephemeral-vm-provisioned-root.spec.ts' | sort -u)"
|
||||
fi
|
||||
# Why: the Docker-SSH specs only ever ran when someone edited a spec, so four
|
||||
# pane-restore regressions shipped from SSH source edits that touched no test.
|
||||
# Unit tests are excluded because they prove the same source without Docker.
|
||||
# ssh-startup-exec-readiness.spec.ts is listed to route e2e.yml's dedicated
|
||||
# ssh-docker-watcher-isolation lane, which filters it out of changed-e2e.
|
||||
if printf '%s\n' "$CHANGED" | grep -Ev '\.test\.tsx?$' | grep -Eq '^(src/main/ssh/|src/main/providers/ssh-|src/main/ipc/(ssh-|pty)|src/relay/|src/renderer/src/components/terminal-pane/(pty-|ssh-|remote-runtime-|terminal-parked-pty))'; then
|
||||
TEST_FILES="$(printf '%s\n' "$TEST_FILES" \
|
||||
'tests/e2e/pty-input-write-queue-ssh.spec.ts' \
|
||||
'tests/e2e/ssh-cold-activation-restore.spec.ts' \
|
||||
'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts' \
|
||||
'tests/e2e/ssh-startup-exec-readiness.spec.ts' \
|
||||
'tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts' | sort -u)"
|
||||
fi
|
||||
# Why: same lesson as the SSH specs above. The IME e2e suite asserts real pty bytes and
|
||||
# real overlay geometry, but only ran when someone edited a spec, so every IME fix in the
|
||||
# 1.4.18x window shipped without it - including one whose diagnosis hardware later refuted.
|
||||
# config/patches is listed because the terminal's input handling lives in the xterm patch.
|
||||
if printf '%s\n' "$CHANGED" | grep -Ev '\.test\.tsx?$' | grep -Eq '^(config/patches/|src/renderer/src/components/terminal-pane/(terminal-ime-|use-terminal-pane-lifecycle|xterm-bypass-policy|terminal-option-shortcut-policy))'; then
|
||||
TEST_FILES="$(printf '%s\n' "$TEST_FILES" \
|
||||
'tests/e2e/terminal-cjk-ime-committed-text.spec.ts' \
|
||||
'tests/e2e/terminal-hangul-wrap-boundary-bytes.spec.ts' \
|
||||
'tests/e2e/terminal-ime-exact-byte.spec.ts' \
|
||||
'tests/e2e/terminal-korean-composing-chord-order.spec.ts' \
|
||||
'tests/e2e/terminal-korean-endofrow-preedit-cell-span.spec.ts' \
|
||||
'tests/e2e/terminal-korean-midline-preedit-occlusion.spec.ts' \
|
||||
'tests/e2e/terminal-korean-preedit-visibility.spec.ts' | sort -u)"
|
||||
fi
|
||||
TEST_FILES_JSON="$(printf '%s\n' "$TEST_FILES" | jq --raw-input --slurp --compact-output 'split("\n") | map(select(length > 0))')"
|
||||
# Source routes are executable contracts so a test can prove exact
|
||||
# authorities, exclusions, and sentinels without evaluating workflow shell.
|
||||
TEST_FILES_JSON="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-e2e-source-routing.mjs)"
|
||||
echo "test_files=$TEST_FILES_JSON" >> "$GITHUB_OUTPUT"
|
||||
if [ "$TEST_FILES_JSON" != '[]' ]; then
|
||||
echo "should_run=true" >> "$GITHUB_OUTPUT"
|
||||
@@ -770,6 +785,7 @@ jobs:
|
||||
- xterm_patch_sync
|
||||
- shell_contracts
|
||||
- test
|
||||
- orcad_browser
|
||||
- managed_hook_node18
|
||||
- package
|
||||
- package_windows
|
||||
@@ -795,6 +811,7 @@ jobs:
|
||||
XTERM_PATCH_SYNC: ${{ needs.xterm_patch_sync.result }}
|
||||
SHELL_CONTRACTS: ${{ needs.shell_contracts.result }}
|
||||
TEST: ${{ needs.test.result }}
|
||||
ORCAD_BROWSER: ${{ needs.orcad_browser.result }}
|
||||
MANAGED_HOOK_NODE18: ${{ needs.managed_hook_node18.result }}
|
||||
PACKAGE: ${{ needs.package.result }}
|
||||
PACKAGE_WINDOWS: ${{ needs.package_windows.result }}
|
||||
@@ -814,6 +831,7 @@ jobs:
|
||||
"$XTERM_PATCH_SYNC" \
|
||||
"$SHELL_CONTRACTS" \
|
||||
"$TEST" \
|
||||
"$ORCAD_BROWSER" \
|
||||
"$MANAGED_HOOK_NODE18" \
|
||||
"$PACKAGE" \
|
||||
"$PACKAGE_WINDOWS"; do
|
||||
@@ -833,6 +851,7 @@ jobs:
|
||||
"$XTERM_PATCH_SYNC" \
|
||||
"$SHELL_CONTRACTS" \
|
||||
"$TEST" \
|
||||
"$ORCAD_BROWSER" \
|
||||
"$MANAGED_HOOK_NODE18" \
|
||||
"$PACKAGE" \
|
||||
"$PACKAGE_WINDOWS"; do
|
||||
|
||||
@@ -14,6 +14,9 @@ const {
|
||||
const { verifyLinuxGlibcFloor } = require('./scripts/verify-linux-glibc-floor.cjs')
|
||||
const { writeMacBuildCompatibility } = require('./scripts/mac-build-compatibility.cjs')
|
||||
const { verifyPackagedPluginResources } = require('./scripts/verify-packaged-plugin-resources.cjs')
|
||||
const {
|
||||
verifyPackagedNodePtyJobOwnership
|
||||
} = require('./scripts/verify-packaged-node-pty-job-ownership.cjs')
|
||||
const { verifySkillsCliRuntime } = require('./scripts/verify-skills-cli-runtime.cjs')
|
||||
|
||||
// Why: dev-channel builds must carry the *release* identity — same bundle id,
|
||||
@@ -266,6 +269,13 @@ module.exports = {
|
||||
const archEnumByNodeArch = { ia32: 0, x64: 1, armv7l: 2, arm64: 3 }
|
||||
const hostArchEnum = archEnumByNodeArch[process.arch]
|
||||
const canExecuteTargetArch = context.arch === hostArchEnum || context.arch === 4
|
||||
if (context.electronPlatformName === 'win32') {
|
||||
if (process.platform === 'win32' && canExecuteTargetArch) {
|
||||
verifyPackagedNodePtyJobOwnership(resourcesDir)
|
||||
} else {
|
||||
console.log('[verify-packaged-node-pty] skipped cross-platform or cross-arch package')
|
||||
}
|
||||
}
|
||||
verifySkillsCliRuntime(join(resourcesDir, 'app.asar.unpacked', 'out'), resourcesDir, {
|
||||
executeCommands: canExecuteTargetArch
|
||||
})
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
# split the oversized file instead (AGENTS.md → "Do Not Disable Max Lines").
|
||||
# Regenerate/prune: pnpm check:max-lines-ratchet --prune (removes stale entries only)
|
||||
inline src/cli/handlers/automations.ts
|
||||
inline src/cli/handlers/orchestration.ts
|
||||
inline src/cli/help.ts
|
||||
inline src/main/agent-hooks/server.ts
|
||||
inline src/main/automations/external-manager.ts
|
||||
@@ -12,49 +11,29 @@ inline src/main/browser/agent-browser-bridge.ts
|
||||
inline src/main/browser/browser-cookie-import.ts
|
||||
inline src/main/browser/browser-manager.ts
|
||||
inline src/main/browser/cdp-bridge.ts
|
||||
inline src/main/browser/grab-guest-script.ts
|
||||
inline src/main/claude-accounts/runtime-auth-service.ts
|
||||
inline src/main/claude-accounts/service.ts
|
||||
inline src/main/cli/cli-installer.ts
|
||||
inline src/main/cli/wsl-cli-installer.ts
|
||||
inline src/main/codex-accounts/runtime-home-service.ts
|
||||
inline src/main/codex-accounts/service.ts
|
||||
inline src/main/codex/config-toml-trust.ts
|
||||
inline src/main/codex/hook-service.ts
|
||||
inline src/main/daemon/daemon-init.ts
|
||||
inline src/main/daemon/daemon-pty-adapter.ts
|
||||
inline src/main/daemon/daemon-server.ts
|
||||
inline src/main/daemon/pty-subprocess.ts
|
||||
inline src/main/git/repo.ts
|
||||
inline src/main/git/runner.ts
|
||||
inline src/main/git/status.ts
|
||||
inline src/main/git/worktree.ts
|
||||
inline src/main/github/pr-refresh-coordinator.ts
|
||||
inline src/main/github/project-view.ts
|
||||
inline src/main/github/project-view/mutations.ts
|
||||
inline src/main/github/work-item-details.ts
|
||||
inline src/main/gitlab/client.ts
|
||||
inline src/main/index.ts
|
||||
inline src/main/ipc/filesystem-watcher.ts
|
||||
inline src/main/ipc/filesystem.ts
|
||||
inline src/main/ipc/github.ts
|
||||
inline src/main/ipc/pty.ts
|
||||
inline src/main/ipc/repos.ts
|
||||
inline src/main/ipc/ssh.ts
|
||||
inline src/main/ipc/worktree-remote.ts
|
||||
inline src/main/ipc/worktrees.ts
|
||||
inline src/main/jira/issues.ts
|
||||
inline src/main/keybindings/keybinding-file.ts
|
||||
inline src/main/linear/issues.ts
|
||||
inline src/main/linear/projects.ts
|
||||
inline src/main/memory/collector.ts
|
||||
inline src/main/opencode/hook-service.ts
|
||||
inline src/main/persistence/loading-store/store.ts
|
||||
inline src/main/ports/advertised-url-watcher.ts
|
||||
inline src/main/ports/local-workspace-port-scanner.ts
|
||||
inline src/main/project-groups/nested-repo-discovery.ts
|
||||
inline src/main/providers/local-pty-provider.ts
|
||||
inline src/main/providers/ssh-git-provider.ts
|
||||
inline src/main/rate-limits/claude-fetcher.ts
|
||||
inline src/main/rate-limits/claude-pty.ts
|
||||
inline src/main/rate-limits/codex-fetcher.ts
|
||||
@@ -65,62 +44,38 @@ inline src/main/runtime/orca-runtime-git.ts
|
||||
inline src/main/runtime/orca-runtime.test.ts
|
||||
inline src/main/runtime/orca-runtime.ts
|
||||
inline src/main/runtime/rpc/methods/orchestration.ts
|
||||
inline src/main/runtime/rpc/methods/terminal.ts
|
||||
inline src/main/runtime/runtime-rpc.ts
|
||||
inline src/main/source-control/hosted-review-creation.ts
|
||||
inline src/main/speech/model-manager.ts
|
||||
inline src/main/speech/stt-service.ts
|
||||
inline src/main/ssh/ssh-channel-multiplexer.ts
|
||||
inline src/main/ssh/ssh-connection.ts
|
||||
inline src/main/ssh/ssh-relay-deploy.ts
|
||||
inline src/main/ssh/ssh-relay-session.ts
|
||||
inline src/main/text-generation/commit-message-text-generation.ts
|
||||
inline src/main/updater.ts
|
||||
inline src/main/window/attach-main-window-services.ts
|
||||
inline src/main/window/createMainWindow.ts
|
||||
inline src/main/workspace-space-analysis.ts
|
||||
inline src/preload/index.ts
|
||||
inline src/relay/dispatcher.ts
|
||||
inline src/relay/external-automations-handler.ts
|
||||
inline src/relay/git-handler.ts
|
||||
inline src/relay/pty-handler.ts
|
||||
inline src/relay/relay.ts
|
||||
inline src/relay/workspace-space-scan.ts
|
||||
inline src/renderer/src/components/GitLabItemDialog.tsx
|
||||
inline src/renderer/src/components/JiraIssueWorkspace.tsx
|
||||
inline src/renderer/src/components/LinearIssueWorkspace.tsx
|
||||
inline src/renderer/src/components/LinearItemDrawer.tsx
|
||||
inline src/renderer/src/components/NewWorkspaceComposerCard.tsx
|
||||
inline src/renderer/src/components/TaskPage.tsx
|
||||
inline src/renderer/src/components/Terminal.tsx
|
||||
inline src/renderer/src/components/UpdateCard.tsx
|
||||
inline src/renderer/src/components/WorktreeJumpPalette.tsx
|
||||
inline src/renderer/src/components/activity/ActivityPrototypePage.tsx
|
||||
inline src/renderer/src/components/automations/AutomationsPage.tsx
|
||||
inline src/renderer/src/components/editor/CombinedDiffViewer.tsx
|
||||
inline src/renderer/src/components/editor/EditorContent.tsx
|
||||
inline src/renderer/src/components/editor/IpynbViewer.tsx
|
||||
inline src/renderer/src/components/editor/MarkdownPreview.tsx
|
||||
inline src/renderer/src/components/feature-wall/BrowserAnimatedVisual.tsx
|
||||
inline src/renderer/src/components/feature-wall/EditorAnimatedVisual.tsx
|
||||
inline src/renderer/src/components/feature-wall/WorkbenchAnimatedVisual.tsx
|
||||
inline src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx
|
||||
inline src/renderer/src/components/github-project/ProjectCell.tsx
|
||||
inline src/renderer/src/components/github-project/ProjectPicker.tsx
|
||||
inline src/renderer/src/components/github-project/ProjectViewWrapper.tsx
|
||||
inline src/renderer/src/components/linear-project-view-surfaces.tsx
|
||||
inline src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx
|
||||
inline src/renderer/src/components/onboarding/use-onboarding-flow.ts
|
||||
inline src/renderer/src/components/right-sidebar/ChecksPanel.tsx
|
||||
inline src/renderer/src/components/right-sidebar/PortsPanel.tsx
|
||||
inline src/renderer/src/components/right-sidebar/checks-panel-content.tsx
|
||||
inline src/renderer/src/components/settings/AccountsPane.tsx
|
||||
inline src/renderer/src/components/settings/AgentsPane.tsx
|
||||
inline src/renderer/src/components/settings/RepositoryHooksSection.tsx
|
||||
inline src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx
|
||||
inline src/renderer/src/components/settings/Settings.tsx
|
||||
inline src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx
|
||||
inline src/renderer/src/components/sidebar/WorktreeContextMenu.tsx
|
||||
inline src/renderer/src/components/sidebar/use-workspace-kanban-area-selection.ts
|
||||
inline src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx
|
||||
inline src/renderer/src/components/status-bar/StatusBar.tsx
|
||||
@@ -132,9 +87,6 @@ inline src/renderer/src/components/terminal-pane/pty-transport.ts
|
||||
inline src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts
|
||||
inline src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts
|
||||
inline src/renderer/src/hooks/useAutomationDispatchEvents.ts
|
||||
inline src/renderer/src/hooks/useComposerState.ts
|
||||
inline src/renderer/src/hooks/useEditorExternalWatch.ts
|
||||
inline src/renderer/src/hooks/useIpcEvents.ts
|
||||
inline src/renderer/src/hooks/useSettingsNavigationMetadata.ts
|
||||
inline src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts
|
||||
inline src/renderer/src/lib/pane-manager/pane-tree-ops.ts
|
||||
@@ -148,18 +100,11 @@ inline src/renderer/src/runtime/web-session-tabs-sync.ts
|
||||
inline src/renderer/src/store/slices/agent-status.ts
|
||||
inline src/renderer/src/store/slices/browser.ts
|
||||
inline src/renderer/src/store/slices/diffComments.ts
|
||||
inline src/renderer/src/store/slices/github.ts
|
||||
inline src/renderer/src/store/slices/hosted-review.ts
|
||||
inline src/renderer/src/store/slices/jira.ts
|
||||
inline src/renderer/src/store/slices/linear.ts
|
||||
inline src/renderer/src/store/slices/repos.ts
|
||||
inline src/renderer/src/store/slices/tabs.ts
|
||||
inline src/renderer/src/store/slices/terminals.ts
|
||||
inline src/renderer/src/store/slices/ui.ts
|
||||
inline src/renderer/src/store/slices/workspace-cleanup.ts
|
||||
inline src/renderer/src/web/web-preload-api.ts
|
||||
inline src/renderer/src/web/web-runtime-client.ts
|
||||
inline src/shared/agent-hook-listener.ts
|
||||
inline src/shared/automation-schedules.ts
|
||||
inline src/shared/commit-message-agent-spec.ts
|
||||
inline src/shared/constants.ts
|
||||
@@ -167,7 +112,6 @@ inline src/shared/keybindings.ts
|
||||
inline src/shared/marine-creatures.ts
|
||||
inline src/shared/remote-runtime-client.ts
|
||||
inline src/shared/runtime-types.ts
|
||||
inline src/shared/source-control-ai.ts
|
||||
inline src/shared/telemetry-events.ts
|
||||
inline src/shared/text-search.ts
|
||||
inline tests/e2e/helpers/terminal.ts
|
||||
|
||||
@@ -348,6 +348,35 @@ function prunePackagedNodePty(resourcesDir, electronPlatformName, electronArch)
|
||||
return
|
||||
}
|
||||
|
||||
// Why delete only conpty.node: node-pty's loader tries build/Release, then
|
||||
// build/Debug, then prebuilds/<platform>-<arch>, swallowing every failure in
|
||||
// between. Only the source build carries Orca's job-object exports, so an ABI
|
||||
// mismatch or an AV quarantine of build/Release/conpty.node would silently
|
||||
// fall through to the UNPATCHED prebuild -- teardown back to guessing by PID
|
||||
// ancestry, with no error anywhere.
|
||||
//
|
||||
// Why NOT the whole prebuilds/ tree: Orca's own patch deletes the
|
||||
// `conpty_console_list` and winpty `pty` gyp targets, so a Windows source
|
||||
// build emits conpty.node and nothing else. conpty_console_list.node,
|
||||
// pty.node, winpty.dll and winpty-agent.exe exist ONLY here. Removing them
|
||||
// silently kills console-membership probing (the forked agent throws at
|
||||
// require, and its caller resolves null with silent: true), and removes the
|
||||
// winpty backend that node-pty still selects below Windows build 18309.
|
||||
//
|
||||
// Why the arch check: a cross-arch package copies the host's build/Release,
|
||||
// so its mere presence does not mean it matches electronArch -- deleting the
|
||||
// target-arch prebuild would then remove the only loadable binary.
|
||||
if (
|
||||
electronPlatformName === 'win32' &&
|
||||
electronArch === process.arch &&
|
||||
existsSync(join(nodePtyDir, 'build', 'Release', 'conpty.node'))
|
||||
) {
|
||||
const prebuildDir = join(nodePtyDir, 'prebuilds', `win32-${electronArch}`)
|
||||
for (const staleFallback of ['conpty.node', 'conpty.pdb']) {
|
||||
rmSync(join(prebuildDir, staleFallback), { force: true })
|
||||
}
|
||||
}
|
||||
|
||||
const allowedPrebuildPrefix = NODE_PTY_PREBUILD_PREFIX_BY_PLATFORM[electronPlatformName]
|
||||
if (allowedPrebuildPrefix) {
|
||||
pruneNodePtyNativeDirectories(
|
||||
|
||||
@@ -2,7 +2,12 @@ diff --git a/binding.gyp b/binding.gyp
|
||||
index 5f63978b07ab50aaf7523219a2170ec737a6b5db..bbd9e06136e8922f40b5779e35d4fc835f1479ab 100644
|
||||
--- a/binding.gyp
|
||||
+++ b/binding.gyp
|
||||
@@ -5,9 +5,6 @@
|
||||
@@ -1,13 +1,10 @@
|
||||
{
|
||||
'target_defaults': {
|
||||
'dependencies': [
|
||||
- "<!(node -p \"require('node-addon-api').targets\"):node_addon_api_except",
|
||||
+ "<!(node -p \"require.resolve('node-addon-api/node_addon_api.gyp')\"):node_addon_api_except",
|
||||
],
|
||||
'conditions': [
|
||||
['OS=="win"', {
|
||||
@@ -12,6 +17,39 @@ index 5f63978b07ab50aaf7523219a2170ec737a6b5db..bbd9e06136e8922f40b5779e35d4fc83
|
||||
'msvs_settings': {
|
||||
'VCCLCompilerTool': {
|
||||
'AdditionalOptions': [
|
||||
@@ -42,32 +39,6 @@
|
||||
'-lshlwapi'
|
||||
],
|
||||
},
|
||||
- {
|
||||
- 'target_name': 'conpty_console_list',
|
||||
- 'sources' : [
|
||||
- 'src/win/conpty_console_list.cc'
|
||||
- ],
|
||||
- },
|
||||
- {
|
||||
- 'target_name': 'pty',
|
||||
- 'include_dirs' : [
|
||||
- '<!(node -p "require(\'node-addon-api\').include_dir")',
|
||||
- 'deps/winpty/src/include',
|
||||
- ],
|
||||
- # Disabled due to winpty
|
||||
- 'msvs_disabled_warnings': [ 4506, 4530 ],
|
||||
- 'dependencies' : [
|
||||
- 'deps/winpty/src/winpty.gyp:winpty-agent',
|
||||
- 'deps/winpty/src/winpty.gyp:winpty',
|
||||
- ],
|
||||
- 'sources' : [
|
||||
- 'src/win/winpty.cc',
|
||||
- 'src/win/path_util.cc'
|
||||
- ],
|
||||
- 'libraries': [
|
||||
- '-lshlwapi'
|
||||
- ],
|
||||
- }
|
||||
]
|
||||
}, { # OS!="win"
|
||||
'targets': [
|
||||
@@ -88,6 +85,16 @@
|
||||
'libraries!': [
|
||||
'-lutil'
|
||||
|
||||
+620
-54
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"updatedAt": "2026-08-20",
|
||||
"updatedAt": "2026-08-23",
|
||||
"policy": {
|
||||
"maturityLevels": ["experimental", "soak", "blocking", "accepted-gap", "deprecated"],
|
||||
"blockingPromotion": {
|
||||
@@ -2674,7 +2674,7 @@
|
||||
"platform": "macos",
|
||||
"command": "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-browser-link.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"result": "failed",
|
||||
"durationSeconds": 60,
|
||||
"durationSeconds": 53,
|
||||
"summary": "The byte-identical final Electron oracle failed at its exact host/client identity barrier when the sole source delta replaced src/renderer/src/store/index.ts registration lines 110-113 with registerRuntimeHttpLinkBrowserOpener(null); no host-owned browser appeared within the deterministic 60-second bound."
|
||||
}
|
||||
],
|
||||
@@ -3680,14 +3680,16 @@
|
||||
"providers": ["local", "daemon", "ssh", "runtime"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["local", "daemon", "ssh", "runtime"],
|
||||
"coverageNotes": "Live macOS Electron tests prove exact local PTY disappearance after parked-tab close and detached-pgid descendant death after agent close. Deterministic tests cover final-pane CLI close durability, persisted-empty restart suppression, same-incarnation handle continuity, replacement and hard graph-loss stale fencing, preallocated-handle supersession, daemon and SSH routing, local/daemon pending-snapshot ownership across natural exit, stale-root descendant-signal suppression, graceful-to-immediate kill upgrades, duplicate-kill completion sharing, locale-stable bounded/fresh/coalesced process-table reads, deadline-safe successor scans, cycle-safe linear descendant traversal, target-only escalation indexing, 32-wide bulk teardown, source-scan timestamp identity, same-second PID ambiguity, ordinary runtime close ownership, unified-only hydration, split ownership, pane detach transfer, restart alias hydration, and late-hook suppression; live Linux, Windows, WSL, SSH, and remote-runtime process evidence remains pending. A paired macOS Electron journey (real attached-window desktop host plus a separate real paired desktop client, isolated profiles) covers the inverse authority direction for host-created terminals: a renderer graph sync must not retire a tab that `orca terminal create` made. That claim now covers BOTH shapes, including the incident shape the real CLI produces - a repo's FIRST host-created terminal, since src/cli/handlers/terminal.ts sends no clientMutationId - each asserted by its own test. An earlier reading of this gate called a preceding host-created terminal and a `clientMutationId` protective; main-process instrumentation refuted that. The renderer's stale session write deleted the host tab from persistence in every variant, and only the delete's timing relative to the measured sync differed, so those rows were false negatives of a single-sync oracle. The defect was in persistence: persistPtyBinding did not raise the repo's terminal topology fence for the first host-admitted tab, leaving the renderer's pre-create tab list free to replay over it. Elapsed time and paired-client mirroring never decided anything. A windowless `orca serve` parity arm runs the identical oracle and passes on both the merge-base and the branch, confirming that topology does not exhibit the defect (the gate it turns on is window-conditioned) and that making the host binding unconditional did not regress it. All of this is macOS-local only. The reported incident occurred on a physical Windows host; we did not run against one. The claim that the mechanism is platform-independent rests on the authoritative-window gate and publication-epoch inheritance being OS-agnostic, not on Windows evidence, so no ConPTY, Windows window-lifecycle, update, or sleep behavior is covered here.",
|
||||
"coverageNotes": "Live macOS Electron tests prove exact local PTY disappearance after parked-tab close and detached-pgid descendant death after agent close. A deterministic final-pane interleaving now proves durable tab retirement commits before externally owned PTY teardown can publish graph loss, and preserves an unrelated live canary through duplicate exit retirement. Deterministic tests also cover final-pane CLI close durability, persisted-empty restart suppression, same-incarnation handle continuity, replacement and hard graph-loss stale fencing, preallocated-handle supersession, daemon and SSH routing, local/daemon pending-snapshot ownership across natural exit, stale-root descendant-signal suppression, graceful-to-immediate kill upgrades, duplicate-kill completion sharing, locale-stable bounded/fresh/coalesced process-table reads, deadline-safe successor scans, cycle-safe linear descendant traversal, target-only escalation indexing, 32-wide bulk teardown, source-scan timestamp identity, same-second PID ambiguity, ordinary runtime close ownership, unified-only hydration, split ownership, pane detach transfer, restart alias hydration, and late-hook suppression; live Linux, Windows, WSL, SSH, and remote-runtime process evidence remains pending. A paired macOS Electron journey (real attached-window desktop host plus a separate real paired desktop client, isolated profiles) covers the inverse authority direction for host-created terminals: a renderer graph sync must not retire a tab that `orca terminal create` made. That claim now covers BOTH shapes, including the incident shape the real CLI produces - a repo's FIRST host-created terminal, since src/cli/handlers/terminal.ts sends no clientMutationId - each asserted by its own test. An earlier reading of this gate called a preceding host-created terminal and a `clientMutationId` protective; main-process instrumentation refuted that. The renderer's stale session write deleted the host tab from persistence in every variant, and only the delete's timing relative to the measured sync differed, so those rows were false negatives of a single-sync oracle. The defect was in persistence: persistPtyBinding did not raise the repo's terminal topology fence for the first host-admitted tab, leaving the renderer's pre-create tab list free to replay over it. Elapsed time and paired-client mirroring never decided anything. A windowless `orca serve` parity arm runs the identical oracle and passes on both the merge-base and the branch, confirming that topology does not exhibit the defect (the gate it turns on is window-conditioned) and that making the host binding unconditional did not regress it. All of this is macOS-local only. The reported incident occurred on a physical Windows host; we did not run against one. The claim that the mechanism is platform-independent rests on the authoritative-window gate and publication-epoch inheritance being OS-agnostic, not on Windows evidence, so no ConPTY, Windows window-lifecycle, update, or sleep behavior is covered here.",
|
||||
"motivatingLinks": [
|
||||
"https://linear.app/stably/issue/STA-4327",
|
||||
"https://github.com/stablyai/orca/pull/14590",
|
||||
"https://github.com/stablyai/orca/pull/8628",
|
||||
"https://github.com/stablyai/orca/pull/8706",
|
||||
"https://github.com/stablyai/orca/issues/14524"
|
||||
],
|
||||
"invariant": "Close permanently removes the owned provider session, agent descendants, persisted tab/layout authority, and resume authority even when no TerminalPane is mounted; final-pane CLI close does not acknowledge before durable tab retirement, and a persisted explicit-empty state prevents initial-tab fallback from repopulating the workspace after reload or restart. A handle remains valid while the same provider-attested PTY incarnation survives renderer reload or re-key, and becomes stale without adopting a replacement incarnation. A terminating id remains reserved through natural exit, duplicate callers await the same completion, and immediate teardown upgrades any graceful request without signalling a recycled PID or a descendant tree after root ownership is lost; process-table work is locale-stable, bounded, fresh for each post-start request, same-turn coalesced, and begins within the requesting caller's deadline, including bulk worktree cleanup; detach and park preserve ownership; aliases prevent a detached agent's immutable physical pane key from being retired with its former tab.",
|
||||
"oracle": "Capture exact tab, pane, PTY, handle, incarnation, and persisted layout identities. Final-pane terminal.close must kill the exact PTY once, invoke one durability-acknowledged tab retirement, stay pending until the persisted row is removed, and never fall back to the fire-and-forget pane event; reload/restart must keep the explicitly emptied worktree at zero terminal tabs and provider sessions. Reloading the renderer with the same tab/pane/PTY/incarnation must keep the handle readable; changing only the incarnation behind the same PTY id, losing the authoritative graph, or superseding the renderer handle with a preallocated handle must reject the old handle with terminal_handle_stale. Parked-close tests prove the exact PTY disappears; descendant/process tests keep natural exits reserved, upgrade teardown safely, bound/coalesce process-table work, and protect recycled identities.",
|
||||
"invariant": "Close permanently removes the owned provider session, agent descendants, persisted tab/layout authority, and resume authority even when no TerminalPane is mounted; final-pane CLI close commits durable tab retirement before PTY stop can publish graph loss, does not acknowledge before that retirement, and treats the later exit retirement as idempotent. A persisted explicit-empty state prevents initial-tab fallback from repopulating the workspace after reload or restart. A handle remains valid while the same provider-attested PTY incarnation survives renderer reload or re-key, and becomes stale without adopting a replacement incarnation. A terminating id remains reserved through natural exit, duplicate callers await the same completion, and immediate teardown upgrades any graceful request without signalling a recycled PID or a descendant tree after root ownership is lost; process-table work is locale-stable, bounded, fresh for each post-start request, same-turn coalesced, and begins within the requesting caller's deadline, including bulk worktree cleanup; detach and park preserve ownership; aliases prevent a detached agent's immutable physical pane key from being retired with its former tab.",
|
||||
"oracle": "Capture exact tab, pane, PTY, handle, incarnation, and persisted layout identities. Final-pane terminal.close must invoke one durability-acknowledged tab retirement before exact PTY teardown; controlled PTY exit and graph removal afterward must remain idempotent, omit the target from persistence and provider inventory, and leave an unrelated canary handle live. The ordinary path stays pending until the persisted row is removed and never falls back to the fire-and-forget pane event; reload/restart must keep the explicitly emptied worktree at zero terminal tabs and provider sessions. Reloading the renderer with the same tab/pane/PTY/incarnation must keep the handle readable; changing only the incarnation behind the same PTY id, losing the authoritative graph, or superseding the renderer handle with a preallocated handle must reject the old handle with terminal_handle_stale. Parked-close tests prove the exact PTY disappears; descendant/process tests keep natural exits reserved, upgrade teardown safely, bound/coalesce process-table work, and protect recycled identities.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-terminal-close-continuity.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal/initial-terminal.test.ts src/renderer/src/lib/worktree-activation-default-tabs.test.ts src/renderer/src/store/slices/terminals-explicit-empty-hydration.test.ts",
|
||||
@@ -3733,6 +3735,7 @@
|
||||
"file": "src/main/runtime/orca-runtime-terminal-close-continuity.test.ts",
|
||||
"assertions": [
|
||||
"final-pane terminal.close remains pending until one renderer durability acknowledgement retires the persisted tab and layout",
|
||||
"final-pane externally owned teardown commits session retirement before PTY stop, tolerates duplicate exit retirement, and preserves an unrelated live canary",
|
||||
"post-ack workspace-session loss falls back to renderer cleanup and verified PTY teardown while every pre-ack retirement error remains non-destructive",
|
||||
"renderer reload with the same provider-attested PTY incarnation preserves the exact handle",
|
||||
"same-id replacement incarnation publishes a new handle and makes the old handle deterministically stale",
|
||||
@@ -3913,7 +3916,7 @@
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "complete",
|
||||
"evidence": "STA-4327 used two byte-identical oracles. Runtime oracle cd248b67cc10ef64f2def315b86c3b0236b102639d03c65a0c5d9519e538d3af failed the same three durability/continuity checks on reported v1.4.180 (0b62333cf1), latest main (9bb8836bb6), and disabled fix; runtime candidate 3cf8a37447 passed 5/5, and final code candidate 121e112d77 separately passed its expanded 6/6 oracle. Renderer oracle 3b0949eef8b792cf5b09cfa55341f4216c08714f158e4b132e988e8114636cea failed both explicit-empty hydration/fallback checks on v1.4.180, latest main, and disabled fix, while renderer candidate 121e112d77 passed 2/2."
|
||||
"evidence": "STA-4327 used two byte-identical oracles. Runtime oracle cd248b67cc10ef64f2def315b86c3b0236b102639d03c65a0c5d9519e538d3af failed the same three durability/continuity checks on reported v1.4.180 (0b62333cf1), latest main (9bb8836bb6), and disabled fix; runtime candidate 3cf8a37447 passed 5/5, and final code candidate 121e112d77 separately passed its expanded 6/6 oracle. Renderer oracle 3b0949eef8b792cf5b09cfa55341f4216c08714f158e4b132e988e8114636cea failed both explicit-empty hydration/fallback checks on v1.4.180, latest main, and disabled fix, while renderer candidate 121e112d77 passed 2/2. The 1.4.187 recurrence oracle deterministically failed with historical tab_not_found when PTY stop was moved ahead of durable retirement, then passed with retirement committed before externally owned teardown while the canary remained live."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
@@ -4402,7 +4405,7 @@
|
||||
},
|
||||
{
|
||||
"id": "agent-status.manual-compact-identity",
|
||||
"title": "Manual Claude compact completion retires only its exact status generation",
|
||||
"title": "A finished Claude compact clears the pane, and an aborted one never strands it",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "agent-session",
|
||||
@@ -4417,19 +4420,19 @@
|
||||
"providers": ["local", "daemon", "ssh", "wsl", "remote-runtime"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["local", "ssh"],
|
||||
"coverageNotes": "Deterministic listener, loopback HTTP, relay-restart, persisted-restore, and fake-SSH transport contracts run on macOS. Claude Code 2.1.220 captures prove manual PreCompact starts a new prompt UUID and exact PreCompact/PostCompact pairs share it. The shared Node paths are platform-independent; live Linux, Windows, WSL, and paired-runtime journeys remain gaps.",
|
||||
"coverageNotes": "Deterministic listener, loopback HTTP, relay-restart, persisted-restore, and fake-SSH transport contracts run on macOS. Behavior tests deliver only events present in CLAUDE_EVENTS, so a mapping that is not registered cannot pass -- the failure mode that let the earlier compact correlation ship unreachable. Claude Code 2.1.227 PTY captures prove a successful manual compact emits PreCompact, a start-less summarizer SubagentStop, SessionStart(source=compact), then PostCompact, that an aborted compact emits PreCompact ALONE, that /compact emits no UserPromptSubmit, and that session_id is stable across the window. The shared Node paths are platform-independent; live Linux, Windows, WSL, and paired-runtime journeys remain gaps.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/issues/11352",
|
||||
"https://github.com/stablyai/orca/pull/11353"
|
||||
],
|
||||
"invariant": "A Claude manual PreCompact may replace only a current Claude row from the same receiving connection and provider session; a manual PostCompact may mark done only when the current authoritative row is its exact manual PreCompact generation, including provider prompt UUID. Missing identity is never a wildcard; later cross-provider work, replay, duplication, pane or tab retirement, and server stop invalidate completion. Automatic Claude and Kimi completion remain fail-closed until their hooks expose independently proven generation identity.",
|
||||
"oracle": "Run one byte-identical state-machine oracle plus local HTTP, relay-restart, SSH, and persisted-restore streams using Claude's observed different prior-turn and compact prompt UUIDs. Require matching manual completion to emit done while wrong or absent prompt UUID, session-presence mismatch, wrong source or connection, stale replay and duplicate delivery, later Codex work, automatic completion, and lifecycle cleanup cannot retire the current row. Assert both emitted event order and the authoritative lastStatusByPaneKey snapshot.",
|
||||
"invariant": "PreCompact is never registered and may never move a pane, because it fires before the compact is validated and an aborted compact emits it alone. Only a manual PostCompact may mark done, and it does so as a session boundary so completion-reactive consumers stay out of it; an automatic PostCompact claims nothing and writes no compact-scoped state, since its turn resumes and emits its own Stop. A completion either CLEARS a row or publishes nothing at all: it never creates a row, so a retired pane cannot be resurrected, and it never restates one it cannot clear, because a restated row loses restoredUnconfirmed and restarts the staleness clock. A manual /compact only completes at an idle prompt, so it retires a subagent that exists ONLY as a disk snapshot; live evidence -- a child observed in this runtime, an unclassifiable running background task, a registered session cron -- still holds the pane at working. A completion requires a valid provider prompt UUID, matches the pane owner by connection and provider session -- except on a restored row, which carries the previous connection and may carry no session at all -- and is applied at most once per compact. Ownership is enforced by the client that owns pane identity; a relay forwards and defers, so a cold relay cache cannot swallow the clearing event. A relay predating this contract forwards its own mapping, so ingest drops automatic envelopes, stamps the boundary on manual ones, carries the summarized turn label onto a promptless completion with or without a trigger, and classifies a trigger-stripped replay by payload state while still enforcing ownership. Kimi and other Claude-compatible vendors remain fail-closed.",
|
||||
"oracle": "Replay the measured Claude compact streams through a harness gated on the real CLAUDE_EVENTS, plus local HTTP, relay, SSH, and persisted-restore transports. Require the successful manual stream to end done with sessionBoundary set, and require the aborted stream (PreCompact alone) to leave both an idle and a working pane exactly as it found them. Drive the restart shape end to end over loopback HTTP from a hydrated row: a pane held only by a snapshot child must reach done with an empty roster, while a pane a runtime child still owns must emit NOTHING and keep its row object identical, so receivedAt and restoredUnconfirmed both survive. Deliver the restored-row cases with the hydrated row still cached at completion time and assert the summarizer published nothing, so the restored-row branch is the branch under test. Require rejection of an automatic completion, a completion with absent prompt UUID, a foreign provider session, a duplicate, and a completion for a pane with no cached row; require an automatic completion to leave the consumed-compact map untouched. Require a relay with a COLD cache to forward the completion while the client still refuses to mint a row it never had. Require ingest to drop legacy automatic envelopes, stamp the boundary on legacy manual ones, carry the summarized turn label onto a promptless completion with and without a trigger, and leave a FINISHED pane finished when a trigger-stripped working replay arrives. Require the consumed-compact map to be empty after each pane-scoped teardown route and to follow a pane move. Assert registration membership, emitted event order, and the authoritative lastStatusByPaneKey snapshot.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/shared/manual-compact-prompt-identity.test.ts src/main/agent-hooks/manual-compact-hook-stream.test.ts src/main/agent-hooks/manual-compact-status-cleanup.test.ts src/shared/agent-hook-listener-claude-turn-state.test.ts src/shared/agent-hook-listener-claude-compatible-vendors.test.ts src/shared/agent-hook-relay.test.ts src/relay/agent-hook-server.test.ts src/main/agent-hooks/server-claude-normalization.test.ts src/main/agent-hooks/server-last-status-write.test.ts src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts --reporter=dot",
|
||||
"Manual Claude Code 2.1.220 interactive compact: isolate UserPromptSubmit, PreCompact, and PostCompact hooks; submit one ordinary prompt then /compact; compare source, trigger, session_id, and prompt_id"
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/claude/compact-status-registration.test.ts src/main/agent-hooks/manual-compact-hook-stream.test.ts src/main/agent-hooks/manual-compact-status-cleanup.test.ts src/shared/agent-hook-listener-claude-turn-state.test.ts src/shared/agent-hook-listener-claude-compatible-vendors.test.ts src/shared/agent-hook-relay.test.ts src/relay/agent-hook-server.test.ts src/main/agent-hooks/server-claude-normalization.test.ts src/main/agent-hooks/server-last-status-write.test.ts src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts --reporter=dot",
|
||||
"Manual Claude Code interactive compact under a PTY: register every hook to a logging script, run one turn then /compact to observe the abort stream, then seven turns and /compact to observe the success stream; compare source, trigger, session_id, and prompt_id across both"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/shared/manual-compact-prompt-identity.test.ts",
|
||||
"src/main/claude/compact-status-registration.test.ts",
|
||||
"src/main/agent-hooks/manual-compact-hook-stream.test.ts",
|
||||
"src/main/agent-hooks/manual-compact-status-cleanup.test.ts",
|
||||
"src/shared/agent-hook-listener-claude-turn-state.test.ts",
|
||||
@@ -4442,19 +4445,29 @@
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/shared/manual-compact-prompt-identity.test.ts",
|
||||
"file": "src/main/claude/compact-status-registration.test.ts",
|
||||
"assertions": [
|
||||
"matching manual identity settles once",
|
||||
"stale, duplicate, session-mismatched, malformed, automatic, and unproven provider completion fail closed",
|
||||
"4,097 rejected pane keys allocate no ownership state"
|
||||
"PostCompact is registered in CLAUDE_EVENTS and written into the settings Claude reads, and PreCompact is absent from both",
|
||||
"the measured successful manual compact ends a stuck working row as a done with sessionBoundary",
|
||||
"an aborted compact (PreCompact alone) leaves an idle pane idle and a working pane working",
|
||||
"an automatic completion claims nothing, writes no compact-scoped state, and the turn's own Stop still closes it",
|
||||
"a completion retires a child that exists only as a disk snapshot, but a runtime child or an active session cron still holds the pane and the completion then publishes nothing at all",
|
||||
"a completion clears a restored row -- delivered while that hydrated row is still the cached owner -- despite a stale connection id or a missing provider session, but never resurrects a retired pane",
|
||||
"absent prompt UUID, foreign provider session, and duplicate delivery fail closed",
|
||||
"the consumed-compact record is dropped by clearPaneCacheState and clearAllListenerCaches and follows movePaneCacheState"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/agent-hooks/manual-compact-hook-stream.test.ts",
|
||||
"assertions": [
|
||||
"local HTTP and relay-restart streams preserve the distinct prior-turn and compact prompt identities",
|
||||
"later provider work and wrong transport identity remain authoritative",
|
||||
"persisted PreCompact restores its prompt"
|
||||
"the measured local HTTP stream settles once and rejects a duplicate completion",
|
||||
"a hydrated restart-stuck pane reaches done with an empty roster, and a pane a runtime child owns emits nothing and keeps its row object identical",
|
||||
"the relay forwards the completion and preserves its compact identity in the replay cache so the client re-runs ownership before applying the clearing row",
|
||||
"a relay with a cold cache still forwards the completion, and the client still refuses to mint a row it never had",
|
||||
"a legacy relay's manual envelope is stamped as a session boundary and its automatic envelope is dropped instead of minting working",
|
||||
"a promptless completion keeps the summarized turn's label with and without a trigger on the envelope",
|
||||
"a trigger-stripped replay is classified by payload state, leaves a FINISHED pane finished, and is still rejected on an owner mismatch",
|
||||
"a PreCompact envelope never drives pane state"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -4470,22 +4483,22 @@
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-08-02",
|
||||
"date": "2026-08-18",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/shared/manual-compact-prompt-identity.test.ts src/main/agent-hooks/manual-compact-hook-stream.test.ts src/main/agent-hooks/manual-compact-status-cleanup.test.ts src/shared/agent-hook-listener-claude-turn-state.test.ts src/shared/agent-hook-listener-claude-compatible-vendors.test.ts src/shared/agent-hook-relay.test.ts src/relay/agent-hook-server.test.ts src/main/agent-hooks/server-claude-normalization.test.ts src/main/agent-hooks/server-last-status-write.test.ts src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts --reporter=dot",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/claude/compact-status-registration.test.ts src/main/agent-hooks/manual-compact-hook-stream.test.ts src/main/agent-hooks/manual-compact-status-cleanup.test.ts src/shared/agent-hook-listener-claude-turn-state.test.ts src/shared/agent-hook-listener-claude-compatible-vendors.test.ts src/shared/agent-hook-relay.test.ts src/relay/agent-hook-server.test.ts src/main/agent-hooks/server-claude-normalization.test.ts src/main/agent-hooks/server-last-status-write.test.ts src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts --reporter=dot",
|
||||
"result": "passed",
|
||||
"durationSeconds": 2.66,
|
||||
"summary": "Eight focused files and 435 tests passed with local HTTP, relay, SSH, persistence, adversarial identity, cleanup, and existing provider-regression coverage."
|
||||
"durationSeconds": 3.28,
|
||||
"summary": "Ten files and 116 tests passed across registration, listener, local HTTP, relay, SSH, persistence, and cleanup. Reverting only the four source files to the merge base first turned 12 of 21 registration-gated tests and 8 of 12 relay/ingest tests red, so the suite observes its own failure rather than passing over unreachable code. Every assertion was additionally mutation-tested: 18 of 18 targeted mutations -- including reaping runtime children as well as snapshot ones, restating a pane live evidence still holds, re-enforcing compact ownership on the relay, and dropping the consumed-compact map from any one teardown route -- are caught."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-02",
|
||||
"date": "2026-08-17",
|
||||
"runner": "manual",
|
||||
"platform": "macos",
|
||||
"command": "Manual Claude Code 2.1.220 interactive compact: isolate UserPromptSubmit, PreCompact, and PostCompact hooks; submit one ordinary prompt then /compact; compare source, trigger, session_id, and prompt_id",
|
||||
"command": "Manual Claude Code interactive compact under a PTY: register every hook to a logging script, run one turn then /compact to observe the abort stream, then seven turns and /compact to observe the success stream; compare source, trigger, session_id, and prompt_id across both",
|
||||
"result": "passed",
|
||||
"durationSeconds": 120,
|
||||
"summary": "A fresh capture proved /compact emits no UserPromptSubmit and its PreCompact uses a new UUID distinct from the preceding user turn; the preserved authenticated capture proved the matching manual PreCompact and PostCompact share that UUID and session. Automatic capture reused one prompt UUID across multiple compact generations, so automatic completion remains disabled."
|
||||
"durationSeconds": 1080,
|
||||
"summary": "Claude Code 2.1.227. Aborted compact emitted PreCompact(manual) ALONE with no PostCompact and no SessionStart(compact). Successful compact emitted PreCompact(manual), a start-less SubagentStop (agent_type empty, no matching SubagentStart), SessionStart(source=compact), PostCompact(manual), then Notification(idle_prompt). /compact emitted no UserPromptSubmit in either the TUI or print mode; session_id was stable across the whole window while the compact allocated its own prompt_id."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
@@ -8047,6 +8060,7 @@
|
||||
"remote-runtime host surface materialization",
|
||||
"remote-runtime mirror polling",
|
||||
"remote-runtime network recovery",
|
||||
"paired client sleep/wake reconnect",
|
||||
"terminal create idempotency",
|
||||
"provider listing",
|
||||
"reattach",
|
||||
@@ -8059,6 +8073,8 @@
|
||||
"coveredProviders": ["ssh", "remote-runtime"],
|
||||
"coverageNotes": "Deterministic renderer coverage proves startup publishes the state returned by ssh.connect, retained native and runtime SSH payloads are admitted through production routes only with valid complete authority, stale cleanup cannot unregister a replacement runtime terminal, direct SSH Git and folder panes clear and retry by exact authority, one authority chain stops after two automatic attempts even when each timeout exceeds the rolling window, rejected acknowledgements mutate no store maps, and one shared exact attempt admits every concurrent split-pane spawn and reattach while preserving the first PTY as the tab fallback. A later sibling failure rotates the tab once, stale callbacks from the prior attempt mutate no state, split remount activity suppression is counted per leaf, primary PTY exit promotes a bound survivor or preserves an empty continuation gap for a late sibling, and primary, non-primary, or null-PTY detach preserves exact authority on both resulting tabs. Intentional pane disposal cancels its settlement timer without breaking StrictMode remount timeout ownership. Target snapshot hydration/reconnect preserves sibling SSH/local/WSL/runtime state, and a mounted remote-runtime terminal survives repeated transport partitions without changing PTY identity. A real encrypted-WebSocket oracle proves a successful reachability probe can replace a pre-ready shared-control socket without rejecting or duplicating the waiting RPC. Direct SSH coordinator tests cover immediate terminal finalization, hydration correction, damping, bounded retry, and telemetry non-interference. Client/server heartbeat tests cover timer suspension, socket generations fence stale callbacks, cold restored-terminal attachment retries, cached pixels remain unhealthy until authoritative replay, automatic retries stop after one minute, manual reconnect preserves the PTY, and pane closure releases recovery UI state. Current macOS Electron journeys against an ephemeral Linux Docker SSH target cover exact-authority repo/worktree hydration, live terminal recovery after disconnect/reconnect, and eager six-terminal remount after renderer reload. A Windows remote-runtime smoke covers reachability and PTY round-trip. Multi-target live fanout, paired-close, WSL, and patched live partition journeys remain gaps.",
|
||||
"motivatingLinks": [
|
||||
"https://linear.app/stably/issue/STA-3107",
|
||||
"https://github.com/stablyai/orca/pull/12664",
|
||||
"https://github.com/stablyai/orca/pull/6951",
|
||||
"https://github.com/stablyai/orca/pull/6955",
|
||||
"https://github.com/stablyai/orca/pull/6979",
|
||||
@@ -8070,6 +8086,7 @@
|
||||
"oracle": "Deterministic tests cover bounded stale-handle replacement, suspended heartbeat clocks, cold and established subscription failure, ten partition/recovery cycles, automatic-recovery cutoff, manual reconnect, and exact direct SSH binding recovery. They assert one atomic store publication clears only exact-target PTY indexes, null-PTY activation remains unchanged, relay identity survives, Git and folder panes retry symmetrically, another target/local/WSL/runtime panes remain byte-identical through target snapshot hydration and reconnect, only an accepted exact failure or timeout starts the second attempt, two 31-second timeouts cannot start a third settlement-triggered attempt, rejected stale/mismatched acknowledgements preserve every store map, and concurrent split-pane spawn and reattach callbacks both commit under the same attempt ID after the first success replaces pending state with live shared authority. A sibling failure revokes that shared authority and starts exactly one second attempt; duplicate failures and late first-attempt PTY callbacks preserve the second attempt and every state map. Attempt-two failure retains continuation authority for later siblings, primary exit promotes a bound survivor or preserves the lease until a late sibling binds, and primary plus non-primary detach retain exact authority and history on both resulting tabs. Both remount callbacks consume split-count activity suppression, intentional dispose emits no failure/timeout, and a same-attempt StrictMode remount still owns one timeout. Hydration clears an untrusted PTY hint without clearing its current pending owner, healthy current-authority bindings suppress correction, hydration finalizes once, and reconnect emits no paired close lifecycle. A provider-level session-ID reattach returns an incarnation, then a legacy exit without an incarnation must resolve to that returned identity rather than minting a fallback identity. The shared-control oracle withholds the first encrypted ready frame, starts one RPC, triggers the successful-probe refresh, then requires exactly two client connections, one host request, a successful response, zero pending calls, and zero retained request bytes. Tests also assert one unsubscribe per remote-runtime epoch, observable recovery phases, stable PTY identity, resumed snapshot/output/input, no healthy state before replay, no retry or input after cutoff, a new manual epoch against the same PTY, quiet recovery UI with an explicit Reconnect action, pane-close state cleanup, one stable create mutation id, old-runtime no-retry behavior, cross-process PTY adoption, and bounded in-flight coordination.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/providers/ssh-pty-provider-reattach-incarnation.test.ts --reporter=dot",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/paired-reconnect-multi-pane-materialization.test.ts src/renderer/src/runtime/paired-reconnect-sidebar-agent-count.test.ts --reporter=dot",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/startup/ssh-startup-reconnect.test.ts src/renderer/src/lib/resolved-worktree-execution-host.test.ts src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts src/renderer/src/runtime/sync-runtime-graph-scheduling.test.ts src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-spawn-retry.test.ts src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-reattach-retry.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-surface-replacement.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-reconnect.test.ts src/renderer/src/runtime/remote-runtime-session-tabs-inflight.test.ts src/renderer/src/runtime/web-session-terminal-handle-events.test.ts src/renderer/src/store/slices/terminal-pty-identity-replacement.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-transport-reattach-admission.test.ts src/renderer/src/components/terminal-pane/pty-transport-detach-attach-handoff.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-attach-subscription.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-surface-replacement.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-reconnect.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-expired-pane-recovery.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-outcome-recovery.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-handoff.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-recovery-state.test.ts src/renderer/src/components/terminal-pane/TerminalRemoteRuntimeReconnectBanner.test.tsx src/renderer/src/components/terminal-pane/terminal-remote-runtime-recovery-ui-state.test.ts src/shared/remote-runtime-socket-liveness.test.ts src/shared/remote-runtime-shared-control-connection.test.ts src/shared/remote-runtime-shared-control-socket-generation.test.ts src/shared/remote-runtime-client-error-classification.test.ts src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts src/main/runtime/rpc/methods/terminal-create-idempotency.test.ts src/main/runtime/orca-runtime-terminal-create-idempotency.test.ts",
|
||||
@@ -8084,6 +8101,8 @@
|
||||
],
|
||||
"testFiles": [
|
||||
"src/main/providers/ssh-pty-provider-reattach-incarnation.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/paired-reconnect-multi-pane-materialization.test.ts",
|
||||
"src/renderer/src/runtime/paired-reconnect-sidebar-agent-count.test.ts",
|
||||
"src/renderer/src/startup/ssh-startup-reconnect.test.ts",
|
||||
"src/renderer/src/lib/resolved-worktree-execution-host.test.ts",
|
||||
"src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts",
|
||||
@@ -8142,6 +8161,20 @@
|
||||
"tests/e2e/ssh-docker-relay-perf.spec.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/paired-reconnect-multi-pane-materialization.test.ts",
|
||||
"assertions": [
|
||||
"six panes sharing one dropped multiplex connection each resubscribe to a distinct live host handle",
|
||||
"a pane parked while the client slept is authoritatively activated and rematerialized without leaving any pane handle null"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/runtime/paired-reconnect-sidebar-agent-count.test.ts",
|
||||
"assertions": [
|
||||
"after a long sleep every live host PTY and tab retains its client-owned sidebar agent row",
|
||||
"unwritten or explicitly released pane authority still cedes deletion to the host"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/startup/ssh-startup-reconnect.test.ts",
|
||||
"assertions": [
|
||||
@@ -8471,6 +8504,15 @@
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-08-23",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/paired-reconnect-multi-pane-materialization.test.ts src/renderer/src/runtime/paired-reconnect-sidebar-agent-count.test.ts --reporter=dot",
|
||||
"result": "passed",
|
||||
"durationSeconds": 2.54,
|
||||
"summary": "Seven deterministic STA-3107 contracts passed across six-pane multiplex rematerialization and client-owned sidebar-row retention after sleep/wake."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-14",
|
||||
"runner": "local",
|
||||
@@ -8554,7 +8596,7 @@
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "partial",
|
||||
"evidence": "The remote-runtime fault tests failed before the recovery changes by leaving a cold restored subscription detached, reporting connected before authoritative replay, delivering a fatal setup error twice, and allowing an unknown create outcome to spawn again after process-local state was lost. On exact pre-fix HEAD 939719443, the split-pane store, fresh-spawn, and reattach oracles failed because the first success removed pending authority and the sibling could not bind; the same three oracles pass after live bindings retain the exact attempt ID. On exact committed HEAD d501f2e96, primary-exit-before-sibling and primary/non-primary split-detach oracles failed because live authority was deleted during the empty gap or transferred to only one resulting tab; all three pass after continuation-gap preservation and two-sided detach projection. On exact committed HEAD d44ea382b with test-only oracles, a pending-only all-null detach deleted the attempt ledger, a sibling mounted after first success committed without its lease, and that sibling could bind after authority rotation; all three pass after pending-only projection and retained-live-lease capture with provider retirement. On exact committed HEAD 9fa84dacf, the production-manager in-progress oracle rejected the fresh authority and the forward-removal barrier delayed stale transport cancellation; both pass after replacement starts cancellation immediately, shares concurrent fresh callers, and waits for teardown before connecting. On exact committed HEAD e5ba9a9e5, overlapping disconnect allowed a replacement connect before forward teardown completed and delayed transport cancellation behind that barrier; disconnect, removal, and terminate now share a target lifecycle barrier, start transport cancellation immediately, retain captured-session identity, and admit the replacement only after cleanup. On exact committed HEAD 9a29e7a81, a rejected forward teardown short-circuited the lifecycle while transport disconnect was pending, removal left its captured relay session alive, same-turn connect escaped admission, and reset remained outside the target barrier; the exact failure oracles pass after both cleanup branches settle, captured sessions always retire, admission is authority-fenced, and reset shares the barrier. On exact committed HEAD 47c7198f2, reset's remaining bespoke forward teardown could still reject after authority rotation but before captured-session retirement; reset now uses the same hardened session teardown and a failed reset remains cleanly retryable. Other direct SSH tests encode red conditions for non-atomic binding clear, cross-target retry, folder omission, duplicate same-authority attempts, hydration overwrite, terminal finalization delayed behind provider work, obsolete-authority pending-spawn adoption, Git lineage namespace mismatch, and snapshot PTY hint promotion without exact-attempt acknowledgement. Existing SSH and stale-handle reattach coverage remains green. Needs WSL, paired-close, and a patched live remote-runtime partition journey before promotion."
|
||||
"evidence": "The remote-runtime fault tests failed before the recovery changes by leaving a cold restored subscription detached, reporting connected before authoritative replay, delivering a fatal setup error twice, and allowing an unknown create outcome to spawn again after process-local state was lost. For STA-3107, reverting #11542 in the six-pane multiplex harness leaves exactly one parked pane without a resubscribed handle while its siblings recover; before #12664, the first post-sleep host snapshot erases every stale client-owned sidebar row despite live tabs and host PTYs. The deterministic contracts pass after host activation rematerializes the missing pane and client-written ownership, rather than freshness, controls row retention. On exact pre-fix HEAD 939719443, the split-pane store, fresh-spawn, and reattach oracles failed because the first success removed pending authority and the sibling could not bind; the same three oracles pass after live bindings retain the exact attempt ID. On exact committed HEAD d501f2e96, primary-exit-before-sibling and primary/non-primary split-detach oracles failed because live authority was deleted during the empty gap or transferred to only one resulting tab; all three pass after continuation-gap preservation and two-sided detach projection. On exact committed HEAD d44ea382b with test-only oracles, a pending-only all-null detach deleted the attempt ledger, a sibling mounted after first success committed without its lease, and that sibling could bind after authority rotation; all three pass after pending-only projection and retained-live-lease capture with provider retirement. On exact committed HEAD 9fa84dacf, the production-manager in-progress oracle rejected the fresh authority and the forward-removal barrier delayed stale transport cancellation; both pass after replacement starts cancellation immediately, shares concurrent fresh callers, and waits for teardown before connecting. On exact committed HEAD e5ba9a9e5, overlapping disconnect allowed a replacement connect before forward teardown completed and delayed transport cancellation behind that barrier; disconnect, removal, and terminate now share a target lifecycle barrier, start transport cancellation immediately, retain captured-session identity, and admit the replacement only after cleanup. On exact committed HEAD 9a29e7a81, a rejected forward teardown short-circuited the lifecycle while transport disconnect was pending, removal left its captured relay session alive, same-turn connect escaped admission, and reset remained outside the target barrier; the exact failure oracles pass after both cleanup branches settle, captured sessions always retire, admission is authority-fenced, and reset shares the barrier. On exact committed HEAD 47c7198f2, reset's remaining bespoke forward teardown could still reject after authority rotation but before captured-session retirement; reset now uses the same hardened session teardown and a failed reset remains cleanly retryable. Other direct SSH tests encode red conditions for non-atomic binding clear, cross-target retry, folder omission, duplicate same-authority attempts, hydration overwrite, terminal finalization delayed behind provider work, obsolete-authority pending-spawn adoption, Git lineage namespace mismatch, and snapshot PTY hint promotion without exact-attempt acknowledgement. Existing SSH and stale-handle reattach coverage remains green. Needs WSL, paired-close, and a patched live remote-runtime partition journey before promotion."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
@@ -9878,22 +9920,27 @@
|
||||
"providers": ["local", "daemon", "ssh", "remote-runtime"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["local"],
|
||||
"coverageNotes": "Local macOS evidence covers the runtime PTY write contract, live Codex orchestration, a dev-runtime CLI repro, and Claude and Codex composer traces. The render gate stays on the PTY-owning runtime, so paired and SSH callers use the same provider write/output path without a wire change. SSH, daemon, remote-runtime, Linux, and Windows live journeys remain gaps.",
|
||||
"coverageNotes": "Local macOS evidence covers the runtime PTY write contract, live Codex orchestration, a dev-runtime CLI repro, and Claude and Codex composer traces. A deterministic worker-start RPC/SQLite contract proves delayed Codex composer acceptance, exactly one submitted turn, durable Dispatch success, and explicit stalled failure when Enter is swallowed without a rescue send. The render gate stays on the PTY-owning runtime, so paired and SSH callers use the same provider write/output path without a wire change. SSH, daemon, remote-runtime, Linux, and Windows live journeys remain gaps.",
|
||||
"motivatingLinks": [
|
||||
"https://linear.app/stably/issue/STA-3800",
|
||||
"https://linear.app/stably/issue/STA-3904",
|
||||
"https://linear.app/stably/issue/STA-4328",
|
||||
"https://github.com/stablyai/orca/issues/7226",
|
||||
"https://github.com/stablyai/orca/issues/13821",
|
||||
"https://github.com/stablyai/orca/issues/14347"
|
||||
],
|
||||
"invariant": "Injected orchestration task prompts for recognized agent CLIs must send the prompt body inside one bracketed-paste frame, sanitize embedded ESC bytes, preserve chunk boundaries without losing the frame, and submit exactly once only after the agent can accept Enter. Claude and Codex must emit a post-paste composer marker and then settle, or reach the bounded fallback first; every other agent retains the platform delay.",
|
||||
"oracle": "Runtime tests assert the exact PTY write sequence, failure cleanup, Claude/Codex marker-gated multi-frame renders, and the legacy platform delay for every other configured agent. The candidate resets settlement on later frames, gives a late marker a fresh bounded window, and still submits once at the hard deadline if output never settles. Orchestration tests assert dispatch/coordinator use the agent prompt path; the live CLI harness covers long Codex-like framing.",
|
||||
"invariant": "Injected orchestration task prompts for recognized agent CLIs must send the prompt body inside one bracketed-paste frame, sanitize embedded ESC bytes, preserve chunk boundaries without losing the frame, and submit exactly once only after the agent can accept Enter. A successful orchestration.workerStart must durably record exactly one accepted and started turn; a swallowed Enter must fail with agent_prompt_stalled and never trigger a blind rescue Enter. Claude and Codex must emit a post-paste composer marker and then settle, or reach the bounded fallback first; every other agent retains the platform delay.",
|
||||
"oracle": "Runtime tests assert the exact PTY write sequence, failure cleanup, Claude/Codex marker-gated multi-frame renders, and the legacy platform delay for every other configured agent. The candidate resets settlement on later frames, gives a late marker a fresh bounded window, and still submits once at the hard deadline if output never settles. The worker-start contract drives the production RPC through a delayed fake Codex composer and independently checks exact turn/Enter counts plus reopened SQLite Task, Dispatch, worker receipt, and mutation receipt state for accepted and swallowed outcomes. Other orchestration tests assert dispatch/coordinator use the agent prompt path; the live CLI harness covers long Codex-like framing.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/shared/agent-prompt-injection.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/orchestration-tasks-dispatch.test.ts src/main/runtime/orchestration/coordinator.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration-worker-start-prompt-contract.test.ts --reporter=dot",
|
||||
"node tests/tools/repro-orchestration-long-prompt.mjs --cli out/bin/orca-dev --mode codex-like --size-kb 32 --timeout-ms 20000"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/shared/agent-prompt-injection.test.ts",
|
||||
"src/main/runtime/orca-runtime.test.ts",
|
||||
"src/main/runtime/rpc/methods/orchestration-tasks-dispatch.test.ts",
|
||||
"src/main/runtime/rpc/methods/orchestration-worker-start-prompt-contract.test.ts",
|
||||
"src/main/runtime/orchestration/coordinator.test.ts",
|
||||
"tests/tools/repro-orchestration-long-prompt.mjs"
|
||||
],
|
||||
@@ -9928,6 +9975,13 @@
|
||||
"failed prompt injection rolls back the active dispatch"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/rpc/methods/orchestration-worker-start-prompt-contract.test.ts",
|
||||
"assertions": [
|
||||
"delayed composer readiness produces exactly one submitted and started turn with no premature Enter and durable ready receipts",
|
||||
"a swallowed Enter records agent_prompt_stalled across Task, Dispatch, worker, and mutation receipts without a rescue Enter"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/orchestration/coordinator.test.ts",
|
||||
"assertions": [
|
||||
@@ -9945,6 +9999,15 @@
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-08-23",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration-worker-start-prompt-contract.test.ts --reporter=dot",
|
||||
"result": "passed",
|
||||
"durationSeconds": 21.84,
|
||||
"summary": "Two deterministic worker-start RPC contracts passed with fake clocks and reopened SQLite receipts for one accepted turn and one swallowed-Enter stalled outcome."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-14",
|
||||
"runner": "local",
|
||||
@@ -10001,7 +10064,7 @@
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "partial",
|
||||
"evidence": "The original Claude-shaped oracle fails on unmodified main and passes with the render gate. The hardened multi-frame oracle then fails on the first-marker candidate, which submits at 751 ms during an intermediate frame, and passes when the gate requires post-marker quiescence. Codex used the legacy platform delay before this candidate; the late-marker oracle proves the original deadline would pre-empt slow-host settlement without re-arming. The earlier live harness also reproduced the unsafe raw multiline contract before its framing fix. Intentional-break evidence remains local rather than a separate CI job."
|
||||
"evidence": "The original Claude-shaped oracle fails on unmodified main and passes with the render gate. The hardened multi-frame oracle then fails on the first-marker candidate, which submits at 751 ms during an intermediate frame, and passes when the gate requires post-marker quiescence. Codex used the legacy platform delay before this candidate; the late-marker oracle proves the original deadline would pre-empt slow-host settlement without re-arming. The worker-start negative controls caught both premature submission before composer readiness and a false-ready swallowed Enter; the accepted candidate records one started turn, while the swallowed arm records agent_prompt_stalled without a rescue Enter. The earlier live harness also reproduced the unsafe raw multiline contract before its framing fix. Intentional-break evidence remains local rather than a separate CI job."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
@@ -12284,7 +12347,7 @@
|
||||
"id": "terminal-render.pixel-refresh-repair",
|
||||
"title": "Forced repaint does not change revealed pane pixels",
|
||||
"maturity": "experimental",
|
||||
"protection": "none",
|
||||
"protection": "partial",
|
||||
"owner": "terminal-rendering",
|
||||
"layer": "electron-visual-golden",
|
||||
"surfaces": [
|
||||
@@ -12296,47 +12359,207 @@
|
||||
],
|
||||
"platforms": ["macos", "linux", "windows"],
|
||||
"providers": ["local", "daemon"],
|
||||
"coveredPlatforms": [],
|
||||
"coveredProviders": [],
|
||||
"coverageNotes": "Registered gap only; the live repro harness with a refresh-repair oracle exists but is not productized into the release-blocking terminal-rendering-golden suite.",
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["local"],
|
||||
"coverageNotes": "A headed macOS Electron test drives explicit destructive atlas recovery while DEC 2026 output is held, then compares compositor screenshots before and after a direct full-buffer present. The same renderer choreography is provider-local, but daemon, SSH, remote-runtime, Linux, and Windows GPU runs remain gaps.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/pull/7133",
|
||||
"https://github.com/stablyai/orca/pull/7054"
|
||||
],
|
||||
"invariant": "After any reveal, restore, or wake, the pixels a pane displays must equal what a forced full model-invalidating repaint would draw from the buffer; a material refresh-repair diff means stale rendering escaped, whatever the mechanism.",
|
||||
"oracle": "Screenshot the revealed pane, force a model-invalidating full repaint, screenshot again, and assert the bounded pixel diff is immaterial; pair with a JS-level render-model-vs-buffer comparison. Compositor screenshots only, because WebGL canvas readback is blank with preserveDrawingBuffer disabled.",
|
||||
"commands": [],
|
||||
"testFiles": [],
|
||||
"assertionRefs": [],
|
||||
"evidenceRuns": [],
|
||||
"invariant": "After proven renderer damage triggers destructive recovery, visible pixels equal a full model-invalidating present of the current buffer.",
|
||||
"oracle": "Hold DEC 2026, invoke explicit atlas recovery, require an atlas clear, screenshot the pane, force a direct renderer present, and assert the bounded pixel diff is immaterial. Compositor screenshots only, because WebGL canvas readback is blank with preserveDrawingBuffer disabled.",
|
||||
"commands": [
|
||||
"pnpm run ensure:electron-runtime && pnpm exec playwright test tests/e2e/terminal-reveal-paused-render-repro.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts src/renderer/src/lib/pane-manager/terminal-canvas-dpr-repair.test.ts src/renderer/src/lib/pane-manager/terminal-render-pause-release.test.ts",
|
||||
"ORCA_E2E_RECORD_VIDEO=1 pnpm exec playwright test tests/e2e/terminal-reveal-paused-render-repro.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1 --repeat-each=3"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts",
|
||||
"src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts",
|
||||
"src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts",
|
||||
"src/renderer/src/lib/pane-manager/terminal-canvas-dpr-repair.test.ts",
|
||||
"src/renderer/src/lib/pane-manager/terminal-render-pause-release.test.ts",
|
||||
"tests/e2e/terminal-reveal-paused-render-repro.spec.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts",
|
||||
"assertions": [
|
||||
"deduplicates a deferred dpr retry and consumes it after repair succeeds",
|
||||
"caps a deferred dpr transition at sixteen animation-frame retries"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts",
|
||||
"assertions": ["defers a heavy-reveal dpr present until the shared atlas recovery"]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/lib/pane-manager/terminal-canvas-dpr-repair.test.ts",
|
||||
"assertions": [
|
||||
"repairs when the canvas and renderer dimensions share a stale dpr cache",
|
||||
"defers an unmeasurable WebGL canvas but accepts a renderer-less pane"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/terminal-reveal-paused-render-repro.spec.ts",
|
||||
"assertions": ["@headful atlas recovery presents a synchronized-output WebGL frame"]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-08-23",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts src/renderer/src/lib/pane-manager/terminal-canvas-dpr-repair.test.ts src/renderer/src/lib/pane-manager/terminal-render-pause-release.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 2,
|
||||
"summary": "All 98 renderer contracts passed, including same-DPR zero-scan behavior, visible-only fanout, tri-state DPR repair, deduplicated bounded retry, heavy-reveal repair, and listener cleanup."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-23",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm run ensure:electron-runtime && pnpm exec playwright test tests/e2e/terminal-reveal-paused-render-repro.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"result": "passed",
|
||||
"durationSeconds": 60,
|
||||
"summary": "Both headed oracles passed together. Explicit destructive atlas recovery presented the held synchronized-output buffer with zero pixels different from a direct renderer present; the test attached before/after screenshots plus pixel-diff metadata."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-22",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "ORCA_E2E_RECORD_VIDEO=1 pnpm exec playwright test tests/e2e/terminal-reveal-paused-render-repro.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1 --repeat-each=3",
|
||||
"result": "passed",
|
||||
"durationSeconds": 48,
|
||||
"summary": "Three consecutive headed real-WebGL runs passed in 15.0-15.7 seconds each and emitted a 1920x1080 video per BrowserWindow."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 300,
|
||||
"scope": "release-blocking golden Electron visual test"
|
||||
"p95Seconds": 120,
|
||||
"scope": "focused prebuilt headed Electron visual test"
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "not-started",
|
||||
"evidence": "Runs nightly only while the per-platform diff threshold stabilizes; the destination is the release-blocking terminal-rendering-golden suite in release-cut.yml."
|
||||
"status": "unknown",
|
||||
"evidence": "Five local macOS headed runs passed, including one on the final branch state and three consecutive video-recorded repeats; CI soak history and cross-platform GPU runs are not yet available."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "missing",
|
||||
"evidence": "Needs an intentionally fossilized render model (skip the model invalidation on reveal) proving the refresh-repair diff fires."
|
||||
"status": "complete",
|
||||
"evidence": "Without the forced synchronized-output present, the damaged compositor differed from a direct renderer present across 87.68% of pixels even though serialization already contained the new frame. With explicit destructive recovery, the same oracle passes the 1.5% material-diff threshold."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "Soak-only; must not add product-path repaints beyond the existing reveal hardening."
|
||||
"evidence": "Destructive recovery remains event-driven and limited to confirmed renderer-lifecycle boundaries. Gate-local contracts prove ordinary same-DPR resize performs zero pane scans; a true transition scans only the visible manager; unavailable canvas retry is deduplicated and capped at 16 frames; and heavy reveal adds one bounded visible-pane scan. The shared terminal-render.atlas-recovery-fanout contracts separately prove zero hidden-manager atlas clears or presents and a per-pane display:none retry capped at 16 frames."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Productize the existing stale-render repro harness with the refresh-repair oracle and failure artifacts.",
|
||||
"Establish a stable material-diff threshold across platforms via nightly runs.",
|
||||
"Promotion means membership in the release-blocking terminal-rendering-golden suite that release-cut.yml runs against every release tag; never PR-blocking.",
|
||||
"Register the gate command as a direct spec-file invocation; the checker rejects --grep tag selectors, so do not reuse the existing --grep-based golden script as the command."
|
||||
"Add Linux and Windows GPU evidence or retain those platform gaps explicitly."
|
||||
],
|
||||
"knownGaps": [
|
||||
"No manifest command yet.",
|
||||
"Buffer-clean, pixels-stale variants are otherwise invisible to every buffer oracle in the manifest."
|
||||
"The deterministic recovery test disables xterm's one-second safety timeout so the held-frame damage remains stable until recovery.",
|
||||
"The renderer-level frame injection isolates the paint failure; provider delivery and hidden-output snapshot restore have separate gates.",
|
||||
"Linux, Windows, daemon, SSH, and remote-runtime live GPU evidence is not yet recorded."
|
||||
],
|
||||
"demotionRule": "Drop from the golden suite back to nightly diagnostic if diff thresholds cannot be kept stable across CI runners."
|
||||
},
|
||||
{
|
||||
"id": "terminal-render.synchronized-reveal-coherence",
|
||||
"title": "Ordinary reveal preserves synchronized-output frame coherence",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "terminal-rendering",
|
||||
"layer": "electron-visual-golden",
|
||||
"surfaces": ["WebGL rendering", "tab reveal", "snapshot restore", "DEC 2026"],
|
||||
"platforms": ["macos", "linux", "windows"],
|
||||
"providers": ["local", "daemon", "ssh", "remote-runtime"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["local"],
|
||||
"coverageNotes": "A headed macOS Electron test uses a real xterm WebGL renderer and production tab reveal. It proves the coherent pre-hide canvas remains visible while DEC 2026 holds a newer buffer, then proves normal release and xterm's one-second watchdog publish complete frames. Renderer behavior is provider-neutral; live Linux, Windows, daemon, SSH, and remote-runtime GPU runs remain gaps.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/pull/7133",
|
||||
"https://github.com/stablyai/orca/pull/7054"
|
||||
],
|
||||
"invariant": "terminal-render.synchronized-reveal-coherence: an ordinary reveal never clears a healthy shared atlas or publishes a partially built DEC 2026 frame; it retains the last coherent canvas until release or xterm's bounded watchdog.",
|
||||
"oracle": "Paint and capture a coherent frame, hide the real terminal tab, build a distinct full-screen frame while DEC 2026 is held, reveal through production, and require zero atlas clears plus pixel-equivalence with the coherent frame. Then release normally and through the one-second watchdog, requiring both compositor frames to match a direct renderer present.",
|
||||
"commands": [
|
||||
"pnpm run ensure:electron-runtime && pnpm exec playwright test tests/e2e/terminal-reveal-paused-render-repro.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts src/renderer/src/lib/pane-manager/terminal-render-pause-release.test.ts"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts",
|
||||
"src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts",
|
||||
"src/renderer/src/lib/pane-manager/terminal-render-pause-release.test.ts",
|
||||
"tests/e2e/terminal-reveal-paused-render-repro.spec.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts",
|
||||
"assertions": [
|
||||
"schedules an atlas-preserving present on a light tab reveal",
|
||||
"keeps heavy atlas recovery off a plain window refocus"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts",
|
||||
"assertions": ["preserves a synchronized frame on an ordinary reveal present"]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/lib/pane-manager/terminal-render-pause-release.test.ts",
|
||||
"assertions": [
|
||||
"routes synchronized output through RenderService instead of the renderer",
|
||||
"releases observer pause before requesting the synchronized frame"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/terminal-reveal-paused-render-repro.spec.ts",
|
||||
"assertions": [
|
||||
"@headful reveal preserves the coherent frame until synchronized output releases"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-08-23",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm run ensure:electron-runtime && pnpm exec playwright test tests/e2e/terminal-reveal-paused-render-repro.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"result": "passed",
|
||||
"durationSeconds": 53,
|
||||
"summary": "Both final-head headed oracles passed together. The coherent held frame differed by 73 of 1,404,288 pixels (0.0052%), ordinary reveal performed zero atlas clears, normal release differed from the direct present by 95 pixels (0.0068%), and watchdog release matched exactly. All three ratios passed the 1.5% compositor threshold."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 120,
|
||||
"scope": "focused prebuilt headed Electron prevention and watchdog test"
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "unknown",
|
||||
"evidence": "One final watchdog-inclusive macOS headed run passed; CI soak history and cross-platform GPU runs are not yet available."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "complete",
|
||||
"evidence": "Before the prevention change, the ordinary reveal oracle failed deterministically because it observed three atlas clears. After the change, reveal performs zero clears and the first visible frame remains pixel-equivalent to the coherent pre-hide frame."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "Ordinary reveal removes three shared-atlas clears and their global visible-manager presents. It adds one settled, event-driven full-viewport refresh request per revealed pane; DEC 2026 coalesces it and uses xterm's existing one-second watchdog. No polling, subprocess, provider call, PTY inventory, background-output work, listener, or unbounded retry was added; the display-none retry remains deduplicated and capped at 16 frames."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Accumulate stable macOS runs for both normal release and watchdog release.",
|
||||
"Add Linux and Windows GPU evidence or retain those platform gaps explicitly.",
|
||||
"Keep the destructive atlas-recovery oracle separate and passing."
|
||||
],
|
||||
"knownGaps": [
|
||||
"The deterministic test injects full-screen marker frames through xterm rather than invoking a live model; genuine OpenCode full-window evidence separately proves the original corruption.",
|
||||
"Linux, Windows, daemon, SSH, and remote-runtime live GPU evidence is not yet recorded.",
|
||||
"The 1.5% compositor threshold tolerates cursor and shell-prompt timing noise; exact buffer content and atlas-clear counts are asserted separately."
|
||||
],
|
||||
"demotionRule": "Demote if ordinary reveal clears the shared atlas, the held frame stops matching its coherent baseline, release/watchdog output diverges from a direct present, or the headed oracle cannot remain deterministic."
|
||||
},
|
||||
{
|
||||
"id": "pty-delivery.renderer-lifecycle-accounting-reset",
|
||||
"title": "Renderer lifecycle reset clears leaked PTY delivery accounting so surviving PTYs cannot freeze",
|
||||
@@ -14089,6 +14312,349 @@
|
||||
"The legacy remote-uploads root is intentionally not reclaimed while a mixed-version relay may still own bytes there; eventual legacy-root retirement requires a separate compatibility decision."
|
||||
],
|
||||
"demotionRule": "Demote if one live owner can remove another owner's bytes, cleanup exceeds 64 inspected entries per attempt, a lifecycle path retains its exact owned archive after settlement, or the process oracle flakes without an identified harness or product defect."
|
||||
},
|
||||
{
|
||||
"id": "terminal-startup.quick-command-pre-bind-recovery",
|
||||
"title": "Quick Command survives a pre-bind terminal recovery",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "terminal-renderer-lifecycle",
|
||||
"layer": "electron-ui-and-real-local-pty",
|
||||
"surfaces": ["Quick Commands", "terminal pane recovery", "fresh PTY startup"],
|
||||
"platforms": ["macos", "linux", "windows"],
|
||||
"providers": ["local"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["local"],
|
||||
"coverageNotes": "A forced pre-spawn barrier makes one visible Quick Command cross a recovery remount before its successor PTY binds. Store and lifecycle contracts retain lower-layer ownership; the Electron sentinel adds the rendered affordance, real PTY, exact-once terminal marker, staged identity file, and unrelated sibling canary.",
|
||||
"motivatingLinks": [
|
||||
"https://linear.app/stably/issue/STA-4876",
|
||||
"https://github.com/stablyai/orca/pull/15630"
|
||||
],
|
||||
"invariant": "A Quick Command selected for a new terminal remains owned by that tab through a recovery remount, executes exactly once only after the successor PTY binds, and never reaches an unrelated terminal.",
|
||||
"oracle": "Arm the E2E-only pre-spawn barrier, click the visible Quick Command, force one recovery generation while the original spawn is blocked, and release it. Require one exact semantic xterm marker, an independently written pane/tab identity file matching the bound live PTY, queue consumption only after bind, and a live interactive sibling canary.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection-queued-startup-consume.test.ts src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts src/renderer/src/store/slices/terminal-startup-command-retention.test.ts --reporter=dot",
|
||||
"pnpm exec playwright test tests/e2e/terminal-quick-command-pre-bind-recovery.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"ORCA_E2E_FORCE_HEADFUL=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/terminal-quick-command-pre-bind-recovery.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/renderer/src/components/terminal-pane/pty-connection-queued-startup-consume.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts",
|
||||
"src/renderer/src/store/slices/terminal-startup-command-retention.test.ts",
|
||||
"tests/e2e/terminal-quick-command-pre-bind-recovery.spec.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/renderer/src/store/slices/terminal-startup-command-retention.test.ts",
|
||||
"assertions": ["recovery remount retains the exact queued startup command"]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/terminal-quick-command-pre-bind-recovery.spec.ts",
|
||||
"assertions": [
|
||||
"the visible Quick Command executes exactly once in the intended fresh PTY",
|
||||
"queue consumption follows PTY binding and the sibling terminal remains live"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-08-23",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"result": "passed",
|
||||
"command": "pnpm exec playwright test tests/e2e/terminal-quick-command-pre-bind-recovery.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"durationSeconds": 23,
|
||||
"summary": "The exact rebased PR head passed the headless Electron flow in 23.0 seconds after an E2E-mode build."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-23",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"result": "passed",
|
||||
"command": "ORCA_E2E_FORCE_HEADFUL=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/terminal-quick-command-pre-bind-recovery.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"durationSeconds": 24,
|
||||
"summary": "The exact rebased PR head passed the visible-window Electron flow in 23.9 seconds using the same E2E build."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 90,
|
||||
"scope": "one isolated Electron app, one forced recovery remount, and two local PTYs"
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "not-started",
|
||||
"evidence": "The new sentinel has local headless and forced-headful evidence but no routed CI soak history."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "complete",
|
||||
"evidence": "The byte-identical spec failed with pending:null before PTY bind when the pre-6e25a900 eager-consume behavior was restored, then passed after restoration."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "The barrier exists only in E2E builds, adds no production timer or polling, and the live sentinel completed in under 20 seconds locally."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Collect 100 consecutive routed CI passes over at least 14 days without an unexplained flake.",
|
||||
"Keep retries at zero and retain both semantic and identity oracles.",
|
||||
"Collect Linux Electron evidence before promoting the focused lane to blocking."
|
||||
],
|
||||
"knownGaps": [
|
||||
"Windows, Linux, SSH, WSL, and paired-runtime topologies are not covered because they were not causal to STA-4876.",
|
||||
"The sentinel remains advisory while the repository-wide E2E signal is red."
|
||||
],
|
||||
"demotionRule": "Demote if the barrier can be bypassed nondeterministically, either independent oracle is removed, the sibling canary is no longer isolated, or one unexplained flake occurs."
|
||||
},
|
||||
{
|
||||
"id": "quick-open.paired-host-path-search",
|
||||
"title": "Paired Quick Open searches bounded host paths",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "runtime-filesystem",
|
||||
"layer": "host-file-search-and-paired-runtime",
|
||||
"surfaces": ["Quick Open", "paired desktop runtime", "headless runtime host"],
|
||||
"platforms": ["macos", "linux", "windows"],
|
||||
"providers": ["local", "ssh", "paired-runtime"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["local", "paired-runtime"],
|
||||
"coverageNotes": "Focused contracts cover bounded host search, ignore traversal, RPC response bounds, renderer cancellation, deterministic ordering, and transport payload size. Headed and headless paired-runtime journeys use an 88,763-file fixture and are source-routed into advisory PR E2E; physical SSH and Windows remain gaps.",
|
||||
"motivatingLinks": [
|
||||
"https://linear.app/stably/issue/STA-4354",
|
||||
"https://github.com/stablyai/orca/pull/15158"
|
||||
],
|
||||
"invariant": "Quick Open queries execute on the worktree host, find exact ignored-path matches beyond the legacy inventory prefix, return deterministic bounded results, and keep the paired runtime usable without overflowing its outbound reply buffer.",
|
||||
"oracle": "Create 88,763 paths with 77,792 ignored entries and a nominal 22 GiB sparse file, query exact late gitignored and .orcaignored targets through headed and headless paired runtimes, and require one deterministic match, a payload below 4 KiB, and a healthy follow-up worktree call.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-search-file-paths.test.ts src/main/runtime/orca-runtime-files-search.test.ts src/main/runtime/rpc/methods/files-path-search.test.ts src/renderer/src/components/quick-open-file-list.react.test.tsx src/renderer/src/runtime/runtime-file-client-search-listing.test.ts src/shared/quick-open-transport-budget.test.ts --reporter=dot",
|
||||
"pnpm exec playwright test tests/e2e/paired-quick-open-large-tree.spec.ts --config tests/playwright.config.ts --workers=1"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/main/ipc/filesystem-search-file-paths.test.ts",
|
||||
"src/main/runtime/orca-runtime-files-search.test.ts",
|
||||
"src/main/runtime/rpc/methods/files-path-search.test.ts",
|
||||
"src/renderer/src/components/quick-open-file-list.react.test.tsx",
|
||||
"src/renderer/src/runtime/runtime-file-client-search-listing.test.ts",
|
||||
"src/shared/quick-open-transport-budget.test.ts",
|
||||
"tests/e2e/paired-quick-open-large-tree.spec.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/main/ipc/filesystem-search-file-paths.test.ts",
|
||||
"assertions": [
|
||||
"host search stays bounded and respects gitignore plus .orcaignore traversal"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/runtime/runtime-file-client-search-listing.test.ts",
|
||||
"assertions": ["paired path queries stay deterministic and bounded across transport"]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/paired-quick-open-large-tree.spec.ts",
|
||||
"assertions": ["headed and headless hosts find late ignored targets and remain callable"]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-08-23",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"result": "passed",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-search-file-paths.test.ts src/main/runtime/orca-runtime-files-search.test.ts src/main/runtime/rpc/methods/files-path-search.test.ts src/renderer/src/components/quick-open-file-list.react.test.tsx src/renderer/src/runtime/runtime-file-client-search-listing.test.ts src/shared/quick-open-transport-budget.test.ts --reporter=dot",
|
||||
"durationSeconds": 5,
|
||||
"summary": "Six focused files passed 52 tests across host search, runtime RPC, renderer query ownership, and transport bounds; existing React act warnings remained non-fatal."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 300,
|
||||
"scope": "focused deterministic contracts plus two serial paired Electron large-tree journeys"
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "not-started",
|
||||
"evidence": "The sentinel is newly source-routed for PR collection; no route-specific soak history exists yet."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "complete",
|
||||
"evidence": "The merged STA-4354 oracle places both targets beyond the removed inventory prefix; the old client-side prefix inventory misses them, while host search returns the exact deterministic paths."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "Search is query-driven, result-limited, cancellation-aware, and payload-bounded; PR routing selects this fixture only for its exact production authorities or direct spec edits."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Collect 100 consecutive routed CI passes or 14 days without an unexplained flake.",
|
||||
"Collect physical SSH and Windows paired-runtime evidence.",
|
||||
"Keep deterministic result hashes, payload bounds, and post-query runtime health green."
|
||||
],
|
||||
"knownGaps": [
|
||||
"The live large-tree evidence uses local headed and headless hosts rather than physical SSH.",
|
||||
"Windows path-search tooling and filesystem behavior are not represented by the local evidence."
|
||||
],
|
||||
"demotionRule": "Demote if the routed fixture flakes without a diagnosed product or harness cause, returns unbounded payloads, misses either late ignored target, or leaves the paired runtime unhealthy."
|
||||
},
|
||||
{
|
||||
"id": "terminal-session.host-cold-park-stream-continuity",
|
||||
"title": "Host cold parking preserves paired terminal streams",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "terminal-runtime-graph",
|
||||
"layer": "renderer-runtime-graph-and-terminal-stream",
|
||||
"surfaces": ["host terminal cold park", "paired remote viewer", "multi-pane runtime graph"],
|
||||
"platforms": ["macos", "linux", "windows"],
|
||||
"providers": ["local-daemon", "ssh-daemon", "paired-runtime"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["local-daemon", "paired-runtime"],
|
||||
"coverageNotes": "Deterministic policy and multiplex tests separate renderer parking from authoritative stream liveness, while runtime-graph tests cover exact parked leaf, pane runtime ID, title, multi-pane active-leaf, and disposal behavior. One headed paired journey proves input and echoed output while the host pane remains cold-parked.",
|
||||
"motivatingLinks": [
|
||||
"https://linear.app/stably/issue/STA-2854",
|
||||
"https://github.com/stablyai/orca/pull/15514"
|
||||
],
|
||||
"invariant": "Cold parking a host renderer pane never retires its live PTY's runtime-graph leaf or interrupts a paired subscriber's stream, input, reconnect, pane identity, or multi-pane routing; the leaf retires only when exact PTY ownership ends.",
|
||||
"oracle": "Cold-park a host-owned pane while a paired client actively views it, require the host manager to unmount, then type through the client and require the same PTY to receive and echo the token without any disconnected sample. Separately publish multi-pane parked leaves with exact pane runtime IDs and require per-PTY disposal to remove only the retired leaf.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts src/renderer/src/runtime/sync-runtime-graph-parked-leaf.test.ts tests/e2e/host-cold-park-remote-subscriber.unit.test.ts --reporter=dot",
|
||||
"pnpm exec playwright test tests/e2e/host-parked-pane-remote-viewer.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts",
|
||||
"src/renderer/src/runtime/sync-runtime-graph-parked-leaf.test.ts",
|
||||
"tests/e2e/host-cold-park-remote-subscriber.unit.test.ts",
|
||||
"tests/e2e/host-parked-pane-remote-viewer.spec.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/renderer/src/runtime/sync-runtime-graph-parked-leaf.test.ts",
|
||||
"assertions": [
|
||||
"a live parked watcher publishes the exact leaf, pane runtime ID, and title",
|
||||
"multi-pane publication never names an unsent active leaf and exact disposal retires ownership"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/host-cold-park-remote-subscriber.unit.test.ts",
|
||||
"assertions": [
|
||||
"a fully unmounted host renderer still streams, accepts input, and resubscribes"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/host-parked-pane-remote-viewer.spec.ts",
|
||||
"assertions": [
|
||||
"a cold-parked host pane carries a complete paired-client input and echo round trip"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-08-23",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"result": "passed",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts src/renderer/src/runtime/sync-runtime-graph-parked-leaf.test.ts tests/e2e/host-cold-park-remote-subscriber.unit.test.ts --reporter=dot",
|
||||
"durationSeconds": 8,
|
||||
"summary": "Three focused files passed 64 watcher, multi-pane runtime-graph, cold-park policy, and authoritative-stream tests."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 180,
|
||||
"scope": "focused deterministic contracts plus one serial headed paired cold-park journey"
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "not-started",
|
||||
"evidence": "The existing sentinel is newly source-routed for PR collection; route-specific CI history has not started."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "complete",
|
||||
"evidence": "On the affected STA-2854 path, cold parking removed the runtime-graph leaf and the paired stream disconnected; retaining the exact live parked watcher leaf keeps every sampled client phase connected and completes input plus echo."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "Publication reads the existing bounded parked-watcher registry and adds no timer, polling, provider scan, subprocess, or wire field; the 90-second live timeline is selected only by exact parking and graph authorities."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Collect 100 consecutive routed CI passes or 14 days without an unexplained flake.",
|
||||
"Collect Linux, Windows, WSL, and physical SSH cold-park evidence.",
|
||||
"Keep exact per-leaf disposal, multi-pane identity, and full client round-trip assertions green."
|
||||
],
|
||||
"knownGaps": [
|
||||
"The live paired cold-park journey is macOS-only and uses a local daemon PTY.",
|
||||
"Physical SSH, WSL, Linux, Windows, and host-restart cold-park journeys are not recorded."
|
||||
],
|
||||
"demotionRule": "Demote if a live parked PTY loses its exact graph leaf, a retired PTY remains published, multi-pane identity drifts, or the routed client round trip flakes without a diagnosed cause."
|
||||
},
|
||||
{
|
||||
"id": "terminal-session.remote-pane-layout-retry",
|
||||
"title": "Remote pane layouts retry identical state after reconnect",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "terminal-layout-sync",
|
||||
"layer": "renderer-paired-runtime-layout",
|
||||
"surfaces": ["paired terminal split layout", "pane title", "runtime reconnect"],
|
||||
"platforms": ["macos", "linux", "windows"],
|
||||
"providers": ["paired-runtime"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["paired-runtime"],
|
||||
"coverageNotes": "Pure contracts cover identity-equal local write bailouts and remote push deduplication, while a headless-host paired Electron journey proves that an identical local layout is retried after a failed disconnected push and becomes visible to an independent observer.",
|
||||
"motivatingLinks": ["https://github.com/stablyai/orca/pull/12420"],
|
||||
"invariant": "Identity-equal pane layout writes avoid local churn but never suppress a remote retry whose prior push failed; reconnecting and repeating the same layout persists it on the host and publishes it to other paired clients.",
|
||||
"oracle": "Disconnect a paired client, change a pane title, and observe the failed host push while local state retains the layout. Reconnect without remounting, submit the identical title again, and require the headless host plus a second paired observer to receive it.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/remote-pane-layout-push.test.ts src/renderer/src/store/slices/terminal-write-identity-bailouts.test.ts --reporter=dot",
|
||||
"pnpm exec playwright test tests/e2e/paired-remote-pane-layout-retry.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/renderer/src/components/terminal-pane/remote-pane-layout-push.test.ts",
|
||||
"src/renderer/src/store/slices/terminal-write-identity-bailouts.test.ts",
|
||||
"tests/e2e/paired-remote-pane-layout-retry.spec.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/remote-pane-layout-push.test.ts",
|
||||
"assertions": ["only successfully pushed layouts deduplicate later remote writes"]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/store/slices/terminal-write-identity-bailouts.test.ts",
|
||||
"assertions": ["identity-equal layout and timer writes avoid redundant local updates"]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/paired-remote-pane-layout-retry.spec.ts",
|
||||
"assertions": [
|
||||
"an identical layout retries after reconnect and reaches a second paired observer"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-08-23",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"result": "passed",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/remote-pane-layout-push.test.ts src/renderer/src/store/slices/terminal-write-identity-bailouts.test.ts --reporter=dot",
|
||||
"durationSeconds": 5,
|
||||
"summary": "Two focused files passed 20 remote-push and identity-bailout contracts."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 90,
|
||||
"scope": "two focused renderer contracts plus one serial headless-host paired journey"
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "not-started",
|
||||
"evidence": "The existing paired sentinel is newly source-routed and has no route-specific CI soak history."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "complete",
|
||||
"evidence": "The affected implementation cached the disconnected layout before host acknowledgment and suppressed the identical retry; the merged oracle keeps only successful remote pushes deduplicated and the observer receives the retried title."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "Successful identity-equal writes remain O(1) bailouts; one failed remote push permits one later identical retry, with no polling, timer, provider scan, subprocess, or new wire field."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Collect 100 consecutive routed CI passes or 14 days without an unexplained flake.",
|
||||
"Collect Linux and Windows paired-runtime reconnect evidence.",
|
||||
"Keep local identity bailouts and failed-push retry ownership green."
|
||||
],
|
||||
"knownGaps": [
|
||||
"The paired reconnect journey has only local macOS evidence.",
|
||||
"The sentinel changes a pane title within an existing layout; concurrent split and close conflicts remain separate coverage."
|
||||
],
|
||||
"demotionRule": "Demote if a failed push suppresses an identical retry, a successful equal write resumes redundant churn, or the routed observer journey flakes without a diagnosed cause."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -7,14 +7,25 @@
|
||||
* the only ones that statically import `node:sqlite`, so dropping them is what keeps
|
||||
* the host Node floor at 18 instead of 22.5+.
|
||||
*/
|
||||
import { fork, spawnSync } from 'node:child_process'
|
||||
import { build } from 'esbuild'
|
||||
import { mkdirSync, rmSync } from 'node:fs'
|
||||
import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs'
|
||||
import { arch, platform, tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import process from 'node:process'
|
||||
|
||||
const ROOT = join(import.meta.dirname, '..', '..')
|
||||
const OUT_DIR = join(ROOT, 'out', 'orcad')
|
||||
const ENTRY = join(ROOT, 'src/main/orcad/main.ts')
|
||||
// Why beside orcad.js: the watcher runs in a forked child so a native @parcel/watcher
|
||||
// fault crashes that child instead of the server, and `resolveWatcherProcessEntryPath`
|
||||
// looks for it in the app root. A deployment has no desktop out/main to fall back to.
|
||||
const WATCHER_ENTRY = join(ROOT, 'src/main/ipc/parcel-watcher-process-entry.ts')
|
||||
const WATCHER_OUT_FILE = join(OUT_DIR, 'parcel-watcher-process-entry.js')
|
||||
const AGENT_BROWSER_NAME = `agent-browser-${platform()}-${arch()}${process.platform === 'win32' ? '.exe' : ''}`
|
||||
const OUT_FILE = join(OUT_DIR, 'orcad.js')
|
||||
const AGENT_BROWSER_SOURCE = join(ROOT, 'node_modules', 'agent-browser', 'bin', AGENT_BROWSER_NAME)
|
||||
const AGENT_BROWSER_OUTPUT = join(OUT_DIR, AGENT_BROWSER_NAME)
|
||||
|
||||
// Native addons must exist on the host; they cannot be bundled.
|
||||
// `electron` is external so a residual import fails loudly at require() time rather
|
||||
@@ -48,6 +59,25 @@ const externalNativeAddons = {
|
||||
|
||||
rmSync(OUT_DIR, { recursive: true, force: true })
|
||||
mkdirSync(OUT_DIR, { recursive: true })
|
||||
copyFileSync(AGENT_BROWSER_SOURCE, AGENT_BROWSER_OUTPUT)
|
||||
if (process.platform !== 'win32') {
|
||||
chmodSync(AGENT_BROWSER_OUTPUT, 0o755)
|
||||
}
|
||||
|
||||
await build({
|
||||
entryPoints: [WATCHER_ENTRY],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
target: 'node18',
|
||||
format: 'cjs',
|
||||
outfile: WATCHER_OUT_FILE,
|
||||
external: EXTERNAL,
|
||||
plugins: [externalNativeAddons],
|
||||
minify: true,
|
||||
sourcemap: false,
|
||||
define: { 'process.env.NODE_ENV': '"production"' },
|
||||
logLevel: 'error'
|
||||
})
|
||||
|
||||
const result = await build({
|
||||
entryPoints: [ENTRY],
|
||||
@@ -55,7 +85,7 @@ const result = await build({
|
||||
platform: 'node',
|
||||
target: 'node18',
|
||||
format: 'cjs',
|
||||
outfile: join(OUT_DIR, 'orcad.js'),
|
||||
outfile: OUT_FILE,
|
||||
external: EXTERNAL,
|
||||
plugins: [jsoncParserEsm, externalNativeAddons],
|
||||
metafile: true,
|
||||
@@ -78,19 +108,117 @@ for (const [file, info] of Object.entries(result.metafile.inputs)) {
|
||||
}
|
||||
}
|
||||
}
|
||||
const sqliteImporters = new Set()
|
||||
for (const [file, info] of Object.entries(result.metafile.inputs)) {
|
||||
for (const imported of info.imports ?? []) {
|
||||
const specifier = imported.original ?? imported.path
|
||||
if (specifier === 'node:sqlite') {
|
||||
sqliteImporters.add(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const graphErrors = []
|
||||
if (electronImporters.size > 0) {
|
||||
console.error(
|
||||
`[build-orcad] ${electronImporters.size} module(s) in the bundle import electron:
|
||||
${[...electronImporters].map((f) => ` - ${f}`).join('\n')}`
|
||||
graphErrors.push(
|
||||
`${electronImporters.size} module(s) in the bundle import electron:\n${[...electronImporters]
|
||||
.map((file) => ` - ${file}`)
|
||||
.join('\n')}`
|
||||
)
|
||||
}
|
||||
if (sqliteImporters.size > 0) {
|
||||
graphErrors.push(
|
||||
`${sqliteImporters.size} module(s) in the bundle import node:sqlite:\n${[...sqliteImporters]
|
||||
.map((file) => ` - ${file}`)
|
||||
.join('\n')}`
|
||||
)
|
||||
}
|
||||
|
||||
if (graphErrors.length > 0) {
|
||||
console.error(`[build-orcad] ${graphErrors.join('\n')}`)
|
||||
// Why this can exceed the ratchet baseline: the ratchet measures the graph reachable
|
||||
// from orca-runtime + runtime-rpc, but this entry also imports ipc/pty directly to
|
||||
// install the PTY controller. Once orcad ships, it should become a ratchet entry
|
||||
// point so the two numbers cannot drift.
|
||||
process.exitCode = 1
|
||||
} else {
|
||||
// Why smoke-load and not just read the metafile: the import scan proves no module
|
||||
// *names* electron, but a graph can still fail to resolve under plain Node — a
|
||||
// dynamic require, a missing native, a top-level throw. The plain-node-entry-guard
|
||||
// smoke-loads its entries for exactly this reason, and orcad cannot join that guard
|
||||
// because it is an esbuild artifact rather than a rollup input.
|
||||
const smoke = spawnSync(process.execPath, [OUT_FILE, '--orcad-smoke-load-check'], {
|
||||
encoding: 'utf8',
|
||||
timeout: 60_000
|
||||
})
|
||||
const smokeOutput = `${smoke.stdout ?? ''}${smoke.stderr ?? ''}`
|
||||
if (
|
||||
smoke.error ||
|
||||
smoke.signal ||
|
||||
!/Unknown argument: --orcad-smoke-load-check/.test(smokeOutput)
|
||||
) {
|
||||
console.error(
|
||||
`[build-orcad] the bundle did not load under plain Node.\n` +
|
||||
`Expected argv rejection, got signal=${smoke.signal ?? 'none'} ` +
|
||||
`error=${smoke.error?.message ?? 'none'}\n${smokeOutput.slice(0, 2000)}`
|
||||
)
|
||||
process.exitCode = 1
|
||||
}
|
||||
const watcherFailure = await smokeLoadWatcherChild()
|
||||
if (watcherFailure) {
|
||||
console.error(
|
||||
`[build-orcad] the watcher child did not run under plain Node.\n${watcherFailure}`
|
||||
)
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
|
||||
if (process.exitCode !== 1) {
|
||||
console.log(
|
||||
`[build-orcad] ok — ${(output.bytes / 1024 / 1024).toFixed(2)} MB, ${Object.keys(output.inputs).length} modules, zero electron imports.`
|
||||
`[build-orcad] ok — ${(output.bytes / 1024 / 1024).toFixed(2)} MB, ${Object.keys(output.inputs).length} modules, zero electron and node:sqlite imports.`
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fork the shipped watcher child and drive one message through it.
|
||||
*
|
||||
* Why a real fork and not existsSync: the file being present says nothing about whether
|
||||
* its graph resolves under plain Node, and this child is only ever reached through
|
||||
* `fork()` at runtime — a broken one degrades silently to in-process watching.
|
||||
* `subscribe-started` is acked before the native module is touched, so this passes on a
|
||||
* build machine with no compiled @parcel/watcher.
|
||||
*/
|
||||
async function smokeLoadWatcherChild() {
|
||||
const probeDir = mkdtempSync(join(tmpdir(), 'orcad-watcher-smoke-'))
|
||||
const child = fork(WATCHER_OUT_FILE, [], { stdio: ['ignore', 'ignore', 'pipe', 'ipc'] })
|
||||
let stderr = ''
|
||||
child.stderr?.on('data', (chunk) => {
|
||||
stderr += String(chunk)
|
||||
})
|
||||
try {
|
||||
return await new Promise((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
child.kill('SIGKILL')
|
||||
resolve(`No 'subscribe-started' ack within 30s.\n${stderr.slice(0, 2000)}`)
|
||||
}, 30_000)
|
||||
const settle = (failure) => {
|
||||
clearTimeout(timer)
|
||||
resolve(failure)
|
||||
}
|
||||
child.on('message', (message) => {
|
||||
if (message?.op === 'subscribe-started') {
|
||||
child.disconnect()
|
||||
}
|
||||
})
|
||||
child.on('error', (error) => settle(`fork failed: ${error.message}`))
|
||||
// Why exit and not disconnect: the child exits 0 on disconnect, so a non-zero code
|
||||
// or a signal here is a load failure rather than a clean teardown.
|
||||
child.on('exit', (code, signal) =>
|
||||
settle(code === 0 ? null : `exit code=${code} signal=${signal}\n${stderr.slice(0, 2000)}`)
|
||||
)
|
||||
child.send({ op: 'subscribe', id: 1, dir: probeDir, opts: {} })
|
||||
})
|
||||
} finally {
|
||||
rmSync(probeDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,7 +139,94 @@ function diagnosticLineRange(root, filename, span) {
|
||||
return { start: startLine, end: startLine + (highlighted.match(/\n/g)?.length ?? 0) }
|
||||
}
|
||||
|
||||
export function diagnosticTouchesAddedLines(diagnostic, rangesByFile, root = process.cwd()) {
|
||||
// Why: a file-splitting refactor makes every line of the new module an "added"
|
||||
// line, so pre-existing lint debt in code that merely MOVED starts failing the
|
||||
// changed-lines gate. The only way to satisfy it is to edit the moved code,
|
||||
// which is exactly what a behavior-preserving refactor must not do. So a
|
||||
// diagnostic is exempt when its highlighted lines already existed, verbatim and
|
||||
// contiguous, somewhere in the base revision of the files this change touches.
|
||||
function normalizeSourceLine(line) {
|
||||
return line.replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
export function collectBaseLineBlocks(root, comparisonBase, files = null) {
|
||||
// Why: in a split, the moved code's base text lives in the ORIGINAL file, which is
|
||||
// often deleted or renamed away. Deleted paths never reach the changed-file list
|
||||
// (it filters to ACMRTUB), so read every path the diff touches, deletions included.
|
||||
const paths =
|
||||
files ??
|
||||
splitNullDelimited(
|
||||
runGit(root, ['diff', '--name-only', '-z', comparisonBase, '--'])
|
||||
).filter((file) => SOURCE_FILE_PATTERN.test(file))
|
||||
const blocks = []
|
||||
for (const file of paths) {
|
||||
const result = spawnSync('git', ['show', `${comparisonBase}:${file}`], {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 64 * 1024 * 1024
|
||||
})
|
||||
if (result.status !== 0 || typeof result.stdout !== 'string') {
|
||||
continue
|
||||
}
|
||||
blocks.push(result.stdout.split(/\r?\n/).map(normalizeSourceLine).filter((line) => line !== ''))
|
||||
}
|
||||
return blocks
|
||||
}
|
||||
|
||||
export function isMovedCode(highlightedLines, baseBlocks) {
|
||||
const needle = highlightedLines.map(normalizeSourceLine).filter((line) => line !== '')
|
||||
if (needle.length === 0) {
|
||||
return false
|
||||
}
|
||||
// Why a near-match rather than an exact contiguous one: a split moves a block
|
||||
// verbatim but a diagnostic's span often reaches past it — most commonly to a
|
||||
// hook dependency array, which legitimately grows when closure variables become
|
||||
// props. Requiring every line to match would report the moved body as new. So:
|
||||
// the block must still start at the same line in the base and appear IN ORDER,
|
||||
// and nearly all of it must be present. Genuinely new code shares neither the
|
||||
// anchor nor the ordering, so it stays reported.
|
||||
const MIN_COVERAGE = 0.9
|
||||
return baseBlocks.some((rawHaystack) => {
|
||||
const haystack = rawHaystack.map(normalizeSourceLine).filter((line) => line !== '')
|
||||
for (let start = 0; start < haystack.length; start += 1) {
|
||||
if (haystack[start] !== needle[0]) {
|
||||
continue
|
||||
}
|
||||
let matched = 1
|
||||
let cursor = start + 1
|
||||
for (let index = 1; index < needle.length && cursor < haystack.length; index += 1) {
|
||||
while (cursor < haystack.length && haystack[cursor] !== needle[index]) {
|
||||
cursor += 1
|
||||
}
|
||||
if (cursor < haystack.length) {
|
||||
matched += 1
|
||||
cursor += 1
|
||||
}
|
||||
}
|
||||
if (matched / needle.length >= MIN_COVERAGE) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
function diagnosticHighlightedLines(root, filename, span) {
|
||||
const absolutePath = path.isAbsolute(filename) ? filename : path.join(root, filename)
|
||||
const source = readFileSync(absolutePath, 'utf8').split(/\r?\n/)
|
||||
const range = diagnosticLineRange(root, filename, span)
|
||||
if (range === null) {
|
||||
return []
|
||||
}
|
||||
return source.slice(range.start - 1, range.end)
|
||||
}
|
||||
|
||||
export function diagnosticTouchesAddedLines(
|
||||
diagnostic,
|
||||
rangesByFile,
|
||||
root = process.cwd(),
|
||||
baseBlocks = []
|
||||
) {
|
||||
const file = normalizedDiagnosticPath(root, diagnostic.filename)
|
||||
const ranges = rangesByFile.get(file)
|
||||
if (!ranges) {
|
||||
@@ -147,7 +234,10 @@ export function diagnosticTouchesAddedLines(diagnostic, rangesByFile, root = pro
|
||||
}
|
||||
return (diagnostic.labels ?? []).some((label) => {
|
||||
const lineRange = diagnosticLineRange(root, diagnostic.filename, label.span)
|
||||
return lineRange !== null && overlapsAddedLines(lineRange.start, lineRange.end, ranges)
|
||||
if (lineRange === null || !overlapsAddedLines(lineRange.start, lineRange.end, ranges)) {
|
||||
return false
|
||||
}
|
||||
return !isMovedCode(diagnosticHighlightedLines(root, diagnostic.filename, label.span), baseBlocks)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -193,10 +283,12 @@ export function main(
|
||||
return 0
|
||||
}
|
||||
|
||||
const baseBlocks = collectBaseLineBlocks(root, comparisonBase)
|
||||
|
||||
let failures = 0
|
||||
for (const scan of OXLINT_SCANS) {
|
||||
const diagnostics = runOxlintScan(root, scan, files).filter((diagnostic) =>
|
||||
diagnosticTouchesAddedLines(diagnostic, rangesByFile, root)
|
||||
diagnosticTouchesAddedLines(diagnostic, rangesByFile, root, baseBlocks)
|
||||
)
|
||||
for (const diagnostic of diagnostics) {
|
||||
printDiagnostic(diagnostic, root)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
OXLINT_SCANS,
|
||||
diagnosticTouchesAddedLines,
|
||||
isMovedCode,
|
||||
overlapsAddedLines,
|
||||
parseAddedLineRanges
|
||||
} from './check-changed-code-quality.mjs'
|
||||
@@ -52,3 +53,54 @@ describe('changed-code quality line matching', () => {
|
||||
expect(scan.args).not.toContain('--disable-nested-config')
|
||||
})
|
||||
})
|
||||
|
||||
describe('moved-code exemption', () => {
|
||||
it('treats a verbatim contiguous block from the base as moved', () => {
|
||||
const base = [['const a = 1', 'items.map((item, index) => (', 'key={index}', '))']]
|
||||
expect(isMovedCode(['items.map((item, index) => (', 'key={index}', '))'], base)).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores indentation and whitespace changes from the move', () => {
|
||||
const base = [[' items.map((item, index) => (', ' key={index}']]
|
||||
expect(isMovedCode(['items.map((item, index) => (', 'key={index}'], base)).toBe(true)
|
||||
})
|
||||
|
||||
it('does not exempt a genuinely new violation', () => {
|
||||
const base = [['const a = 1', 'const b = 2']]
|
||||
expect(isMovedCode(['rows.map((row, i) => <td key={i} />)'], base)).toBe(false)
|
||||
})
|
||||
|
||||
it('does not exempt a block that is only partly present in the base', () => {
|
||||
const base = [['doThing()', 'unrelated()']]
|
||||
expect(isMovedCode(['doThing()', 'newlyAddedSideEffect()'], base)).toBe(false)
|
||||
})
|
||||
|
||||
it('tolerates a few lines appended inside the moved block', () => {
|
||||
// A split commonly grows a hook dependency array when closure variables
|
||||
// become props; the moved body around it is still moved.
|
||||
const body = Array.from({ length: 20 }, (_, i) => `line${i}()`)
|
||||
const base = [body]
|
||||
const moved = [...body.slice(0, 19), 'newDep,', body[19]]
|
||||
expect(isMovedCode(moved, base)).toBe(true)
|
||||
})
|
||||
|
||||
it('does not exempt when the anchor line is absent from the base', () => {
|
||||
const base = [['doThing()', 'filler()', 'other()']]
|
||||
expect(isMovedCode(['brandNewCall()', 'doThing()', 'other()'], base)).toBe(false)
|
||||
})
|
||||
|
||||
it('does not exempt when most of the block is absent from the base', () => {
|
||||
const base = [['keep0()', 'keep1()', 'unrelated()']]
|
||||
const mostlyNew = ['keep0()', ...Array.from({ length: 18 }, (_, i) => `fresh${i}()`)]
|
||||
expect(isMovedCode(mostlyNew, base)).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores blank lines when matching', () => {
|
||||
const base = [['a()', 'b()']]
|
||||
expect(isMovedCode(['a()', '', 'b()'], base)).toBe(true)
|
||||
})
|
||||
|
||||
it('never exempts an empty highlight', () => {
|
||||
expect(isMovedCode(['', ' '], [['a()']])).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -34,7 +34,11 @@ const BASELINE_PATH = path.join(ROOT, 'config', 'runtime-electron-baseline.txt')
|
||||
// itself and the RPC server that fronts it.
|
||||
const ENTRY_POINTS = [
|
||||
path.join(ROOT, 'src', 'main', 'runtime', 'orca-runtime.ts'),
|
||||
path.join(ROOT, 'src', 'main', 'runtime', 'runtime-rpc.ts')
|
||||
path.join(ROOT, 'src', 'main', 'runtime', 'runtime-rpc.ts'),
|
||||
// Why orcad too: it imports ipc/pty directly to install the PTY controller, so its
|
||||
// graph is strictly larger than the two runtime entries. Measuring only those let the
|
||||
// two numbers drift — the gate would read zero while the shipped artifact regressed.
|
||||
path.join(ROOT, 'src', 'main', 'orcad', 'main.ts')
|
||||
]
|
||||
|
||||
// Native addons and electron cannot be bundled; externalising them is what the
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const { assertNodePtyJobOwnership } = require('./node-pty-job-ownership.cjs')
|
||||
const NODE_PTY_PATCH = readFileSync(
|
||||
new URL('../patches/node-pty@1.1.0.patch', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
const PATCHED = {
|
||||
dir: 'build/Release/',
|
||||
module: {
|
||||
listJobProcessIds: () => [],
|
||||
terminateJob: () => true,
|
||||
assignCurrentProcessToJob: () => true
|
||||
}
|
||||
}
|
||||
const PREBUILD = {
|
||||
dir: 'prebuilds/win32-x64/',
|
||||
module: {
|
||||
startProcess: () => {},
|
||||
connect: () => {},
|
||||
resize: () => {},
|
||||
clear: () => {},
|
||||
kill: () => {}
|
||||
}
|
||||
}
|
||||
|
||||
describe('assertNodePtyJobOwnership', () => {
|
||||
it('keeps node-addon-api project paths absolute during Windows source builds', () => {
|
||||
expect(NODE_PTY_PATCH).toContain(
|
||||
`+ "<!(node -p \\"require.resolve('node-addon-api/node_addon_api.gyp')\\"):node_addon_api_except"`
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves the unchanged Windows helper and fallback on their upstream prebuilds', () => {
|
||||
expect(NODE_PTY_PATCH).toContain("- 'target_name': 'conpty_console_list'")
|
||||
expect(NODE_PTY_PATCH).toContain("- 'target_name': 'pty'")
|
||||
})
|
||||
|
||||
it('rejects the prebuild that shipped without the job exports', () => {
|
||||
expect(() =>
|
||||
assertNodePtyJobOwnership({ platform: 'win32', nativeName: 'conpty', native: PREBUILD })
|
||||
).toThrow(/listJobProcessIds, terminateJob, assignCurrentProcessToJob/)
|
||||
})
|
||||
|
||||
it('names where the bad native came from, so the fix is obvious', () => {
|
||||
expect(() =>
|
||||
assertNodePtyJobOwnership({ platform: 'win32', nativeName: 'conpty', native: PREBUILD })
|
||||
).toThrow(/prebuilds\/win32-x64/)
|
||||
})
|
||||
|
||||
it('accepts a source build carrying the patch', () => {
|
||||
expect(() =>
|
||||
assertNodePtyJobOwnership({ platform: 'win32', nativeName: 'conpty', native: PATCHED })
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['non-Windows hosts', { platform: 'darwin', nativeName: 'pty' }],
|
||||
['the pre-ConPTY winpty backend', { platform: 'win32', nativeName: 'pty' }]
|
||||
])('stays out of the way on %s', (_case, spec) => {
|
||||
expect(() => assertNodePtyJobOwnership({ ...spec, native: PREBUILD })).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -7,6 +7,7 @@ import { release } from 'node:os'
|
||||
import { basename, resolve } from 'node:path'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const { assertNodePtyJobOwnership } = require('./node-pty-job-ownership.cjs')
|
||||
const scriptPath = import.meta.filename
|
||||
const projectDir = resolve(import.meta.dirname, '../..')
|
||||
const runtime = readRuntimeArg()
|
||||
@@ -277,6 +278,7 @@ function loadNodePtyNativeModule() {
|
||||
// terminal is created, so require('node-pty') alone can miss ABI mismatches.
|
||||
const native = loadNativeModule(nativeName)
|
||||
assertNodePtyWindowsConptyRuntime(native?.dir)
|
||||
assertNodePtyJobOwnership({ nativeName, native })
|
||||
if (requiresPatchedNodePtySourceBuild() && !isNodePtyReleaseBuildDir(native?.dir)) {
|
||||
throw new Error(
|
||||
`node-pty resolved to ${native.dir}; expected build/Release so Orca's node-pty patch is active`
|
||||
|
||||
@@ -14,6 +14,9 @@ import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const sourceScriptPath = fileURLToPath(new URL('./ensure-native-runtime.mjs', import.meta.url))
|
||||
const sourceNodePtyJobOwnershipPath = fileURLToPath(
|
||||
new URL('./node-pty-job-ownership.cjs', import.meta.url)
|
||||
)
|
||||
|
||||
describe('ensure-native-runtime', () => {
|
||||
it('rechecks Node native modules in fresh child processes after rebuilding', () => {
|
||||
@@ -156,6 +159,10 @@ describe('ensure-native-runtime', () => {
|
||||
function mkTempProject() {
|
||||
const projectDir = mkdtempSync(join(tmpdir(), 'orca-native-runtime-'))
|
||||
mkdirSync(join(projectDir, 'config', 'scripts'), { recursive: true })
|
||||
copyFileSync(
|
||||
sourceNodePtyJobOwnershipPath,
|
||||
join(projectDir, 'config', 'scripts', 'node-pty-job-ownership.cjs')
|
||||
)
|
||||
return projectDir
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
'use strict'
|
||||
|
||||
const NODE_PTY_JOB_EXPORTS = [
|
||||
'listJobProcessIds',
|
||||
'terminateJob',
|
||||
'assignCurrentProcessToJob'
|
||||
]
|
||||
|
||||
function assertNodePtyJobOwnership({ nativeName, native, platform = process.platform }) {
|
||||
if (platform !== 'win32' || nativeName !== 'conpty') {
|
||||
return
|
||||
}
|
||||
const exported = native?.module ?? native
|
||||
const missing = NODE_PTY_JOB_EXPORTS.filter((name) => typeof exported?.[name] !== 'function')
|
||||
if (missing.length === 0) {
|
||||
return
|
||||
}
|
||||
throw new Error(
|
||||
[
|
||||
`node-pty's conpty native is missing ${missing.join(', ')}.`,
|
||||
`Resolved from: ${native?.dir ?? 'unknown'}`,
|
||||
'That build cannot own a PTY tree, so terminatePtyJob degrades to "unavailable"',
|
||||
'and pane teardown falls back to guessing by PID ancestry.',
|
||||
'Rebuild node-pty from source so config/patches/node-pty@1.1.0.patch applies.'
|
||||
].join(' ')
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = { assertNodePtyJobOwnership }
|
||||
@@ -0,0 +1,105 @@
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { createRequire } from 'node:module'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const { prunePackagedNodePty } = require('../packaged-runtime-node-modules.cjs')
|
||||
|
||||
/**
|
||||
* node-pty's loader tries build/Release, then build/Debug, then
|
||||
* prebuilds/<platform>-<arch>, swallowing failures in between. Only the source
|
||||
* build carries Orca's job-object exports, so leaving the prebuilt conpty.node
|
||||
* beside it means an ABI mismatch or an AV quarantine silently downgrades the
|
||||
* app to a binary that cannot own a PTY tree.
|
||||
*
|
||||
* The siblings must survive: Orca's patch deletes the `conpty_console_list` and
|
||||
* winpty `pty` gyp targets, so those binaries exist nowhere but here.
|
||||
*/
|
||||
describe('prunePackagedNodePty: the Windows conpty fallback', () => {
|
||||
let resources
|
||||
const HOST_ARCH = process.arch
|
||||
|
||||
const nodePty = () => join(resources, 'node_modules', 'node-pty')
|
||||
const write = (relative) => {
|
||||
const target = join(nodePty(), relative)
|
||||
mkdirSync(join(target, '..'), { recursive: true })
|
||||
writeFileSync(target, 'x')
|
||||
}
|
||||
const prebuilt = (name, arch = HOST_ARCH) => join(nodePty(), 'prebuilds', `win32-${arch}`, name)
|
||||
|
||||
/** What a real packaged win32 prebuilds/<arch> directory holds. */
|
||||
const SIBLINGS = ['conpty_console_list.node', 'pty.node', 'winpty.dll', 'winpty-agent.exe']
|
||||
|
||||
const seedWindowsTree = (arch = HOST_ARCH) => {
|
||||
write(join('build', 'Release', 'conpty.node'))
|
||||
write(join('third_party', 'conpty', 'v1', `win10-${arch}`, 'conpty.dll'))
|
||||
write(join('third_party', 'conpty', 'v1', `win10-${arch}`, 'OpenConsole.exe'))
|
||||
write(join('prebuilds', `win32-${arch}`, 'conpty.node'))
|
||||
for (const sibling of SIBLINGS) {
|
||||
write(join('prebuilds', `win32-${arch}`, sibling))
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resources = mkdtempSync(join(tmpdir(), 'orca-prune-'))
|
||||
})
|
||||
afterEach(() => {
|
||||
rmSync(resources, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('removes the stale conpty fallback when the source build is present', () => {
|
||||
seedWindowsTree()
|
||||
|
||||
prunePackagedNodePty(resources, 'win32', HOST_ARCH)
|
||||
|
||||
expect(existsSync(join(nodePty(), 'build', 'Release', 'conpty.node'))).toBe(true)
|
||||
expect(existsSync(prebuilt('conpty.node'))).toBe(false)
|
||||
})
|
||||
|
||||
it.each(SIBLINGS)('keeps %s, which exists nowhere else in a packaged build', (sibling) => {
|
||||
// Orca's patch removes the conpty_console_list and winpty gyp targets, so a
|
||||
// Windows source build never produces these. Deleting them kills console
|
||||
// membership silently and breaks PTY spawn below Windows build 18309.
|
||||
seedWindowsTree()
|
||||
|
||||
prunePackagedNodePty(resources, 'win32', HOST_ARCH)
|
||||
|
||||
expect(existsSync(prebuilt(sibling))).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps the fallback when there is no source build to prefer', () => {
|
||||
write(join('prebuilds', `win32-${HOST_ARCH}`, 'conpty.node'))
|
||||
write(join('third_party', 'conpty', 'v1', `win10-${HOST_ARCH}`, 'conpty.dll'))
|
||||
write(join('third_party', 'conpty', 'v1', `win10-${HOST_ARCH}`, 'OpenConsole.exe'))
|
||||
|
||||
prunePackagedNodePty(resources, 'win32', HOST_ARCH)
|
||||
|
||||
expect(existsSync(prebuilt('conpty.node'))).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps the fallback on a cross-arch package, where build/Release is the host arch', () => {
|
||||
// electron-builder --win --arm64 on an x64 host copies an x64
|
||||
// build/Release; deleting the arm64 prebuild would remove the only binary
|
||||
// the shipped app could load.
|
||||
const target = HOST_ARCH === 'arm64' ? 'x64' : 'arm64'
|
||||
seedWindowsTree(target)
|
||||
|
||||
prunePackagedNodePty(resources, 'win32', target)
|
||||
|
||||
expect(existsSync(prebuilt('conpty.node', target))).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['darwin', 'arm64'],
|
||||
['linux', 'x64']
|
||||
])('leaves %s prebuilds alone', (platform, arch) => {
|
||||
write(join('build', 'Release', 'conpty.node'))
|
||||
write(join('prebuilds', `${platform}-${arch}`, 'pty.node'))
|
||||
|
||||
prunePackagedNodePty(resources, platform, arch)
|
||||
|
||||
expect(existsSync(join(nodePty(), 'prebuilds', `${platform}-${arch}`, 'pty.node'))).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,11 +1,17 @@
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { parse as parseJsonc } from 'jsonc-parser'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parse } from 'yaml'
|
||||
import { parse as parseYaml } from 'yaml'
|
||||
import { PR_E2E_SOURCE_ROUTES, selectPrE2eSpecs } from './pr-e2e-source-routing.mjs'
|
||||
|
||||
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 prWorkflow = parseYaml(readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8'))
|
||||
const e2eWorkflow = parseYaml(readFileSync(join(projectDir, '.github/workflows/e2e.yml'), 'utf8'))
|
||||
const reliabilityManifest = parseJsonc(
|
||||
readFileSync(join(projectDir, 'config/reliability-gates.jsonc'), 'utf8')
|
||||
)
|
||||
const playwrightConfig = readFileSync(join(projectDir, 'tests/playwright.config.ts'), 'utf8')
|
||||
const sshDockerRunner = readFileSync(
|
||||
join(projectDir, 'config/scripts/run-ssh-docker-terminal-parking-e2e.mjs'),
|
||||
'utf8'
|
||||
@@ -69,8 +75,15 @@ describe('PR E2E gate contract', () => {
|
||||
|
||||
it('selects modified Playwright specs without running deleted tests', () => {
|
||||
expect(filterStep.run).toContain('--diff-filter=AMCR')
|
||||
expect(filterStep.run).toContain("'^tests/e2e/.*\\.spec\\.ts$'")
|
||||
expect(filterStep.run).toContain('config/scripts/pr-e2e-source-routing.mjs')
|
||||
expect(filterStep.run).not.toContain('tests/playwright\\.')
|
||||
expect(
|
||||
selectPrE2eSpecs([
|
||||
'tests/e2e/active-view-restart-restore.spec.ts',
|
||||
'tests/e2e/deleted.spec.ts.bak',
|
||||
'tests/e2e/global-teardown.unit.test.ts'
|
||||
])
|
||||
).toEqual(['tests/e2e/active-view-restart-restore.spec.ts'])
|
||||
})
|
||||
|
||||
it('uses one runner for changed specs and keeps full runs sharded', () => {
|
||||
@@ -89,6 +102,7 @@ describe('PR E2E gate contract', () => {
|
||||
expect(changedRun.run).toContain(
|
||||
'pnpm run test:e2e "${TEST_FILES[@]}" --workers=1 "${E2E_PROJECT_ARGS[@]}"'
|
||||
)
|
||||
expect(playwrightConfig).toContain('retries: 0')
|
||||
})
|
||||
|
||||
it('keeps startup-exec live parity in the isolated SSH lane', () => {
|
||||
@@ -121,7 +135,7 @@ describe('PR E2E gate contract', () => {
|
||||
]
|
||||
|
||||
for (const file of dedicatedWorkflows) {
|
||||
const workflow = parse(readFileSync(join(projectDir, '.github/workflows', file), 'utf8'))
|
||||
const workflow = parseYaml(readFileSync(join(projectDir, '.github/workflows', file), 'utf8'))
|
||||
expect(workflow.on.pull_request, file).toBeUndefined()
|
||||
}
|
||||
})
|
||||
@@ -138,12 +152,14 @@ describe('PR E2E gate contract', () => {
|
||||
const sshSourceAuthorities = [
|
||||
'src/main/ssh/',
|
||||
'src/main/providers/ssh-',
|
||||
'src/main/ipc/(ssh-|pty)',
|
||||
'src/main/ipc/pty',
|
||||
'src/relay/',
|
||||
'src/renderer/src/components/terminal-pane/(pty-|ssh-|remote-runtime-|terminal-parked-pty)'
|
||||
'src/renderer/src/components/terminal-pane/remote-runtime-'
|
||||
]
|
||||
for (const authority of sshSourceAuthorities) {
|
||||
expect(filterStep.run).toContain(authority)
|
||||
expect(selectPrE2eSpecs([`${authority}routing.ts`])).toContain(
|
||||
'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts'
|
||||
)
|
||||
}
|
||||
|
||||
const mappedSpecs = [
|
||||
@@ -154,7 +170,7 @@ describe('PR E2E gate contract', () => {
|
||||
'tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts'
|
||||
]
|
||||
for (const spec of mappedSpecs) {
|
||||
expect(filterStep.run).toContain(`'${spec}'`)
|
||||
expect(selectPrE2eSpecs(['src/main/ssh/connection.ts'])).toContain(spec)
|
||||
expect(existsSync(join(projectDir, spec)), spec).toBe(true)
|
||||
// Why: a spec that stops reading the flag would silently run without Docker.
|
||||
if (spec !== 'tests/e2e/ssh-startup-exec-readiness.spec.ts') {
|
||||
@@ -162,8 +178,7 @@ describe('PR E2E gate contract', () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Why: unit tests next to the mapped sources prove the same code without Docker.
|
||||
expect(filterStep.run).toContain("grep -Ev '\\.test\\.tsx?$'")
|
||||
expect(selectPrE2eSpecs(['src/main/ssh/connection.test.ts'])).toEqual([])
|
||||
|
||||
// Why: startup readiness is filtered out of changed-e2e, so listing it is only
|
||||
// meaningful while it still routes the dedicated Docker lane.
|
||||
@@ -183,6 +198,99 @@ describe('PR E2E gate contract', () => {
|
||||
expect(rollbackStep.run).toContain('--merge-base "$BASE_SHA" "$HEAD_SHA"')
|
||||
expect(rollbackStep.run).toContain('src/shared/ephemeral-vm-recipes.ts')
|
||||
expect(rollbackStep.run).toContain('src/shared/orca-yaml-hook-types.ts')
|
||||
expect(filterStep.run).toContain('ephemeral-vm-recipes|orca-yaml-hook-types')
|
||||
expect(selectPrE2eSpecs(['src/shared/ephemeral-vm-recipes.ts'])).toEqual([
|
||||
'tests/e2e/ephemeral-vm-provisioned-root.spec.ts'
|
||||
])
|
||||
})
|
||||
|
||||
it('routes P0 sentinels from their causal sources', () => {
|
||||
const cases = [
|
||||
[
|
||||
'src/renderer/src/components/tab-bar/TabBarQuickCommandsMenu.tsx',
|
||||
'tests/e2e/terminal-quick-command-pre-bind-recovery.spec.ts'
|
||||
],
|
||||
['src/main/runtime/orca-runtime-files.ts', 'tests/e2e/paired-quick-open-large-tree.spec.ts'],
|
||||
[
|
||||
'src/renderer/src/runtime/sync-runtime-graph.ts',
|
||||
'tests/e2e/host-parked-pane-remote-viewer.spec.ts'
|
||||
],
|
||||
[
|
||||
'src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts',
|
||||
'tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts'
|
||||
],
|
||||
[
|
||||
'src/renderer/src/components/terminal-pane/remote-pane-layout-push.ts',
|
||||
'tests/e2e/paired-remote-pane-layout-retry.spec.ts'
|
||||
]
|
||||
]
|
||||
for (const [source, spec] of cases) {
|
||||
expect(selectPrE2eSpecs([source]), source).toEqual([spec])
|
||||
expect(selectPrE2eSpecs([source.replace(/\.tsx?$/, '.test.ts')]), source).toEqual([])
|
||||
expect(existsSync(join(projectDir, spec)), spec).toBe(true)
|
||||
}
|
||||
const quickCommandSpec = 'tests/e2e/terminal-quick-command-pre-bind-recovery.spec.ts'
|
||||
for (const source of [
|
||||
'src/renderer/src/components/terminal-pane/pty-connection.ts',
|
||||
'src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty.ts',
|
||||
'src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts',
|
||||
'src/renderer/src/components/terminal-pane/pty-connection/pane-pty-visibility-bind.ts',
|
||||
'src/renderer/src/components/terminal-pane/pty-connection/pty-input-recovery.ts'
|
||||
]) {
|
||||
expect(selectPrE2eSpecs([source]), source).toContain(quickCommandSpec)
|
||||
expect(selectPrE2eSpecs([source.replace(/\.ts$/, '.test.ts')]), source).not.toContain(
|
||||
quickCommandSpec
|
||||
)
|
||||
}
|
||||
for (const source of [
|
||||
'src/main/ipc/rg-availability.ts',
|
||||
'src/shared/ripgrep-process-availability.ts'
|
||||
]) {
|
||||
expect(selectPrE2eSpecs([source]), source).toEqual([
|
||||
'tests/e2e/paired-quick-open-large-tree.spec.ts'
|
||||
])
|
||||
}
|
||||
expect(
|
||||
selectPrE2eSpecs([
|
||||
'src/main/runtime/orca-runtime-files.ts',
|
||||
'tests/e2e/paired-quick-open-large-tree.spec.ts'
|
||||
])
|
||||
).toEqual(['tests/e2e/paired-quick-open-large-tree.spec.ts'])
|
||||
expect(selectPrE2eSpecs(['src/renderer/src/components/FileExplorer.tsx'])).toEqual([])
|
||||
expect(
|
||||
selectPrE2eSpecs([
|
||||
'src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts'
|
||||
])
|
||||
).toContain('tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts')
|
||||
expect(
|
||||
selectPrE2eSpecs([
|
||||
'src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-test-harness.ts'
|
||||
])
|
||||
).not.toContain('tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts')
|
||||
expect(selectPrE2eSpecs(['src/main/ipc/pty.ts'])).not.toContain(
|
||||
'tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps source-routed sentinels registered to their reliability gates', () => {
|
||||
const routedGateIds = [
|
||||
'terminal-startup.quick-command-pre-bind-recovery',
|
||||
'quick-open.paired-host-path-search',
|
||||
'terminal-session.host-cold-park-stream-continuity',
|
||||
'terminal-provider.ssh-remote-reattach-contract',
|
||||
'terminal-session.remote-pane-layout-retry'
|
||||
]
|
||||
for (const gateId of routedGateIds) {
|
||||
const route = PR_E2E_SOURCE_ROUTES.find((candidate) => candidate.id === gateId)
|
||||
const gate = reliabilityManifest.gates.find((candidate) => candidate.id === gateId)
|
||||
expect(route, gateId).toBeDefined()
|
||||
expect(gate, gateId).toMatchObject({ maturity: 'experimental', protection: 'partial' })
|
||||
for (const spec of route.specs) {
|
||||
expect(gate.testFiles, gateId).toContain(spec)
|
||||
expect(
|
||||
gate.commands.some((command) => command.includes(spec)),
|
||||
gateId
|
||||
).toBe(true)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import process from 'node:process'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
const isProductSource = (file) => !/\.test\.tsx?$/.test(file)
|
||||
|
||||
export const PR_E2E_SOURCE_ROUTES = [
|
||||
{
|
||||
id: 'ephemeral-vm-runtime.rollback-readable-sidecar',
|
||||
specs: ['tests/e2e/ephemeral-vm-provisioned-root.spec.ts'],
|
||||
matches: (file) =>
|
||||
/^(?:src\/main\/ephemeral-vm-(?:runtime-(?:service|provisioning-persistence)|failed-start-cleanup)|src\/shared\/(?:ephemeral-vm-runtime-(?:store|feature-store|rollback-projection|runtimes)|ephemeral-vm-recipes|orca-yaml-hook-types))\.ts$/.test(
|
||||
file
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'ssh-terminal-source',
|
||||
specs: [
|
||||
'tests/e2e/pty-input-write-queue-ssh.spec.ts',
|
||||
'tests/e2e/ssh-cold-activation-restore.spec.ts',
|
||||
'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts',
|
||||
'tests/e2e/ssh-startup-exec-readiness.spec.ts',
|
||||
'tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts'
|
||||
],
|
||||
matches: (file) =>
|
||||
isProductSource(file) &&
|
||||
/^(?:src\/main\/ssh\/|src\/main\/providers\/ssh-|src\/main\/ipc\/(?:ssh-|pty)|src\/relay\/|src\/renderer\/src\/components\/terminal-pane\/(?:pty-|ssh-|remote-runtime-|terminal-parked-pty))/.test(
|
||||
file
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'terminal-input.ime-and-synthetic-forwarding',
|
||||
specs: [
|
||||
'tests/e2e/terminal-cjk-ime-committed-text.spec.ts',
|
||||
'tests/e2e/terminal-hangul-wrap-boundary-bytes.spec.ts',
|
||||
'tests/e2e/terminal-ime-exact-byte.spec.ts',
|
||||
'tests/e2e/terminal-korean-composing-chord-order.spec.ts',
|
||||
'tests/e2e/terminal-korean-endofrow-preedit-cell-span.spec.ts',
|
||||
'tests/e2e/terminal-korean-midline-preedit-occlusion.spec.ts',
|
||||
'tests/e2e/terminal-korean-preedit-visibility.spec.ts'
|
||||
],
|
||||
matches: (file) =>
|
||||
isProductSource(file) &&
|
||||
/^(?:config\/patches\/|src\/renderer\/src\/components\/terminal-pane\/(?:terminal-ime-|use-terminal-pane-lifecycle|xterm-bypass-policy|terminal-option-shortcut-policy))/.test(
|
||||
file
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'terminal-startup.quick-command-pre-bind-recovery',
|
||||
specs: ['tests/e2e/terminal-quick-command-pre-bind-recovery.spec.ts'],
|
||||
matches: (file) =>
|
||||
isProductSource(file) &&
|
||||
/^(?:src\/renderer\/src\/components\/tab-bar\/TabBarQuickCommandsMenu\.tsx|src\/renderer\/src\/hooks\/use-terminal-quick-command-hosts\.ts|src\/renderer\/src\/components\/terminal-pane\/(?:pty-connection|pty-transport|terminal-pty-pre-spawn-e2e-barrier)\.ts|src\/renderer\/src\/components\/terminal-pane\/pty-connection\/(?:connect-pane-pty|fresh-spawn-start|pane-pty-visibility-bind|pty-input-recovery)\.ts|src\/renderer\/src\/components\/terminal-pane\/(?:TerminalPane|use-terminal-pane-lifecycle)\.tsx?|src\/renderer\/src\/store\/slices\/terminals\.ts)$/.test(
|
||||
file
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'quick-open.paired-host-path-search',
|
||||
specs: ['tests/e2e/paired-quick-open-large-tree.spec.ts'],
|
||||
matches: (file) =>
|
||||
isProductSource(file) &&
|
||||
/^(?:src\/main\/ipc\/(?:filesystem-(?:list-files|search-file-paths)|rg-availability)\.ts|src\/main\/providers\/(?:filesystem-provider-contract|ssh-filesystem-provider(?:-capabilities)?)\.ts|src\/main\/runtime\/(?:orca-runtime-files|rpc\/methods\/files)\.ts|src\/relay\/(?:fs-handler(?:-install-rg|-list-files|-ripgrep-fallback)?|fs-list-files-fallback-chain)\.ts|src\/renderer\/src\/(?:components\/(?:QuickOpen|quick-open-file-list|quick-open-search)\.tsx?|runtime\/(?:runtime-file-client|runtime-legacy-quick-open-inventory)\.ts)|src\/shared\/(?:quick-open-(?:install-rg|path-search|transport-budget)|ripgrep-process-availability)\.ts)$/.test(
|
||||
file
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'terminal-session.host-cold-park-stream-continuity',
|
||||
specs: ['tests/e2e/host-parked-pane-remote-viewer.spec.ts'],
|
||||
matches: (file) =>
|
||||
isProductSource(file) &&
|
||||
/^(?:src\/renderer\/src\/components\/terminal-pane\/(?:terminal-hidden-view-parking|terminal-tab-park-candidates|terminal-tab-activation-order|terminal-parked-pty-watcher|terminal-parked-tab-watchers|terminal-parked-watcher-registry)\.ts|src\/renderer\/src\/runtime\/sync-runtime-graph\.ts)$/.test(
|
||||
file
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'terminal-provider.ssh-remote-reattach-contract',
|
||||
specs: ['tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts'],
|
||||
matches: (file) =>
|
||||
isProductSource(file) &&
|
||||
!file.endsWith('-test-harness.ts') &&
|
||||
/^(?:src\/renderer\/src\/components\/terminal-pane\/remote-runtime-pty-transport(?:-[a-z0-9-]+)?\.ts|src\/renderer\/src\/runtime\/remote-runtime-terminal-multiplexer\.ts)$/.test(
|
||||
file
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'terminal-session.remote-pane-layout-retry',
|
||||
specs: ['tests/e2e/paired-remote-pane-layout-retry.spec.ts'],
|
||||
matches: (file) =>
|
||||
isProductSource(file) &&
|
||||
/^(?:src\/renderer\/src\/components\/terminal-pane\/(?:remote-pane-layout-push|TerminalPane)\.tsx?|src\/renderer\/src\/lib\/terminal-layout-equality\.ts|src\/renderer\/src\/runtime\/web-session-tabs-sync\.ts|src\/renderer\/src\/store\/slices\/terminals\.ts)$/.test(
|
||||
file
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
export function selectPrE2eSpecs(changedPaths, reportRoute = () => undefined) {
|
||||
const specs = new Set(changedPaths.filter((file) => /^tests\/e2e\/.*\.spec\.ts$/.test(file)))
|
||||
for (const route of PR_E2E_SOURCE_ROUTES) {
|
||||
const matchedFiles = changedPaths.filter(route.matches)
|
||||
if (matchedFiles.length === 0) {
|
||||
continue
|
||||
}
|
||||
route.specs.forEach((spec) => specs.add(spec))
|
||||
reportRoute(`[pr-e2e] ${route.id}: ${route.specs.join(', ')}`)
|
||||
}
|
||||
return [...specs].sort((left, right) => left.localeCompare(right))
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
let input = ''
|
||||
process.stdin.setEncoding('utf8')
|
||||
for await (const chunk of process.stdin) {
|
||||
input += chunk
|
||||
}
|
||||
const changedPaths = input.split(/\r?\n/).filter(Boolean)
|
||||
const specs = selectPrE2eSpecs(changedPaths, (message) => console.error(message))
|
||||
process.stdout.write(`${JSON.stringify(specs)}\n`)
|
||||
}
|
||||
@@ -345,6 +345,7 @@ describe('PR workflow parallelism', () => {
|
||||
'xterm_patch_sync',
|
||||
'shell_contracts',
|
||||
'test',
|
||||
'orcad_browser',
|
||||
'managed_hook_node18',
|
||||
'package',
|
||||
'package_windows'
|
||||
@@ -354,5 +355,9 @@ describe('PR workflow parallelism', () => {
|
||||
)
|
||||
expect(verifyStep.env.MANAGED_HOOK_NODE18).toBe('${{ needs.managed_hook_node18.result }}')
|
||||
expect(verifyStep.run).toContain('"$MANAGED_HOOK_NODE18"')
|
||||
// Why assert this one too: the browser provider test skips itself without
|
||||
// ORCA_BROWSER_EXECUTABLE, so it only guards anything if verify actually reads it.
|
||||
expect(verifyStep.env.ORCAD_BROWSER).toBe('${{ needs.orcad_browser.result }}')
|
||||
expect(verifyStep.run).toContain('"$ORCAD_BROWSER"')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -475,9 +475,14 @@ function loadNativeModule(moduleName) {
|
||||
}
|
||||
if (moduleName === 'node-pty') {
|
||||
projectRequire('node-pty')
|
||||
const { assertNodePtyJobOwnership } = projectRequire(
|
||||
'./config/scripts/node-pty-job-ownership.cjs'
|
||||
)
|
||||
const { loadNativeModule } = projectRequire('node-pty/lib/utils')
|
||||
const native = loadNativeModule(getNodePtyNativeModuleName())
|
||||
const nativeName = getNodePtyNativeModuleName()
|
||||
const native = loadNativeModule(nativeName)
|
||||
assertNodePtyWindowsConptyRuntime(native.dir)
|
||||
assertNodePtyJobOwnership({ nativeName, native })
|
||||
if (requirePatchedNodePtySourceBuild && !isNodePtyReleaseBuildDir(native.dir)) {
|
||||
throw new Error(
|
||||
'node-pty resolved to ' +
|
||||
|
||||
@@ -18,6 +18,9 @@ const sourceScriptPath = fileURLToPath(new URL('./rebuild-native-deps.mjs', impo
|
||||
const sourceInstallScriptPath = fileURLToPath(
|
||||
new URL('./install-electron-package-binary.mjs', import.meta.url)
|
||||
)
|
||||
const sourceNodePtyJobOwnershipPath = fileURLToPath(
|
||||
new URL('./node-pty-job-ownership.cjs', import.meta.url)
|
||||
)
|
||||
|
||||
describe('rebuild-native-deps Electron install fallback', () => {
|
||||
it('continues non-strict postinstall when Electron retry download fails', () => {
|
||||
@@ -135,6 +138,7 @@ describe('rebuild-native-deps patched node-pty rebuild', () => {
|
||||
writeFakeElectronRebuild(projectDir, { logPathEnv: 'ORCA_REBUILD_TEST_LOG' })
|
||||
writeFakeLoadableNodePty(projectDir, { nativeDir: '../build/Release/' })
|
||||
writeFakeWindowsRegistry(projectDir)
|
||||
writeFakeWindowsProcessTree(projectDir)
|
||||
writeFakeNodePtyConptyPayload(projectDir, process.arch)
|
||||
|
||||
const result = runRebuildScript(projectDir, {
|
||||
@@ -189,6 +193,7 @@ describe('rebuild-native-deps patched node-pty rebuild', () => {
|
||||
writeFakeUsableElectronPackage(projectDir, { platform: 'win32' })
|
||||
writeFakeElectronRebuild(projectDir, { logPathEnv: 'ORCA_REBUILD_TEST_LOG' })
|
||||
writeFakeLoadableNodePty(projectDir)
|
||||
writeFakeWindowsProcessTree(projectDir)
|
||||
writeFakeNodePtyConptyPayload(projectDir, process.arch)
|
||||
|
||||
const result = runRebuildScript(projectDir, {
|
||||
@@ -207,6 +212,36 @@ describe('rebuild-native-deps patched node-pty rebuild', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(process.platform !== 'win32')(
|
||||
'rebuilds a loadable ConPTY native that lacks Orca job ownership',
|
||||
() => {
|
||||
const projectDir = mkTempProject()
|
||||
|
||||
try {
|
||||
const rebuildLogPath = join(projectDir, 'electron-rebuild.log')
|
||||
writeFakeUsableElectronPackage(projectDir, { platform: 'win32' })
|
||||
writeFakeElectronRebuild(projectDir, { logPathEnv: 'ORCA_REBUILD_TEST_LOG' })
|
||||
writeFakeLoadableNodePty(projectDir, { ownsPtyJob: false })
|
||||
writeFakeWindowsRegistry(projectDir)
|
||||
writeFakeWindowsProcessTree(projectDir)
|
||||
|
||||
const result = runRebuildScript(projectDir, {
|
||||
ORCA_REBUILD_TEST_LOG: rebuildLogPath,
|
||||
npm_config_platform: 'win32',
|
||||
npm_config_arch: process.arch
|
||||
})
|
||||
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
expect(result.stdout).toContain('Rebuilding failed native modules: node-pty')
|
||||
expect(result.stdout).toContain('missing listJobProcessIds')
|
||||
const rebuildCall = JSON.parse(readFileSync(rebuildLogPath, 'utf8').trim())
|
||||
expect(rebuildCall.onlyModules).toEqual(['node-pty'])
|
||||
} finally {
|
||||
rmSync(projectDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'rebuilds when Electron can load node-pty but patched build artifacts are missing',
|
||||
() => {
|
||||
@@ -305,6 +340,10 @@ function mkTempProject() {
|
||||
sourceInstallScriptPath,
|
||||
join(projectDir, 'config', 'scripts', 'install-electron-package-binary.mjs')
|
||||
)
|
||||
copyFileSync(
|
||||
sourceNodePtyJobOwnershipPath,
|
||||
join(projectDir, 'config', 'scripts', 'node-pty-job-ownership.cjs')
|
||||
)
|
||||
return projectDir
|
||||
}
|
||||
|
||||
@@ -495,7 +534,10 @@ function writeFakeNodePtyConptyPayload(projectDir, arch) {
|
||||
writeFileSync(join(sourceDir, 'OpenConsole.exe'), `OpenConsole.exe ${arch}`)
|
||||
}
|
||||
|
||||
function writeFakeLoadableNodePty(projectDir, { nativeDir = 'prebuilds/pty' } = {}) {
|
||||
function writeFakeLoadableNodePty(
|
||||
projectDir,
|
||||
{ nativeDir = 'prebuilds/pty', ownsPtyJob = true } = {}
|
||||
) {
|
||||
const nodePtyDir = join(projectDir, 'node_modules', 'node-pty')
|
||||
mkdirSync(join(nodePtyDir, 'lib'), { recursive: true })
|
||||
writeFileSync(join(nodePtyDir, 'index.js'), 'module.exports = {}\n')
|
||||
@@ -503,7 +545,19 @@ function writeFakeLoadableNodePty(projectDir, { nativeDir = 'prebuilds/pty' } =
|
||||
join(nodePtyDir, 'lib', 'utils.js'),
|
||||
`
|
||||
exports.loadNativeModule = function loadNativeModule(nativeName) {
|
||||
return { dir: ${JSON.stringify(nativeDir)}, module: { nativeName } }
|
||||
return {
|
||||
dir: ${JSON.stringify(nativeDir)},
|
||||
module: {
|
||||
nativeName,
|
||||
...(${JSON.stringify(ownsPtyJob)}
|
||||
? {
|
||||
listJobProcessIds() {},
|
||||
terminateJob() {},
|
||||
assignCurrentProcessToJob() {}
|
||||
}
|
||||
: {})
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
)
|
||||
@@ -518,6 +572,12 @@ function writeFakeWindowsRegistry(projectDir) {
|
||||
)
|
||||
}
|
||||
|
||||
function writeFakeWindowsProcessTree(projectDir) {
|
||||
const processTreeDir = join(projectDir, 'node_modules', '@vscode', 'windows-process-tree')
|
||||
mkdirSync(processTreeDir, { recursive: true })
|
||||
writeFileSync(join(processTreeDir, 'index.js'), 'module.exports = {}\n')
|
||||
}
|
||||
|
||||
function writeNodePtyPatchFile(projectDir) {
|
||||
mkdirSync(join(projectDir, 'config', 'patches'), { recursive: true })
|
||||
writeFileSync(join(projectDir, 'config', 'patches', 'node-pty@1.1.0.patch'), 'patch marker\n')
|
||||
|
||||
@@ -18,11 +18,14 @@
|
||||
* - a paired client can list worktrees and create a terminal,
|
||||
* - a command run in that terminal produces its output,
|
||||
* - the server exits when asked.
|
||||
* Optional `--browser` assertions:
|
||||
* - create, navigate, evaluate, and screenshot through the selected host provider.
|
||||
*/
|
||||
import { spawn, spawnSync } from 'node:child_process'
|
||||
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import process from 'node:process'
|
||||
|
||||
@@ -274,6 +277,50 @@ async function main() {
|
||||
throw new Error('worktree.create succeeded but worktree.show cannot resolve it')
|
||||
}
|
||||
log(`server resolves ${shown.id}`)
|
||||
if (process.argv.includes('--browser')) {
|
||||
const status = orca(pairingCode, ['status'])
|
||||
if (!status?.runtime?.capabilities?.includes('browser.headless.v1')) {
|
||||
throw new Error(
|
||||
`status omitted browser.headless.v1: ${JSON.stringify(status?.runtime?.capabilities)}`
|
||||
)
|
||||
}
|
||||
const fixturePath = join(userDataDir, 'browser-smoke.html')
|
||||
writeFileSync(
|
||||
fixturePath,
|
||||
'<!doctype html><title>Orcad Browser Smoke</title><main>browser-ready</main>'
|
||||
)
|
||||
const browserPageId = orca(pairingCode, [
|
||||
'tab',
|
||||
'create',
|
||||
'--worktree',
|
||||
created.id,
|
||||
'--url',
|
||||
'about:blank'
|
||||
])?.browserPageId
|
||||
if (!browserPageId) {
|
||||
throw new Error('browser.tabCreate returned no browser page id')
|
||||
}
|
||||
const targetFlags = ['--worktree', created.id, '--page', browserPageId]
|
||||
const targetUrl = pathToFileURL(fixturePath).href
|
||||
const navigation = orca(pairingCode, ['goto', ...targetFlags, '--url', targetUrl])
|
||||
if (navigation?.url !== targetUrl || navigation?.title !== 'Orcad Browser Smoke') {
|
||||
throw new Error(`browser.goto returned the wrong page: ${JSON.stringify(navigation)}`)
|
||||
}
|
||||
const evaluated = orca(pairingCode, [
|
||||
'eval',
|
||||
...targetFlags,
|
||||
'--expression',
|
||||
'document.querySelector("main")?.textContent'
|
||||
])
|
||||
if (evaluated?.result !== 'browser-ready') {
|
||||
throw new Error(`browser.eval returned ${JSON.stringify(evaluated)}`)
|
||||
}
|
||||
const screenshot = orca(pairingCode, ['screenshot', ...targetFlags])
|
||||
if (screenshot?.format !== 'png' || typeof screenshot.data !== 'string' || !screenshot.data) {
|
||||
throw new Error('browser.screenshot returned no PNG data')
|
||||
}
|
||||
log('browser navigate/evaluate/screenshot round trip OK')
|
||||
}
|
||||
|
||||
const terminal = orca(pairingCode, ['terminal', 'create', '--worktree', created.id])?.terminal
|
||||
if (!terminal?.handle) {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
const { createRequire } = require('node:module')
|
||||
const { join } = require('node:path')
|
||||
const { assertNodePtyJobOwnership } = require('./node-pty-job-ownership.cjs')
|
||||
|
||||
function loadPackagedConpty(resourcesDir) {
|
||||
const packagedRequire = createRequire(join(resourcesDir, 'package.json'))
|
||||
const { loadNativeModule } = packagedRequire('./node_modules/node-pty/lib/utils')
|
||||
return loadNativeModule('conpty')
|
||||
}
|
||||
|
||||
function verifyPackagedNodePtyJobOwnership(resourcesDir, options = {}) {
|
||||
const platform = options.platform ?? process.platform
|
||||
if (platform !== 'win32') {
|
||||
return
|
||||
}
|
||||
|
||||
const native = (options.loadNative ?? loadPackagedConpty)(resourcesDir)
|
||||
assertNodePtyJobOwnership({ platform, nativeName: 'conpty', native })
|
||||
if (!native.dir.replace(/\\/g, '/').includes('build/Release/')) {
|
||||
throw new Error(`Packaged node-pty resolved to ${native.dir}; expected patched build/Release`)
|
||||
}
|
||||
console.log('[verify-packaged-node-pty] OK — packaged ConPTY owns process trees')
|
||||
}
|
||||
|
||||
module.exports = { verifyPackagedNodePtyJobOwnership }
|
||||
@@ -0,0 +1,51 @@
|
||||
import { createRequire } from 'node:module'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const {
|
||||
verifyPackagedNodePtyJobOwnership
|
||||
} = require('./verify-packaged-node-pty-job-ownership.cjs')
|
||||
|
||||
const PATCHED = {
|
||||
dir: '../build/Release/',
|
||||
module: {
|
||||
listJobProcessIds: () => [],
|
||||
terminateJob: () => true,
|
||||
assignCurrentProcessToJob: () => true
|
||||
}
|
||||
}
|
||||
|
||||
describe('verifyPackagedNodePtyJobOwnership', () => {
|
||||
it('accepts the packaged patched ConPTY binding', () => {
|
||||
expect(() =>
|
||||
verifyPackagedNodePtyJobOwnership('resources', {
|
||||
platform: 'win32',
|
||||
loadNative: () => PATCHED
|
||||
})
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects a packaged upstream prebuild', () => {
|
||||
expect(() =>
|
||||
verifyPackagedNodePtyJobOwnership('resources', {
|
||||
platform: 'win32',
|
||||
loadNative: () => ({ dir: '../prebuilds/win32-x64/', module: {} })
|
||||
})
|
||||
).toThrow(/missing listJobProcessIds, terminateJob, assignCurrentProcessToJob/)
|
||||
})
|
||||
|
||||
it('requires the patched source-build directory', () => {
|
||||
expect(() =>
|
||||
verifyPackagedNodePtyJobOwnership('resources', {
|
||||
platform: 'win32',
|
||||
loadNative: () => ({ ...PATCHED, dir: '../prebuilds/win32-x64/' })
|
||||
})
|
||||
).toThrow(/expected patched build\/Release/)
|
||||
})
|
||||
|
||||
it('does not load Windows natives for other targets', () => {
|
||||
const loadNative = vi.fn()
|
||||
verifyPackagedNodePtyJobOwnership('resources', { platform: 'linux', loadNative })
|
||||
expect(loadNative).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parse } from 'yaml'
|
||||
|
||||
const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8'))
|
||||
const packageJson = JSON.parse(readFileSync('package.json', 'utf8'))
|
||||
|
||||
describe('packaged Windows PTY native capability routing', () => {
|
||||
it('runs the packaged executable immediately after the unpacked app is built', () => {
|
||||
const job = workflow.jobs.package_windows
|
||||
const packageIndex = job.steps.findIndex((step) => step.name === 'Package unpacked app')
|
||||
const smokeIndex = job.steps.findIndex(
|
||||
(step) => step.name === 'Smoke packaged Windows PTY native capability'
|
||||
)
|
||||
const smoke = job.steps[smokeIndex]
|
||||
|
||||
expect(job['runs-on']).toBe('windows-2022')
|
||||
expect(smokeIndex).toBe(packageIndex + 1)
|
||||
expect(smoke.run).toBe(
|
||||
'pnpm run smoke:windows-pty-native-capability -- --exe=dist/win-unpacked/Orca.exe'
|
||||
)
|
||||
expect(smoke.if).toBeUndefined()
|
||||
expect(smoke['continue-on-error']).toBeUndefined()
|
||||
expect(packageJson.scripts['smoke:windows-pty-native-capability']).toBe(
|
||||
'node tests/tools/windows-pty-native-capability-smoke/run.mjs'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps patched source rebuild, release build, runtime reuse, and aggregate routing intact', () => {
|
||||
const job = workflow.jobs.package_windows
|
||||
const sourceRebuild = job.steps.find(
|
||||
(step) => step.name === 'Rebuild node-pty from patched source'
|
||||
)
|
||||
const build = job.steps.find((step) => step.name === 'Build package inputs')
|
||||
const prepare = job.steps.find((step) => step.name === 'Prepare Electron native runtime')
|
||||
const packageStep = job.steps.find((step) => step.name === 'Package unpacked app')
|
||||
const verify = workflow.jobs.verify.steps.find(
|
||||
(step) => step.name === 'Require successful checks'
|
||||
)
|
||||
|
||||
expect(sourceRebuild.env.npm_config_build_from_source).toBe('true')
|
||||
expect(sourceRebuild.run).toBe('pnpm rebuild node-pty')
|
||||
expect(build.run).toBe('pnpm run build:release:parallel')
|
||||
expect(prepare.run).toBe('node config/scripts/ensure-native-runtime.mjs --runtime=electron')
|
||||
expect(packageStep.env.ORCA_REUSE_PREPARED_NATIVE_RUNTIME).toBe('1')
|
||||
expect(workflow.jobs.verify.needs).toContain('package_windows')
|
||||
expect(verify.env.PACKAGE_WINDOWS).toBe('${{ needs.package_windows.result }}')
|
||||
expect(verify.run).toContain('"$PACKAGE_WINDOWS"')
|
||||
})
|
||||
|
||||
it('keeps the native probe event-based, scoped, and runnable in packaged Node mode', () => {
|
||||
const driver = readFileSync('tests/tools/windows-pty-native-capability-smoke/run.mjs', 'utf8')
|
||||
const probe = readFileSync(
|
||||
'tests/tools/windows-pty-native-capability-smoke/packaged-node-pty-capability-probe.cjs',
|
||||
'utf8'
|
||||
)
|
||||
const source = `${driver}\n${probe}`
|
||||
|
||||
expect(driver).toContain("ELECTRON_RUN_AS_NODE: '1'")
|
||||
expect(source).not.toMatch(/\b(?:sleep|tasklist|taskkill)\b/i)
|
||||
expect(source).not.toContain('maxRetries')
|
||||
expect(source).not.toContain('retryDelay')
|
||||
expect(source).not.toContain("from 'node:child_process'")
|
||||
expect(source).not.toContain("require('node:child_process')")
|
||||
expect(probe).toContain("'System32', 'wscript.exe'")
|
||||
expect(probe).toContain('real-orca-detached-launcher.vbs')
|
||||
expect(probe).not.toMatch(/cmd\.exe|start "" \/b/i)
|
||||
expect(probe).toContain('native.terminateJob(target._pty, target.pid)')
|
||||
})
|
||||
})
|
||||
@@ -46,6 +46,7 @@
|
||||
"../src/main/codex/codex-managed-trust-reconciliation.ts",
|
||||
"../src/main/codex/codex-process-exit-deadline.ts",
|
||||
"../src/main/codex/codex-state-db.ts",
|
||||
"../src/main/codex/codex-trust-identity.ts",
|
||||
"../src/main/codex/codex-trust-config-rollback.ts",
|
||||
"../src/main/codex/codex-trust-grant-telemetry.ts",
|
||||
"../src/main/codex/codex-trust-grant-host.ts",
|
||||
@@ -58,10 +59,16 @@
|
||||
"../src/main/codex/config-settings-promotion.ts",
|
||||
"../src/main/codex/config-settings-promotion-write-target.ts",
|
||||
"../src/main/codex/config-sync-stall.ts",
|
||||
"../src/main/codex/config-toml-atomic-write.ts",
|
||||
"../src/main/codex/config-toml-deprecated-hook-flag.ts",
|
||||
"../src/main/codex/config-toml-hook-trust-blocks.ts",
|
||||
"../src/main/codex/config-toml-hook-trust-edit.ts",
|
||||
"../src/main/codex/config-toml-hook-trust-read.ts",
|
||||
"../src/main/codex/config-toml-key-path.ts",
|
||||
"../src/main/codex/config-toml-line-scan.ts",
|
||||
"../src/main/codex/config-toml-project-trust.ts",
|
||||
"../src/main/codex/config-toml-runtime-owned-sections.ts",
|
||||
"../src/main/codex/config-toml-syntax.ts",
|
||||
"../src/main/codex/config-toml-trust.ts",
|
||||
"../src/main/codex/hook-service.ts",
|
||||
"../src/main/codex/hook-trust-promotion.ts",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 27m">
|
||||
<title>downloads: 27m</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 28m">
|
||||
<title>downloads: 28m</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">27m</text>
|
||||
<text x="90" y="14">27m</text>
|
||||
<text x="90" y="15" fill="#010101" fill-opacity=".3">28m</text>
|
||||
<text x="90" y="14">28m</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 935 B After Width: | Height: | Size: 935 B |
@@ -33,13 +33,14 @@ authority.
|
||||
|
||||
## Current Capabilities
|
||||
|
||||
| Capability | Preferred behavior | Compatibility behavior |
|
||||
| ----------------------- | ------------------------------------------------- | --------------------------------------------------------------------------------------- |
|
||||
| `worktree-list-z` | NUL-delimited worktree paths with `prunable` marks | Line-block parser for Git before `worktree list -z` (2.36); the `prunable`/`locked` annotations still parse on Git 2.31–2.35, and a path-existence probe restores `prunable` detection for Git before 2.31 |
|
||||
| `rev-parse-path-format` | Absolute repo metadata paths | Resolve legacy relative output against the scanned repo |
|
||||
| `for-each-ref-exclude` | Exclude remote HEAD before the output limit | Request extra refs, then filter remote HEAD in Orca |
|
||||
| `merge-tree-write-tree` | Derive real-merge conflicts and no-op tree proofs | Omit the conflict summary and keep conservative branch cleanup behavior before Git 2.38 |
|
||||
| `merge-tree-merge-base` | Supply the already-resolved merge base | Use the older two-commit `merge-tree --write-tree` form |
|
||||
| Capability | Preferred behavior | Compatibility behavior |
|
||||
| --------------------------- | ----------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `fetch-no-write-fetch-head` | Fetch a private rebase ref without changing worktree-local `FETCH_HEAD` | Serialize all Orca fetch/pull operations per worktree Git directory before Git 2.29 |
|
||||
| `worktree-list-z` | NUL-delimited worktree paths with `prunable` marks | Line-block parser for Git before `worktree list -z` (2.36); the `prunable`/`locked` annotations still parse on Git 2.31–2.35, and a path-existence probe restores `prunable` detection for Git before 2.31 |
|
||||
| `rev-parse-path-format` | Absolute repo metadata paths | Resolve legacy relative output against the scanned repo |
|
||||
| `for-each-ref-exclude` | Exclude remote HEAD before the output limit | Request extra refs, then filter remote HEAD in Orca |
|
||||
| `merge-tree-write-tree` | Derive real-merge conflicts and no-op tree proofs | Omit the conflict summary and keep conservative branch cleanup behavior before Git 2.38 |
|
||||
| `merge-tree-merge-base` | Supply the already-resolved merge base | Use the older two-commit `merge-tree --write-tree` form |
|
||||
|
||||
## Why Not `simple-git`
|
||||
|
||||
@@ -54,7 +55,7 @@ capability problem.
|
||||
## CI Contract
|
||||
|
||||
PR checks run the capability contract against real Git 2.25.5, 2.38.1, and
|
||||
2.49.1 binaries. This spans the core-workflow baseline, the transitional
|
||||
2.49.1 binaries. This spans the pre-2.29 serialized `FETCH_HEAD` fallback, the transitional
|
||||
`merge-tree --write-tree` behavior before `--merge-base`, and current Git.
|
||||
|
||||
Keep the unit tests alongside that matrix. They cover concurrent probes,
|
||||
|
||||
+2
-1361
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,11 @@ import {
|
||||
type HostStackNavigationState
|
||||
} from '../navigation/host-stack-navigation'
|
||||
|
||||
const homeSource = readFileSync(new URL('../../app/index.tsx', import.meta.url), 'utf8')
|
||||
const homeSource = readFileSync(new URL('../home/MobileHomeScreen.tsx', import.meta.url), 'utf8')
|
||||
const accountCardsSource = readFileSync(
|
||||
new URL('../home/MobileHomeAccountUsageCards.tsx', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
function navigationHarness(initialState: HostStackNavigationState) {
|
||||
const stateListeners = new Set<() => void>()
|
||||
@@ -83,14 +87,8 @@ describe('mobile accounts route', () => {
|
||||
})
|
||||
|
||||
it('opens the home account-usage card through the cold-navigator-safe transition', () => {
|
||||
const start = homeSource.indexOf('{/* ─── Account usage ─── */}')
|
||||
|
||||
// Assert the marker first: a renamed banner would otherwise slice garbage and report a
|
||||
// missing call instead of the real cause.
|
||||
expect(start).toBeGreaterThanOrEqual(0)
|
||||
|
||||
const accountsSection = homeSource.slice(start)
|
||||
expect(accountsSection).toContain('openMobileAccounts(host.id)')
|
||||
expect(accountsSection).not.toContain('/accounts`')
|
||||
expect(homeSource).toContain('onOpenAccounts={openMobileAccounts}')
|
||||
expect(accountCardsSource).toContain('props.onOpen(host.id)')
|
||||
expect(accountCardsSource).not.toContain('/accounts`')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { Radio } from 'lucide-react-native'
|
||||
import { Animated, Easing, StyleSheet, View } from 'react-native'
|
||||
import type { AgentWorkingMode } from '../../../src/shared/agent-status-types'
|
||||
|
||||
type WorktreeStatus = 'working' | 'active' | 'permission' | 'done' | 'inactive'
|
||||
|
||||
@@ -17,11 +19,18 @@ const STATUS_COLORS: Record<WorktreeStatus, string> = {
|
||||
inactive: 'rgba(115,115,115,0.4)'
|
||||
}
|
||||
|
||||
export function AgentSpinner({ status }: { status: WorktreeStatus }) {
|
||||
export function AgentSpinner({
|
||||
status,
|
||||
workingMode
|
||||
}: {
|
||||
status: WorktreeStatus
|
||||
workingMode?: AgentWorkingMode
|
||||
}) {
|
||||
const spinValue = useRef(new Animated.Value(0)).current
|
||||
const monitoring = status === 'working' && workingMode === 'monitoring'
|
||||
|
||||
useEffect(() => {
|
||||
if (status === 'working') {
|
||||
if (status === 'working' && !monitoring) {
|
||||
const animation = Animated.loop(
|
||||
Animated.timing(spinValue, {
|
||||
toValue: 1,
|
||||
@@ -34,10 +43,18 @@ export function AgentSpinner({ status }: { status: WorktreeStatus }) {
|
||||
return () => animation.stop()
|
||||
}
|
||||
spinValue.setValue(0)
|
||||
}, [status, spinValue])
|
||||
}, [monitoring, status, spinValue])
|
||||
|
||||
const color = STATUS_COLORS[status] ?? STATUS_COLORS.inactive
|
||||
|
||||
if (monitoring) {
|
||||
return (
|
||||
<View style={styles.wrapper} accessibilityLabel="Monitoring background tasks">
|
||||
<Radio size={12} color={STATUS_COLORS.working} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
if (status === 'working') {
|
||||
const rotate = spinValue.interpolate({
|
||||
inputRange: [0, 1],
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { Radio } from 'lucide-react-native'
|
||||
import { Animated, Easing, StyleSheet, View } from 'react-native'
|
||||
import type { AgentDotState } from '../worktree/agent-row-display'
|
||||
|
||||
@@ -7,13 +8,14 @@ import type { AgentDotState } from '../worktree/agent-row-display'
|
||||
// emerald for 'done', red for blocked/waiting/interrupted (attention), neutral
|
||||
// for idle. Distinct from the worktree-level AgentSpinner, which collapses the
|
||||
// agent vocabulary into the 5-state rollup the sidebar dot uses.
|
||||
const DOT_COLORS: Record<Exclude<AgentDotState, 'working'>, string> = {
|
||||
const DOT_COLORS: Record<Exclude<AgentDotState, 'working' | 'monitoring'>, string> = {
|
||||
done: '#10b981',
|
||||
blocked: '#ef4444',
|
||||
waiting: '#ef4444',
|
||||
interrupted: '#ef4444',
|
||||
idle: 'rgba(115,115,115,0.4)'
|
||||
}
|
||||
const WORKING_COLOR = '#eab308'
|
||||
|
||||
export function AgentStateDot({ state }: { state: AgentDotState }) {
|
||||
const spinValue = useRef(new Animated.Value(0)).current
|
||||
@@ -44,6 +46,14 @@ export function AgentStateDot({ state }: { state: AgentDotState }) {
|
||||
)
|
||||
}
|
||||
|
||||
if (state === 'monitoring') {
|
||||
return (
|
||||
<View style={styles.wrapper} accessibilityLabel="Monitoring background tasks">
|
||||
<Radio size={10} color={WORKING_COLOR} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.wrapper}>
|
||||
<View style={[styles.dot, { backgroundColor: DOT_COLORS[state] }]} />
|
||||
@@ -59,7 +69,7 @@ const styles = StyleSheet.create({
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
borderWidth: 1.5,
|
||||
borderColor: '#eab308',
|
||||
borderColor: WORKING_COLOR,
|
||||
borderTopColor: 'transparent'
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Pressable, Switch, Text, View } from 'react-native'
|
||||
import type { WorkspaceCreateSetupDecision } from '../tasks/workspace-create-params'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import { newWorktreeFormStyles as styles } from './new-worktree-form-styles'
|
||||
import type { SetupRunPolicy } from './new-worktree-modal-types'
|
||||
|
||||
export function NewWorkspaceSetupScriptField({
|
||||
command,
|
||||
source,
|
||||
runPolicy,
|
||||
decision,
|
||||
runSetup,
|
||||
onDecisionChange,
|
||||
onRunSetupChange
|
||||
}: {
|
||||
command: string
|
||||
source: string | null
|
||||
runPolicy: SetupRunPolicy
|
||||
decision: Exclude<WorkspaceCreateSetupDecision, 'inherit'> | null
|
||||
runSetup: boolean
|
||||
onDecisionChange: (decision: Exclude<WorkspaceCreateSetupDecision, 'inherit'>) => void
|
||||
onRunSetupChange: (run: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.field}>
|
||||
<View style={styles.setupHeader}>
|
||||
<Text style={styles.label}>Setup script</Text>
|
||||
{source ? (
|
||||
<View style={styles.sourceBadge}>
|
||||
<Text style={styles.sourceBadgeText}>
|
||||
{source === 'orca.yaml' ? 'ORCA.YAML' : 'HOOKS'}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
<View style={styles.setupBox}>
|
||||
{runPolicy === 'ask' ? (
|
||||
<View style={styles.setupChoiceRow}>
|
||||
<Pressable
|
||||
style={[
|
||||
styles.setupChoiceButton,
|
||||
decision === 'run' && styles.setupChoiceButtonSelected
|
||||
]}
|
||||
onPress={() => onDecisionChange('run')}
|
||||
>
|
||||
<Text style={styles.setupChoiceText}>Run</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={[
|
||||
styles.setupChoiceButton,
|
||||
decision === 'skip' && styles.setupChoiceButtonSelected
|
||||
]}
|
||||
onPress={() => onDecisionChange('skip')}
|
||||
>
|
||||
<Text style={styles.setupChoiceText}>Skip</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.setupToggleRow}>
|
||||
<Text style={styles.setupToggleLabel}>Run setup command</Text>
|
||||
<Switch
|
||||
value={runSetup}
|
||||
onValueChange={onRunSetupChange}
|
||||
trackColor={{ false: colors.borderSubtle, true: colors.textSecondary }}
|
||||
thumbColor={colors.textPrimary}
|
||||
style={styles.setupSwitch}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.setupCommandBlock}>
|
||||
<Text style={styles.setupCommand}>{command}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Pressable, Text, View } from 'react-native'
|
||||
import type { WorkspaceSshGate } from '../tasks/workspace-ssh-gate'
|
||||
import { workspaceSshStatusLabel } from '../tasks/workspace-ssh-gate'
|
||||
import { newWorktreeFormStyles as styles } from './new-worktree-form-styles'
|
||||
|
||||
export function NewWorkspaceSshConnectionField({
|
||||
repoName,
|
||||
sshGate,
|
||||
onConnect
|
||||
}: {
|
||||
repoName: string
|
||||
sshGate: WorkspaceSshGate
|
||||
onConnect: () => void
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>SSH Connection</Text>
|
||||
<View style={styles.sshBox}>
|
||||
<View style={styles.sshRow}>
|
||||
<View
|
||||
style={[
|
||||
styles.sshDot,
|
||||
sshGate.status === 'connected'
|
||||
? styles.sshDotConnected
|
||||
: sshGate.connectInProgress
|
||||
? styles.sshDotProgress
|
||||
: styles.sshDotDisconnected
|
||||
]}
|
||||
/>
|
||||
<View style={styles.sshCopy}>
|
||||
<Text style={styles.sshTitle} numberOfLines={1}>
|
||||
{repoName}
|
||||
</Text>
|
||||
<Text style={styles.sshSubtitle}>{workspaceSshStatusLabel(sshGate.status)}</Text>
|
||||
</View>
|
||||
{sshGate.status === 'connected' ? null : (
|
||||
<Pressable
|
||||
style={[styles.sshConnectButton, sshGate.connectInProgress && styles.disabled]}
|
||||
disabled={sshGate.connectInProgress}
|
||||
onPress={onConnect}
|
||||
>
|
||||
<Text style={styles.sshConnectText}>
|
||||
{sshGate.connectInProgress ? 'Connecting...' : 'Connect'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
{sshGate.error ? <Text style={styles.errorInline}>{sshGate.error}</Text> : null}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { ActivityIndicator, Pressable, Text, TextInput, View } from 'react-native'
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react-native'
|
||||
import type { WorkspaceCreateSetupDecision } from '../tasks/workspace-create-params'
|
||||
import type { WorkspaceSshGate } from '../tasks/workspace-ssh-gate'
|
||||
import type { useMobileComposerSource } from '../tasks/use-mobile-composer-source'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import { BottomDrawer } from './BottomDrawer'
|
||||
import { MobileAgentIcon } from './MobileAgentIcon'
|
||||
import type { NewWorktreeAgentOption } from './new-worktree-agent-selection'
|
||||
import { newWorktreeFormStyles as styles } from './new-worktree-form-styles'
|
||||
import type { SetupRunPolicy } from './new-worktree-modal-types'
|
||||
import { NewWorktreeProjectTargetFields } from './NewWorktreeProjectTargetFields'
|
||||
import { NewWorkspaceSetupScriptField } from './NewWorkspaceSetupScriptField'
|
||||
import { NewWorkspaceSshConnectionField } from './NewWorkspaceSshConnectionField'
|
||||
import { SmartWorkspaceAdvancedFields } from './SmartWorkspaceAdvancedFields'
|
||||
import { SmartWorkspaceSourceField } from './SmartWorkspaceSourceField'
|
||||
|
||||
type Composer = ReturnType<typeof useMobileComposerSource>
|
||||
type Selection = { label: string; detail?: string }
|
||||
|
||||
export function NewWorktreeFormSheet(props: {
|
||||
visible: boolean
|
||||
interactive: boolean
|
||||
loading: boolean
|
||||
hasRepos: boolean
|
||||
project: Selection | null
|
||||
runTarget: Selection | null
|
||||
projectBadgeColor: string | null
|
||||
selectedRepoIsGit: boolean
|
||||
selectedRepoConnectionId: string | null
|
||||
selectedRepoName: string
|
||||
sshGate: WorkspaceSshGate
|
||||
composer: Composer
|
||||
selectedAgent: NewWorktreeAgentOption
|
||||
showAdvanced: boolean
|
||||
note: string
|
||||
setupCommand: string | null
|
||||
setupSource: string | null
|
||||
setupRunPolicy: SetupRunPolicy
|
||||
setupDecisionChoice: Exclude<WorkspaceCreateSetupDecision, 'inherit'> | null
|
||||
runSetup: boolean
|
||||
error: string
|
||||
creating: boolean
|
||||
canCreate: boolean
|
||||
onClose: () => void
|
||||
onOpenProject: () => void
|
||||
onOpenRunTarget: () => void
|
||||
onOpenSource: () => void
|
||||
onClearError: () => void
|
||||
onConnect: () => void
|
||||
onOpenAgent: () => void
|
||||
onShowAdvancedChange: (show: boolean) => void
|
||||
onNoteChange: (note: string) => void
|
||||
onSetupDecisionChange: (decision: Exclude<WorkspaceCreateSetupDecision, 'inherit'>) => void
|
||||
onRunSetupChange: (run: boolean) => void
|
||||
onCreate: () => void
|
||||
}) {
|
||||
return (
|
||||
<BottomDrawer visible={props.visible} interactive={props.interactive} onClose={props.onClose}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>Create worktree</Text>
|
||||
</View>
|
||||
|
||||
{props.loading ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="small" color={colors.textSecondary} />
|
||||
</View>
|
||||
) : !props.hasRepos ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<Text style={styles.emptyText}>No projects found</Text>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<NewWorktreeProjectTargetFields
|
||||
project={props.project}
|
||||
runTarget={props.runTarget}
|
||||
projectBadgeColor={props.projectBadgeColor}
|
||||
onOpenProject={props.onOpenProject}
|
||||
onOpenRunTarget={props.onOpenRunTarget}
|
||||
/>
|
||||
|
||||
<SmartWorkspaceSourceField
|
||||
composer={props.composer}
|
||||
label={props.selectedRepoIsGit ? "Name or 'Create From'" : 'Workspace name'}
|
||||
disabled={props.sshGate.requiresConnection}
|
||||
interactive={props.interactive}
|
||||
onBeforeOpen={props.onClearError}
|
||||
onOpenDrawer={props.onOpenSource}
|
||||
/>
|
||||
|
||||
{props.composer.forkPushWarning ? (
|
||||
<Text style={styles.sourceWarning}>{props.composer.forkPushWarning}</Text>
|
||||
) : null}
|
||||
|
||||
{props.selectedRepoConnectionId ? (
|
||||
<NewWorkspaceSshConnectionField
|
||||
repoName={props.selectedRepoName}
|
||||
sshGate={props.sshGate}
|
||||
onConnect={props.onConnect}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Agent</Text>
|
||||
<Pressable
|
||||
style={[styles.fieldButton, props.sshGate.requiresConnection && styles.disabled]}
|
||||
disabled={props.sshGate.requiresConnection}
|
||||
onPress={props.onOpenAgent}
|
||||
>
|
||||
<MobileAgentIcon agentId={props.selectedAgent.id} size={16} />
|
||||
<Text style={styles.fieldButtonText} numberOfLines={1}>
|
||||
{props.sshGate.requiresConnection
|
||||
? 'Connect target first'
|
||||
: props.selectedAgent.label}
|
||||
</Text>
|
||||
<ChevronDown size={14} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
style={styles.advancedToggle}
|
||||
onPress={() => props.onShowAdvancedChange(!props.showAdvanced)}
|
||||
>
|
||||
<Text style={styles.advancedText}>Advanced</Text>
|
||||
{props.showAdvanced ? (
|
||||
<ChevronUp size={14} color={colors.textSecondary} />
|
||||
) : (
|
||||
<ChevronDown size={14} color={colors.textSecondary} />
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
{props.showAdvanced ? (
|
||||
<>
|
||||
<SmartWorkspaceAdvancedFields
|
||||
composer={props.composer}
|
||||
selectedRepoIsGit={props.selectedRepoIsGit}
|
||||
/>
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Note</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={props.note}
|
||||
onChangeText={props.onNoteChange}
|
||||
placeholder="Write a note"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
</View>
|
||||
{props.setupCommand ? (
|
||||
<NewWorkspaceSetupScriptField
|
||||
command={props.setupCommand}
|
||||
source={props.setupSource}
|
||||
runPolicy={props.setupRunPolicy}
|
||||
decision={props.setupDecisionChoice}
|
||||
runSetup={props.runSetup}
|
||||
onDecisionChange={props.onSetupDecisionChange}
|
||||
onRunSetupChange={props.onRunSetupChange}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{props.error ? <Text style={styles.error}>{props.error}</Text> : null}
|
||||
<View style={styles.actions}>
|
||||
<Pressable
|
||||
style={[styles.createButton, !props.canCreate && styles.createButtonDisabled]}
|
||||
disabled={!props.canCreate}
|
||||
onPress={props.onCreate}
|
||||
>
|
||||
{props.creating ? (
|
||||
<ActivityIndicator size="small" color={colors.bgBase} />
|
||||
) : (
|
||||
<Text style={styles.createText}>
|
||||
{props.sshGate.requiresConnection ? 'Connect target' : 'Create worktree'}
|
||||
</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</BottomDrawer>
|
||||
)
|
||||
}
|
||||
@@ -68,6 +68,21 @@ function pickerItems(
|
||||
return picker?.props.items ?? []
|
||||
}
|
||||
|
||||
function sourceInputs(renderer: ReactTestRenderer) {
|
||||
return renderer.root.findAll(
|
||||
(node) =>
|
||||
node.type === 'TextInput' && node.props.placeholder === 'Type a name or search a source'
|
||||
)
|
||||
}
|
||||
|
||||
async function flushUpdates(): Promise<void> {
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
}
|
||||
|
||||
describe('NewWorktreeModal project targets', () => {
|
||||
let renderer: ReactTestRenderer
|
||||
|
||||
@@ -164,4 +179,224 @@ describe('NewWorktreeModal project targets', () => {
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('loads SSH state before detecting agents on the selected remote target', async () => {
|
||||
const remoteRepo = {
|
||||
id: 'repo-remote',
|
||||
displayName: 'orca',
|
||||
path: '/home/dev/orca',
|
||||
connectionId: 'build-server',
|
||||
kind: 'git',
|
||||
upstream: { owner: 'stablyai', repo: 'orca' }
|
||||
}
|
||||
setCachedRepos('host-ssh', [remoteRepo])
|
||||
const sendRequest = vi.fn().mockImplementation((method: string) => {
|
||||
if (method === 'repo.list') {
|
||||
return Promise.resolve({ ok: true, result: { repos: [remoteRepo] } })
|
||||
}
|
||||
if (method === 'ssh.getState') {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
result: {
|
||||
state: {
|
||||
targetId: 'build-server',
|
||||
status: 'connected',
|
||||
error: null,
|
||||
reconnectAttempt: 0
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
if (method === 'preflight.detectRemoteAgents') {
|
||||
return Promise.resolve({ ok: true, result: ['codex'] })
|
||||
}
|
||||
if (method === 'settings.get') {
|
||||
return Promise.resolve({ ok: true, result: { settings: { defaultTuiAgent: 'codex' } } })
|
||||
}
|
||||
if (method === 'ui.get') {
|
||||
return Promise.resolve({ ok: true, result: { ui: {} } })
|
||||
}
|
||||
if (method === 'preflight.check') {
|
||||
return Promise.resolve({ ok: true, result: {} })
|
||||
}
|
||||
if (method === 'linear.status') {
|
||||
return Promise.resolve({ ok: true, result: {} })
|
||||
}
|
||||
if (method === 'status.get') {
|
||||
return Promise.resolve({ ok: true, result: { hostPlatform: 'linux' } })
|
||||
}
|
||||
if (method === 'repo.hooks') {
|
||||
return Promise.resolve({ ok: true, result: { hooks: null, source: null } })
|
||||
}
|
||||
return Promise.resolve({ ok: true, result: {} })
|
||||
})
|
||||
const client = { sendRequest } as unknown as RpcClient
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(NewWorktreeModal, {
|
||||
visible: true,
|
||||
client,
|
||||
hostId: 'host-ssh',
|
||||
onCreated: () => {},
|
||||
onClose: () => {}
|
||||
})
|
||||
)
|
||||
})
|
||||
await flushUpdates()
|
||||
|
||||
expect(sendRequest).toHaveBeenCalledWith('ssh.getState', { targetId: 'build-server' })
|
||||
expect(sendRequest).toHaveBeenCalledWith('preflight.detectRemoteAgents', {
|
||||
connectionId: 'build-server'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps folder workspaces on the local execution and text-only source path', async () => {
|
||||
const folderRepo = {
|
||||
id: 'folder-1',
|
||||
displayName: 'notes',
|
||||
path: '/src/notes',
|
||||
kind: 'folder'
|
||||
}
|
||||
setCachedRepos('host-folder', [folderRepo])
|
||||
const sendRequest = vi.fn().mockImplementation((method: string) => {
|
||||
if (method === 'repo.list') {
|
||||
return Promise.resolve({ ok: true, result: { repos: [folderRepo] } })
|
||||
}
|
||||
if (method === 'preflight.detectAgents') {
|
||||
return Promise.resolve({ ok: true, result: ['codex'] })
|
||||
}
|
||||
if (method === 'settings.get') {
|
||||
return Promise.resolve({ ok: true, result: { settings: { defaultTuiAgent: 'codex' } } })
|
||||
}
|
||||
if (method === 'ui.get') {
|
||||
return Promise.resolve({ ok: true, result: { ui: {} } })
|
||||
}
|
||||
if (method === 'preflight.check') {
|
||||
return Promise.resolve({ ok: true, result: {} })
|
||||
}
|
||||
if (method === 'linear.status') {
|
||||
return Promise.resolve({ ok: true, result: {} })
|
||||
}
|
||||
if (method === 'status.get') {
|
||||
return Promise.resolve({ ok: true, result: { hostPlatform: 'darwin' } })
|
||||
}
|
||||
if (method === 'repo.hooks') {
|
||||
return Promise.resolve({ ok: true, result: { hooks: null, source: null } })
|
||||
}
|
||||
return Promise.resolve({ ok: true, result: {} })
|
||||
})
|
||||
const client = { sendRequest } as unknown as RpcClient
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(NewWorktreeModal, {
|
||||
visible: true,
|
||||
client,
|
||||
hostId: 'host-folder',
|
||||
onCreated: () => {},
|
||||
onClose: () => {}
|
||||
})
|
||||
)
|
||||
})
|
||||
await flushUpdates()
|
||||
|
||||
expect(renderer.root.findAll((node) => node.props.label === 'Workspace name')).toHaveLength(1)
|
||||
expect(sendRequest).toHaveBeenCalledWith('preflight.detectAgents')
|
||||
expect(sendRequest).not.toHaveBeenCalledWith('ssh.getState', expect.anything())
|
||||
})
|
||||
|
||||
it('ignores a stale repo list after the client changes', async () => {
|
||||
let resolveOldList: ((value: unknown) => void) | undefined
|
||||
const oldList = new Promise((resolve) => {
|
||||
resolveOldList = resolve
|
||||
})
|
||||
const freshRepo = { ...repos[0]!, id: 'repo-fresh', displayName: 'fresh' }
|
||||
const oldClient = {
|
||||
sendRequest: vi.fn().mockImplementation((method: string) => {
|
||||
if (method === 'repo.list') {
|
||||
return oldList
|
||||
}
|
||||
return new Promise(() => {})
|
||||
})
|
||||
} as unknown as RpcClient
|
||||
const freshClient = {
|
||||
sendRequest: vi.fn().mockImplementation((method: string) => {
|
||||
if (method === 'repo.list') {
|
||||
return Promise.resolve({ ok: true, result: { repos: [freshRepo] } })
|
||||
}
|
||||
if (method === 'status.get') {
|
||||
return Promise.resolve({ ok: true, result: { hostPlatform: 'darwin' } })
|
||||
}
|
||||
return new Promise(() => {})
|
||||
})
|
||||
} as unknown as RpcClient
|
||||
const modalProps = {
|
||||
visible: true,
|
||||
hostId: 'host-1',
|
||||
onCreated: () => {},
|
||||
onClose: () => {}
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(createElement(NewWorktreeModal, { ...modalProps, client: oldClient }))
|
||||
})
|
||||
const sourceInput = sourceInputs(renderer)[0]!
|
||||
act(() => sourceInput.props.onChangeText('stale-client-name'))
|
||||
expect(sourceInput.props.value).toBe('stale-client-name')
|
||||
await act(async () => {
|
||||
renderer.update(createElement(NewWorktreeModal, { ...modalProps, client: freshClient }))
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(sourceInputs(renderer).map((input) => input.props.value)).toEqual(['', ''])
|
||||
resolveOldList?.({ ok: true, result: { repos } })
|
||||
await flushUpdates()
|
||||
|
||||
expect(pickerItems(renderer, 'Project')).toEqual([expect.objectContaining({ label: 'fresh' })])
|
||||
})
|
||||
|
||||
it('remounts before the reopened session renders, so no stale instance sees visible', async () => {
|
||||
const sendRequest = vi.fn().mockImplementation(() => new Promise(() => {}))
|
||||
const client = { sendRequest } as unknown as RpcClient
|
||||
const modalProps = {
|
||||
client,
|
||||
hostId: 'host-1',
|
||||
onCreated: () => {},
|
||||
onClose: () => {}
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(createElement(NewWorktreeModal, { ...modalProps, visible: true }))
|
||||
})
|
||||
act(() => renderer.update(createElement(NewWorktreeModal, { ...modalProps, visible: false })))
|
||||
act(() => renderer.update(createElement(NewWorktreeModal, { ...modalProps, visible: true })))
|
||||
|
||||
// One repo.list per opening. A third means the previous session's instance
|
||||
// re-ran its visible-gated effects before the remount key caught up.
|
||||
const repoListCalls = sendRequest.mock.calls.filter(([method]) => method === 'repo.list')
|
||||
expect(repoListCalls).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('starts with fresh form state after closing and reopening', async () => {
|
||||
const client = {
|
||||
sendRequest: vi.fn().mockImplementation(() => new Promise(() => {}))
|
||||
} as unknown as RpcClient
|
||||
const modalProps = {
|
||||
client,
|
||||
hostId: 'host-1',
|
||||
onCreated: () => {},
|
||||
onClose: () => {}
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(createElement(NewWorktreeModal, { ...modalProps, visible: true }))
|
||||
})
|
||||
const sourceInput = sourceInputs(renderer)[0]!
|
||||
act(() => sourceInput.props.onChangeText('previous-workspace'))
|
||||
|
||||
act(() => renderer.update(createElement(NewWorktreeModal, { ...modalProps, visible: false })))
|
||||
act(() => renderer.update(createElement(NewWorktreeModal, { ...modalProps, visible: true })))
|
||||
|
||||
expect(sourceInputs(renderer).map((input) => input.props.value)).toEqual(['', ''])
|
||||
})
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,115 @@
|
||||
import { View } from 'react-native'
|
||||
import { Monitor } from 'lucide-react-native'
|
||||
import type { SmartModeAvailabilityInput } from '../tasks/mobile-smart-source-modes'
|
||||
import type { PasteRepoCandidate } from '../tasks/smart-source-paste-intent'
|
||||
import type { useMobileComposerSource } from '../tasks/use-mobile-composer-source'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import { MobileAgentIcon } from './MobileAgentIcon'
|
||||
import type { NewWorktreeAgentOption } from './new-worktree-agent-selection'
|
||||
import { newWorktreeFormStyles as styles } from './new-worktree-form-styles'
|
||||
import type { MobileWorkspaceRepo } from './new-worktree-modal-types'
|
||||
import type {
|
||||
NewWorkspaceProjectOption,
|
||||
NewWorkspaceRunTargetOption
|
||||
} from './new-workspace-project-targets'
|
||||
import { getMobileWorkspaceRepoBadgeColor } from './new-worktree-modal-types'
|
||||
import { PickerListDrawer } from './PickerListDrawer'
|
||||
import { SetupHookTrustDrawer, type SetupTrustPrompt } from './SetupHookTrustDrawer'
|
||||
import { SmartWorkspaceSourceDrawer } from './SmartWorkspaceSourceDrawer'
|
||||
import type { NewWorktreeDrawerView } from './use-new-worktree-drawer-navigation'
|
||||
|
||||
type Composer = ReturnType<typeof useMobileComposerSource>
|
||||
|
||||
export function NewWorktreeModalDrawers(props: {
|
||||
visible: boolean
|
||||
drawerView: NewWorktreeDrawerView
|
||||
client: Parameters<typeof SmartWorkspaceSourceDrawer>[0]['client']
|
||||
composer: Composer
|
||||
sourceAvailability: SmartModeAvailabilityInput
|
||||
selectedRepo: MobileWorkspaceRepo | null
|
||||
repos: MobileWorkspaceRepo[]
|
||||
pasteRepos: PasteRepoCandidate[]
|
||||
sshReady: boolean
|
||||
projectPickerItems: NewWorkspaceProjectOption<MobileWorkspaceRepo>[]
|
||||
selectedProjectId: string | null
|
||||
runTargetPickerItems: NewWorkspaceRunTargetOption<MobileWorkspaceRepo>[]
|
||||
pickerAgentOptions: NewWorktreeAgentOption[]
|
||||
selectedAgent: NewWorktreeAgentOption
|
||||
setupTrustPrompt: SetupTrustPrompt | null
|
||||
creating: boolean
|
||||
onSourceRepoChange: (repo: MobileWorkspaceRepo) => void
|
||||
onRepoChange: (repo: MobileWorkspaceRepo) => void
|
||||
onAgentChange: (agent: NewWorktreeAgentOption) => void
|
||||
onTransitionToForm: () => void
|
||||
onApproveSetupTrust: (alwaysTrust: boolean) => void
|
||||
onSkipSetupTrust: () => void
|
||||
onCloseSetupTrust: () => void
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<SmartWorkspaceSourceDrawer
|
||||
visible={props.visible && props.drawerView === 'source'}
|
||||
client={props.client}
|
||||
composer={props.composer}
|
||||
availability={props.sourceAvailability}
|
||||
repoId={props.selectedRepo?.id ?? null}
|
||||
repos={props.pasteRepos}
|
||||
sshReady={props.sshReady}
|
||||
onRepoChange={(repoId) => {
|
||||
const nextRepo = props.repos.find((repo) => repo.id === repoId)
|
||||
if (nextRepo) {
|
||||
props.onSourceRepoChange(nextRepo)
|
||||
}
|
||||
}}
|
||||
onClose={props.onTransitionToForm}
|
||||
/>
|
||||
|
||||
<PickerListDrawer
|
||||
visible={props.visible && props.drawerView === 'project'}
|
||||
title="Project"
|
||||
items={props.projectPickerItems}
|
||||
selectedId={props.selectedProjectId ?? ''}
|
||||
onSelect={(item) => props.onRepoChange(item.repo)}
|
||||
onClose={props.onTransitionToForm}
|
||||
renderIcon={(item) => (
|
||||
<View
|
||||
style={[
|
||||
styles.repoDot,
|
||||
{ backgroundColor: getMobileWorkspaceRepoBadgeColor(item.repo) }
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<PickerListDrawer
|
||||
visible={props.visible && props.drawerView === 'runTarget'}
|
||||
title="Run on"
|
||||
items={props.runTargetPickerItems}
|
||||
selectedId={props.selectedRepo?.id ?? ''}
|
||||
onSelect={(item) => props.onRepoChange(item.repo)}
|
||||
onClose={props.onTransitionToForm}
|
||||
renderIcon={() => <Monitor size={16} color={colors.textMuted} />}
|
||||
/>
|
||||
|
||||
<PickerListDrawer
|
||||
visible={props.visible && props.drawerView === 'agent'}
|
||||
title="Agent"
|
||||
items={props.pickerAgentOptions}
|
||||
selectedId={props.selectedAgent.id}
|
||||
onSelect={props.onAgentChange}
|
||||
onClose={props.onTransitionToForm}
|
||||
renderIcon={(agent) => <MobileAgentIcon agentId={agent.id} size={18} />}
|
||||
/>
|
||||
|
||||
<SetupHookTrustDrawer
|
||||
visible={props.visible && props.drawerView === 'trust' && props.setupTrustPrompt != null}
|
||||
prompt={props.setupTrustPrompt}
|
||||
busy={props.creating}
|
||||
onRunOnce={() => props.onApproveSetupTrust(false)}
|
||||
onAlwaysTrust={() => props.onApproveSetupTrust(true)}
|
||||
onDontRun={props.onSkipSetupTrust}
|
||||
onClose={props.onCloseSetupTrust}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -199,7 +199,7 @@ describe('memoized worktree rows', () => {
|
||||
await act(async () => {
|
||||
renderer!.update(
|
||||
createElement(WorktreeAgentRow, {
|
||||
agent: agent({ state: 'done', updatedAt: 2_000 }),
|
||||
agent: agent({ state: 'working', workingMode: 'monitoring', updatedAt: 2_000 }),
|
||||
depth: 0,
|
||||
now: 2_000,
|
||||
unvisited: false
|
||||
@@ -207,5 +207,22 @@ describe('memoized worktree rows', () => {
|
||||
)
|
||||
})
|
||||
expect(agentStateDotRender).toHaveBeenCalledTimes(2)
|
||||
expect(agentStateDotRender).toHaveBeenLastCalledWith({ state: 'monitoring' })
|
||||
})
|
||||
|
||||
it('passes workspace monitoring mode to the status indicator', async () => {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(ListRowHarness, {
|
||||
item: { ...baseItem, status: 'working', workingMode: 'monitoring' },
|
||||
now: 2_000
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
expect(agentSpinnerRender).toHaveBeenLastCalledWith({
|
||||
status: 'working',
|
||||
workingMode: 'monitoring'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import { memo } from 'react'
|
||||
import { Bell, ChevronDown, ChevronRight, GitBranch, GitPullRequest } from 'lucide-react-native'
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native'
|
||||
import type { RepoIcon } from '../../../src/shared/repo-icon'
|
||||
import type { AgentWorkingMode } from '../../../src/shared/agent-status-types'
|
||||
import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types'
|
||||
import { triggerMediumImpact } from '../platform/haptics'
|
||||
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
|
||||
@@ -39,6 +40,7 @@ export type WorktreeListRowItem = {
|
||||
lineageChildCount?: number
|
||||
lineageCollapsed?: boolean
|
||||
agents?: RuntimeWorktreeAgentRow[]
|
||||
workingMode?: AgentWorkingMode
|
||||
}
|
||||
|
||||
type WorktreeRollupStatus = 'working' | 'active' | 'permission' | 'done' | 'inactive'
|
||||
@@ -97,7 +99,7 @@ function WorktreeListRowComponent<T extends WorktreeListRowItem>({
|
||||
delayLongPress={400}
|
||||
>
|
||||
<View style={styles.indicatorCol}>
|
||||
<AgentSpinner status={status} />
|
||||
<AgentSpinner status={status} workingMode={item.workingMode} />
|
||||
{item.unread && (
|
||||
<Bell
|
||||
size={10}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AgentSpinner } from './AgentSpinner'
|
||||
import { AgentStateDot } from './AgentStateDot'
|
||||
|
||||
const DESKTOP_WORKING_COLOR = '#eab308'
|
||||
|
||||
type MonitoringTestRenderer = {
|
||||
readonly root: {
|
||||
findByType(type: string): { props: Record<string, unknown> }
|
||||
}
|
||||
unmount(): void
|
||||
}
|
||||
|
||||
const { animationLoop, animationTiming, setValue } = vi.hoisted(() => ({
|
||||
animationLoop: vi.fn(() => ({ start: vi.fn(), stop: vi.fn() })),
|
||||
animationTiming: vi.fn(() => ({})),
|
||||
setValue: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('lucide-react-native', () => ({ Radio: 'Radio' }))
|
||||
vi.mock('react-native', () => ({
|
||||
Animated: {
|
||||
Value: function Value() {
|
||||
return { interpolate: vi.fn(() => 'rotation'), setValue }
|
||||
},
|
||||
View: 'AnimatedView',
|
||||
loop: animationLoop,
|
||||
timing: animationTiming
|
||||
},
|
||||
Easing: { linear: 'linear' },
|
||||
StyleSheet: { create: <T>(styles: T) => styles },
|
||||
View: 'View'
|
||||
}))
|
||||
|
||||
describe('mobile monitoring indicators', () => {
|
||||
let renderer: MonitoringTestRenderer | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
animationLoop.mockClear()
|
||||
animationTiming.mockClear()
|
||||
setValue.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
})
|
||||
|
||||
it('renders a static Radio for a monitoring agent', async () => {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(AgentStateDot, { state: 'monitoring' }))
|
||||
})
|
||||
|
||||
expect(renderer?.root.findByType('Radio').props).toMatchObject({
|
||||
color: DESKTOP_WORKING_COLOR,
|
||||
size: 10
|
||||
})
|
||||
expect(animationTiming).not.toHaveBeenCalled()
|
||||
expect(animationLoop).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders a static Radio for an all-monitoring workspace', async () => {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(AgentSpinner, { status: 'working', workingMode: 'monitoring' })
|
||||
)
|
||||
})
|
||||
|
||||
expect(renderer?.root.findByType('Radio').props).toMatchObject({
|
||||
color: DESKTOP_WORKING_COLOR,
|
||||
size: 12
|
||||
})
|
||||
expect(animationTiming).not.toHaveBeenCalled()
|
||||
expect(animationLoop).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the spinner fallback when workingMode is absent', async () => {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(AgentSpinner, { status: 'working' }))
|
||||
})
|
||||
|
||||
expect(animationTiming).toHaveBeenCalledOnce()
|
||||
expect(animationLoop).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,244 @@
|
||||
import { Platform, StyleSheet } from 'react-native'
|
||||
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
|
||||
|
||||
export const newWorktreeFormStyles = StyleSheet.create({
|
||||
header: {
|
||||
paddingHorizontal: spacing.xs,
|
||||
marginBottom: spacing.md
|
||||
},
|
||||
title: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
loadingContainer: {
|
||||
paddingVertical: spacing.xl,
|
||||
alignItems: 'center'
|
||||
},
|
||||
emptyText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.bodySize
|
||||
},
|
||||
field: {
|
||||
marginBottom: spacing.md
|
||||
},
|
||||
label: {
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
color: colors.textSecondary,
|
||||
marginBottom: spacing.xs
|
||||
},
|
||||
labelHint: {
|
||||
fontWeight: '400',
|
||||
color: colors.textMuted
|
||||
},
|
||||
fieldButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
backgroundColor: colors.bgRaised,
|
||||
borderRadius: radii.input,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: Platform.OS === 'ios' ? spacing.sm + 2 : spacing.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle
|
||||
},
|
||||
fieldButtonText: {
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textPrimary
|
||||
},
|
||||
fieldButtonPlaceholder: {
|
||||
color: colors.textMuted
|
||||
},
|
||||
repoDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 999
|
||||
},
|
||||
disabled: {
|
||||
opacity: 0.55
|
||||
},
|
||||
sshBox: {
|
||||
backgroundColor: colors.bgRaised,
|
||||
borderRadius: radii.input,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
gap: spacing.xs
|
||||
},
|
||||
sshRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm
|
||||
},
|
||||
sshDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 999
|
||||
},
|
||||
sshDotConnected: {
|
||||
backgroundColor: colors.statusGreen
|
||||
},
|
||||
sshDotProgress: {
|
||||
backgroundColor: colors.statusAmber
|
||||
},
|
||||
sshDotDisconnected: {
|
||||
backgroundColor: colors.statusRed
|
||||
},
|
||||
sshCopy: {
|
||||
flex: 1,
|
||||
minWidth: 0
|
||||
},
|
||||
sshTitle: {
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textPrimary,
|
||||
fontWeight: '600'
|
||||
},
|
||||
sshSubtitle: {
|
||||
fontSize: 12,
|
||||
color: colors.textSecondary,
|
||||
marginTop: 1
|
||||
},
|
||||
sshConnectButton: {
|
||||
borderRadius: radii.button,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs
|
||||
},
|
||||
sshConnectText: {
|
||||
color: colors.textPrimary,
|
||||
fontSize: 12,
|
||||
fontWeight: '600'
|
||||
},
|
||||
errorInline: {
|
||||
color: colors.statusRed,
|
||||
fontSize: 12
|
||||
},
|
||||
input: {
|
||||
backgroundColor: colors.bgRaised,
|
||||
color: colors.textPrimary,
|
||||
borderRadius: radii.input,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: Platform.OS === 'ios' ? spacing.sm + 2 : spacing.sm,
|
||||
fontSize: typography.bodySize,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle
|
||||
},
|
||||
error: {
|
||||
color: colors.statusRed,
|
||||
fontSize: 13,
|
||||
marginBottom: spacing.md
|
||||
},
|
||||
sourceWarning: {
|
||||
marginTop: -spacing.sm,
|
||||
marginBottom: spacing.md,
|
||||
fontSize: 12,
|
||||
color: colors.statusAmber
|
||||
},
|
||||
advancedToggle: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
paddingVertical: spacing.sm,
|
||||
marginBottom: spacing.xs
|
||||
},
|
||||
advancedText: {
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '500',
|
||||
color: colors.textSecondary
|
||||
},
|
||||
setupHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: spacing.xs
|
||||
},
|
||||
sourceBadge: {
|
||||
backgroundColor: colors.bgRaised,
|
||||
borderRadius: 4,
|
||||
paddingHorizontal: spacing.xs + 2,
|
||||
paddingVertical: 2
|
||||
},
|
||||
sourceBadgeText: {
|
||||
fontSize: 10,
|
||||
fontWeight: '600',
|
||||
color: colors.textMuted,
|
||||
letterSpacing: 0.5
|
||||
},
|
||||
setupBox: {
|
||||
backgroundColor: colors.bgRaised,
|
||||
borderRadius: radii.input,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
padding: spacing.md
|
||||
},
|
||||
setupToggleRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: spacing.sm
|
||||
},
|
||||
setupToggleLabel: {
|
||||
fontSize: 13,
|
||||
color: colors.textSecondary
|
||||
},
|
||||
setupChoiceRow: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.sm,
|
||||
marginBottom: spacing.sm
|
||||
},
|
||||
setupChoiceButton: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
borderRadius: radii.button,
|
||||
paddingVertical: spacing.sm
|
||||
},
|
||||
setupChoiceButtonSelected: {
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderColor: colors.textSecondary
|
||||
},
|
||||
setupChoiceText: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
setupSwitch: {
|
||||
transform: [{ scaleX: 0.7 }, { scaleY: 0.7 }]
|
||||
},
|
||||
setupCommandBlock: {
|
||||
backgroundColor: colors.bgBase,
|
||||
borderRadius: 6,
|
||||
paddingHorizontal: spacing.sm + 2,
|
||||
paddingVertical: spacing.sm
|
||||
},
|
||||
setupCommand: {
|
||||
fontSize: 13,
|
||||
fontFamily: typography.monoFamily,
|
||||
color: colors.textPrimary
|
||||
},
|
||||
actions: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
marginTop: spacing.sm
|
||||
},
|
||||
createButton: {
|
||||
backgroundColor: colors.textPrimary,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radii.button,
|
||||
minWidth: 160,
|
||||
alignItems: 'center'
|
||||
},
|
||||
createButtonDisabled: {
|
||||
opacity: 0.4
|
||||
},
|
||||
createText: {
|
||||
color: colors.bgBase,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '600'
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { Repo as SharedRepo } from '../../../src/shared/repo-types'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { SetupHookTrust } from '../tasks/setup-hook-trust'
|
||||
import { repoColor } from '../worktree/repo-color'
|
||||
|
||||
export type MobileWorkspaceRepo = Pick<SharedRepo, 'id' | 'displayName' | 'path'> &
|
||||
Partial<
|
||||
Pick<
|
||||
SharedRepo,
|
||||
| 'badgeColor'
|
||||
| 'connectionId'
|
||||
| 'executionHostId'
|
||||
| 'kind'
|
||||
| 'upstream'
|
||||
| 'repoIcon'
|
||||
| 'gitRemoteIdentity'
|
||||
>
|
||||
>
|
||||
|
||||
export type NewWorktreeModalProps = {
|
||||
visible: boolean
|
||||
client: RpcClient | null
|
||||
hostId?: string
|
||||
existingWorktreePaths?: readonly string[]
|
||||
existingWorktrees?: readonly { repoId: string; branch: string }[]
|
||||
onCreated: (worktreeId: string, name: string) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export type SetupRunPolicy = 'ask' | 'run-by-default' | 'skip-by-default'
|
||||
|
||||
export type RepoHooksResponse = {
|
||||
hooks: { scripts?: { setup?: string } } | null
|
||||
source: string | null
|
||||
setupRunPolicy?: SetupRunPolicy
|
||||
setupTrust?: SetupHookTrust
|
||||
}
|
||||
|
||||
export type SetupHookDetails = {
|
||||
repoId: string
|
||||
command: string | null
|
||||
source: string | null
|
||||
trust: SetupHookTrust | null
|
||||
runPolicy: SetupRunPolicy
|
||||
}
|
||||
|
||||
export function getMobileWorkspaceRepoBadgeColor(repo: MobileWorkspaceRepo | null): string {
|
||||
return repo?.badgeColor || repoColor(repo?.displayName ?? 'repository')
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useState } from 'react'
|
||||
import { isMobileTuiAgentEnabled } from '../tasks/mobile-tui-agents'
|
||||
import {
|
||||
NEW_WORKTREE_AGENT_OPTIONS,
|
||||
NEW_WORKTREE_BLANK_AGENT,
|
||||
resolveNewWorktreeAgentSelection,
|
||||
type NewWorktreeAgentOption,
|
||||
type NewWorktreeRuntimeSettings
|
||||
} from './new-worktree-agent-selection'
|
||||
|
||||
export function useNewWorkspaceAgentSelection(args: {
|
||||
visible: boolean
|
||||
runtimeSettings: NewWorktreeRuntimeSettings | null
|
||||
detectedAgentIds: Set<string> | null
|
||||
}): {
|
||||
selectedAgent: NewWorktreeAgentOption
|
||||
setSelectedAgent: (agent: NewWorktreeAgentOption) => void
|
||||
setAgentOverridden: (overridden: boolean) => void
|
||||
pickerAgentOptions: NewWorktreeAgentOption[]
|
||||
} {
|
||||
const [selectedAgentState, setSelectedAgent] = useState<NewWorktreeAgentOption>(
|
||||
NEW_WORKTREE_AGENT_OPTIONS[0]!
|
||||
)
|
||||
const [agentOverriddenState, setAgentOverridden] = useState(false)
|
||||
const resolution = resolveNewWorktreeAgentSelection({
|
||||
visible: args.visible,
|
||||
selectedAgent: selectedAgentState,
|
||||
agentOverridden: agentOverriddenState,
|
||||
runtimeSettings: args.runtimeSettings,
|
||||
detectedAgentIds: args.detectedAgentIds
|
||||
})
|
||||
if (
|
||||
selectedAgentState.id !== resolution.selectedAgent.id ||
|
||||
agentOverriddenState !== resolution.agentOverridden
|
||||
) {
|
||||
setSelectedAgent(resolution.selectedAgent)
|
||||
setAgentOverridden(resolution.agentOverridden)
|
||||
}
|
||||
const visibleAgentOptions = NEW_WORKTREE_AGENT_OPTIONS.filter(
|
||||
(agent) =>
|
||||
agent.id !== '__blank__' &&
|
||||
(args.detectedAgentIds === null || args.detectedAgentIds.has(agent.id)) &&
|
||||
isMobileTuiAgentEnabled(agent.id, args.runtimeSettings?.disabledTuiAgents)
|
||||
)
|
||||
return {
|
||||
selectedAgent: resolution.selectedAgent,
|
||||
setSelectedAgent,
|
||||
setAgentOverridden,
|
||||
pickerAgentOptions: [...visibleAgentOptions, NEW_WORKTREE_BLANK_AGENT]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import { useRef, useState, type Dispatch, type SetStateAction } from 'react'
|
||||
import type { PersistedTrustedOrcaHooks } from '../../../src/shared/orca-yaml-hook-types'
|
||||
import type { RetiredNameRegistry } from '../../../src/shared/worktree/retired-name-registry'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcSuccess } from '../transport/types'
|
||||
import { createBlankWorkspace } from '../tasks/blank-workspace-create'
|
||||
import { isMobileTuiAgentEnabled } from '../tasks/mobile-tui-agents'
|
||||
import {
|
||||
isSetupHookTrusted,
|
||||
persistSetupHookTrustApproval,
|
||||
wasSetupHookPreviouslyApproved,
|
||||
type SetupHookTrust
|
||||
} from '../tasks/setup-hook-trust'
|
||||
import { createWorkspaceFromComposerSource } from '../tasks/source-workspace-create'
|
||||
import { normalizeWorkspaceAgent } from '../tasks/workspace-agent-selection'
|
||||
import type { WorkspaceCreateSetupDecision } from '../tasks/workspace-create-params'
|
||||
import type { WorkspaceSshGate } from '../tasks/workspace-ssh-gate'
|
||||
import type { useMobileComposerSource } from '../tasks/use-mobile-composer-source'
|
||||
import {
|
||||
pickPreferredNewWorktreeAgent,
|
||||
type NewWorktreeAgentOption,
|
||||
type NewWorktreeRuntimeSettings
|
||||
} from './new-worktree-agent-selection'
|
||||
import type { MobileWorkspaceRepo, SetupRunPolicy } from './new-worktree-modal-types'
|
||||
import type { SetupTrustPrompt } from './SetupHookTrustDrawer'
|
||||
import type { NewWorktreeDrawerView } from './use-new-worktree-drawer-navigation'
|
||||
import { getSuggestedCreatureName } from './worktree-name-suggestion'
|
||||
|
||||
type CreateOptions = {
|
||||
setupOverride?: Exclude<WorkspaceCreateSetupDecision, 'inherit'>
|
||||
approvedSetupContentHash?: string
|
||||
}
|
||||
|
||||
type Composer = ReturnType<typeof useMobileComposerSource>
|
||||
|
||||
export function useNewWorkspaceCreateSubmit(args: {
|
||||
client: RpcClient | null
|
||||
selectedRepo: MobileWorkspaceRepo | null
|
||||
selectedAgent: NewWorktreeAgentOption
|
||||
setSelectedAgent: (agent: NewWorktreeAgentOption) => void
|
||||
setAgentOverridden: (overridden: boolean) => void
|
||||
runtimeSettings: NewWorktreeRuntimeSettings | null
|
||||
setRuntimeSettings: (settings: NewWorktreeRuntimeSettings) => void
|
||||
detectedAgentIds: Set<string> | null
|
||||
sshGate: WorkspaceSshGate
|
||||
composer: Composer
|
||||
note: string
|
||||
existingWorktreePaths?: readonly string[]
|
||||
retiredWorktreeNames: RetiredNameRegistry
|
||||
setupCommand: string | null
|
||||
setupTrust: SetupHookTrust | null
|
||||
setupRunPolicy: SetupRunPolicy
|
||||
setupDecisionChoice: Exclude<WorkspaceCreateSetupDecision, 'inherit'> | null
|
||||
runSetup: boolean
|
||||
trustedOrcaHooks: PersistedTrustedOrcaHooks
|
||||
setTrustedOrcaHooks: (trust: PersistedTrustedOrcaHooks) => void
|
||||
getWorktreeCreateCutoverSupport: () => Promise<boolean>
|
||||
transitionDrawer: (view: Exclude<NewWorktreeDrawerView, 'transition'>) => void
|
||||
setError: Dispatch<SetStateAction<string>>
|
||||
onCreated: (worktreeId: string, name: string) => void
|
||||
onClose: () => void
|
||||
}): {
|
||||
creating: boolean
|
||||
setupTrustPrompt: SetupTrustPrompt | null
|
||||
create: () => Promise<void>
|
||||
approveSetupTrust: (alwaysTrust: boolean) => Promise<void>
|
||||
closeSetupTrust: () => void
|
||||
skipSetupTrust: () => void
|
||||
} {
|
||||
const createInFlightRef = useRef(false)
|
||||
const setupTrustActionInFlightRef = useRef(false)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [setupTrustPrompt, setSetupTrustPrompt] = useState<SetupTrustPrompt | null>(null)
|
||||
|
||||
async function create(options: CreateOptions = {}): Promise<void> {
|
||||
const { client, selectedRepo } = args
|
||||
if (!client || !selectedRepo || createInFlightRef.current) {
|
||||
return
|
||||
}
|
||||
createInFlightRef.current = true
|
||||
setCreating(true)
|
||||
args.setError('')
|
||||
try {
|
||||
if (args.sshGate.requiresConnection) {
|
||||
args.setError(`Connect ${selectedRepo.displayName} before creating a workspace.`)
|
||||
return
|
||||
}
|
||||
let latestRuntimeSettings = args.runtimeSettings
|
||||
try {
|
||||
const settingsResponse = await client.sendRequest('settings.get')
|
||||
if (settingsResponse.ok) {
|
||||
const result = (settingsResponse as RpcSuccess).result as {
|
||||
settings: NewWorktreeRuntimeSettings
|
||||
}
|
||||
latestRuntimeSettings = result.settings
|
||||
args.setRuntimeSettings(result.settings)
|
||||
}
|
||||
} catch {
|
||||
// The runtime validates the same setting before spawning.
|
||||
}
|
||||
if (
|
||||
args.selectedAgent.id !== '__blank__' &&
|
||||
!isMobileTuiAgentEnabled(args.selectedAgent.id, latestRuntimeSettings?.disabledTuiAgents)
|
||||
) {
|
||||
args.setSelectedAgent(
|
||||
pickPreferredNewWorktreeAgent(latestRuntimeSettings, args.detectedAgentIds)
|
||||
)
|
||||
args.setAgentOverridden(false)
|
||||
args.setError('Selected agent is disabled. Choose an enabled agent before creating.')
|
||||
return
|
||||
}
|
||||
|
||||
const trimmedName = args.composer.name.trim()
|
||||
const baseName =
|
||||
trimmedName ||
|
||||
getSuggestedCreatureName(
|
||||
args.existingWorktreePaths ?? [],
|
||||
undefined,
|
||||
args.retiredWorktreeNames
|
||||
)
|
||||
let setupDecision: WorkspaceCreateSetupDecision = 'inherit'
|
||||
if (args.setupCommand) {
|
||||
if (options.setupOverride) {
|
||||
setupDecision = options.setupOverride
|
||||
} else if (args.setupRunPolicy === 'ask') {
|
||||
if (!args.setupDecisionChoice) {
|
||||
args.setError('Choose whether to run the setup script.')
|
||||
return
|
||||
}
|
||||
setupDecision = args.setupDecisionChoice
|
||||
} else {
|
||||
setupDecision = args.runSetup ? 'run' : 'skip'
|
||||
}
|
||||
}
|
||||
if (
|
||||
setupDecision === 'run' &&
|
||||
args.setupTrust &&
|
||||
args.setupTrust.contentHash !== options.approvedSetupContentHash &&
|
||||
!isSetupHookTrusted(args.trustedOrcaHooks, selectedRepo.id, args.setupTrust.contentHash)
|
||||
) {
|
||||
setSetupTrustPrompt({
|
||||
repoId: selectedRepo.id,
|
||||
repoName: selectedRepo.displayName,
|
||||
scriptContent: args.setupTrust.scriptContent,
|
||||
contentHash: args.setupTrust.contentHash,
|
||||
previouslyApproved: wasSetupHookPreviouslyApproved(args.trustedOrcaHooks, selectedRepo.id)
|
||||
})
|
||||
args.transitionDrawer('trust')
|
||||
return
|
||||
}
|
||||
|
||||
const createdWithAgentId =
|
||||
args.selectedAgent.id !== '__blank__' ? args.selectedAgent.id : undefined
|
||||
const trimmedNote = args.note.trim() || undefined
|
||||
const selection = args.composer.createSelection
|
||||
const result = selection
|
||||
? await createWorkspaceFromComposerSource({
|
||||
client,
|
||||
selection,
|
||||
targetRepoId: selectedRepo.id,
|
||||
setupDecision,
|
||||
agent: { choice: normalizeWorkspaceAgent(args.selectedAgent.id) ?? 'blank' },
|
||||
workspaceName: trimmedName || undefined,
|
||||
note: trimmedNote,
|
||||
nameIsAutoManaged: args.composer.isNameAutoManaged,
|
||||
supportsIdempotentCutoverRetry: args.getWorktreeCreateCutoverSupport()
|
||||
})
|
||||
: await createBlankWorkspace({
|
||||
client,
|
||||
repoId: selectedRepo.id,
|
||||
baseName,
|
||||
nameWasGenerated: !trimmedName,
|
||||
createdWithAgentId,
|
||||
comment: trimmedNote,
|
||||
setupDecision,
|
||||
supportsIdempotentCutoverRetry: args.getWorktreeCreateCutoverSupport()
|
||||
})
|
||||
if ('error' in result) {
|
||||
args.setError(result.error)
|
||||
return
|
||||
}
|
||||
args.onClose()
|
||||
args.onCreated(result.worktreeId, result.name)
|
||||
} catch (error) {
|
||||
args.setError(error instanceof Error ? error.message : 'Failed to create workspace')
|
||||
} finally {
|
||||
createInFlightRef.current = false
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function approveSetupTrust(alwaysTrust: boolean): Promise<void> {
|
||||
if (
|
||||
!args.client ||
|
||||
!setupTrustPrompt ||
|
||||
setupTrustActionInFlightRef.current ||
|
||||
createInFlightRef.current
|
||||
) {
|
||||
return
|
||||
}
|
||||
setupTrustActionInFlightRef.current = true
|
||||
setCreating(true)
|
||||
try {
|
||||
const nextTrust = await persistSetupHookTrustApproval({
|
||||
client: args.client,
|
||||
trust: args.trustedOrcaHooks,
|
||||
repoId: setupTrustPrompt.repoId,
|
||||
contentHash: setupTrustPrompt.contentHash,
|
||||
alwaysTrust
|
||||
})
|
||||
args.setTrustedOrcaHooks(nextTrust)
|
||||
const approvedHash = setupTrustPrompt.contentHash
|
||||
setSetupTrustPrompt(null)
|
||||
args.transitionDrawer('form')
|
||||
await create({ setupOverride: 'run', approvedSetupContentHash: approvedHash })
|
||||
} catch (error) {
|
||||
args.setError(error instanceof Error ? error.message : 'Failed to trust setup script.')
|
||||
} finally {
|
||||
setupTrustActionInFlightRef.current = false
|
||||
if (!createInFlightRef.current) {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function closeSetupTrust(): void {
|
||||
if (setupTrustActionInFlightRef.current || createInFlightRef.current) {
|
||||
return
|
||||
}
|
||||
setSetupTrustPrompt(null)
|
||||
args.transitionDrawer('form')
|
||||
}
|
||||
|
||||
function skipSetupTrust(): void {
|
||||
if (setupTrustActionInFlightRef.current || createInFlightRef.current) {
|
||||
return
|
||||
}
|
||||
closeSetupTrust()
|
||||
void create({ setupOverride: 'skip' })
|
||||
}
|
||||
|
||||
return { creating, setupTrustPrompt, create, approveSetupTrust, closeSetupTrust, skipSetupTrust }
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { SshConnectionState } from '../../../src/shared/ssh-types'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcSuccess } from '../transport/types'
|
||||
import { deriveWorkspaceSshGate, type WorkspaceSshGate } from '../tasks/workspace-ssh-gate'
|
||||
|
||||
type DetectedAgentIdsState = {
|
||||
connectionId: string | null
|
||||
ids: Set<string>
|
||||
}
|
||||
|
||||
function fallbackSshState(
|
||||
targetId: string,
|
||||
status: SshConnectionState['status'],
|
||||
error: string | null
|
||||
): SshConnectionState {
|
||||
return { targetId, status, error, reconnectAttempt: 0 }
|
||||
}
|
||||
|
||||
export function useNewWorkspaceExecutionTarget(args: {
|
||||
client: RpcClient | null
|
||||
connectionId: string | null
|
||||
visible: boolean
|
||||
}): {
|
||||
sshGate: WorkspaceSshGate
|
||||
detectedAgentIds: Set<string> | null
|
||||
connect: () => Promise<void>
|
||||
} {
|
||||
const { client, connectionId, visible } = args
|
||||
const [sshState, setSshState] = useState<SshConnectionState | null>(null)
|
||||
const [connectingTargetId, setConnectingTargetId] = useState<string | null>(null)
|
||||
const [detectedAgentIdsState, setDetectedAgentIdsState] = useState<DetectedAgentIdsState | null>(
|
||||
null
|
||||
)
|
||||
const sshGate = deriveWorkspaceSshGate({
|
||||
connectionId,
|
||||
state: sshState,
|
||||
connecting: connectingTargetId === connectionId
|
||||
})
|
||||
const detectedAgentIds =
|
||||
detectedAgentIdsState?.connectionId === connectionId &&
|
||||
(connectionId === null || sshGate.status === 'connected')
|
||||
? detectedAgentIdsState.ids
|
||||
: null
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible || !client || !connectionId) {
|
||||
return
|
||||
}
|
||||
let stale = false
|
||||
void client
|
||||
.sendRequest('ssh.getState', { targetId: connectionId })
|
||||
.then((response) => {
|
||||
if (stale) {
|
||||
return
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error.message)
|
||||
}
|
||||
const state = (response as RpcSuccess).result as { state?: SshConnectionState | null }
|
||||
setSshState(state.state ?? fallbackSshState(connectionId, 'disconnected', null))
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!stale) {
|
||||
setSshState(
|
||||
fallbackSshState(
|
||||
connectionId,
|
||||
'error',
|
||||
error instanceof Error ? error.message : 'Failed to read SSH connection state.'
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
stale = true
|
||||
}
|
||||
}, [client, connectionId, visible])
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible || !client || (connectionId && sshGate.status !== 'connected')) {
|
||||
return
|
||||
}
|
||||
let stale = false
|
||||
void (async () => {
|
||||
try {
|
||||
const response = connectionId
|
||||
? await client.sendRequest('preflight.detectRemoteAgents', { connectionId })
|
||||
: await client.sendRequest('preflight.detectAgents')
|
||||
if (!stale) {
|
||||
setDetectedAgentIdsState({
|
||||
connectionId,
|
||||
ids: response.ok ? new Set((response as RpcSuccess).result as string[]) : new Set()
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
if (!stale) {
|
||||
setDetectedAgentIdsState({ connectionId, ids: new Set() })
|
||||
}
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
stale = true
|
||||
}
|
||||
}, [client, connectionId, sshGate.status, visible])
|
||||
|
||||
async function connect(): Promise<void> {
|
||||
if (!client || !connectionId) {
|
||||
return
|
||||
}
|
||||
setConnectingTargetId(connectionId)
|
||||
setSshState(fallbackSshState(connectionId, 'connecting', null))
|
||||
try {
|
||||
const response = await client.sendRequest(
|
||||
'ssh.connect',
|
||||
{ targetId: connectionId },
|
||||
{ timeoutMs: 120_000 }
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error.message)
|
||||
}
|
||||
const result = (response as RpcSuccess).result as { state?: SshConnectionState | null }
|
||||
setSshState(result.state ?? fallbackSshState(connectionId, 'connected', null))
|
||||
} catch (error) {
|
||||
setSshState(
|
||||
fallbackSshState(
|
||||
connectionId,
|
||||
'error',
|
||||
error instanceof Error ? error.message : 'Failed to connect to SSH repository.'
|
||||
)
|
||||
)
|
||||
} finally {
|
||||
setConnectingTargetId((current) => (current === connectionId ? null : current))
|
||||
}
|
||||
}
|
||||
|
||||
return { sshGate, detectedAgentIds, connect }
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcSuccess } from '../transport/types'
|
||||
import { getCachedRepos, setCachedRepos } from '../cache/repo-cache'
|
||||
import { useLastVisitedWorktreeRepoId } from '../worktree/use-last-visited-worktree-repo'
|
||||
import {
|
||||
getMobileNewWorkspaceDialogEligibleRepos,
|
||||
refreshMobileNewWorkspaceDialogSelectedRepo,
|
||||
resolveMobileNewWorkspaceDialogRepoId
|
||||
} from '../worktree/new-workspace-dialog-repo-selection'
|
||||
import type { MobileWorkspaceRepo } from './new-worktree-modal-types'
|
||||
|
||||
export function useNewWorkspaceRepositories(args: {
|
||||
client: RpcClient | null
|
||||
hostId?: string
|
||||
visible: boolean
|
||||
}): {
|
||||
repos: MobileWorkspaceRepo[]
|
||||
selectedRepo: MobileWorkspaceRepo | null
|
||||
setSelectedRepo: (repo: MobileWorkspaceRepo | null) => void
|
||||
loading: boolean
|
||||
} {
|
||||
const { client, hostId, visible } = args
|
||||
const [initialRepos] = useState(() =>
|
||||
hostId ? (getCachedRepos(hostId) as MobileWorkspaceRepo[] | null) : null
|
||||
)
|
||||
const [repos, setRepos] = useState<MobileWorkspaceRepo[]>(initialRepos ?? [])
|
||||
const [selectedRepo, setSelectedRepo] = useState<MobileWorkspaceRepo | null>(null)
|
||||
const [loading, setLoading] = useState(initialRepos == null)
|
||||
const lastVisitedRepo = useLastVisitedWorktreeRepoId(hostId, visible)
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible || !lastVisitedRepo.loaded || selectedRepo || repos.length === 0) {
|
||||
return
|
||||
}
|
||||
const eligibleRepos = getMobileNewWorkspaceDialogEligibleRepos(repos)
|
||||
const preferredRepoId = resolveMobileNewWorkspaceDialogRepoId({
|
||||
eligibleRepos,
|
||||
activeRepoId: lastVisitedRepo.repoId
|
||||
})
|
||||
const preferredRepo = repos.find((repo) => repo.id === preferredRepoId) ?? null
|
||||
if (preferredRepo) {
|
||||
setSelectedRepo(preferredRepo)
|
||||
}
|
||||
}, [lastVisitedRepo.loaded, lastVisitedRepo.repoId, repos, selectedRepo, visible])
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible || !client) {
|
||||
return
|
||||
}
|
||||
let stale = false
|
||||
setLoading(true)
|
||||
void client
|
||||
.sendRequest('repo.list')
|
||||
.then((response) => {
|
||||
if (stale || !response.ok) {
|
||||
return
|
||||
}
|
||||
const result = (response as RpcSuccess).result as { repos: MobileWorkspaceRepo[] }
|
||||
setRepos(result.repos)
|
||||
if (hostId) {
|
||||
setCachedRepos(hostId, result.repos)
|
||||
}
|
||||
setSelectedRepo((current) =>
|
||||
refreshMobileNewWorkspaceDialogSelectedRepo(result.repos, current)
|
||||
)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
if (!stale) {
|
||||
setLoading(false)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
stale = true
|
||||
}
|
||||
}, [visible, client, hostId])
|
||||
|
||||
return { repos, selectedRepo, setSelectedRepo, loading: loading && repos.length === 0 }
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { PersistedTrustedOrcaHooks } from '../../../src/shared/orca-yaml-hook-types'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcResponse, RpcSuccess } from '../transport/types'
|
||||
import {
|
||||
filterAvailableTaskProviders,
|
||||
normalizeVisibleTaskProviders,
|
||||
type TaskProvider
|
||||
} from '../tasks/mobile-task-providers'
|
||||
import type { NewWorktreeRuntimeSettings } from './new-worktree-agent-selection'
|
||||
|
||||
function settledSuccess(entry: PromiseSettledResult<RpcResponse>): RpcSuccess | null {
|
||||
return entry.status === 'fulfilled' && entry.value.ok ? (entry.value as RpcSuccess) : null
|
||||
}
|
||||
|
||||
export function useNewWorkspaceRuntimeContext(
|
||||
client: RpcClient | null,
|
||||
visible: boolean,
|
||||
hostId?: string
|
||||
): {
|
||||
runtimeSettings: NewWorktreeRuntimeSettings | null
|
||||
setRuntimeSettings: (settings: NewWorktreeRuntimeSettings) => void
|
||||
trustedOrcaHooks: PersistedTrustedOrcaHooks
|
||||
setTrustedOrcaHooks: (trust: PersistedTrustedOrcaHooks) => void
|
||||
availableProviders: TaskProvider[]
|
||||
} {
|
||||
const [runtimeSettings, setRuntimeSettings] = useState<NewWorktreeRuntimeSettings | null>(null)
|
||||
const [trustedOrcaHooks, setTrustedOrcaHooks] = useState<PersistedTrustedOrcaHooks>({})
|
||||
const [availableProviders, setAvailableProviders] = useState<TaskProvider[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible || !client) {
|
||||
return
|
||||
}
|
||||
let stale = false
|
||||
void (async () => {
|
||||
const probes = Promise.allSettled([
|
||||
client.sendRequest('preflight.check'),
|
||||
client.sendRequest('linear.status')
|
||||
])
|
||||
const [settingsRes, uiRes] = await Promise.allSettled([
|
||||
client.sendRequest('settings.get'),
|
||||
client.sendRequest('ui.get')
|
||||
])
|
||||
if (stale) {
|
||||
return
|
||||
}
|
||||
|
||||
const settingsResult = settledSuccess(settingsRes)
|
||||
const settingsValue = settingsResult
|
||||
? (
|
||||
settingsResult.result as {
|
||||
settings: NewWorktreeRuntimeSettings & { visibleTaskProviders?: unknown }
|
||||
}
|
||||
).settings
|
||||
: null
|
||||
if (settingsValue) {
|
||||
setRuntimeSettings(settingsValue)
|
||||
}
|
||||
const uiResult = settledSuccess(uiRes)
|
||||
if (uiResult) {
|
||||
const ui = (uiResult.result as { ui?: { trustedOrcaHooks?: PersistedTrustedOrcaHooks } }).ui
|
||||
setTrustedOrcaHooks(ui?.trustedOrcaHooks ?? {})
|
||||
}
|
||||
|
||||
const [preflightRes, linearRes] = await probes
|
||||
if (stale) {
|
||||
return
|
||||
}
|
||||
const glabInstalled =
|
||||
(settledSuccess(preflightRes)?.result as { glab?: { installed?: boolean } } | undefined)
|
||||
?.glab?.installed === true
|
||||
const linearConnected =
|
||||
(settledSuccess(linearRes)?.result as { connected?: boolean } | undefined)?.connected ===
|
||||
true
|
||||
const visibleProviders = normalizeVisibleTaskProviders(settingsValue?.visibleTaskProviders)
|
||||
setAvailableProviders(
|
||||
filterAvailableTaskProviders(visibleProviders, {
|
||||
gitlabInstalled: glabInstalled,
|
||||
linearConnected
|
||||
}).filter((provider) => visibleProviders.includes(provider))
|
||||
)
|
||||
})()
|
||||
return () => {
|
||||
stale = true
|
||||
}
|
||||
}, [visible, client, hostId])
|
||||
|
||||
return {
|
||||
runtimeSettings,
|
||||
setRuntimeSettings,
|
||||
trustedOrcaHooks,
|
||||
setTrustedOrcaHooks,
|
||||
availableProviders
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcSuccess } from '../transport/types'
|
||||
import { normalizeSetupHookTrust } from '../tasks/setup-hook-trust'
|
||||
import type { WorkspaceCreateSetupDecision } from '../tasks/workspace-create-params'
|
||||
import type {
|
||||
MobileWorkspaceRepo,
|
||||
RepoHooksResponse,
|
||||
SetupHookDetails
|
||||
} from './new-worktree-modal-types'
|
||||
|
||||
export function useNewWorkspaceSetupScript(args: {
|
||||
client: RpcClient | null
|
||||
selectedRepo: MobileWorkspaceRepo | null
|
||||
}): {
|
||||
setupCommand: string | null
|
||||
setupSource: string | null
|
||||
setupTrust: SetupHookDetails['trust']
|
||||
setupRunPolicy: SetupHookDetails['runPolicy']
|
||||
setupDecisionChoice: Exclude<WorkspaceCreateSetupDecision, 'inherit'> | null
|
||||
setSetupDecisionChoice: (decision: Exclude<WorkspaceCreateSetupDecision, 'inherit'>) => void
|
||||
runSetup: boolean
|
||||
setRunSetup: (run: boolean) => void
|
||||
showAdvanced: boolean
|
||||
setShowAdvanced: (show: boolean) => void
|
||||
} {
|
||||
const { client, selectedRepo } = args
|
||||
const [details, setDetails] = useState<SetupHookDetails | null>(null)
|
||||
const [setupDecisionChoice, setSetupDecisionChoice] = useState<Exclude<
|
||||
WorkspaceCreateSetupDecision,
|
||||
'inherit'
|
||||
> | null>(null)
|
||||
const [runSetup, setRunSetup] = useState(true)
|
||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||
const activeDetails = selectedRepo && details?.repoId === selectedRepo.id ? details : null
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || !selectedRepo) {
|
||||
return
|
||||
}
|
||||
let stale = false
|
||||
void client
|
||||
.sendRequest('repo.hooks', { repo: `id:${selectedRepo.id}` })
|
||||
.then((response) => {
|
||||
if (stale || !response.ok) {
|
||||
return
|
||||
}
|
||||
const result = (response as RpcSuccess).result as RepoHooksResponse
|
||||
const command = result.hooks?.scripts?.setup?.trim() || null
|
||||
const runPolicy = result.setupRunPolicy ?? 'run-by-default'
|
||||
setDetails({
|
||||
repoId: selectedRepo.id,
|
||||
command,
|
||||
source: result.source,
|
||||
trust: normalizeSetupHookTrust(result.setupTrust),
|
||||
runPolicy
|
||||
})
|
||||
setSetupDecisionChoice(null)
|
||||
setRunSetup(runPolicy !== 'skip-by-default')
|
||||
if (command && runPolicy === 'ask') {
|
||||
setShowAdvanced(true)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!stale) {
|
||||
setDetails({
|
||||
repoId: selectedRepo.id,
|
||||
command: null,
|
||||
source: null,
|
||||
trust: null,
|
||||
runPolicy: 'run-by-default'
|
||||
})
|
||||
setSetupDecisionChoice(null)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
stale = true
|
||||
}
|
||||
}, [client, selectedRepo])
|
||||
|
||||
return {
|
||||
setupCommand: activeDetails?.command ?? null,
|
||||
setupSource: activeDetails?.source ?? null,
|
||||
setupTrust: activeDetails?.trust ?? null,
|
||||
setupRunPolicy: activeDetails?.runPolicy ?? 'run-by-default',
|
||||
setupDecisionChoice,
|
||||
setSetupDecisionChoice,
|
||||
runSetup,
|
||||
setRunSetup,
|
||||
showAdvanced,
|
||||
setShowAdvanced
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const homeSource = readFileSync(new URL('../app/index.tsx', import.meta.url), 'utf8')
|
||||
const homeSource = readFileSync(new URL('./home/MobileHomeScreen.tsx', import.meta.url), 'utf8')
|
||||
|
||||
describe('Home host edit navigation wiring', () => {
|
||||
it('uses the cold-navigator-safe edit transition', () => {
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native'
|
||||
import { ClaudeIcon, OpenAIIcon } from '../components/AgentIcons'
|
||||
import {
|
||||
getActiveProviderRateLimits,
|
||||
getUsageBarState,
|
||||
hasActiveProviderUsage,
|
||||
UsageBar,
|
||||
type AccountsSnapshot,
|
||||
type ProviderKey
|
||||
} from '../components/AccountUsage'
|
||||
import { colors, radii, spacing } from '../theme/mobile-theme'
|
||||
import type { HostProfile } from '../transport/types'
|
||||
|
||||
export function MobileHomeAccountUsageCards(props: {
|
||||
items: { host: HostProfile; snapshot: AccountsSnapshot }[]
|
||||
onOpen: (hostId: string) => void
|
||||
}) {
|
||||
if (props.items.length === 0) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Text style={styles.sectionHeading}>Account usage</Text>
|
||||
{props.items.map(({ host, snapshot }) => {
|
||||
const claudeActive =
|
||||
snapshot.claude.accounts.find(
|
||||
(account) => account.id === snapshot.claude.activeAccountId
|
||||
) ?? null
|
||||
const codexActive =
|
||||
snapshot.codex.accounts.find(
|
||||
(account) => account.id === snapshot.codex.activeAccountId
|
||||
) ?? null
|
||||
return (
|
||||
<Pressable
|
||||
key={host.id}
|
||||
style={({ pressed }) => [styles.card, pressed && styles.cardPressed]}
|
||||
onPress={() => props.onOpen(host.id)}
|
||||
>
|
||||
{props.items.length > 1 ? (
|
||||
<Text style={styles.hostLabel} numberOfLines={1}>
|
||||
{host.name}
|
||||
</Text>
|
||||
) : null}
|
||||
{(['claude', 'codex'] as ProviderKey[]).map((provider) => {
|
||||
const active = provider === 'claude' ? claudeActive : codexActive
|
||||
const accounts =
|
||||
provider === 'claude' ? snapshot.claude.accounts : snapshot.codex.accounts
|
||||
const limits = getActiveProviderRateLimits(snapshot, provider)
|
||||
if (accounts.length === 0 && !hasActiveProviderUsage(limits)) {
|
||||
return null
|
||||
}
|
||||
const sessionBar = getUsageBarState(limits, 'session')
|
||||
const weeklyBar = getUsageBarState(limits, 'weekly')
|
||||
return (
|
||||
<View key={provider} style={styles.row}>
|
||||
<View style={styles.icon}>
|
||||
{provider === 'claude' ? (
|
||||
<ClaudeIcon size={18} />
|
||||
) : (
|
||||
<OpenAIIcon size={18} color={colors.textPrimary} />
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.info}>
|
||||
<Text style={styles.email} numberOfLines={1}>
|
||||
{active?.email ?? 'System default'}
|
||||
</Text>
|
||||
<View style={styles.bars}>
|
||||
<UsageBar
|
||||
label="5h"
|
||||
usedPercent={sessionBar.usedPercent}
|
||||
unavailable={sessionBar.unavailable}
|
||||
loading={sessionBar.loading}
|
||||
/>
|
||||
<UsageBar
|
||||
label="7d"
|
||||
usedPercent={weeklyBar.usedPercent}
|
||||
unavailable={weeklyBar.unavailable}
|
||||
loading={weeklyBar.loading}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
})}
|
||||
</Pressable>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
sectionHeading: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
color: colors.textMuted,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.6,
|
||||
marginTop: spacing.xl,
|
||||
marginBottom: spacing.sm,
|
||||
paddingHorizontal: spacing.xs
|
||||
},
|
||||
card: {
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
borderRadius: radii.card,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm + 2,
|
||||
gap: spacing.sm,
|
||||
marginBottom: spacing.sm
|
||||
},
|
||||
cardPressed: { backgroundColor: colors.bgRaised },
|
||||
hostLabel: {
|
||||
fontSize: 11,
|
||||
color: colors.textMuted,
|
||||
fontWeight: '500',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.4
|
||||
},
|
||||
row: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm + 2 },
|
||||
icon: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 9,
|
||||
backgroundColor: colors.bgRaised,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
info: { flex: 1, minWidth: 0, gap: 2 },
|
||||
email: { fontSize: 13, fontWeight: '600', color: colors.textPrimary },
|
||||
bars: { flexDirection: 'row', gap: spacing.md, marginTop: 4 }
|
||||
})
|
||||
@@ -0,0 +1,137 @@
|
||||
import { QrCode } from 'lucide-react-native'
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native'
|
||||
import { colors, radii, spacing } from '../theme/mobile-theme'
|
||||
|
||||
const ONBOARDING_STEPS = [
|
||||
{
|
||||
title: 'Open Orca desktop',
|
||||
desc: 'Go to Settings → Mobile and generate a pairing QR code.'
|
||||
},
|
||||
{
|
||||
title: 'Scan the code',
|
||||
desc: 'Tap the button above to open the scanner. Point at the QR code on your screen.'
|
||||
},
|
||||
{
|
||||
title: "You're connected",
|
||||
desc: 'Your desktop will appear here. Everything is encrypted end-to-end.'
|
||||
}
|
||||
]
|
||||
|
||||
export function MobileHomeEmptyState(props: {
|
||||
bottomInset: number
|
||||
contentMaxWidth: number
|
||||
isWideLayout: boolean
|
||||
onPairDesktop: () => void
|
||||
}) {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.emptyContainer,
|
||||
{ paddingBottom: props.bottomInset },
|
||||
props.isWideLayout && {
|
||||
maxWidth: props.contentMaxWidth,
|
||||
width: '100%',
|
||||
alignSelf: 'center'
|
||||
}
|
||||
]}
|
||||
>
|
||||
<View style={styles.emptyHero}>
|
||||
<Text style={styles.emptyTitle}>Connect your desktop</Text>
|
||||
<Text style={styles.emptyBody}>
|
||||
Pair with Orca on your computer to check on your agents, jump into any terminal, and drive
|
||||
work from your phone.
|
||||
</Text>
|
||||
<Pressable style={styles.primaryButton} onPress={props.onPairDesktop}>
|
||||
<QrCode size={17} color={colors.bgBase} />
|
||||
<Text style={styles.primaryButtonText}>Pair Desktop</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<View style={styles.stepsSection}>
|
||||
<Text style={styles.sectionHeading}>How it works</Text>
|
||||
{ONBOARDING_STEPS.map((step, index) => (
|
||||
<View key={step.title} style={[styles.stepRow, index > 0 && styles.stepRowBorder]}>
|
||||
<View style={styles.stepNum}>
|
||||
<Text style={styles.stepNumText}>{index + 1}</Text>
|
||||
</View>
|
||||
<View style={styles.stepText}>
|
||||
<Text style={styles.stepTitle}>{step.title}</Text>
|
||||
<Text style={styles.stepDesc}>{step.desc}</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
emptyContainer: { flex: 1 },
|
||||
emptyHero: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 32,
|
||||
paddingBottom: 40
|
||||
},
|
||||
emptyTitle: {
|
||||
fontSize: 22,
|
||||
fontWeight: '700',
|
||||
color: colors.textPrimary,
|
||||
textAlign: 'center',
|
||||
marginBottom: 10
|
||||
},
|
||||
emptyBody: {
|
||||
fontSize: 15,
|
||||
color: colors.textSecondary,
|
||||
textAlign: 'center',
|
||||
lineHeight: 22,
|
||||
marginBottom: 32
|
||||
},
|
||||
primaryButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
backgroundColor: colors.textPrimary,
|
||||
paddingHorizontal: 28,
|
||||
paddingVertical: 14,
|
||||
borderRadius: radii.card
|
||||
},
|
||||
primaryButtonText: { color: colors.bgBase, fontSize: 15, fontWeight: '700' },
|
||||
stepsSection: { paddingHorizontal: spacing.xl },
|
||||
sectionHeading: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
color: colors.textMuted,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.6,
|
||||
marginBottom: spacing.sm,
|
||||
paddingHorizontal: spacing.xs
|
||||
},
|
||||
stepRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
gap: 14,
|
||||
paddingVertical: spacing.lg
|
||||
},
|
||||
stepRowBorder: { borderTopWidth: 1, borderTopColor: colors.borderSubtle },
|
||||
stepNum: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 8,
|
||||
backgroundColor: 'rgba(255,255,255,0.04)',
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginTop: 1
|
||||
},
|
||||
stepNumText: { fontSize: 12, fontWeight: '700', color: colors.textSecondary },
|
||||
stepText: { flex: 1 },
|
||||
stepTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary,
|
||||
marginBottom: 3
|
||||
},
|
||||
stepDesc: { fontSize: 12, color: colors.textMuted, lineHeight: 17 }
|
||||
})
|
||||
@@ -0,0 +1,144 @@
|
||||
import { memo, useCallback, type ReactElement } from 'react'
|
||||
import { FlatList, StyleSheet, View } from 'react-native'
|
||||
import type { ListRenderItemInfo } from 'react-native'
|
||||
import { MobileHostCard } from '../components/MobileHostCard'
|
||||
import type { HomeStatsSummary } from '../stats/home-stats-total'
|
||||
import { spacing } from '../theme/mobile-theme'
|
||||
import { classifyConnection } from '../transport/connection-health'
|
||||
import { resolveHomeHostConnectionState } from '../transport/home-host-auto-connect'
|
||||
import type { MobileConnectionPath } from '../transport/stable-logical-rpc-client'
|
||||
import type { ConnectionState, HostCatalogEntry } from '../transport/types'
|
||||
import type { HostWorktreeInfo } from '../worktree/home-worktree-info'
|
||||
import { MobileHomeListHeader } from './MobileHomeListHeader'
|
||||
|
||||
type MobileHomeHostListProps = {
|
||||
autoConnectHostIds: string[]
|
||||
bottomInset: number
|
||||
contentMaxWidth: number
|
||||
footer: ReactElement
|
||||
hostAttempts: Record<string, number>
|
||||
hostLastConnected: Record<string, number | null>
|
||||
hostPairingRejected: Record<string, boolean>
|
||||
hostPaths: Record<string, MobileConnectionPath>
|
||||
hostPendingPaths: Record<string, MobileConnectionPath | null>
|
||||
hosts: HostCatalogEntry[]
|
||||
hostStates: Record<string, ConnectionState>
|
||||
isWideLayout: boolean
|
||||
stats: HomeStatsSummary | null
|
||||
worktreeInfo: Record<string, HostWorktreeInfo>
|
||||
onOpen: (host: HostCatalogEntry) => void
|
||||
onLongPress: (host: HostCatalogEntry) => void
|
||||
onOpenActions: (host: HostCatalogEntry) => void
|
||||
}
|
||||
|
||||
export function MobileHomeHostList(props: MobileHomeHostListProps) {
|
||||
const renderHost = useCallback(
|
||||
({ item }: ListRenderItemInfo<HostCatalogEntry>) => (
|
||||
<MobileHomeHostRow
|
||||
item={item}
|
||||
autoConnectHostIds={props.autoConnectHostIds}
|
||||
hostAttempts={props.hostAttempts}
|
||||
hostLastConnected={props.hostLastConnected}
|
||||
hostPairingRejected={props.hostPairingRejected}
|
||||
hostPaths={props.hostPaths}
|
||||
hostPendingPaths={props.hostPendingPaths}
|
||||
hostStates={props.hostStates}
|
||||
worktreeInfo={props.worktreeInfo}
|
||||
onOpen={props.onOpen}
|
||||
onLongPress={props.onLongPress}
|
||||
onOpenActions={props.onOpenActions}
|
||||
/>
|
||||
),
|
||||
[
|
||||
props.autoConnectHostIds,
|
||||
props.hostAttempts,
|
||||
props.hostLastConnected,
|
||||
props.hostPairingRejected,
|
||||
props.hostPaths,
|
||||
props.hostPendingPaths,
|
||||
props.hostStates,
|
||||
props.onLongPress,
|
||||
props.onOpen,
|
||||
props.onOpenActions,
|
||||
props.worktreeInfo
|
||||
]
|
||||
)
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
data={props.hosts}
|
||||
keyExtractor={(host) => host.id}
|
||||
contentContainerStyle={[
|
||||
styles.list,
|
||||
{ paddingBottom: spacing.xl + props.bottomInset },
|
||||
props.isWideLayout && {
|
||||
maxWidth: props.contentMaxWidth,
|
||||
width: '100%',
|
||||
alignSelf: 'center'
|
||||
}
|
||||
]}
|
||||
ListHeaderComponent={<MobileHomeListHeader stats={props.stats} />}
|
||||
ItemSeparatorComponent={CardGap}
|
||||
renderItem={renderHost}
|
||||
ListFooterComponent={props.footer}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type MobileHomeHostRowProps = Pick<
|
||||
MobileHomeHostListProps,
|
||||
| 'autoConnectHostIds'
|
||||
| 'hostAttempts'
|
||||
| 'hostLastConnected'
|
||||
| 'hostPairingRejected'
|
||||
| 'hostPaths'
|
||||
| 'hostPendingPaths'
|
||||
| 'hostStates'
|
||||
| 'worktreeInfo'
|
||||
| 'onOpen'
|
||||
| 'onLongPress'
|
||||
| 'onOpenActions'
|
||||
> & { item: HostCatalogEntry }
|
||||
|
||||
const MobileHomeHostRow = memo(function MobileHomeHostRow(props: MobileHomeHostRowProps) {
|
||||
const { item, onLongPress, onOpen, onOpenActions } = props
|
||||
const state = resolveHomeHostConnectionState(
|
||||
item.id,
|
||||
props.hostStates[item.id],
|
||||
props.autoConnectHostIds
|
||||
)
|
||||
const verdict = classifyConnection({
|
||||
state,
|
||||
reconnectAttempts: props.hostAttempts[item.id] ?? 0,
|
||||
lastConnectedAt: props.hostLastConnected[item.id] ?? null,
|
||||
endpoint: item.endpoint,
|
||||
pendingPath: props.hostPendingPaths[item.id] ?? null,
|
||||
pairingRejected: props.hostPairingRejected[item.id] ?? false
|
||||
})
|
||||
const open = useCallback(() => onOpen(item), [item, onOpen])
|
||||
const longPress = useCallback(() => onLongPress(item), [item, onLongPress])
|
||||
const openActions = useCallback(() => onOpenActions(item), [item, onOpenActions])
|
||||
|
||||
return (
|
||||
<MobileHostCard
|
||||
host={item}
|
||||
credentialStatus={item.credentialStatus}
|
||||
state={state}
|
||||
verdict={verdict}
|
||||
path={props.hostPaths[item.id] ?? 'lan'}
|
||||
worktreeInfo={props.worktreeInfo[item.id]}
|
||||
onPress={open}
|
||||
onLongPress={longPress}
|
||||
onOpenActions={openActions}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
function CardGap() {
|
||||
return <View style={styles.cardGap} />
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
list: { paddingHorizontal: spacing.lg, paddingBottom: spacing.xl },
|
||||
cardGap: { height: spacing.sm }
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import { StyleSheet, Text, View } from 'react-native'
|
||||
import type { AccountsSnapshot } from '../components/AccountUsage'
|
||||
import { MobileHomeQuickActions } from '../components/MobileHomeQuickActions'
|
||||
import type { TaskProvider } from '../tasks/mobile-task-providers'
|
||||
import { colors, spacing } from '../theme/mobile-theme'
|
||||
import type { HostProfile } from '../transport/types'
|
||||
import type { HomeResumeCard } from '../worktree/home-resume-card'
|
||||
import { MobileHomeAccountUsageCards } from './MobileHomeAccountUsageCards'
|
||||
import { MobileHomeResumeCard } from './MobileHomeResumeCard'
|
||||
import { MobileHomeTasksCard } from './MobileHomeTasksCard'
|
||||
|
||||
export function MobileHomeListFooter(props: {
|
||||
accountsHosts: { host: HostProfile; snapshot: AccountsSnapshot }[]
|
||||
connectedHosts: HostProfile[]
|
||||
primaryHost: HostProfile | null
|
||||
primaryTaskProviders: TaskProvider[]
|
||||
resumeCard: HomeResumeCard | null
|
||||
onCreateWorkspace: (hostId: string) => void
|
||||
onOpenAccounts: (hostId: string) => void
|
||||
onOpenResume: (card: HomeResumeCard) => void
|
||||
onOpenTasks: (provider?: TaskProvider) => void
|
||||
onPairDesktop: () => void
|
||||
}) {
|
||||
return (
|
||||
<View>
|
||||
{props.resumeCard ? (
|
||||
<>
|
||||
<Text style={styles.sectionHeading}>Resume</Text>
|
||||
<MobileHomeResumeCard card={props.resumeCard} onOpen={props.onOpenResume} />
|
||||
</>
|
||||
) : null}
|
||||
<Text style={styles.sectionHeading}>Tasks</Text>
|
||||
<MobileHomeTasksCard
|
||||
enabled={props.primaryHost != null}
|
||||
providers={props.primaryTaskProviders}
|
||||
onOpen={props.onOpenTasks}
|
||||
/>
|
||||
<MobileHomeQuickActions
|
||||
connectedHosts={props.connectedHosts}
|
||||
onPairDesktop={props.onPairDesktop}
|
||||
onCreateWorkspace={props.onCreateWorkspace}
|
||||
/>
|
||||
<MobileHomeAccountUsageCards items={props.accountsHosts} onOpen={props.onOpenAccounts} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
sectionHeading: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
color: colors.textMuted,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.6,
|
||||
marginTop: spacing.lg,
|
||||
marginBottom: spacing.sm,
|
||||
paddingHorizontal: spacing.xs
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
import { StyleSheet, Text, View } from 'react-native'
|
||||
import type { HomeStatsSummary } from '../stats/home-stats-total'
|
||||
import { colors, spacing } from '../theme/mobile-theme'
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
const totalMinutes = Math.floor(ms / 60_000)
|
||||
const totalHours = Math.floor(totalMinutes / 60)
|
||||
const days = Math.floor(totalHours / 24)
|
||||
const hours = totalHours % 24
|
||||
if (days > 0) {
|
||||
return `${days}d ${hours}h`
|
||||
}
|
||||
const minutes = totalMinutes % 60
|
||||
return totalHours > 0 ? `${totalHours}h ${minutes}m` : `${totalMinutes}m`
|
||||
}
|
||||
|
||||
export function MobileHomeListHeader({ stats }: { stats: HomeStatsSummary | null }) {
|
||||
return (
|
||||
<View>
|
||||
<View style={styles.hero}>
|
||||
<Text style={styles.heroTitle}>Welcome back</Text>
|
||||
</View>
|
||||
{stats ? (
|
||||
<View style={styles.statsRow}>
|
||||
<View style={styles.statCard}>
|
||||
<Text style={styles.statValue}>{stats.totalAgentsSpawned.toLocaleString()}</Text>
|
||||
<Text style={styles.statLabel}>Agents spawned</Text>
|
||||
</View>
|
||||
<View style={styles.statCard}>
|
||||
<Text style={styles.statValue}>{formatDuration(stats.totalAgentTimeMs)}</Text>
|
||||
<Text style={styles.statLabel}>Agent time</Text>
|
||||
</View>
|
||||
<View style={styles.statCard}>
|
||||
<Text style={styles.statValue}>{stats.totalPRsCreated.toLocaleString()}</Text>
|
||||
<Text style={styles.statLabel}>PRs created</Text>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
<Text style={styles.sectionHeading}>Desktops</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
hero: { paddingTop: spacing.xs, paddingBottom: spacing.md },
|
||||
heroTitle: {
|
||||
color: colors.textPrimary,
|
||||
fontSize: 24,
|
||||
fontWeight: '800',
|
||||
letterSpacing: -0.3
|
||||
},
|
||||
statsRow: { flexDirection: 'row', gap: 10, marginBottom: spacing.lg },
|
||||
statCard: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(26,26,26,0.6)',
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
borderRadius: 10,
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: spacing.md
|
||||
},
|
||||
statValue: {
|
||||
color: colors.textPrimary,
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
letterSpacing: -0.3
|
||||
},
|
||||
statLabel: { color: colors.textMuted, fontSize: 11, fontWeight: '500', marginTop: 2 },
|
||||
sectionHeading: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
color: colors.textMuted,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.6,
|
||||
marginBottom: spacing.sm,
|
||||
paddingHorizontal: spacing.xs
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
import { ChevronRight, Terminal } from 'lucide-react-native'
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native'
|
||||
import { colors, radii, spacing } from '../theme/mobile-theme'
|
||||
import type { HomeResumeCard } from '../worktree/home-resume-card'
|
||||
|
||||
const REPO_COLORS = ['#8b5cf6', '#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#ec4899', '#06b6d4']
|
||||
|
||||
function homeResumeRepoColor(name: string): string {
|
||||
let hash = 0
|
||||
for (let index = 0; index < name.length; index += 1) {
|
||||
hash = (hash * 31 + name.charCodeAt(index)) | 0
|
||||
}
|
||||
return REPO_COLORS[Math.abs(hash) % REPO_COLORS.length]
|
||||
}
|
||||
|
||||
export function MobileHomeResumeCard(props: {
|
||||
card: HomeResumeCard
|
||||
onOpen: (card: HomeResumeCard) => void
|
||||
}) {
|
||||
return (
|
||||
<Pressable
|
||||
disabled={!props.card.actionable}
|
||||
style={({ pressed }) => [
|
||||
styles.resumeCard,
|
||||
!props.card.actionable && styles.cardDisabled,
|
||||
pressed && styles.cardPressed
|
||||
]}
|
||||
onPress={() => props.onOpen(props.card)}
|
||||
>
|
||||
<View style={styles.resumeIcon}>
|
||||
<Terminal size={18} color={colors.textSecondary} />
|
||||
</View>
|
||||
<View style={styles.resumeMain}>
|
||||
<Text style={styles.resumeTitle} numberOfLines={1}>
|
||||
{props.card.worktree.displayName}
|
||||
</Text>
|
||||
<View style={styles.resumeSub}>
|
||||
<View
|
||||
style={[
|
||||
styles.repoDot,
|
||||
{ backgroundColor: homeResumeRepoColor(props.card.worktree.repo) }
|
||||
]}
|
||||
/>
|
||||
<Text style={styles.resumeSubText} numberOfLines={1}>
|
||||
{props.card.worktree.repo}
|
||||
{' · '}
|
||||
{props.card.worktree.branch}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<ChevronRight size={16} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
resumeCard: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
borderRadius: radii.card,
|
||||
paddingLeft: spacing.md,
|
||||
paddingRight: spacing.md,
|
||||
paddingVertical: 12
|
||||
},
|
||||
cardDisabled: { opacity: 0.45 },
|
||||
cardPressed: { backgroundColor: colors.bgRaised },
|
||||
resumeIcon: {
|
||||
width: 46,
|
||||
height: 46,
|
||||
borderRadius: 13,
|
||||
backgroundColor: colors.bgRaised,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: 14
|
||||
},
|
||||
resumeMain: { flex: 1, minWidth: 0 },
|
||||
resumeTitle: { fontSize: 13, fontWeight: '600', color: colors.textPrimary },
|
||||
resumeSub: { flexDirection: 'row', alignItems: 'center', gap: 6, marginTop: 3 },
|
||||
repoDot: { width: 7, height: 7, borderRadius: 3.5 },
|
||||
resumeSubText: { fontSize: 12, color: colors.textSecondary, flex: 1 }
|
||||
})
|
||||
@@ -0,0 +1,200 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { Alert, StyleSheet } from 'react-native'
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useOpenMobileAccounts } from '../accounts/use-open-mobile-accounts'
|
||||
import { getProvenCachedWorktrees } from '../cache/worktree-cache'
|
||||
import { ActionSheetModal } from '../components/ActionSheetModal'
|
||||
import { ConfirmModal } from '../components/ConfirmModal'
|
||||
import { getHostListActionSheetActions } from '../host-list-action-sheet-actions'
|
||||
import { hostNewWorktreeRoute } from '../host-route-action-state'
|
||||
import { hostRouteWithNotice } from '../host-route-notice'
|
||||
import { useResponsiveLayout } from '../layout/responsive-layout'
|
||||
import { triggerMediumImpact } from '../platform/haptics'
|
||||
import { useOpenMobileSession } from '../session/use-open-mobile-session'
|
||||
import type { TaskProvider } from '../tasks/mobile-task-providers'
|
||||
import { useOpenMobileTasks } from '../tasks/use-open-mobile-tasks'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import {
|
||||
useDisconnectHostClient,
|
||||
useForceReconnect,
|
||||
useForgetHostClient
|
||||
} from '../transport/client-context'
|
||||
import { hostEndpointLabel } from '../transport/host-endpoint-label'
|
||||
import { resolveHomeHostConnectionState } from '../transport/home-host-auto-connect'
|
||||
import { removeHostAndCloseClient } from '../transport/host-removal-lifecycle'
|
||||
import { loadHostCatalog } from '../transport/host-store'
|
||||
import type { HostCatalogEntry, HostProfile } from '../transport/types'
|
||||
import { useOpenMobileHostEdit } from '../transport/use-open-mobile-host-edit'
|
||||
import type { HomeWorktreeSummary } from '../worktree/home-worktree-info'
|
||||
import { isResumeTargetConfirmedMissing, type HomeResumeCard } from '../worktree/home-resume-card'
|
||||
import { MobileHomeEmptyState } from './MobileHomeEmptyState'
|
||||
import { MobileHomeHostList } from './MobileHomeHostList'
|
||||
import { MobileHomeListFooter } from './MobileHomeListFooter'
|
||||
import { MobileHomeTopBar } from './MobileHomeTopBar'
|
||||
import { useMobileHomeData } from './use-mobile-home-data'
|
||||
|
||||
export function MobileHomeScreen() {
|
||||
const data = useMobileHomeData()
|
||||
const insets = useSafeAreaInsets()
|
||||
const { isWideLayout, contentMaxWidth } = useResponsiveLayout()
|
||||
const openMobileHostEdit = useOpenMobileHostEdit()
|
||||
const openMobileTasks = useOpenMobileTasks()
|
||||
const openMobileSession = useOpenMobileSession()
|
||||
const openMobileAccounts = useOpenMobileAccounts()
|
||||
const disconnectHostClient = useDisconnectHostClient()
|
||||
const forgetHostClient = useForgetHostClient()
|
||||
const forceReconnectHost = useForceReconnect()
|
||||
const [actionTarget, setActionTarget] = useState<HostProfile | null>(null)
|
||||
const [confirmRemove, setConfirmRemove] = useState<{ id: string; name: string } | null>(null)
|
||||
|
||||
const openResume = useCallback(
|
||||
(card: HomeResumeCard) => {
|
||||
if (
|
||||
isResumeTargetConfirmedMissing(
|
||||
card,
|
||||
getProvenCachedWorktrees(card.hostId) as HomeWorktreeSummary[] | null
|
||||
)
|
||||
) {
|
||||
data.router.push(hostRouteWithNotice(card.hostId, 'worktree-missing'))
|
||||
return
|
||||
}
|
||||
openMobileSession({
|
||||
hostId: card.hostId,
|
||||
worktreeId: card.worktree.worktreeId,
|
||||
name: card.worktree.displayName || card.worktree.repo
|
||||
})
|
||||
},
|
||||
[data.router, openMobileSession]
|
||||
)
|
||||
|
||||
const openTasks = useCallback(
|
||||
(provider?: TaskProvider) => {
|
||||
if (data.primaryHost) {
|
||||
openMobileTasks(data.primaryHost.id, provider)
|
||||
}
|
||||
},
|
||||
[data.primaryHost, openMobileTasks]
|
||||
)
|
||||
|
||||
function openHost(host: HostCatalogEntry): void {
|
||||
if (host.credentialStatus === 'missing') {
|
||||
data.router.push('/pair-scan')
|
||||
} else if (host.credentialStatus === 'temporarily-unavailable') {
|
||||
void loadHostCatalog()
|
||||
.then(data.setHostCatalog)
|
||||
.catch(() => Alert.alert('Could not check pairing', 'Please try again.'))
|
||||
} else {
|
||||
data.router.push(`/h/${host.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
function openHostActions(host: HostCatalogEntry): void {
|
||||
if (host.profile) {
|
||||
setActionTarget(host.profile)
|
||||
} else {
|
||||
setConfirmRemove(host)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemove(): Promise<void> {
|
||||
if (!confirmRemove) {
|
||||
return
|
||||
}
|
||||
const host = confirmRemove
|
||||
try {
|
||||
await removeHostAndCloseClient(host.id, forgetHostClient)
|
||||
setConfirmRemove(null)
|
||||
data.setHostCatalog(await loadHostCatalog())
|
||||
} catch {
|
||||
setConfirmRemove(host)
|
||||
Alert.alert('Could not remove host', 'Please try again.')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
<MobileHomeTopBar onOpenSettings={() => data.router.push('/settings')} />
|
||||
{data.hostCatalog.length === 0 ? (
|
||||
<MobileHomeEmptyState
|
||||
bottomInset={insets.bottom}
|
||||
contentMaxWidth={contentMaxWidth}
|
||||
isWideLayout={isWideLayout}
|
||||
onPairDesktop={() => data.router.push('/pair-scan')}
|
||||
/>
|
||||
) : (
|
||||
<MobileHomeHostList
|
||||
autoConnectHostIds={data.autoConnectHostIds}
|
||||
bottomInset={insets.bottom}
|
||||
contentMaxWidth={contentMaxWidth}
|
||||
footer={
|
||||
<MobileHomeListFooter
|
||||
accountsHosts={data.accountsHosts}
|
||||
connectedHosts={data.connectedHosts}
|
||||
primaryHost={data.primaryHost}
|
||||
primaryTaskProviders={data.primaryTaskProviders}
|
||||
resumeCard={data.resumeCard}
|
||||
onCreateWorkspace={(hostId) => data.router.push(hostNewWorktreeRoute(hostId))}
|
||||
onOpenAccounts={openMobileAccounts}
|
||||
onOpenResume={openResume}
|
||||
onOpenTasks={openTasks}
|
||||
onPairDesktop={() => data.router.push('/pair-scan')}
|
||||
/>
|
||||
}
|
||||
hostAttempts={data.hostAttempts}
|
||||
hostLastConnected={data.hostLastConnected}
|
||||
hostPairingRejected={data.hostPairingRejected}
|
||||
hostPaths={data.hostPaths}
|
||||
hostPendingPaths={data.hostPendingPaths}
|
||||
hosts={data.sortedHostCatalog}
|
||||
hostStates={data.hostStates}
|
||||
isWideLayout={isWideLayout}
|
||||
stats={data.stats}
|
||||
worktreeInfo={data.worktreeInfo}
|
||||
onOpen={openHost}
|
||||
onLongPress={(host) => {
|
||||
triggerMediumImpact()
|
||||
openHostActions(host)
|
||||
}}
|
||||
onOpenActions={openHostActions}
|
||||
/>
|
||||
)}
|
||||
<ActionSheetModal
|
||||
visible={actionTarget != null}
|
||||
title={actionTarget?.name}
|
||||
message={actionTarget ? hostEndpointLabel(actionTarget.endpoint) : undefined}
|
||||
actions={getHostListActionSheetActions({
|
||||
host: actionTarget,
|
||||
state: actionTarget
|
||||
? resolveHomeHostConnectionState(
|
||||
actionTarget.id,
|
||||
data.hostStates[actionTarget.id],
|
||||
data.autoConnectHostIds
|
||||
)
|
||||
: 'disconnected',
|
||||
hasEverConnected: actionTarget
|
||||
? (data.hostLastConnected[actionTarget.id] ?? null) != null
|
||||
: false,
|
||||
onDismiss: () => setActionTarget(null),
|
||||
onReconnect: (hostId) => void forceReconnectHost(hostId),
|
||||
onDisconnect: disconnectHostClient,
|
||||
onEdit: openMobileHostEdit,
|
||||
onRemove: (host) => setConfirmRemove(host)
|
||||
})}
|
||||
onClose={() => setActionTarget(null)}
|
||||
/>
|
||||
<ConfirmModal
|
||||
visible={confirmRemove != null}
|
||||
title="Remove Host"
|
||||
message={`Remove "${confirmRemove?.name}"? You can re-pair later.`}
|
||||
confirmLabel="Remove"
|
||||
destructive
|
||||
onConfirm={() => void handleRemove()}
|
||||
onCancel={() => setConfirmRemove(null)}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: colors.bgBase }
|
||||
})
|
||||
@@ -0,0 +1,118 @@
|
||||
import { ChevronRight, ListTodo } from 'lucide-react-native'
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native'
|
||||
import { TaskProviderLogo } from '../components/TaskProviderLogo'
|
||||
import type { TaskProvider } from '../tasks/mobile-task-providers'
|
||||
import { colors, radii, spacing } from '../theme/mobile-theme'
|
||||
|
||||
const TASK_PROVIDER_LABELS: Record<TaskProvider, string> = {
|
||||
github: 'GitHub',
|
||||
gitlab: 'GitLab',
|
||||
linear: 'Linear'
|
||||
}
|
||||
|
||||
export function MobileHomeTasksCard(props: {
|
||||
enabled: boolean
|
||||
providers: TaskProvider[]
|
||||
onOpen: (provider?: TaskProvider) => void
|
||||
}) {
|
||||
return (
|
||||
<Pressable
|
||||
disabled={!props.enabled}
|
||||
style={({ pressed }) => [
|
||||
styles.card,
|
||||
!props.enabled && styles.cardDisabled,
|
||||
pressed && styles.cardPressed
|
||||
]}
|
||||
onPress={() => props.onOpen()}
|
||||
>
|
||||
<View style={styles.icon}>
|
||||
<ListTodo size={18} color={colors.textSecondary} />
|
||||
</View>
|
||||
<View style={styles.main}>
|
||||
<Text style={styles.title}>Tasks</Text>
|
||||
<Text style={styles.subtitle} numberOfLines={1}>
|
||||
{props.providers.length > 0
|
||||
? props.providers.map((provider) => TASK_PROVIDER_LABELS[provider]).join(' · ')
|
||||
: 'No task sources connected'}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.trailing}>
|
||||
<View
|
||||
style={styles.providerRow}
|
||||
accessibilityLabel={props.providers
|
||||
.map((provider) => TASK_PROVIDER_LABELS[provider])
|
||||
.join(', ')}
|
||||
>
|
||||
{props.providers.map((provider) => (
|
||||
<Pressable
|
||||
key={provider}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Open ${TASK_PROVIDER_LABELS[provider]} tasks`}
|
||||
hitSlop={8}
|
||||
style={({ pressed }) => [
|
||||
styles.providerButton,
|
||||
pressed && styles.providerButtonPressed
|
||||
]}
|
||||
onPress={(event) => {
|
||||
event.stopPropagation()
|
||||
props.onOpen(provider)
|
||||
}}
|
||||
>
|
||||
<TaskProviderLogo provider={provider} size={22} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
<ChevronRight size={16} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
borderRadius: radii.card,
|
||||
minHeight: 72,
|
||||
paddingLeft: spacing.md,
|
||||
paddingRight: spacing.md,
|
||||
paddingVertical: 12
|
||||
},
|
||||
cardDisabled: { opacity: 0.45 },
|
||||
cardPressed: { backgroundColor: colors.bgRaised },
|
||||
icon: {
|
||||
width: 46,
|
||||
height: 46,
|
||||
borderRadius: 13,
|
||||
backgroundColor: colors.bgRaised,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: 14
|
||||
},
|
||||
main: { flex: 1, minWidth: 0 },
|
||||
title: { fontSize: 13, fontWeight: '600', color: colors.textPrimary },
|
||||
subtitle: { fontSize: 12, color: colors.textSecondary, marginTop: 3 },
|
||||
trailing: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexShrink: 0,
|
||||
marginLeft: spacing.sm
|
||||
},
|
||||
providerRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
gap: 2
|
||||
},
|
||||
providerButton: {
|
||||
width: 34,
|
||||
height: 34,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: radii.button
|
||||
},
|
||||
providerButtonPressed: { backgroundColor: colors.bgRaised }
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Settings } from 'lucide-react-native'
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native'
|
||||
import { OrcaLogo } from '../components/OrcaLogo'
|
||||
import { colors, spacing } from '../theme/mobile-theme'
|
||||
|
||||
export function MobileHomeTopBar({ onOpenSettings }: { onOpenSettings: () => void }) {
|
||||
return (
|
||||
<View style={styles.topBar}>
|
||||
<View style={styles.brandLockup}>
|
||||
<View style={styles.logoMark}>
|
||||
<OrcaLogo size={18} />
|
||||
</View>
|
||||
<Text style={styles.brandName}>Orca</Text>
|
||||
</View>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.iconButton, pressed && styles.iconButtonPressed]}
|
||||
onPress={onOpenSettings}
|
||||
>
|
||||
<Settings size={18} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
topBar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingTop: spacing.sm,
|
||||
paddingBottom: spacing.md
|
||||
},
|
||||
brandLockup: { flexDirection: 'row', alignItems: 'center', minWidth: 0 },
|
||||
logoMark: { marginRight: spacing.sm },
|
||||
brandName: { color: colors.textPrimary, fontSize: 17, fontWeight: '700' },
|
||||
iconButton: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
iconButtonPressed: { backgroundColor: colors.bgRaised }
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { HostCatalogEntry } from '../transport/types'
|
||||
import {
|
||||
reconcileMobileHomeHostStates,
|
||||
type MobileHomeClientEntry
|
||||
} from './mobile-home-connection-state'
|
||||
|
||||
function catalogHost(
|
||||
id: string,
|
||||
credentialStatus: HostCatalogEntry['credentialStatus']
|
||||
): HostCatalogEntry {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
endpoint: `ws://${id}`,
|
||||
publicKeyB64: `key-${id}`,
|
||||
lastConnected: 0,
|
||||
credentialStatus,
|
||||
profile: null
|
||||
}
|
||||
}
|
||||
|
||||
function liveClient(hostId: string, state: MobileHomeClientEntry['state']): MobileHomeClientEntry {
|
||||
return { hostId, state, client: {} as RpcClient }
|
||||
}
|
||||
|
||||
describe('mobile home connection state', () => {
|
||||
it('keeps an unacquired ready host absent on the initial frame', () => {
|
||||
const previous = {}
|
||||
const next = reconcileMobileHomeHostStates(previous, [], [catalogHost('host-1', 'ready')])
|
||||
|
||||
expect(next).toBe(previous)
|
||||
})
|
||||
|
||||
it('uses credential status when a host has no live client', () => {
|
||||
const next = reconcileMobileHomeHostStates(
|
||||
{ ready: 'connected' },
|
||||
[],
|
||||
[
|
||||
catalogHost('missing', 'missing'),
|
||||
catalogHost('locked', 'temporarily-unavailable'),
|
||||
catalogHost('ready', 'ready')
|
||||
]
|
||||
)
|
||||
|
||||
expect(next).toEqual({ missing: 'auth-failed', locked: 'disconnected', ready: 'disconnected' })
|
||||
})
|
||||
|
||||
it('prefers the live client and drops hosts removed from the catalog', () => {
|
||||
const next = reconcileMobileHomeHostStates(
|
||||
{ removed: 'connected', current: 'reconnecting' },
|
||||
[liveClient('current', 'connected')],
|
||||
[catalogHost('current', 'missing')]
|
||||
)
|
||||
|
||||
expect(next).toEqual({ current: 'connected' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { ConnectionState, HostCatalogEntry } from '../transport/types'
|
||||
|
||||
export type MobileHomeClientEntry = {
|
||||
hostId: string
|
||||
client: RpcClient
|
||||
state: ConnectionState
|
||||
}
|
||||
|
||||
export function readMobileHomeReconnectAttempts(
|
||||
previous: Record<string, number>,
|
||||
clients: MobileHomeClientEntry[]
|
||||
): Record<string, number> {
|
||||
const next = { ...previous }
|
||||
let changed = false
|
||||
for (const entry of clients) {
|
||||
const attempts = entry.client.getReconnectAttempt()
|
||||
if (next[entry.hostId] !== attempts) {
|
||||
next[entry.hostId] = attempts
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed ? next : previous
|
||||
}
|
||||
|
||||
export function readMobileHomeLastConnected(
|
||||
previous: Record<string, number | null>,
|
||||
clients: MobileHomeClientEntry[]
|
||||
): Record<string, number | null> {
|
||||
const next = { ...previous }
|
||||
let changed = false
|
||||
for (const entry of clients) {
|
||||
const connectedAt = entry.client.getLastConnectedAt()
|
||||
if (next[entry.hostId] !== connectedAt) {
|
||||
next[entry.hostId] = connectedAt
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed ? next : previous
|
||||
}
|
||||
|
||||
export function reconcileMobileHomeHostStates(
|
||||
previous: Record<string, ConnectionState>,
|
||||
clients: MobileHomeClientEntry[],
|
||||
hostCatalog: HostCatalogEntry[]
|
||||
): Record<string, ConnectionState> {
|
||||
const next = { ...previous }
|
||||
let changed = false
|
||||
const liveIds = new Set(clients.map((entry) => entry.hostId))
|
||||
for (const entry of clients) {
|
||||
if (next[entry.hostId] !== entry.state) {
|
||||
next[entry.hostId] = entry.state
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
for (const host of hostCatalog) {
|
||||
if (liveIds.has(host.id)) {
|
||||
continue
|
||||
}
|
||||
const fallback = host.credentialStatus === 'missing' ? 'auth-failed' : 'disconnected'
|
||||
const previousState = next[host.id]
|
||||
const shouldTrack =
|
||||
host.credentialStatus !== 'ready' ||
|
||||
(previousState != null && previousState !== 'disconnected' && previousState !== 'auth-failed')
|
||||
if (shouldTrack && previousState !== fallback) {
|
||||
next[host.id] = fallback
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
for (const id of Object.keys(next)) {
|
||||
if (!liveIds.has(id) && !hostCatalog.some((host) => host.id === id)) {
|
||||
delete next[id]
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed ? next : previous
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { decodeAccountsSnapshot, type AccountsSnapshot } from '../components/AccountUsage'
|
||||
import type { HomeStatsSummary } from '../stats/home-stats-total'
|
||||
import {
|
||||
filterAvailableTaskProviders,
|
||||
normalizeVisibleTaskProviders,
|
||||
type TaskProvider
|
||||
} from '../tasks/mobile-task-providers'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { sendSingleFlightRequest } from '../transport/request-single-flight'
|
||||
|
||||
type HomeTaskSettings = {
|
||||
visibleTaskProviders?: unknown
|
||||
}
|
||||
|
||||
type HomePreflightStatus = {
|
||||
glab?: { installed?: boolean }
|
||||
}
|
||||
|
||||
type HomeLinearStatus = {
|
||||
connected?: boolean
|
||||
}
|
||||
|
||||
export type HomeStatsSetter = (
|
||||
updater: (previous: Record<string, HomeStatsSummary>) => Record<string, HomeStatsSummary>
|
||||
) => void
|
||||
|
||||
export type HomeAccountsSetter = (
|
||||
updater: (previous: Record<string, AccountsSnapshot>) => Record<string, AccountsSnapshot>
|
||||
) => void
|
||||
|
||||
export type HomeTaskProvidersSetter = (
|
||||
updater: (previous: Record<string, TaskProvider[]>) => Record<string, TaskProvider[]>
|
||||
) => void
|
||||
|
||||
export function fetchMobileHomeStats(
|
||||
client: RpcClient,
|
||||
hostId: string,
|
||||
setStats: HomeStatsSetter,
|
||||
disposed: () => boolean
|
||||
): void {
|
||||
sendSingleFlightRequest(client, hostId, 'stats.summary')
|
||||
.then((response) => {
|
||||
if (!disposed() && response.ok) {
|
||||
setStats((previous) => ({
|
||||
...previous,
|
||||
[hostId]: response.result as HomeStatsSummary
|
||||
}))
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
export function fetchMobileHomeAccounts(
|
||||
client: RpcClient,
|
||||
hostId: string,
|
||||
setSnapshots: HomeAccountsSetter,
|
||||
disposed: () => boolean
|
||||
): void {
|
||||
sendSingleFlightRequest(client, hostId, 'accounts.list')
|
||||
.then((response) => {
|
||||
if (!disposed() && response.ok) {
|
||||
const snapshot = decodeAccountsSnapshot(response.result)
|
||||
setSnapshots((previous) => ({ ...previous, [hostId]: snapshot }))
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
export function fetchMobileHomeTaskProviders(
|
||||
client: RpcClient,
|
||||
hostId: string,
|
||||
setProviders: HomeTaskProvidersSetter,
|
||||
disposed: () => boolean
|
||||
): void {
|
||||
Promise.all([
|
||||
sendSingleFlightRequest(client, hostId, 'settings.get'),
|
||||
sendSingleFlightRequest(client, hostId, 'preflight.check'),
|
||||
sendSingleFlightRequest(client, hostId, 'linear.status')
|
||||
])
|
||||
.then(([settingsResponse, preflightResponse, linearResponse]) => {
|
||||
if (disposed()) {
|
||||
return
|
||||
}
|
||||
const settings = settingsResponse.ok
|
||||
? (((settingsResponse.result as { settings?: HomeTaskSettings }).settings ??
|
||||
{}) as HomeTaskSettings)
|
||||
: {}
|
||||
const preflight = preflightResponse.ok
|
||||
? (preflightResponse.result as HomePreflightStatus)
|
||||
: null
|
||||
const linear = linearResponse.ok ? (linearResponse.result as HomeLinearStatus) : null
|
||||
const providers = filterAvailableTaskProviders(
|
||||
normalizeVisibleTaskProviders(settings.visibleTaskProviders),
|
||||
{
|
||||
gitlabInstalled: preflight?.glab?.installed === true,
|
||||
linearConnected: linear?.connected === true
|
||||
}
|
||||
)
|
||||
setProviders((previous) => ({ ...previous, [hostId]: providers }))
|
||||
})
|
||||
.catch(() => {
|
||||
if (!disposed()) {
|
||||
setProviders((previous) =>
|
||||
previous[hostId] ? previous : { ...previous, [hostId]: ['github'] }
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import { useRouter, useFocusEffect } from 'expo-router'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { AccountsSnapshot } from '../components/AccountUsage'
|
||||
import { hasRenderableUsage } from '../components/AccountUsage'
|
||||
import { loadHomeSnapshot, saveHomeSnapshot } from '../cache/home-snapshot-cache'
|
||||
import { getCachedWorktrees, setCachedWorktrees } from '../cache/worktree-cache'
|
||||
import {
|
||||
loadMobileOnboardingSteps,
|
||||
mobileOnboardingDestination
|
||||
} from '../onboarding/mobile-onboarding-plan'
|
||||
import { totalHomeStats, type HomeStatsSummary } from '../stats/home-stats-total'
|
||||
import type { TaskProvider } from '../tasks/mobile-task-providers'
|
||||
import { selectConnectableHostProfiles } from '../transport/host-catalog-selection'
|
||||
import { loadHostCatalog } from '../transport/host-store'
|
||||
import type { HostCatalogEntry, HostProfile } from '../transport/types'
|
||||
import { fetchHomeHostWorktreeInfo } from '../worktree/home-host-worktree-fetch'
|
||||
import type { HomeWorktreeSummary, HostWorktreeInfo } from '../worktree/home-worktree-info'
|
||||
import {
|
||||
LAST_VISITED_WORKTREE_STORAGE_KEY,
|
||||
readLastVisitedWorktreeRecord
|
||||
} from '../worktree/last-visited-worktree-repo'
|
||||
import { selectHomeResumeCard } from '../worktree/home-resume-card'
|
||||
import {
|
||||
fetchMobileHomeAccounts,
|
||||
fetchMobileHomeStats,
|
||||
fetchMobileHomeTaskProviders
|
||||
} from './mobile-home-host-requests'
|
||||
import { useMobileHomeHostConnections } from './use-mobile-home-host-connections'
|
||||
|
||||
export function useMobileHomeData() {
|
||||
const router = useRouter()
|
||||
const [hostCatalog, setHostCatalog] = useState<HostCatalogEntry[]>([])
|
||||
const [statsByHost, setStatsByHost] = useState<Record<string, HomeStatsSummary>>({})
|
||||
const [worktreeInfo, setWorktreeInfo] = useState<Record<string, HostWorktreeInfo>>({})
|
||||
const [accountsByHost, setAccountsByHost] = useState<Record<string, AccountsSnapshot>>({})
|
||||
const [taskProvidersByHost, setTaskProvidersByHost] = useState<Record<string, TaskProvider[]>>({})
|
||||
const [lastVisited, setLastVisited] = useState<{ hostId: string; worktreeId: string } | null>(
|
||||
null
|
||||
)
|
||||
const onboardingCheckedRef = useRef(false)
|
||||
const hydratedRef = useRef(false)
|
||||
const hosts = useMemo(() => selectConnectableHostProfiles(hostCatalog), [hostCatalog])
|
||||
const connections = useMobileHomeHostConnections(hosts, hostCatalog, {
|
||||
setStats: setStatsByHost,
|
||||
setWorktreeInfo,
|
||||
setAccounts: setAccountsByHost,
|
||||
setTaskProviders: setTaskProvidersByHost
|
||||
})
|
||||
const allClientsRef = useRef(connections.allClients)
|
||||
|
||||
useEffect(() => {
|
||||
if (hydratedRef.current) {
|
||||
return
|
||||
}
|
||||
hydratedRef.current = true
|
||||
let cancelled = false
|
||||
void loadHomeSnapshot().then((snapshot) => {
|
||||
if (cancelled || !snapshot) {
|
||||
return
|
||||
}
|
||||
setWorktreeInfo((previous) =>
|
||||
Object.keys(previous).length > 0 ? previous : snapshot.worktreeInfo
|
||||
)
|
||||
setAccountsByHost((previous) =>
|
||||
Object.keys(previous).length > 0 ? previous : snapshot.accountsByHost
|
||||
)
|
||||
for (const [hostId, info] of Object.entries(snapshot.worktreeInfo)) {
|
||||
if (info.lastActiveWorktree) {
|
||||
setCachedWorktrees(hostId, [info.lastActiveWorktree])
|
||||
}
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (Object.keys(worktreeInfo).length > 0 || Object.keys(accountsByHost).length > 0) {
|
||||
saveHomeSnapshot({ worktreeInfo, accountsByHost, savedAt: Date.now() })
|
||||
}
|
||||
}, [worktreeInfo, accountsByHost])
|
||||
|
||||
useEffect(() => {
|
||||
allClientsRef.current = connections.allClients
|
||||
}, [connections.allClients])
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
let stale = false
|
||||
void loadHostCatalog().then(async (catalog) => {
|
||||
if (stale) {
|
||||
return
|
||||
}
|
||||
setHostCatalog(catalog)
|
||||
if (catalog.length === 0 || onboardingCheckedRef.current) {
|
||||
return
|
||||
}
|
||||
onboardingCheckedRef.current = true
|
||||
const steps = await loadMobileOnboardingSteps()
|
||||
if (!stale && steps.length > 0) {
|
||||
router.replace(mobileOnboardingDestination(steps))
|
||||
}
|
||||
})
|
||||
void AsyncStorage.getItem(LAST_VISITED_WORKTREE_STORAGE_KEY).then((raw) => {
|
||||
if (!stale) {
|
||||
setLastVisited(readLastVisitedWorktreeRecord(raw))
|
||||
}
|
||||
})
|
||||
for (const entry of allClientsRef.current) {
|
||||
if (entry.client.getState() === 'connected') {
|
||||
fetchMobileHomeStats(entry.client, entry.hostId, setStatsByHost, () => stale)
|
||||
void fetchHomeHostWorktreeInfo(entry.client, entry.hostId, setWorktreeInfo, () => stale)
|
||||
fetchMobileHomeAccounts(entry.client, entry.hostId, setAccountsByHost, () => stale)
|
||||
fetchMobileHomeTaskProviders(
|
||||
entry.client,
|
||||
entry.hostId,
|
||||
setTaskProvidersByHost,
|
||||
() => stale
|
||||
)
|
||||
}
|
||||
}
|
||||
return () => {
|
||||
stale = true
|
||||
}
|
||||
}, [router])
|
||||
)
|
||||
|
||||
const sortedHosts = useMemo(
|
||||
() => hosts.toSorted((left, right) => right.lastConnected - left.lastConnected),
|
||||
[hosts]
|
||||
)
|
||||
const sortedHostCatalog = useMemo(
|
||||
() => hostCatalog.toSorted((left, right) => right.lastConnected - left.lastConnected),
|
||||
[hostCatalog]
|
||||
)
|
||||
const hostIds = useMemo(() => hosts.map((host) => host.id), [hosts])
|
||||
const stats = useMemo(() => totalHomeStats(statsByHost, hostIds), [statsByHost, hostIds])
|
||||
const resumeCard = useMemo(
|
||||
() =>
|
||||
selectHomeResumeCard({
|
||||
hosts: sortedHosts,
|
||||
hostStates: connections.hostStates,
|
||||
worktreeInfo,
|
||||
lastVisited,
|
||||
cachedWorktrees: (hostId) => getCachedWorktrees(hostId) as HomeWorktreeSummary[] | null
|
||||
}),
|
||||
[sortedHosts, connections.hostStates, worktreeInfo, lastVisited]
|
||||
)
|
||||
const accountsHosts = useMemo(() => {
|
||||
const items: { host: HostProfile; snapshot: AccountsSnapshot }[] = []
|
||||
for (const host of sortedHosts) {
|
||||
const snapshot = accountsByHost[host.id]
|
||||
if (
|
||||
connections.hostStates[host.id] === 'connected' &&
|
||||
snapshot &&
|
||||
(hasRenderableUsage(snapshot, 'claude') || hasRenderableUsage(snapshot, 'codex'))
|
||||
) {
|
||||
items.push({ host, snapshot })
|
||||
}
|
||||
}
|
||||
return items
|
||||
}, [sortedHosts, connections.hostStates, accountsByHost])
|
||||
const connectedHosts = useMemo(
|
||||
() => sortedHosts.filter((host) => connections.hostStates[host.id] === 'connected'),
|
||||
[sortedHosts, connections.hostStates]
|
||||
)
|
||||
const primaryHost = connectedHosts[0] ?? null
|
||||
const primaryTaskProviders = primaryHost
|
||||
? (taskProvidersByHost[primaryHost.id] ?? ['github'])
|
||||
: []
|
||||
const hostPaths = Object.fromEntries(
|
||||
connections.allClients.map(({ hostId, path }) => [hostId, path])
|
||||
)
|
||||
const hostPendingPaths = Object.fromEntries(
|
||||
connections.allClients.map(({ hostId, pendingPath }) => [hostId, pendingPath])
|
||||
)
|
||||
const hostPairingRejected = Object.fromEntries(
|
||||
connections.allClients.map(({ hostId, pairingRejected }) => [hostId, pairingRejected])
|
||||
)
|
||||
|
||||
return {
|
||||
...connections,
|
||||
accountsHosts,
|
||||
connectedHosts,
|
||||
hostCatalog,
|
||||
hostPairingRejected,
|
||||
hostPaths,
|
||||
hostPendingPaths,
|
||||
primaryHost,
|
||||
primaryTaskProviders,
|
||||
resumeCard,
|
||||
router,
|
||||
setHostCatalog,
|
||||
sortedHostCatalog,
|
||||
stats,
|
||||
worktreeInfo
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { decodeAccountsSnapshot } from '../components/AccountUsage'
|
||||
import { subscribeToDesktopNotifications } from '../notifications/mobile-notifications'
|
||||
import { usePrimeHosts } from '../transport/client-context'
|
||||
import { createHostConnectRefetchGate } from '../transport/host-connect-refetch-gate'
|
||||
import { selectHomeAutoConnectHostIds } from '../transport/home-host-auto-connect'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { ConnectionState, HostCatalogEntry, HostProfile } from '../transport/types'
|
||||
import { useAllHostClients } from '../transport/use-all-host-clients'
|
||||
import {
|
||||
fetchHomeHostWorktreeInfo,
|
||||
type HostWorktreeInfoSetter
|
||||
} from '../worktree/home-host-worktree-fetch'
|
||||
import {
|
||||
readMobileHomeLastConnected,
|
||||
readMobileHomeReconnectAttempts,
|
||||
reconcileMobileHomeHostStates
|
||||
} from './mobile-home-connection-state'
|
||||
import {
|
||||
fetchMobileHomeStats,
|
||||
fetchMobileHomeTaskProviders,
|
||||
type HomeAccountsSetter,
|
||||
type HomeStatsSetter,
|
||||
type HomeTaskProvidersSetter
|
||||
} from './mobile-home-host-requests'
|
||||
|
||||
type Setters = {
|
||||
setStats: HomeStatsSetter
|
||||
setWorktreeInfo: HostWorktreeInfoSetter
|
||||
setAccounts: HomeAccountsSetter
|
||||
setTaskProviders: HomeTaskProvidersSetter
|
||||
}
|
||||
|
||||
function wireMobileHomeHostSubscriptions(
|
||||
entry: { hostId: string; client: RpcClient; state: ConnectionState },
|
||||
setters: Setters
|
||||
): () => void {
|
||||
let unsubscribeNotifications: (() => void) | null = null
|
||||
let unsubscribeAccounts: (() => void) | null = null
|
||||
const refetchGate = createHostConnectRefetchGate()
|
||||
const wireState = (state: ConnectionState): void => {
|
||||
const reconnected = refetchGate.observe(state)
|
||||
if (state === 'connected') {
|
||||
unsubscribeNotifications ??= subscribeToDesktopNotifications(entry.client, entry.hostId)
|
||||
unsubscribeAccounts ??= entry.client.subscribe('accounts.subscribe', null, (payload) => {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return
|
||||
}
|
||||
const event = payload as { type?: string; snapshot?: unknown }
|
||||
if (event.type !== 'ready' && event.type !== 'snapshot') {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const snapshot = decodeAccountsSnapshot(event.snapshot)
|
||||
setters.setAccounts((previous) => ({ ...previous, [entry.hostId]: snapshot }))
|
||||
} catch {
|
||||
// Keep the last proven snapshot when a mixed-version host publishes malformed data.
|
||||
}
|
||||
})
|
||||
if (reconnected) {
|
||||
fetchMobileHomeStats(entry.client, entry.hostId, setters.setStats, () => false)
|
||||
void fetchHomeHostWorktreeInfo(
|
||||
entry.client,
|
||||
entry.hostId,
|
||||
setters.setWorktreeInfo,
|
||||
() => false
|
||||
)
|
||||
fetchMobileHomeTaskProviders(
|
||||
entry.client,
|
||||
entry.hostId,
|
||||
setters.setTaskProviders,
|
||||
() => false
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
unsubscribeNotifications?.()
|
||||
unsubscribeNotifications = null
|
||||
unsubscribeAccounts?.()
|
||||
unsubscribeAccounts = null
|
||||
}
|
||||
wireState(entry.state)
|
||||
const unsubscribeState = entry.client.onStateChange(wireState)
|
||||
return () => {
|
||||
unsubscribeState()
|
||||
unsubscribeNotifications?.()
|
||||
unsubscribeAccounts?.()
|
||||
}
|
||||
}
|
||||
|
||||
export function useMobileHomeHostConnections(
|
||||
hosts: HostProfile[],
|
||||
hostCatalog: HostCatalogEntry[],
|
||||
setters: Setters
|
||||
) {
|
||||
const [hostStates, setHostStates] = useState<Record<string, ConnectionState>>({})
|
||||
const [hostAttempts, setHostAttempts] = useState<Record<string, number>>({})
|
||||
const [hostLastConnected, setHostLastConnected] = useState<Record<string, number | null>>({})
|
||||
const hostIds = useMemo(() => hosts.map((host) => host.id), [hosts])
|
||||
const autoConnectHostIds = useMemo(() => selectHomeAutoConnectHostIds(hosts), [hosts])
|
||||
const allClients = useAllHostClients(hostIds, {
|
||||
autoConnectHostIds,
|
||||
closeUnusedOnRelease: true
|
||||
})
|
||||
const primeHosts = usePrimeHosts()
|
||||
const subscriptionsRef = useRef(new Map<string, { client: RpcClient; cleanup: () => void }>())
|
||||
const { setAccounts, setStats, setTaskProviders, setWorktreeInfo } = setters
|
||||
|
||||
useEffect(() => {
|
||||
if (hosts.length > 0) {
|
||||
primeHosts(hosts)
|
||||
}
|
||||
}, [hosts, primeHosts])
|
||||
|
||||
useEffect(() => {
|
||||
setHostAttempts((previous) => readMobileHomeReconnectAttempts(previous, allClients))
|
||||
setHostLastConnected((previous) => readMobileHomeLastConnected(previous, allClients))
|
||||
setHostStates((previous) => reconcileMobileHomeHostStates(previous, allClients, hostCatalog))
|
||||
}, [allClients, hostCatalog])
|
||||
|
||||
useEffect(() => {
|
||||
const clientsByHostId = new Map(allClients.map((entry) => [entry.hostId, entry.client]))
|
||||
for (const [hostId, subscription] of subscriptionsRef.current) {
|
||||
if (clientsByHostId.get(hostId) !== subscription.client) {
|
||||
subscription.cleanup()
|
||||
subscriptionsRef.current.delete(hostId)
|
||||
}
|
||||
}
|
||||
const activeSetters = { setAccounts, setStats, setTaskProviders, setWorktreeInfo }
|
||||
for (const entry of allClients) {
|
||||
if (!subscriptionsRef.current.has(entry.hostId)) {
|
||||
subscriptionsRef.current.set(entry.hostId, {
|
||||
client: entry.client,
|
||||
cleanup: wireMobileHomeHostSubscriptions(entry, activeSetters)
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [allClients, setAccounts, setStats, setTaskProviders, setWorktreeInfo])
|
||||
|
||||
useEffect(() => {
|
||||
const subscriptions = subscriptionsRef.current
|
||||
return () => {
|
||||
for (const subscription of subscriptions.values()) {
|
||||
subscription.cleanup()
|
||||
}
|
||||
subscriptions.clear()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
allClients,
|
||||
autoConnectHostIds,
|
||||
hostAttempts,
|
||||
hostLastConnected,
|
||||
hostStates
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
type HostStackNavigationState
|
||||
} from '../navigation/host-stack-navigation'
|
||||
|
||||
const homeSource = readFileSync(new URL('../../app/index.tsx', import.meta.url), 'utf8')
|
||||
const homeSource = readFileSync(new URL('../home/MobileHomeScreen.tsx', import.meta.url), 'utf8')
|
||||
|
||||
function navigationHarness(initialState: HostStackNavigationState) {
|
||||
const stateListeners = new Set<() => void>()
|
||||
@@ -97,28 +97,18 @@ describe('mobile session route', () => {
|
||||
})
|
||||
|
||||
it('routes the home Resume card through the cold-navigator-safe transition', () => {
|
||||
const start = homeSource.indexOf('{/* ─── Resume card ─── */}')
|
||||
const end = homeSource.indexOf('{/* ─── Quick actions ─── */}', start)
|
||||
|
||||
// Assert the markers first: a renamed banner would otherwise slice garbage and
|
||||
// report a missing call instead of the real cause.
|
||||
expect(start).toBeGreaterThanOrEqual(0)
|
||||
expect(end).toBeGreaterThan(start)
|
||||
|
||||
const resumeCard = homeSource.slice(start, end)
|
||||
expect(resumeCard).toContain('openResume(')
|
||||
expect(resumeCard).not.toContain('router.push(')
|
||||
expect(homeSource).toContain('onOpenResume={openResume}')
|
||||
|
||||
// The tap handler itself must go through the coordinated transition; its only
|
||||
// direct push is the shallow noticed host-index route for a proven-missing target.
|
||||
const handlerStart = homeSource.indexOf('const openResume = useCallback(')
|
||||
const handlerEnd = homeSource.indexOf('[openMobileSession, router]', handlerStart)
|
||||
const handlerEnd = homeSource.indexOf('[data.router, openMobileSession]', handlerStart)
|
||||
expect(handlerStart).toBeGreaterThanOrEqual(0)
|
||||
expect(handlerEnd).toBeGreaterThan(handlerStart)
|
||||
|
||||
const openResume = homeSource.slice(handlerStart, handlerEnd)
|
||||
expect(openResume).toContain('openMobileSession({')
|
||||
expect(openResume.match(/router\.push\(/g)).toHaveLength(1)
|
||||
expect(openResume).toContain('router.push(hostRouteWithNotice(')
|
||||
expect(openResume).toContain('data.router.push(hostRouteWithNotice(')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -697,7 +697,9 @@ describe('useMobileNativeChatController streaming scope', () => {
|
||||
launchAgent: 'claude',
|
||||
agentStatus: {
|
||||
state: 'working',
|
||||
workingMode: undefined as 'monitoring' | undefined,
|
||||
agentType: 'claude',
|
||||
lastAssistantMessage: 'Partial reply',
|
||||
providerSession: { id: 'session-1' }
|
||||
},
|
||||
isActive: true
|
||||
@@ -723,6 +725,7 @@ describe('useMobileNativeChatController streaming scope', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
viewMode.isTabChatView = () => true
|
||||
workingTab.agentStatus.workingMode = undefined
|
||||
act(() => {
|
||||
renderer = create(createElement(Harness))
|
||||
})
|
||||
@@ -737,6 +740,7 @@ describe('useMobileNativeChatController streaming scope', () => {
|
||||
|
||||
it('holds the stream scope and liveness while the user peeks at the terminal', () => {
|
||||
expect(controller?.showNativeChat).toBe(true)
|
||||
expect(controller?.nativeChatAgentWorking).toBe(true)
|
||||
const scopeKey = controller?.nativeChatStreamScopeKey
|
||||
expect(scopeKey).toContain('session-1')
|
||||
expect(controller?.nativeChatStreamLive).toBe(true)
|
||||
@@ -750,6 +754,16 @@ describe('useMobileNativeChatController streaming scope', () => {
|
||||
expect(controller?.nativeChatStreamLive).toBe(true)
|
||||
})
|
||||
|
||||
it('treats passive monitoring as outside the foreground turn', () => {
|
||||
workingTab.agentStatus.workingMode = 'monitoring'
|
||||
act(() => renderer?.update(createElement(Harness)))
|
||||
|
||||
expect(controller?.nativeChatAgentWorking).toBe(false)
|
||||
expect(controller?.nativeChatStreamLive).toBe(false)
|
||||
expect(controller?.nativeChatStreamingText).toBeUndefined()
|
||||
expect(controller?.nativeChatSessionOptions?.isWorking).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the delayed-send route guard view-gated, unlike the stream scope', () => {
|
||||
const before = answerSendArgs.at(-1)?.streamIdentity
|
||||
expect(before).toBe(controller?.nativeChatStreamScopeKey)
|
||||
|
||||
@@ -115,11 +115,14 @@ export function useMobileNativeChatController(args: {
|
||||
transcriptSettled: nativeChatSession.status === 'ready'
|
||||
})
|
||||
|
||||
const nativeChatStatus = activeChatResolution ? activeSessionTab?.agentStatus : null
|
||||
const nativeChatAgentWorking = nativeChatStatus?.state === 'working'
|
||||
const activeTabStatus = activeSessionTab?.agentStatus
|
||||
const activeTabAgentWorking =
|
||||
activeTabStatus?.state === 'working' && activeTabStatus.workingMode !== 'monitoring'
|
||||
const nativeChatStatus = activeChatResolution ? activeTabStatus : null
|
||||
const nativeChatAgentWorking = activeChatResolution != null && activeTabAgentWorking
|
||||
// Deliberately not gated on the chat view being visible: the streaming gate
|
||||
// has to tell "hidden mid-turn" from "the turn ended".
|
||||
const nativeChatStreamLive = activeSessionTab?.agentStatus?.state === 'working'
|
||||
const nativeChatStreamLive = activeTabAgentWorking
|
||||
// Throttle the streaming bubble: OpenCode emits a status frame per streamed
|
||||
// part, and each one re-renders and re-parses the whole accumulated markdown.
|
||||
const nativeChatStreamingText = useThrottledLatestValue(
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
import type { ConnectOptions, RpcClient, SendRequestOptions } from './rpc-client'
|
||||
import { RpcClientAuthenticationRetry } from './rpc-client-authentication-retry'
|
||||
import { RpcClientConnectionState } from './rpc-client-connection-state'
|
||||
import {
|
||||
RpcClientReconnectSchedule,
|
||||
RPC_RECONNECT_ATTEMPT_LIMIT
|
||||
} from './rpc-client-reconnect-schedule'
|
||||
import { RpcClientRequestTracker } from './rpc-client-request-tracker'
|
||||
import { RpcClientSocketCloseController } from './rpc-client-socket-close-controller'
|
||||
import { RpcClientSocketFactory } from './rpc-client-socket-factory'
|
||||
import type { RpcClientSocketSession } from './rpc-client-socket-session'
|
||||
import {
|
||||
RpcClientStreamRegistry,
|
||||
type RpcStreamingListener,
|
||||
type RpcStreamSubscribeOptions
|
||||
} from './rpc-client-stream-registry'
|
||||
import { RpcSessionLivenessWatchdog } from './rpc-session-liveness-watchdog'
|
||||
import { isStaleForegroundDial } from './rpc-stale-dial'
|
||||
import type {
|
||||
ConnectionLogLevel,
|
||||
ConnectionState,
|
||||
ForegroundNudgeReason,
|
||||
RpcResponse
|
||||
} from './types'
|
||||
|
||||
const LIVENESS_REQUEST_ID_PREFIX = 'mobile-liveness-'
|
||||
|
||||
export class DirectRpcClient implements RpcClient {
|
||||
private socketSession: RpcClientSocketSession | null = null
|
||||
private readonly connectionState: RpcClientConnectionState
|
||||
private readonly reconnect: RpcClientReconnectSchedule
|
||||
private readonly requests: RpcClientRequestTracker
|
||||
private readonly streams: RpcClientStreamRegistry
|
||||
private readonly liveness: RpcSessionLivenessWatchdog
|
||||
private readonly socketFactory: RpcClientSocketFactory
|
||||
private readonly authenticationRetry: RpcClientAuthenticationRetry
|
||||
private readonly socketClose: RpcClientSocketCloseController
|
||||
private requestCounter = 0
|
||||
private logCounter = 0
|
||||
private intentionallyClosed = false
|
||||
private authenticationGeneration = 0
|
||||
private livenessSession: RpcClientSocketSession | null = null
|
||||
|
||||
constructor(
|
||||
private readonly endpoint: string,
|
||||
private readonly deviceToken: string,
|
||||
serverPublicKeyB64: string,
|
||||
private readonly options: ConnectOptions
|
||||
) {
|
||||
this.reconnect = new RpcClientReconnectSchedule({
|
||||
openConnection: () => this.openConnection(),
|
||||
rejectConnectWaiters: (reason) => this.connectionState.rejectWaiters(reason),
|
||||
emitLog: (message, detail) => this.emitLog('info', message, detail)
|
||||
})
|
||||
this.connectionState = new RpcClientConnectionState({
|
||||
endpoint,
|
||||
initialListener: options.onStateChange,
|
||||
getReconnectAttempt: () => this.reconnect.getAttempt(),
|
||||
isClosed: () => this.intentionallyClosed
|
||||
})
|
||||
this.streams = new RpcClientStreamRegistry({
|
||||
nextId: () => this.nextId(),
|
||||
deviceToken,
|
||||
getState: () => this.connectionState.get(),
|
||||
sendEncrypted: (request) => this.sendEncrypted(request)
|
||||
})
|
||||
this.requests = new RpcClientRequestTracker({
|
||||
nextId: () => this.nextId(),
|
||||
deviceToken,
|
||||
getState: () => this.connectionState.get(),
|
||||
waitForConnected: (timeoutMs) => this.waitForConnected(timeoutMs),
|
||||
sendEncrypted: (request) => this.sendEncrypted(request)
|
||||
})
|
||||
this.liveness = new RpcSessionLivenessWatchdog({
|
||||
transport: 'direct',
|
||||
sendProbe: (identity) => this.sendLivenessProbe(identity),
|
||||
terminate: (identity) => {
|
||||
if (identity === this.livenessSession && this.socketSession === this.livenessSession) {
|
||||
this.socketClose.forceClose(this.livenessSession)
|
||||
}
|
||||
}
|
||||
})
|
||||
this.socketFactory = new RpcClientSocketFactory({
|
||||
endpoint,
|
||||
deviceToken,
|
||||
serverPublicKeyB64,
|
||||
getCurrentSocket: () => this.socketSession?.socket ?? null,
|
||||
getState: () => this.getState(),
|
||||
getReconnectAttempt: () => this.getReconnectAttempt(),
|
||||
getLastConnectedAt: () => this.getLastConnectedAt(),
|
||||
isIntentionallyClosed: () => this.intentionallyClosed,
|
||||
emitLog: (level, message, detail) => this.emitLog(level, message, detail),
|
||||
onHandshakeStarted: () => this.connectionState.publish('handshaking'),
|
||||
onAuthenticated: (session) => this.handleAuthenticated(session),
|
||||
onAuthRejected: (reason) => this.authenticationRetry.reject(reason),
|
||||
onRpcResponse: (response) => this.handleRpcResponse(response),
|
||||
onBinary: (bytes) => this.streams.handleBinary(bytes),
|
||||
onAuthenticatedInbound: (session) => this.liveness.noteAuthenticatedInbound(session),
|
||||
onClosed: (session, closeCode) => this.socketClose.handle(session, closeCode),
|
||||
onForcedClose: (session) => this.socketClose.forceClose(session)
|
||||
})
|
||||
this.authenticationRetry = new RpcClientAuthenticationRetry({
|
||||
endpoint,
|
||||
stopLiveness: () => this.stopLiveness(),
|
||||
emitWarning: (message, detail) => this.emitLog('warn', message, detail),
|
||||
retry: (reason) => this.retryAuthentication(reason),
|
||||
latchFailure: (reason) => this.latchAuthenticationFailure(reason)
|
||||
})
|
||||
this.socketClose = new RpcClientSocketCloseController({
|
||||
connectionState: this.connectionState,
|
||||
reconnect: this.reconnect,
|
||||
requests: this.requests,
|
||||
streams: this.streams,
|
||||
socketFactory: this.socketFactory,
|
||||
authenticationRetry: this.authenticationRetry,
|
||||
getCurrentSession: () => this.socketSession,
|
||||
clearCurrentSession: () => (this.socketSession = null),
|
||||
getAuthenticationGeneration: () => this.authenticationGeneration,
|
||||
isIntentionallyClosed: () => this.intentionallyClosed,
|
||||
stopLiveness: (session) => {
|
||||
if (this.livenessSession === session) {
|
||||
this.stopLiveness()
|
||||
}
|
||||
},
|
||||
emitWarning: (message, detail) => this.emitLog('warn', message, detail)
|
||||
})
|
||||
this.openConnection()
|
||||
}
|
||||
|
||||
sendRequest(
|
||||
method: string,
|
||||
params?: unknown,
|
||||
options?: SendRequestOptions
|
||||
): Promise<RpcResponse> {
|
||||
return this.requests.sendRequest(method, params, options)
|
||||
}
|
||||
|
||||
subscribe(
|
||||
method: string,
|
||||
params: unknown,
|
||||
onData: RpcStreamingListener,
|
||||
options?: RpcStreamSubscribeOptions
|
||||
): () => void {
|
||||
return this.streams.subscribe(method, params, onData, options)
|
||||
}
|
||||
|
||||
updateTerminalSubscriptionViewport(
|
||||
terminal: string,
|
||||
viewport: { cols: number; rows: number }
|
||||
): void {
|
||||
this.streams.updateTerminalViewport(terminal, viewport)
|
||||
}
|
||||
|
||||
getState(): ConnectionState {
|
||||
return this.connectionState.get()
|
||||
}
|
||||
|
||||
getReconnectAttempt(): number {
|
||||
return this.reconnect.getAttempt()
|
||||
}
|
||||
|
||||
getLastConnectedAt(): number | null {
|
||||
return this.connectionState.getLastConnectedAt()
|
||||
}
|
||||
|
||||
getLastInboundAt = (): number | null => this.liveness.getLastInboundAt() || null
|
||||
|
||||
onStateChange(listener: (state: ConnectionState) => void): () => void {
|
||||
return this.connectionState.addListener(listener)
|
||||
}
|
||||
|
||||
notifyForeground(_reason?: ForegroundNudgeReason): void {
|
||||
if (this.intentionallyClosed) {
|
||||
return
|
||||
}
|
||||
if (this.getState() === 'connected') {
|
||||
console.log('[net] foreground — probing live connection')
|
||||
if (this.livenessSession) {
|
||||
this.liveness.probeNow(this.livenessSession)
|
||||
}
|
||||
return
|
||||
}
|
||||
const dialing = this.socketSession
|
||||
const dialAgeMs = Date.now() - this.socketFactory.getDialStartedAt()
|
||||
let abandoned = false
|
||||
if (dialing && isStaleForegroundDial(this.getState(), dialAgeMs)) {
|
||||
console.log('[net] foreground — abandoning stale dial', {
|
||||
state: this.getState(),
|
||||
dialAgeMs
|
||||
})
|
||||
this.socketClose.forceClose(dialing)
|
||||
abandoned = true
|
||||
}
|
||||
if (this.getState() === 'reconnecting') {
|
||||
console.log('[net] foreground — restarting reconnect loop', {
|
||||
attempt: this.getReconnectAttempt(),
|
||||
hadTimer: this.reconnect.hasTimer()
|
||||
})
|
||||
this.reconnect.redialNow(!abandoned)
|
||||
}
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.intentionallyClosed = true
|
||||
this.reconnect.cancel()
|
||||
const session = this.socketSession
|
||||
session?.clearTimers()
|
||||
if (this.livenessSession) {
|
||||
this.liveness.stop(this.livenessSession)
|
||||
this.livenessSession = null
|
||||
}
|
||||
session?.close()
|
||||
this.socketSession = null
|
||||
session?.clearKey()
|
||||
this.connectionState.publish('disconnected')
|
||||
this.requests.rejectAll('Client closed', { deliveryUnknown: true })
|
||||
}
|
||||
|
||||
private openConnection(): void {
|
||||
if (this.intentionallyClosed) {
|
||||
return
|
||||
}
|
||||
this.connectionState.publish('connecting')
|
||||
this.socketSession = this.socketFactory.open()
|
||||
}
|
||||
|
||||
private handleAuthenticated(session: RpcClientSocketSession): void {
|
||||
console.log('[net] e2ee_authenticated — connected', { streamCount: this.streams.size() })
|
||||
this.livenessSession = session
|
||||
this.liveness.start(session)
|
||||
this.authenticationGeneration++
|
||||
this.reconnect.authenticated()
|
||||
this.authenticationRetry.accepted()
|
||||
this.connectionState.publish('connected')
|
||||
this.emitLog('success', 'Authenticated', 'Channel ready for RPC')
|
||||
this.streams.replayAfterAuthentication()
|
||||
}
|
||||
|
||||
private handleRpcResponse(response: RpcResponse): void {
|
||||
if (response.id.startsWith(LIVENESS_REQUEST_ID_PREFIX)) {
|
||||
return
|
||||
}
|
||||
if (!response.ok && response.error.code === 'unauthorized') {
|
||||
this.authenticationRetry.reject('Unauthorized — pairing may be revoked')
|
||||
return
|
||||
}
|
||||
if (!this.streams.handleResponse(response)) {
|
||||
this.requests.resolve(response)
|
||||
}
|
||||
}
|
||||
|
||||
private stopLiveness(): void {
|
||||
if (this.livenessSession) {
|
||||
this.liveness.stop(this.livenessSession)
|
||||
this.livenessSession = null
|
||||
}
|
||||
}
|
||||
|
||||
private retryAuthentication(reason: string): void {
|
||||
const closing = this.socketSession
|
||||
this.socketSession = null
|
||||
closing?.clearKey()
|
||||
this.streams.markForReplay()
|
||||
this.requests.rejectAll(reason)
|
||||
closing?.close()
|
||||
this.connectionState.publish('reconnecting')
|
||||
this.reconnect.schedule()
|
||||
}
|
||||
|
||||
private latchAuthenticationFailure(reason: string): void {
|
||||
this.intentionallyClosed = true
|
||||
this.socketSession?.close()
|
||||
this.socketSession = null
|
||||
this.connectionState.publish('auth-failed')
|
||||
this.requests.rejectAll(reason)
|
||||
}
|
||||
|
||||
private sendEncrypted(request: unknown): boolean {
|
||||
if (this.socketSession) {
|
||||
return this.socketSession.sendEncrypted(request)
|
||||
}
|
||||
console.log('[net] sendEncrypted FAILED — channel not ready', {
|
||||
hasWs: false,
|
||||
hasKey: false,
|
||||
state: this.getState()
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
private sendLivenessProbe(identity: object): boolean {
|
||||
if (identity !== this.livenessSession || this.getState() !== 'connected') {
|
||||
return false
|
||||
}
|
||||
return this.sendEncrypted({
|
||||
id: `${LIVENESS_REQUEST_ID_PREFIX}${this.nextId()}`,
|
||||
deviceToken: this.deviceToken,
|
||||
method: 'status.get'
|
||||
})
|
||||
}
|
||||
|
||||
private waitForConnected(timeoutMs?: number): Promise<void> {
|
||||
if (
|
||||
this.getState() === 'reconnecting' &&
|
||||
this.getReconnectAttempt() >= RPC_RECONNECT_ATTEMPT_LIMIT
|
||||
) {
|
||||
return Promise.reject(new Error('Connection retry limit reached'))
|
||||
}
|
||||
return this.connectionState.waitForConnected(timeoutMs)
|
||||
}
|
||||
|
||||
private nextId(): string {
|
||||
return `rpc-${++this.requestCounter}-${Date.now()}`
|
||||
}
|
||||
|
||||
private emitLog(level: ConnectionLogLevel, message: string, detail?: string): void {
|
||||
this.options.onLog?.({
|
||||
id: `log-${++this.logCounter}-${Date.now()}`,
|
||||
ts: Date.now(),
|
||||
level,
|
||||
message,
|
||||
detail
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -59,4 +59,114 @@ describe('MobileRelayE2eeLink', () => {
|
||||
)
|
||||
expect(socket.close).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps a typed close code when transport error precedes close', () => {
|
||||
const socket = new ThrowingSocket()
|
||||
const onError = vi.fn()
|
||||
new MobileRelayE2eeLink({
|
||||
endpoint: {
|
||||
cellUrl: 'https://relay-c1.onorca.dev',
|
||||
relayHostId: 'AbCdEf0123_-xyZ9'
|
||||
},
|
||||
credential: 'credential',
|
||||
expectedCredentialKind: 'resume',
|
||||
deviceToken: 'device-token',
|
||||
desktopPublicKeyB64: 'desktop-key',
|
||||
onAuthenticated: vi.fn(),
|
||||
onText: vi.fn(),
|
||||
onBinary: vi.fn(),
|
||||
onError,
|
||||
createSocket: () => socket as unknown as WebSocket
|
||||
})
|
||||
|
||||
socket.onerror?.()
|
||||
socket.onclose?.({ code: 4409 })
|
||||
|
||||
expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'relay_outer_4409' }))
|
||||
})
|
||||
|
||||
it('classifies an opaque close after transport error as 1006', () => {
|
||||
const socket = new ThrowingSocket()
|
||||
const onError = vi.fn()
|
||||
new MobileRelayE2eeLink({
|
||||
endpoint: {
|
||||
cellUrl: 'https://relay-c1.onorca.dev',
|
||||
relayHostId: 'AbCdEf0123_-xyZ9'
|
||||
},
|
||||
credential: 'credential',
|
||||
expectedCredentialKind: 'resume',
|
||||
deviceToken: 'device-token',
|
||||
desktopPublicKeyB64: 'desktop-key',
|
||||
onAuthenticated: vi.fn(),
|
||||
onText: vi.fn(),
|
||||
onBinary: vi.fn(),
|
||||
onError,
|
||||
createSocket: () => socket as unknown as WebSocket
|
||||
})
|
||||
|
||||
socket.onerror?.()
|
||||
socket.onclose?.({ code: 0 })
|
||||
|
||||
expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'relay_outer_1006' }))
|
||||
})
|
||||
|
||||
it('bounds an error when the platform never emits close', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const socket = new ThrowingSocket()
|
||||
const onError = vi.fn()
|
||||
const link = new MobileRelayE2eeLink({
|
||||
endpoint: {
|
||||
cellUrl: 'https://relay-c1.onorca.dev',
|
||||
relayHostId: 'AbCdEf0123_-xyZ9'
|
||||
},
|
||||
credential: 'credential',
|
||||
expectedCredentialKind: 'resume',
|
||||
deviceToken: 'device-token',
|
||||
desktopPublicKeyB64: 'desktop-key',
|
||||
onAuthenticated: vi.fn(),
|
||||
onText: vi.fn(),
|
||||
onBinary: vi.fn(),
|
||||
onError,
|
||||
createSocket: () => socket as unknown as WebSocket
|
||||
})
|
||||
socket.onerror?.()
|
||||
await vi.advanceTimersByTimeAsync(250)
|
||||
|
||||
expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'relay_outer_1006' }))
|
||||
link.close()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('cancels the missing-close timer when explicitly closed', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const socket = new ThrowingSocket()
|
||||
const onError = vi.fn()
|
||||
const link = new MobileRelayE2eeLink({
|
||||
endpoint: {
|
||||
cellUrl: 'https://relay-c1.onorca.dev',
|
||||
relayHostId: 'AbCdEf0123_-xyZ9'
|
||||
},
|
||||
credential: 'credential',
|
||||
expectedCredentialKind: 'resume',
|
||||
deviceToken: 'device-token',
|
||||
desktopPublicKeyB64: 'desktop-key',
|
||||
onAuthenticated: vi.fn(),
|
||||
onText: vi.fn(),
|
||||
onBinary: vi.fn(),
|
||||
onError,
|
||||
createSocket: () => socket as unknown as WebSocket
|
||||
})
|
||||
socket.onerror?.()
|
||||
link.close()
|
||||
await vi.advanceTimersByTimeAsync(250)
|
||||
|
||||
expect(onError).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,6 +6,10 @@ import { MobileE2EEV2ClientSession } from './mobile-e2ee-v2-client-session'
|
||||
import { MobileE2EEV2PhysicalChannel } from './mobile-e2ee-v2-physical-channel'
|
||||
import { websocketPayloadToUint8 } from './websocket-payload-bytes'
|
||||
|
||||
// Native WebSockets normally emit close immediately after error; bound the
|
||||
// missing-close case so a dead socket cannot leave recovery pending forever.
|
||||
const RELAY_ERROR_CLOSE_GRACE_MS = 250
|
||||
|
||||
export class RelayOuterError extends Error {
|
||||
constructor(readonly code: number) {
|
||||
super(`relay_outer_${code}`)
|
||||
@@ -32,6 +36,7 @@ export class MobileRelayE2eeLink {
|
||||
private readonly channel: MobileE2EEV2PhysicalChannel
|
||||
private outerReady = false
|
||||
private closed = false
|
||||
private transportErrorTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private inboundChain: Promise<void> = Promise.resolve()
|
||||
|
||||
constructor(options: MobileRelayE2eeLinkOptions) {
|
||||
@@ -70,6 +75,10 @@ export class MobileRelayE2eeLink {
|
||||
return
|
||||
}
|
||||
this.closed = true
|
||||
if (this.transportErrorTimer) {
|
||||
clearTimeout(this.transportErrorTimer)
|
||||
this.transportErrorTimer = null
|
||||
}
|
||||
this.channel.dispose()
|
||||
this.socket.close()
|
||||
}
|
||||
@@ -103,8 +112,21 @@ export class MobileRelayE2eeLink {
|
||||
})
|
||||
.catch((error: unknown) => this.fail(asError(error)))
|
||||
}
|
||||
this.socket.onerror = () => this.fail(new Error('relay transport error'))
|
||||
this.socket.onclose = (event) => this.fail(new RelayOuterError(event.code || 1006))
|
||||
// `error` is often delivered just before `close`; wait for close so a
|
||||
// typed relay code is not replaced by a generic transport error.
|
||||
this.socket.onerror = () => {
|
||||
this.transportErrorTimer ??= setTimeout(() => {
|
||||
this.transportErrorTimer = null
|
||||
this.fail(new RelayOuterError(1006))
|
||||
}, RELAY_ERROR_CLOSE_GRACE_MS)
|
||||
}
|
||||
this.socket.onclose = (event) => {
|
||||
if (this.transportErrorTimer) {
|
||||
clearTimeout(this.transportErrorTimer)
|
||||
this.transportErrorTimer = null
|
||||
}
|
||||
this.fail(new RelayOuterError(event.code || 1006))
|
||||
}
|
||||
}
|
||||
|
||||
private acceptHello(raw: unknown): void {
|
||||
@@ -137,6 +159,10 @@ export class MobileRelayE2eeLink {
|
||||
return
|
||||
}
|
||||
this.closed = true
|
||||
if (this.transportErrorTimer) {
|
||||
clearTimeout(this.transportErrorTimer)
|
||||
this.transportErrorTimer = null
|
||||
}
|
||||
this.channel.dispose()
|
||||
this.options.onError(error)
|
||||
this.socket.close()
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolvePairingInviteThroughDirector } from './mobile-relay-invite-director'
|
||||
import {
|
||||
RelayDirectorMoveNotNewerError,
|
||||
resolvePairingInviteThroughDirector
|
||||
} from './mobile-relay-invite-director'
|
||||
|
||||
class FakeSocket {
|
||||
sent: string[] = []
|
||||
@@ -59,7 +62,7 @@ describe('pairing invite director resolution', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects same/older epochs and untrusted extra fields', async () => {
|
||||
it('rejects malformed moves with untrusted extra fields', async () => {
|
||||
const socket = new FakeSocket()
|
||||
const resolving = resolvePairingInviteThroughDirector({
|
||||
relay,
|
||||
@@ -75,6 +78,31 @@ describe('pairing invite director resolution', () => {
|
||||
})
|
||||
})
|
||||
|
||||
await expect(resolving).rejects.toThrow(/not strictly newer/)
|
||||
await expect(resolving).rejects.toThrow(/invalid relay director move/)
|
||||
})
|
||||
|
||||
it('describes a same-epoch move without accepting it', async () => {
|
||||
const socket = new FakeSocket()
|
||||
const resolving = resolvePairingInviteThroughDirector({
|
||||
relay,
|
||||
createSocket: () => socket as unknown as WebSocket
|
||||
})
|
||||
socket.onmessage?.({
|
||||
data: JSON.stringify({
|
||||
type: 'relay-moved',
|
||||
v: 1,
|
||||
cellUrl: relay.cellUrl,
|
||||
assignmentEpoch: relay.assignmentEpoch
|
||||
})
|
||||
})
|
||||
|
||||
const error = await resolving.catch((value: unknown) => value)
|
||||
expect(error).toBeInstanceOf(RelayDirectorMoveNotNewerError)
|
||||
expect(error).toMatchObject({
|
||||
cellUrl: relay.cellUrl,
|
||||
assignmentEpoch: relay.assignmentEpoch,
|
||||
currentCellUrl: relay.cellUrl,
|
||||
currentAssignmentEpoch: relay.assignmentEpoch
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,27 @@
|
||||
import type { PairingRelay } from '../../../src/shared/mobile-relay-pairing-offer'
|
||||
import { RelayMovedSchema } from '../../../src/shared/mobile-relay-phone-protocol'
|
||||
|
||||
export class RelayDirectorMoveNotNewerError extends Error {
|
||||
readonly cellUrl: string
|
||||
readonly assignmentEpoch: number
|
||||
readonly currentCellUrl: string
|
||||
readonly currentAssignmentEpoch: number
|
||||
|
||||
constructor(args: {
|
||||
cellUrl: string
|
||||
assignmentEpoch: number
|
||||
currentCellUrl: string
|
||||
currentAssignmentEpoch: number
|
||||
}) {
|
||||
super('relay director move was not strictly newer')
|
||||
this.name = 'RelayDirectorMoveNotNewerError'
|
||||
this.cellUrl = args.cellUrl
|
||||
this.assignmentEpoch = args.assignmentEpoch
|
||||
this.currentCellUrl = args.currentCellUrl
|
||||
this.currentAssignmentEpoch = args.currentAssignmentEpoch
|
||||
}
|
||||
}
|
||||
|
||||
export function resolvePairingInviteThroughDirector(args: {
|
||||
relay: PairingRelay
|
||||
timeoutMs?: number
|
||||
@@ -38,8 +59,19 @@ export function resolvePairingInviteThroughDirector(args: {
|
||||
return
|
||||
}
|
||||
const moved = RelayMovedSchema.safeParse(value)
|
||||
if (!moved.success || moved.data.assignmentEpoch <= args.relay.assignmentEpoch) {
|
||||
finish(new Error('relay director move was not strictly newer'))
|
||||
if (!moved.success) {
|
||||
finish(new Error('invalid relay director move'))
|
||||
return
|
||||
}
|
||||
if (moved.data.assignmentEpoch <= args.relay.assignmentEpoch) {
|
||||
finish(
|
||||
new RelayDirectorMoveNotNewerError({
|
||||
cellUrl: moved.data.cellUrl,
|
||||
assignmentEpoch: moved.data.assignmentEpoch,
|
||||
currentCellUrl: args.relay.cellUrl,
|
||||
currentAssignmentEpoch: args.relay.assignmentEpoch
|
||||
})
|
||||
)
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
|
||||
@@ -134,6 +134,57 @@ describe('mobile relay physical pairing client', () => {
|
||||
expect(fakes.start).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the typed close code when transport error precedes close', async () => {
|
||||
const socket = new FakeSocket()
|
||||
const client = connectMobileRelayForPairing({
|
||||
relay,
|
||||
deviceToken: 'device-token',
|
||||
desktopPublicKeyB64: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',
|
||||
createSocket: () => socket as unknown as WebSocket
|
||||
})
|
||||
const status = client.sendRequest('status.get')
|
||||
socket.onerror?.()
|
||||
socket.onclose?.({ code: 4409 })
|
||||
|
||||
await expect(status).rejects.toEqual(new RelayOuterError(4409))
|
||||
})
|
||||
|
||||
it('classifies an opaque close after transport error as 1006', async () => {
|
||||
const socket = new FakeSocket()
|
||||
const client = connectMobileRelayForPairing({
|
||||
relay,
|
||||
deviceToken: 'device-token',
|
||||
desktopPublicKeyB64: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',
|
||||
createSocket: () => socket as unknown as WebSocket
|
||||
})
|
||||
const status = client.sendRequest('status.get')
|
||||
socket.onerror?.()
|
||||
socket.onclose?.({ code: 0 })
|
||||
|
||||
await expect(status).rejects.toEqual(new RelayOuterError(1006))
|
||||
})
|
||||
|
||||
it('settles after an error when the platform never emits close', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const socket = new FakeSocket()
|
||||
const client = connectMobileRelayForPairing({
|
||||
relay,
|
||||
deviceToken: 'device-token',
|
||||
desktopPublicKeyB64: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',
|
||||
createSocket: () => socket as unknown as WebSocket
|
||||
})
|
||||
const status = client.sendRequest('status.get')
|
||||
const rejected = expect(status).rejects.toEqual(new RelayOuterError(1006))
|
||||
socket.onerror?.()
|
||||
await vi.advanceTimersByTimeAsync(250)
|
||||
|
||||
await rejected
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('narrates dial, outer auth, handshake and authentication without leaking the invite', async () => {
|
||||
const socket = new FakeSocket()
|
||||
const entries: ConnectionLogEntry[] = []
|
||||
|
||||
@@ -10,6 +10,8 @@ import { websocketPayloadToUint8 } from './websocket-payload-bytes'
|
||||
export { RelayOuterError } from './mobile-relay-e2ee-link'
|
||||
import { RelayOuterError } from './mobile-relay-e2ee-link'
|
||||
|
||||
const RELAY_ERROR_CLOSE_GRACE_MS = 250
|
||||
|
||||
type PendingRequest = {
|
||||
resolve: (response: RpcResponse) => void
|
||||
reject: (error: Error) => void
|
||||
@@ -46,6 +48,7 @@ export function connectMobileRelayForPairing(args: {
|
||||
let requestCounter = 0
|
||||
let closed = false
|
||||
let intentionallyClosed = false
|
||||
let transportErrorTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let outerReady = false
|
||||
let authenticated = false
|
||||
let resolveAuthenticated!: () => void
|
||||
@@ -111,8 +114,21 @@ export function connectMobileRelayForPairing(args: {
|
||||
})
|
||||
.catch((error: unknown) => fail(asError(error)))
|
||||
}
|
||||
socket.onerror = () => fail(new Error('relay transport error'))
|
||||
socket.onclose = (event) => fail(new RelayOuterError(event.code || 1006))
|
||||
// WebSocket implementations commonly emit `error` immediately before
|
||||
// `close`; the bounded fallback represents an opaque 1006 close.
|
||||
socket.onerror = () => {
|
||||
transportErrorTimer ??= setTimeout(() => {
|
||||
transportErrorTimer = null
|
||||
fail(new RelayOuterError(1006))
|
||||
}, RELAY_ERROR_CLOSE_GRACE_MS)
|
||||
}
|
||||
socket.onclose = (event) => {
|
||||
if (transportErrorTimer) {
|
||||
clearTimeout(transportErrorTimer)
|
||||
transportErrorTimer = null
|
||||
}
|
||||
fail(new RelayOuterError(event.code || 1006))
|
||||
}
|
||||
|
||||
function acceptRelayHello(raw: unknown): void {
|
||||
if (typeof raw !== 'string') {
|
||||
@@ -144,6 +160,10 @@ export function connectMobileRelayForPairing(args: {
|
||||
return
|
||||
}
|
||||
closed = true
|
||||
if (transportErrorTimer) {
|
||||
clearTimeout(transportErrorTimer)
|
||||
transportErrorTimer = null
|
||||
}
|
||||
if (intentionallyClosed) {
|
||||
log('info', 'Relay: pairing socket closed', cellHost)
|
||||
} else {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import type { PairingCandidateClient } from './mobile-relay-physical-client'
|
||||
import { RelayOuterError } from './mobile-relay-physical-client'
|
||||
import { createRecoveringPairingRelayCandidate } from './pairing-relay-candidate'
|
||||
import { RelayDirectorMoveNotNewerError } from './mobile-relay-invite-director'
|
||||
import type { MobileRelayPairingJournal } from './mobile-relay-pairing-journal'
|
||||
import type { ConnectionLogEntry } from './types'
|
||||
|
||||
@@ -89,6 +90,109 @@ describe('recovering pairing relay candidate', () => {
|
||||
expect(stale.close).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('retries the authoritative cell when the director confirms the same assignment', async () => {
|
||||
const stale = client(Promise.reject(new RelayOuterError(1006)))
|
||||
const target = client(Promise.resolve(success()))
|
||||
const persistMove = vi.fn()
|
||||
let connects = 0
|
||||
const candidate = createRecoveringPairingRelayCandidate({
|
||||
journal,
|
||||
connect: (relay) => {
|
||||
expect(relay.cellUrl).toBe(journal.metadata.relay.cellUrl)
|
||||
expect(relay.assignmentEpoch).toBe(journal.metadata.relay.assignmentEpoch)
|
||||
return connects++ === 0 ? stale : target
|
||||
},
|
||||
resolveDirector: async (relay) => {
|
||||
throw new RelayDirectorMoveNotNewerError({
|
||||
cellUrl: relay.cellUrl,
|
||||
assignmentEpoch: relay.assignmentEpoch,
|
||||
currentCellUrl: relay.cellUrl,
|
||||
currentAssignmentEpoch: relay.assignmentEpoch
|
||||
})
|
||||
},
|
||||
persistMove,
|
||||
now: () => 1,
|
||||
random: () => 0,
|
||||
sleep: async () => {}
|
||||
})
|
||||
|
||||
await expect(candidate.sendRequest('status.get')).resolves.toEqual(success())
|
||||
expect(connects).toBe(2)
|
||||
expect(persistMove).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps recovery bounded when the authoritative-cell constructor throws', async () => {
|
||||
const stale = client(Promise.reject(new RelayOuterError(1006)))
|
||||
const target = client(Promise.resolve(success()))
|
||||
const entries: ConnectionLogEntry[] = []
|
||||
let connects = 0
|
||||
const candidate = createRecoveringPairingRelayCandidate({
|
||||
journal,
|
||||
connect: () => {
|
||||
connects++
|
||||
if (connects === 1) {
|
||||
return stale
|
||||
}
|
||||
if (connects === 2) {
|
||||
throw new Error('relay constructor failed')
|
||||
}
|
||||
return target
|
||||
},
|
||||
resolveDirector: async (relay) => {
|
||||
throw new RelayDirectorMoveNotNewerError({
|
||||
cellUrl: relay.cellUrl,
|
||||
assignmentEpoch: relay.assignmentEpoch,
|
||||
currentCellUrl: relay.cellUrl,
|
||||
currentAssignmentEpoch: relay.assignmentEpoch
|
||||
})
|
||||
},
|
||||
persistMove: vi.fn(),
|
||||
now: () => 1,
|
||||
random: () => 0,
|
||||
sleep: async () => {},
|
||||
onLog: (entry) => entries.push(entry)
|
||||
})
|
||||
|
||||
await expect(candidate.sendRequest('status.get')).resolves.toEqual(success())
|
||||
expect(connects).toBe(3)
|
||||
expect(entries).toContainEqual(
|
||||
expect.objectContaining({
|
||||
level: 'warn',
|
||||
message: 'Relay: recovery attempt 1 failed',
|
||||
detail: 'Error: relay constructor failed'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('does not retry a same-epoch move to a different cell', async () => {
|
||||
const stale = client(Promise.reject(new RelayOuterError(1006)))
|
||||
const resolveDirector = vi.fn(async (relay) => {
|
||||
throw new RelayDirectorMoveNotNewerError({
|
||||
cellUrl: 'https://relay-c2.onorca.dev',
|
||||
assignmentEpoch: relay.assignmentEpoch,
|
||||
currentCellUrl: relay.cellUrl,
|
||||
currentAssignmentEpoch: relay.assignmentEpoch
|
||||
})
|
||||
})
|
||||
let connects = 0
|
||||
const candidate = createRecoveringPairingRelayCandidate({
|
||||
journal,
|
||||
connect: () => {
|
||||
connects++
|
||||
return stale
|
||||
},
|
||||
resolveDirector,
|
||||
persistMove: vi.fn(),
|
||||
now: () => 1,
|
||||
random: () => 0,
|
||||
sleep: async () => {}
|
||||
})
|
||||
|
||||
await expect(candidate.sendRequest('status.get')).rejects.toThrow(/not strictly newer/)
|
||||
expect(resolveDirector).toHaveBeenCalledTimes(3)
|
||||
expect(connects).toBe(1)
|
||||
})
|
||||
|
||||
it('does not ask the director to reinterpret endpoint-scoped host-offline', async () => {
|
||||
const offline = client(Promise.reject(new RelayOuterError(4404)))
|
||||
const resolveDirector = vi.fn()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { PairingRelay } from '../../../src/shared/mobile-relay-pairing-offer'
|
||||
import type { MobileRelayPairingJournal } from './mobile-relay-pairing-journal'
|
||||
import { RelayOuterError, type PairingCandidateClient } from './mobile-relay-physical-client'
|
||||
import { RelayDirectorMoveNotNewerError } from './mobile-relay-invite-director'
|
||||
import { createPairingRelayLogger, pairingRelayErrorDetail } from './pairing-relay-log'
|
||||
import { redactSocketEndpoint } from './socket-event-debug'
|
||||
import type { ConnectionLogSink } from './types'
|
||||
@@ -88,6 +89,35 @@ export function createRecoveringPairingRelayCandidate(args: {
|
||||
return await client.sendRequest(method, params)
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
if (isCurrentAssignmentMove(error, relay)) {
|
||||
log(
|
||||
'info',
|
||||
'Relay: director kept current assignment',
|
||||
redactSocketEndpoint(relay.cellUrl)
|
||||
)
|
||||
client.close()
|
||||
await backOff(attempt)
|
||||
if (closed) {
|
||||
throw new Error('relay pairing client closed')
|
||||
}
|
||||
try {
|
||||
client = args.connect(relay, args.onLog)
|
||||
return await client.sendRequest(method, params)
|
||||
} catch (retryError) {
|
||||
lastError = retryError
|
||||
log(
|
||||
'warn',
|
||||
`Relay: recovery attempt ${attempt + 1} failed`,
|
||||
pairingRelayErrorDetail(retryError)
|
||||
)
|
||||
if (!isDirectorRecoverable(retryError) || attempt + 1 >= maxAttempts) {
|
||||
log('error', 'Relay: recovery gave up', `after ${attempt + 1} attempt(s)`)
|
||||
throw retryError
|
||||
}
|
||||
await backOff(attempt)
|
||||
continue
|
||||
}
|
||||
}
|
||||
log('warn', `Relay: recovery attempt ${attempt + 1} failed`, pairingRelayErrorDetail(error))
|
||||
if (!isDirectorRecoverable(error) || attempt + 1 >= maxAttempts) {
|
||||
log('error', 'Relay: recovery gave up', `after ${attempt + 1} attempt(s)`)
|
||||
@@ -100,6 +130,16 @@ export function createRecoveringPairingRelayCandidate(args: {
|
||||
}
|
||||
}
|
||||
|
||||
function isCurrentAssignmentMove(error: unknown, relay: PairingRelay): boolean {
|
||||
return (
|
||||
error instanceof RelayDirectorMoveNotNewerError &&
|
||||
error.assignmentEpoch === relay.assignmentEpoch &&
|
||||
error.cellUrl === relay.cellUrl &&
|
||||
error.currentAssignmentEpoch === relay.assignmentEpoch &&
|
||||
error.currentCellUrl === relay.cellUrl
|
||||
)
|
||||
}
|
||||
|
||||
function pairingRelayFromJournal(journal: MobileRelayPairingJournal): PairingRelay {
|
||||
return {
|
||||
...journal.metadata.relay,
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { redactSocketEndpoint } from './socket-event-debug'
|
||||
|
||||
const AUTH_RETRY_BUDGET = 3
|
||||
|
||||
type AuthenticationRetryOptions = {
|
||||
endpoint: string
|
||||
stopLiveness: () => void
|
||||
emitWarning: (message: string, detail: string) => void
|
||||
retry: (reason: string) => void
|
||||
latchFailure: (reason: string) => void
|
||||
}
|
||||
|
||||
export class RpcClientAuthenticationRetry {
|
||||
private rejectionCount = 0
|
||||
|
||||
constructor(private readonly options: AuthenticationRetryOptions) {}
|
||||
|
||||
accepted(): void {
|
||||
this.rejectionCount = 0
|
||||
}
|
||||
|
||||
reject(reason: string, preserveRecovery = false): void {
|
||||
this.options.stopLiveness()
|
||||
this.rejectionCount++
|
||||
if (this.rejectionCount < AUTH_RETRY_BUDGET) {
|
||||
console.log('[net] auth rejected — retrying handshake', {
|
||||
attempt: this.rejectionCount,
|
||||
budget: AUTH_RETRY_BUDGET,
|
||||
endpoint: redactSocketEndpoint(this.options.endpoint)
|
||||
})
|
||||
this.options.emitWarning(
|
||||
'Authentication rejected',
|
||||
`Retrying (${this.rejectionCount}/${AUTH_RETRY_BUDGET})`
|
||||
)
|
||||
if (!preserveRecovery) {
|
||||
this.options.retry(reason)
|
||||
}
|
||||
return
|
||||
}
|
||||
console.log('[net] auth rejected — budget exhausted, latching auth-failed', {
|
||||
attempt: this.rejectionCount,
|
||||
endpoint: redactSocketEndpoint(this.options.endpoint)
|
||||
})
|
||||
this.options.latchFailure(reason)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { redactSocketEndpoint } from './socket-event-debug'
|
||||
import type { ConnectionState } from './types'
|
||||
|
||||
type ConnectWaiter = {
|
||||
resolve: () => void
|
||||
reject: (error: Error) => void
|
||||
timeout: ReturnType<typeof setTimeout> | null
|
||||
}
|
||||
|
||||
type ConnectionStateOptions = {
|
||||
endpoint: string
|
||||
initialListener?: (state: ConnectionState) => void
|
||||
getReconnectAttempt: () => number
|
||||
isClosed: () => boolean
|
||||
}
|
||||
|
||||
export class RpcClientConnectionState {
|
||||
private state: ConnectionState = 'disconnected'
|
||||
private lastConnectedAt: number | null = null
|
||||
private stateEnteredAt = Date.now()
|
||||
private readonly listeners = new Set<(state: ConnectionState) => void>()
|
||||
private readonly waiters: ConnectWaiter[] = []
|
||||
|
||||
constructor(private readonly options: ConnectionStateOptions) {
|
||||
if (options.initialListener) {
|
||||
this.listeners.add(options.initialListener)
|
||||
}
|
||||
}
|
||||
|
||||
get(): ConnectionState {
|
||||
return this.state
|
||||
}
|
||||
|
||||
getLastConnectedAt(): number | null {
|
||||
return this.lastConnectedAt
|
||||
}
|
||||
|
||||
publish(next: ConnectionState): void {
|
||||
if (this.state === next) {
|
||||
return
|
||||
}
|
||||
const previous = this.state
|
||||
const dweltMs = Date.now() - this.stateEnteredAt
|
||||
this.state = next
|
||||
this.stateEnteredAt = Date.now()
|
||||
console.log('[net] state', {
|
||||
from: previous,
|
||||
to: next,
|
||||
dweltMs,
|
||||
attempt: this.options.getReconnectAttempt(),
|
||||
endpoint: redactSocketEndpoint(this.options.endpoint)
|
||||
})
|
||||
if (next === 'connected') {
|
||||
this.lastConnectedAt = Date.now()
|
||||
this.resolveWaiters()
|
||||
} else if (next === 'disconnected' || next === 'auth-failed') {
|
||||
this.rejectWaiters(
|
||||
next === 'auth-failed' ? 'Unauthorized — pairing may be revoked' : 'Connection closed'
|
||||
)
|
||||
}
|
||||
for (const listener of this.listeners) {
|
||||
listener(next)
|
||||
}
|
||||
}
|
||||
|
||||
waitForConnected(timeoutMs?: number): Promise<void> {
|
||||
if (this.state === 'connected') {
|
||||
return Promise.resolve()
|
||||
}
|
||||
if (this.options.isClosed()) {
|
||||
return Promise.reject(new Error('Client closed'))
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const waiter: ConnectWaiter = { resolve, reject, timeout: null }
|
||||
if (timeoutMs !== undefined) {
|
||||
waiter.timeout = setTimeout(
|
||||
() => {
|
||||
const index = this.waiters.indexOf(waiter)
|
||||
if (index !== -1) {
|
||||
this.waiters.splice(index, 1)
|
||||
}
|
||||
reject(new Error('Timed out while connecting to the remote Orca runtime.'))
|
||||
},
|
||||
Math.max(0, timeoutMs)
|
||||
)
|
||||
}
|
||||
this.waiters.push(waiter)
|
||||
})
|
||||
}
|
||||
|
||||
rejectWaiters(reason: string): void {
|
||||
const error = new Error(reason)
|
||||
for (const waiter of this.waiters.splice(0)) {
|
||||
if (waiter.timeout) {
|
||||
clearTimeout(waiter.timeout)
|
||||
}
|
||||
waiter.reject(error)
|
||||
}
|
||||
}
|
||||
|
||||
addListener(listener: (state: ConnectionState) => void): () => void {
|
||||
this.listeners.add(listener)
|
||||
return () => this.listeners.delete(listener)
|
||||
}
|
||||
|
||||
private resolveWaiters(): void {
|
||||
for (const waiter of this.waiters.splice(0)) {
|
||||
if (waiter.timeout) {
|
||||
clearTimeout(waiter.timeout)
|
||||
}
|
||||
waiter.resolve()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
const RECONNECT_DELAYS = [500, 1000, 2000, 4000, 8000, 15_000, 30_000, 60_000]
|
||||
export const RPC_RECONNECT_ATTEMPT_LIMIT = 12
|
||||
const TRICKLE_RECONNECT_DELAY_MS = 90_000
|
||||
|
||||
type ReconnectScheduleOptions = {
|
||||
openConnection: () => void
|
||||
rejectConnectWaiters: (reason: string) => void
|
||||
emitLog: (message: string, detail: string) => void
|
||||
}
|
||||
|
||||
export class RpcClientReconnectSchedule {
|
||||
private attempt = 0
|
||||
private timer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
constructor(private readonly options: ReconnectScheduleOptions) {}
|
||||
|
||||
getAttempt(): number {
|
||||
return this.attempt
|
||||
}
|
||||
|
||||
authenticated(): void {
|
||||
this.attempt = 0
|
||||
}
|
||||
|
||||
schedule(): void {
|
||||
const trickle = this.attempt >= RPC_RECONNECT_ATTEMPT_LIMIT
|
||||
let delayMs: number
|
||||
if (trickle) {
|
||||
delayMs = TRICKLE_RECONNECT_DELAY_MS
|
||||
this.options.rejectConnectWaiters('Connection retry limit reached')
|
||||
} else {
|
||||
delayMs = RECONNECT_DELAYS[Math.min(this.attempt, RECONNECT_DELAYS.length - 1)]!
|
||||
this.attempt++
|
||||
}
|
||||
console.log('[net] scheduleReconnect', {
|
||||
delayMs,
|
||||
attempt: this.attempt,
|
||||
trickle
|
||||
})
|
||||
this.options.emitLog(
|
||||
`Reconnect scheduled in ${delayMs}ms`,
|
||||
trickle ? `Attempt ${this.attempt} (slow retry)` : `Attempt ${this.attempt}`
|
||||
)
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = null
|
||||
this.options.openConnection()
|
||||
}, delayMs)
|
||||
}
|
||||
|
||||
redialNow(resetAttempts: boolean): void {
|
||||
this.cancel()
|
||||
if (resetAttempts) {
|
||||
this.attempt = 0
|
||||
}
|
||||
this.options.openConnection()
|
||||
}
|
||||
|
||||
hasTimer(): boolean {
|
||||
return this.timer !== null
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { markRpcDeliveryUnknown } from './rpc-delivery-ambiguity'
|
||||
import { openRpcRequestBudget, resolvePostConnectRequestTimeout } from './rpc-request-budget'
|
||||
import type { SendRequestOptions } from './rpc-client'
|
||||
import type { ConnectionState, RpcResponse } from './types'
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 30_000
|
||||
|
||||
type PendingRequest = {
|
||||
resolve: (response: RpcResponse) => void
|
||||
reject: (error: Error) => void
|
||||
}
|
||||
|
||||
type RequestTrackerOptions = {
|
||||
nextId: () => string
|
||||
getState: () => ConnectionState
|
||||
waitForConnected: (timeoutMs?: number) => Promise<void>
|
||||
sendEncrypted: (request: unknown) => boolean
|
||||
deviceToken: string
|
||||
}
|
||||
|
||||
export class RpcClientRequestTracker {
|
||||
private readonly pending = new Map<string, PendingRequest>()
|
||||
|
||||
constructor(private readonly options: RequestTrackerOptions) {}
|
||||
|
||||
async sendRequest(
|
||||
method: string,
|
||||
params?: unknown,
|
||||
requestOptions?: SendRequestOptions
|
||||
): Promise<RpcResponse> {
|
||||
const budget = openRpcRequestBudget(requestOptions)
|
||||
const waitStart = budget.startedAt
|
||||
const wasConnected = this.options.getState() === 'connected'
|
||||
if (requestOptions?.failWhenDisconnected && !wasConnected) {
|
||||
throw new Error(`Not connected: ${method}`)
|
||||
}
|
||||
await this.options.waitForConnected(requestOptions?.timeoutMs)
|
||||
if (!wasConnected) {
|
||||
console.log('[net] sendRequest waited for connect', {
|
||||
method,
|
||||
waitedMs: Date.now() - waitStart
|
||||
})
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = this.options.nextId()
|
||||
const timeoutMs = resolvePostConnectRequestTimeout(budget, REQUEST_TIMEOUT_MS)
|
||||
const timeout = setTimeout(() => {
|
||||
this.pending.delete(id)
|
||||
console.log('[net] sendRequest TIMEOUT', {
|
||||
method,
|
||||
timeoutMs,
|
||||
state: this.options.getState()
|
||||
})
|
||||
reject(markRpcDeliveryUnknown(new Error(`Request timed out: ${method}`)))
|
||||
}, timeoutMs)
|
||||
this.pending.set(id, {
|
||||
resolve: (response) => {
|
||||
clearTimeout(timeout)
|
||||
resolve(response)
|
||||
},
|
||||
reject: (error) => {
|
||||
clearTimeout(timeout)
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
if (
|
||||
!this.options.sendEncrypted({
|
||||
id,
|
||||
deviceToken: this.options.deviceToken,
|
||||
method,
|
||||
params
|
||||
})
|
||||
) {
|
||||
this.pending.delete(id)
|
||||
clearTimeout(timeout)
|
||||
reject(new Error('Connection interrupted'))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
resolve(response: RpcResponse): boolean {
|
||||
const request = this.pending.get(response.id)
|
||||
if (!request) {
|
||||
return false
|
||||
}
|
||||
this.pending.delete(response.id)
|
||||
request.resolve(response)
|
||||
return true
|
||||
}
|
||||
|
||||
rejectAll(reason: string, options?: { deliveryUnknown?: boolean }): void {
|
||||
const error = options?.deliveryUnknown
|
||||
? markRpcDeliveryUnknown(new Error(reason))
|
||||
: new Error(reason)
|
||||
for (const [id, request] of this.pending) {
|
||||
this.pending.delete(id)
|
||||
queueMicrotask(() => request.reject(error))
|
||||
}
|
||||
}
|
||||
|
||||
size(): number {
|
||||
return this.pending.size
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export function buildReadyStreamUnsubscribe(
|
||||
method: string,
|
||||
subscriptionId: string
|
||||
): { method: string; params: { subscriptionId: string } } | null {
|
||||
if (method === 'browser.screencast') {
|
||||
return { method: 'browser.screencast.unsubscribe', params: { subscriptionId } }
|
||||
}
|
||||
if (method === 'runtime.clientEvents.subscribe') {
|
||||
return { method: 'runtime.clientEvents.unsubscribe', params: { subscriptionId } }
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { RpcClientAuthenticationRetry } from './rpc-client-authentication-retry'
|
||||
import type { RpcClientConnectionState } from './rpc-client-connection-state'
|
||||
import type { RpcClientReconnectSchedule } from './rpc-client-reconnect-schedule'
|
||||
import type { RpcClientRequestTracker } from './rpc-client-request-tracker'
|
||||
import type { RpcClientSocketFactory } from './rpc-client-socket-factory'
|
||||
import type { RpcClientSocketSession } from './rpc-client-socket-session'
|
||||
import type { RpcClientStreamRegistry } from './rpc-client-stream-registry'
|
||||
import { RpcSynthesizedCloseIndex } from './rpc-socket-close-evidence'
|
||||
|
||||
const UNAUTHORIZED_CLOSE_CODE = 4001
|
||||
|
||||
type SocketCloseControllerOptions = {
|
||||
connectionState: RpcClientConnectionState
|
||||
reconnect: RpcClientReconnectSchedule
|
||||
requests: RpcClientRequestTracker
|
||||
streams: RpcClientStreamRegistry
|
||||
socketFactory: RpcClientSocketFactory
|
||||
authenticationRetry: RpcClientAuthenticationRetry
|
||||
getCurrentSession: () => RpcClientSocketSession | null
|
||||
clearCurrentSession: () => void
|
||||
getAuthenticationGeneration: () => number
|
||||
isIntentionallyClosed: () => boolean
|
||||
stopLiveness: (session: RpcClientSocketSession) => void
|
||||
emitWarning: (message: string, detail: string) => void
|
||||
}
|
||||
|
||||
export class RpcClientSocketCloseController {
|
||||
private readonly synthesizedCloses = new RpcSynthesizedCloseIndex()
|
||||
|
||||
constructor(private readonly options: SocketCloseControllerOptions) {}
|
||||
|
||||
forceClose(session: RpcClientSocketSession): void {
|
||||
session.close()
|
||||
if (this.options.getCurrentSession() === session) {
|
||||
this.synthesizedCloses.remember(session.socket, this.options.getAuthenticationGeneration())
|
||||
this.handle(session)
|
||||
}
|
||||
}
|
||||
|
||||
handle(session: RpcClientSocketSession, closeCode?: number): void {
|
||||
if (this.options.getCurrentSession() !== session) {
|
||||
if (
|
||||
this.synthesizedCloses.takeUnauthorized(
|
||||
session.socket,
|
||||
closeCode,
|
||||
this.options.getAuthenticationGeneration(),
|
||||
UNAUTHORIZED_CLOSE_CODE
|
||||
)
|
||||
) {
|
||||
this.options.authenticationRetry.reject('Unauthorized — pairing may be revoked', true)
|
||||
return
|
||||
}
|
||||
console.log('[net] handleSocketClosed STALE — ignoring (ws already swapped)', {
|
||||
state: this.options.connectionState.get(),
|
||||
attempt: this.options.reconnect.getAttempt()
|
||||
})
|
||||
return
|
||||
}
|
||||
this.options.socketFactory.noteClosed()
|
||||
session.clearTimers()
|
||||
session.clearKey()
|
||||
this.options.clearCurrentSession()
|
||||
this.options.streams.markForReplay()
|
||||
this.options.stopLiveness(session)
|
||||
if (this.options.isIntentionallyClosed()) {
|
||||
console.log('[net] handleSocketClosed — intentional close')
|
||||
this.options.connectionState.publish('disconnected')
|
||||
this.options.requests.rejectAll('Connection closed', { deliveryUnknown: true })
|
||||
return
|
||||
}
|
||||
if (closeCode === UNAUTHORIZED_CLOSE_CODE) {
|
||||
console.log('[net] handleSocketClosed — unauthorized close code', {
|
||||
attempt: this.options.reconnect.getAttempt()
|
||||
})
|
||||
this.options.authenticationRetry.reject('Unauthorized — pairing may be revoked')
|
||||
return
|
||||
}
|
||||
console.log('[net] handleSocketClosed → reconnect', {
|
||||
pendingCount: this.options.requests.size(),
|
||||
streamCount: this.options.streams.size(),
|
||||
attempt: this.options.reconnect.getAttempt()
|
||||
})
|
||||
this.options.emitWarning('WebSocket closed', 'Will attempt to reconnect')
|
||||
this.options.requests.rejectAll('Connection interrupted', { deliveryUnknown: true })
|
||||
this.options.connectionState.publish('reconnecting')
|
||||
this.options.reconnect.schedule()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { publicKeyFromBase64 } from './e2ee'
|
||||
import { RpcClientSocketSession } from './rpc-client-socket-session'
|
||||
import { redactSocketEndpoint } from './socket-event-debug'
|
||||
import type { ConnectionLogLevel, ConnectionState, RpcResponse } from './types'
|
||||
|
||||
type SocketFactoryOptions = {
|
||||
endpoint: string
|
||||
deviceToken: string
|
||||
serverPublicKeyB64: string
|
||||
getCurrentSocket: () => WebSocket | null
|
||||
getState: () => ConnectionState
|
||||
getReconnectAttempt: () => number
|
||||
getLastConnectedAt: () => number | null
|
||||
isIntentionallyClosed: () => boolean
|
||||
emitLog: (level: ConnectionLogLevel, message: string, detail?: string) => void
|
||||
onHandshakeStarted: () => void
|
||||
onAuthenticated: (session: RpcClientSocketSession) => void
|
||||
onAuthRejected: (reason: string) => void
|
||||
onRpcResponse: (response: RpcResponse) => void
|
||||
onBinary: (bytes: Uint8Array) => void
|
||||
onAuthenticatedInbound: (session: RpcClientSocketSession) => void
|
||||
onClosed: (session: RpcClientSocketSession, closeCode?: number) => void
|
||||
onForcedClose: (session: RpcClientSocketSession) => void
|
||||
}
|
||||
|
||||
export class RpcClientSocketFactory {
|
||||
private readonly serverPublicKey: Uint8Array
|
||||
private lastInboundAt: number | null = null
|
||||
private lastSocketClosedAt: number | null = null
|
||||
private constructionCount = 0
|
||||
private dialStartedAt = 0
|
||||
|
||||
constructor(private readonly options: SocketFactoryOptions) {
|
||||
this.serverPublicKey = publicKeyFromBase64(options.serverPublicKeyB64)
|
||||
}
|
||||
|
||||
open(): RpcClientSocketSession {
|
||||
const now = Date.now()
|
||||
const lastConnectedAt = this.options.getLastConnectedAt()
|
||||
this.constructionCount++
|
||||
console.log('[net] openConnection', {
|
||||
attempt: this.options.getReconnectAttempt(),
|
||||
endpoint: redactSocketEndpoint(this.options.endpoint),
|
||||
wsCount: this.constructionCount,
|
||||
msSinceLastConnected: lastConnectedAt !== null ? now - lastConnectedAt : null,
|
||||
msSinceLastClose: this.lastSocketClosedAt !== null ? now - this.lastSocketClosedAt : null,
|
||||
msSinceLastInbound: this.lastInboundAt !== null ? now - this.lastInboundAt : null
|
||||
})
|
||||
this.dialStartedAt = now
|
||||
this.options.emitLog(
|
||||
'info',
|
||||
this.options.getReconnectAttempt() > 0
|
||||
? `Reconnecting (attempt ${this.options.getReconnectAttempt() + 1})`
|
||||
: 'Opening WebSocket',
|
||||
redactSocketEndpoint(this.options.endpoint)
|
||||
)
|
||||
return new RpcClientSocketSession({
|
||||
endpoint: this.options.endpoint,
|
||||
deviceToken: this.options.deviceToken,
|
||||
serverPublicKey: this.serverPublicKey,
|
||||
getCurrentSocket: this.options.getCurrentSocket,
|
||||
getState: this.options.getState,
|
||||
getReconnectAttempt: this.options.getReconnectAttempt,
|
||||
isIntentionallyClosed: this.options.isIntentionallyClosed,
|
||||
emitLog: this.options.emitLog,
|
||||
onHandshakeStarted: this.options.onHandshakeStarted,
|
||||
onAuthenticated: this.options.onAuthenticated,
|
||||
onAuthRejected: this.options.onAuthRejected,
|
||||
onRpcResponse: this.options.onRpcResponse,
|
||||
onBinary: this.options.onBinary,
|
||||
onAnyInbound: (receivedAt) => (this.lastInboundAt = receivedAt),
|
||||
onAuthenticatedInbound: this.options.onAuthenticatedInbound,
|
||||
onClosed: this.options.onClosed,
|
||||
onForcedClose: this.options.onForcedClose
|
||||
})
|
||||
}
|
||||
|
||||
getDialStartedAt(): number {
|
||||
return this.dialStartedAt
|
||||
}
|
||||
|
||||
noteClosed(): void {
|
||||
this.lastSocketClosedAt = Date.now()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
import {
|
||||
decrypt,
|
||||
decryptBytes,
|
||||
deriveSharedKey,
|
||||
encrypt,
|
||||
generateKeyPair,
|
||||
publicKeyToBase64
|
||||
} from './e2ee'
|
||||
import { isRpcResponse } from './rpc-response-shape'
|
||||
import { isStaleRpcSocketEvent, logRpcSocketClose } from './rpc-socket-close-evidence'
|
||||
import { describeSocketEvent, redactSocketEndpoint } from './socket-event-debug'
|
||||
import type { ConnectionLogLevel, ConnectionState, RpcResponse } from './types'
|
||||
import { websocketPayloadToUint8 } from './websocket-payload-bytes'
|
||||
|
||||
const CONNECT_TIMEOUT_MS = 12_000
|
||||
const HANDSHAKE_TIMEOUT_MS = 5_000
|
||||
const WEBSOCKET_CONNECTING_STATE = 0
|
||||
|
||||
type SocketSessionOptions = {
|
||||
endpoint: string
|
||||
deviceToken: string
|
||||
serverPublicKey: Uint8Array
|
||||
getCurrentSocket: () => WebSocket | null
|
||||
getState: () => ConnectionState
|
||||
getReconnectAttempt: () => number
|
||||
isIntentionallyClosed: () => boolean
|
||||
emitLog: (level: ConnectionLogLevel, message: string, detail?: string) => void
|
||||
onHandshakeStarted: () => void
|
||||
onAuthenticated: (session: RpcClientSocketSession) => void
|
||||
onAuthRejected: (reason: string) => void
|
||||
onRpcResponse: (response: RpcResponse) => void
|
||||
onBinary: (bytes: Uint8Array) => void
|
||||
onAnyInbound: (receivedAt: number) => void
|
||||
onAuthenticatedInbound: (session: RpcClientSocketSession) => void
|
||||
onClosed: (session: RpcClientSocketSession, closeCode?: number) => void
|
||||
onForcedClose: (session: RpcClientSocketSession) => void
|
||||
}
|
||||
|
||||
export class RpcClientSocketSession {
|
||||
readonly socket: WebSocket
|
||||
readonly constructedAt = Date.now()
|
||||
private sharedKey: Uint8Array | null = null
|
||||
private authenticated = false
|
||||
private lastInboundAt: number | null = null
|
||||
private connectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private handshakeTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
constructor(private readonly options: SocketSessionOptions) {
|
||||
this.socket = new WebSocket(options.endpoint)
|
||||
this.attachHandlers()
|
||||
this.armConnectTimeout()
|
||||
}
|
||||
|
||||
sendEncrypted(request: unknown): boolean {
|
||||
if (this.socket.readyState === WebSocket.OPEN && this.sharedKey) {
|
||||
try {
|
||||
this.socket.send(encrypt(JSON.stringify(request), this.sharedKey))
|
||||
return true
|
||||
} catch {
|
||||
if (this.options.getCurrentSocket() === this.socket) {
|
||||
this.options.onForcedClose(this)
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
console.log('[net] sendEncrypted FAILED — channel not ready', {
|
||||
hasWs: this.options.getCurrentSocket() !== null,
|
||||
readyState: this.socket.readyState,
|
||||
hasKey: this.sharedKey !== null,
|
||||
state: this.options.getState()
|
||||
})
|
||||
if (
|
||||
this.options.getState() === 'connected' &&
|
||||
this.options.getCurrentSocket() === this.socket &&
|
||||
this.socket.readyState !== WebSocket.OPEN
|
||||
) {
|
||||
console.log('[net] sendEncrypted detected ws desync — forcing reconnect', {
|
||||
readyState: this.socket.readyState
|
||||
})
|
||||
this.options.onForcedClose(this)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.socket.close()
|
||||
}
|
||||
|
||||
clearTimers(): void {
|
||||
if (this.connectTimer) {
|
||||
clearTimeout(this.connectTimer)
|
||||
this.connectTimer = null
|
||||
}
|
||||
if (this.handshakeTimer) {
|
||||
clearTimeout(this.handshakeTimer)
|
||||
this.handshakeTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
clearKey(): void {
|
||||
this.sharedKey = null
|
||||
}
|
||||
|
||||
private attachHandlers(): void {
|
||||
this.socket.onopen = () => {
|
||||
if (this.isStale('open')) {
|
||||
return
|
||||
}
|
||||
console.log('[net] ws.onopen', { attempt: this.options.getReconnectAttempt() })
|
||||
this.clearConnectTimer()
|
||||
this.options.onHandshakeStarted()
|
||||
this.options.emitLog('success', 'WebSocket open', 'Starting E2EE handshake')
|
||||
const ephemeral = generateKeyPair()
|
||||
const hello = JSON.stringify({
|
||||
type: 'e2ee_hello',
|
||||
publicKeyB64: publicKeyToBase64(ephemeral.publicKey)
|
||||
})
|
||||
try {
|
||||
this.socket.send(hello)
|
||||
} catch {
|
||||
this.options.onForcedClose(this)
|
||||
return
|
||||
}
|
||||
this.options.emitLog('info', 'Sent e2ee_hello', 'Awaiting server e2ee_ready')
|
||||
this.sharedKey = deriveSharedKey(ephemeral.secretKey, this.options.serverPublicKey)
|
||||
this.armHandshakeTimeout()
|
||||
}
|
||||
this.socket.onmessage = (event) => {
|
||||
if (!this.isStale('message')) {
|
||||
void this.handleMessage(event.data)
|
||||
}
|
||||
}
|
||||
this.socket.onclose = (event) => {
|
||||
const closeCode = logRpcSocketClose({
|
||||
event,
|
||||
state: this.options.getState(),
|
||||
attempt: this.options.getReconnectAttempt(),
|
||||
intentionallyClosed: this.options.isIntentionallyClosed(),
|
||||
endpoint: redactSocketEndpoint(this.options.endpoint),
|
||||
constructedAt: this.constructedAt,
|
||||
authenticated: this.authenticated,
|
||||
lastInboundAt: this.lastInboundAt
|
||||
})
|
||||
this.options.onClosed(this, closeCode)
|
||||
}
|
||||
this.socket.onerror = (event) => {
|
||||
if (this.isStale('error')) {
|
||||
return
|
||||
}
|
||||
const error = event as { message?: string } | undefined
|
||||
const description = describeSocketEvent(event)
|
||||
console.log('[net] ws.onerror', {
|
||||
message: error?.message,
|
||||
state: this.options.getState(),
|
||||
attempt: this.options.getReconnectAttempt(),
|
||||
eventKeys: description.keys,
|
||||
eventStr: description.json
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private async handleMessage(rawData: unknown): Promise<void> {
|
||||
const receivedAt = Date.now()
|
||||
this.lastInboundAt = receivedAt
|
||||
this.options.onAnyInbound(receivedAt)
|
||||
const raw = typeof rawData === 'string' ? rawData : null
|
||||
if (!this.authenticated) {
|
||||
this.handleHandshakeMessage(raw)
|
||||
return
|
||||
}
|
||||
if (!this.sharedKey || this.sharedKey.length !== 32) {
|
||||
return
|
||||
}
|
||||
if (raw === null) {
|
||||
const bytes = await websocketPayloadToUint8(rawData)
|
||||
if (this.options.getCurrentSocket() !== this.socket || !bytes) {
|
||||
return
|
||||
}
|
||||
const plaintext = decryptBytes(bytes, this.sharedKey)
|
||||
if (!plaintext) {
|
||||
return
|
||||
}
|
||||
this.options.onAuthenticatedInbound(this)
|
||||
this.options.onBinary(plaintext)
|
||||
return
|
||||
}
|
||||
const plaintext = decrypt(raw, this.sharedKey)
|
||||
if (plaintext === null) {
|
||||
return
|
||||
}
|
||||
this.options.onAuthenticatedInbound(this)
|
||||
let response: unknown
|
||||
try {
|
||||
response = JSON.parse(plaintext)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (isRpcResponse(response)) {
|
||||
this.options.onRpcResponse(response)
|
||||
}
|
||||
}
|
||||
|
||||
private handleHandshakeMessage(raw: string | null): void {
|
||||
if (raw === null) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const message = JSON.parse(raw) as { type?: unknown }
|
||||
if (message.type === 'e2ee_ready') {
|
||||
this.options.emitLog('success', 'Received e2ee_ready', 'Sending device token')
|
||||
this.sendEncrypted({ type: 'e2ee_auth', deviceToken: this.options.deviceToken })
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// The authenticated handshake messages are encrypted.
|
||||
}
|
||||
if (!this.sharedKey || this.sharedKey.length !== 32) {
|
||||
return
|
||||
}
|
||||
const plaintext = decrypt(raw, this.sharedKey)
|
||||
if (plaintext === null) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const message = JSON.parse(plaintext) as {
|
||||
type?: unknown
|
||||
ok?: unknown
|
||||
error?: { code?: unknown }
|
||||
}
|
||||
if (message.type === 'e2ee_authenticated') {
|
||||
this.clearHandshakeTimer()
|
||||
this.authenticated = true
|
||||
this.options.onAuthenticated(this)
|
||||
} else if (
|
||||
message.type === 'e2ee_error' ||
|
||||
(message.ok === false && message.error?.code === 'unauthorized')
|
||||
) {
|
||||
console.log('[net] e2ee auth FAILED', {
|
||||
msgType: message.type,
|
||||
error: message.error
|
||||
})
|
||||
this.clearHandshakeTimer()
|
||||
this.options.onAuthRejected('Unauthorized — pairing may be revoked')
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed handshake payloads.
|
||||
}
|
||||
}
|
||||
|
||||
private armConnectTimeout(): void {
|
||||
this.connectTimer = setTimeout(() => {
|
||||
this.connectTimer = null
|
||||
if (
|
||||
this.options.getCurrentSocket() === this.socket &&
|
||||
this.socket.readyState === WEBSOCKET_CONNECTING_STATE
|
||||
) {
|
||||
console.log('[net] connect-timeout fired (onopen never arrived)', {
|
||||
attempt: this.options.getReconnectAttempt(),
|
||||
timeoutMs: CONNECT_TIMEOUT_MS
|
||||
})
|
||||
this.options.emitLog(
|
||||
'error',
|
||||
'WebSocket connect timeout',
|
||||
`No TCP/WS handshake within ${CONNECT_TIMEOUT_MS / 1000}s — endpoint unreachable?`
|
||||
)
|
||||
this.options.onForcedClose(this)
|
||||
}
|
||||
}, CONNECT_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
private armHandshakeTimeout(): void {
|
||||
this.handshakeTimer = setTimeout(() => {
|
||||
this.handshakeTimer = null
|
||||
if (this.options.getCurrentSocket() !== this.socket || this.authenticated) {
|
||||
return
|
||||
}
|
||||
console.log('[net] handshake-timeout fired (e2ee_authenticated never arrived)', {
|
||||
timeoutMs: HANDSHAKE_TIMEOUT_MS
|
||||
})
|
||||
this.options.emitLog(
|
||||
'error',
|
||||
'Handshake timeout',
|
||||
`No e2ee_ready/e2ee_authenticated within ${HANDSHAKE_TIMEOUT_MS / 1000}s`
|
||||
)
|
||||
this.options.onForcedClose(this)
|
||||
}, HANDSHAKE_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
private clearConnectTimer(): void {
|
||||
if (this.connectTimer) {
|
||||
clearTimeout(this.connectTimer)
|
||||
this.connectTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
private clearHandshakeTimer(): void {
|
||||
if (this.handshakeTimer) {
|
||||
clearTimeout(this.handshakeTimer)
|
||||
this.handshakeTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
private isStale(eventName: string): boolean {
|
||||
return isStaleRpcSocketEvent(
|
||||
this.options.getCurrentSocket(),
|
||||
this.socket,
|
||||
eventName,
|
||||
this.options.getState(),
|
||||
this.options.getReconnectAttempt()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { RpcClientStreamRegistry } from './rpc-client-stream-registry'
|
||||
import { encodeTerminalStreamFrame, TerminalStreamOpcode } from './terminal-stream-protocol'
|
||||
import type { ConnectionState, RpcResponse } from './types'
|
||||
|
||||
type SentRequest = {
|
||||
id: string
|
||||
method: string
|
||||
params?: unknown
|
||||
}
|
||||
|
||||
function createRegistry(initialState: ConnectionState = 'connected') {
|
||||
const sent: SentRequest[] = []
|
||||
let state = initialState
|
||||
let id = 0
|
||||
const registry = new RpcClientStreamRegistry({
|
||||
nextId: () => `rpc-${++id}`,
|
||||
deviceToken: 'device-token',
|
||||
getState: () => state,
|
||||
sendEncrypted: (request) => {
|
||||
sent.push(request as SentRequest)
|
||||
return true
|
||||
}
|
||||
})
|
||||
return {
|
||||
registry,
|
||||
sent,
|
||||
setState(next: ConnectionState) {
|
||||
state = next
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function streamingResponse(id: string, result: unknown): RpcResponse {
|
||||
return {
|
||||
id,
|
||||
ok: true,
|
||||
streaming: true,
|
||||
result,
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
}
|
||||
}
|
||||
|
||||
function terminalOutput(streamId: number, chunk: string): Uint8Array {
|
||||
return encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.Output,
|
||||
streamId,
|
||||
seq: 1,
|
||||
payload: new TextEncoder().encode(chunk)
|
||||
})
|
||||
}
|
||||
|
||||
describe('RpcClientStreamRegistry', () => {
|
||||
it('replays the latest terminal viewport without retaining stale stream routing', () => {
|
||||
const { registry, sent } = createRegistry()
|
||||
const events: unknown[] = []
|
||||
registry.subscribe(
|
||||
'terminal.subscribe',
|
||||
{ terminal: 'term-1', viewport: { cols: 45, rows: 20 } },
|
||||
(event) => events.push(event)
|
||||
)
|
||||
const first = sent[0]!
|
||||
registry.handleResponse(streamingResponse(first.id, { type: 'subscribed', streamId: 7 }))
|
||||
registry.handleBinary(terminalOutput(7, 'before'))
|
||||
|
||||
registry.updateTerminalViewport('term-1', { cols: 60, rows: 24 })
|
||||
registry.markForReplay()
|
||||
registry.replayAfterAuthentication()
|
||||
registry.handleBinary(terminalOutput(7, 'stale'))
|
||||
|
||||
expect(sent[1]).toMatchObject({
|
||||
id: first.id,
|
||||
method: 'terminal.subscribe',
|
||||
params: { terminal: 'term-1', viewport: { cols: 60, rows: 24 } }
|
||||
})
|
||||
expect(events).toEqual([
|
||||
{ type: 'subscribed', streamId: 7 },
|
||||
{ type: 'data', streamId: 7, chunk: 'before' }
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps a disposed browser tombstone until ready can be unsubscribed', () => {
|
||||
const { registry, sent } = createRegistry()
|
||||
const dispose = registry.subscribe('browser.screencast', { page: 'page-1' }, () => {})
|
||||
const request = sent[0]!
|
||||
|
||||
dispose()
|
||||
expect(sent).toHaveLength(1)
|
||||
|
||||
registry.handleResponse(
|
||||
streamingResponse(request.id, {
|
||||
type: 'ready',
|
||||
subscriptionId: 'browser-screencast:page-1:test'
|
||||
})
|
||||
)
|
||||
expect(sent[1]).toMatchObject({
|
||||
method: 'browser.screencast.unsubscribe',
|
||||
params: { subscriptionId: 'browser-screencast:page-1:test' }
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,315 @@
|
||||
import {
|
||||
decodeBrowserScreencastFrame,
|
||||
type BrowserScreencastFrame
|
||||
} from './browser-screencast-protocol'
|
||||
import {
|
||||
buildStreamUnsubscribe,
|
||||
buildTerminalUnsubscribeParams,
|
||||
updateTerminalSubscriptionViewport
|
||||
} from './rpc-client-terminal-subscription'
|
||||
import { buildReadyStreamUnsubscribe } from './rpc-client-server-subscription'
|
||||
import {
|
||||
isStreamingSubscriptionReadyResult,
|
||||
isTerminalSubscribedResult
|
||||
} from './rpc-subscription-result-shapes'
|
||||
import { RpcClientTerminalStreamRouter } from './rpc-client-terminal-stream-router'
|
||||
import type { ConnectionState, RpcResponse, RpcSuccess } from './types'
|
||||
|
||||
export type RpcStreamingListener = (result: unknown) => void
|
||||
|
||||
export type RpcStreamSubscribeOptions = {
|
||||
onBinaryFrame?: (frame: BrowserScreencastFrame) => void
|
||||
}
|
||||
|
||||
type StreamRequest = {
|
||||
method: string
|
||||
params: unknown
|
||||
listener: RpcStreamingListener
|
||||
onBinaryFrame?: (frame: BrowserScreencastFrame) => void
|
||||
subscriptionId?: string
|
||||
cancelled?: boolean
|
||||
sent?: boolean
|
||||
}
|
||||
|
||||
type StreamRegistryOptions = {
|
||||
nextId: () => string
|
||||
deviceToken: string
|
||||
getState: () => ConnectionState
|
||||
sendEncrypted: (request: unknown) => boolean
|
||||
}
|
||||
|
||||
export class RpcClientStreamRegistry {
|
||||
private readonly streams = new Map<string, StreamRequest>()
|
||||
private readonly terminalRouter = new RpcClientTerminalStreamRouter()
|
||||
private activeBrowserRequestId: string | null = null
|
||||
private pendingBrowserRequestId: string | null = null
|
||||
|
||||
constructor(private readonly options: StreamRegistryOptions) {}
|
||||
|
||||
subscribe(
|
||||
method: string,
|
||||
params: unknown,
|
||||
listener: RpcStreamingListener,
|
||||
subscribeOptions?: RpcStreamSubscribeOptions
|
||||
): () => void {
|
||||
const id = this.options.nextId()
|
||||
const stream: StreamRequest = {
|
||||
method,
|
||||
params,
|
||||
listener,
|
||||
onBinaryFrame: subscribeOptions?.onBinaryFrame
|
||||
}
|
||||
this.streams.set(id, stream)
|
||||
if (method === 'browser.screencast') {
|
||||
this.replaceBrowserStream(id)
|
||||
}
|
||||
if (this.options.getState() === 'connected') {
|
||||
if (this.send(id, stream)) {
|
||||
stream.sent = true
|
||||
} else {
|
||||
this.emitError(stream, 'Connection interrupted')
|
||||
this.remove(id)
|
||||
}
|
||||
} else {
|
||||
console.log('[net] subscribe queued — waiting for connected', {
|
||||
method,
|
||||
state: this.options.getState()
|
||||
})
|
||||
}
|
||||
return () => this.dispose(id)
|
||||
}
|
||||
|
||||
replayAfterAuthentication(): void {
|
||||
for (const [id, stream] of this.streams) {
|
||||
if (stream.cancelled) {
|
||||
this.remove(id)
|
||||
continue
|
||||
}
|
||||
if (stream.sent) {
|
||||
continue
|
||||
}
|
||||
if (stream.method === 'browser.screencast') {
|
||||
this.pendingBrowserRequestId = id
|
||||
this.activeBrowserRequestId = null
|
||||
}
|
||||
this.resetTerminalRouting(id)
|
||||
if (this.send(id, stream)) {
|
||||
stream.sent = true
|
||||
} else {
|
||||
this.markForReplay()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
markForReplay(): void {
|
||||
this.activeBrowserRequestId = null
|
||||
this.pendingBrowserRequestId = null
|
||||
for (const [id, stream] of this.streams) {
|
||||
stream.sent = false
|
||||
this.resetTerminalRouting(id)
|
||||
}
|
||||
}
|
||||
|
||||
handleResponse(response: RpcResponse): boolean {
|
||||
if (response.ok && response.streaming === true) {
|
||||
this.handleStreamingResponse(response)
|
||||
return true
|
||||
}
|
||||
const stream = this.streams.get(response.id)
|
||||
if (response.ok) {
|
||||
const result = (response as RpcSuccess).result as Record<string, unknown> | null
|
||||
if (stream && result?.type === 'end') {
|
||||
if (!stream.cancelled) {
|
||||
stream.listener(result)
|
||||
}
|
||||
this.remove(response.id)
|
||||
return true
|
||||
}
|
||||
if (stream && result?.type === 'scrollback') {
|
||||
stream.listener(result)
|
||||
return true
|
||||
}
|
||||
}
|
||||
if (!stream) {
|
||||
return false
|
||||
}
|
||||
this.emitError(
|
||||
stream,
|
||||
response.ok ? 'Streaming request ended before it was ready.' : response.error.message,
|
||||
response.ok ? undefined : response.error
|
||||
)
|
||||
this.remove(response.id)
|
||||
return true
|
||||
}
|
||||
|
||||
handleBinary(bytes: Uint8Array): void {
|
||||
const browserFrame = decodeBrowserScreencastFrame(bytes)
|
||||
if (browserFrame) {
|
||||
this.handleBrowserFrame(browserFrame)
|
||||
return
|
||||
}
|
||||
this.terminalRouter.handle(bytes)
|
||||
}
|
||||
|
||||
updateTerminalViewport(terminal: string, viewport: { cols: number; rows: number }): void {
|
||||
updateTerminalSubscriptionViewport(this.streams.values(), terminal, viewport)
|
||||
}
|
||||
|
||||
size(): number {
|
||||
return this.streams.size
|
||||
}
|
||||
|
||||
private handleStreamingResponse(response: RpcSuccess): void {
|
||||
const stream = this.streams.get(response.id)
|
||||
if (!stream) {
|
||||
return
|
||||
}
|
||||
const result = response.result
|
||||
if (isStreamingSubscriptionReadyResult(result)) {
|
||||
stream.subscriptionId = result.subscriptionId
|
||||
if (stream.cancelled) {
|
||||
this.sendServerSubscriptionUnsubscribe(stream)
|
||||
this.remove(response.id)
|
||||
return
|
||||
}
|
||||
if (stream.method === 'browser.screencast') {
|
||||
if (
|
||||
this.pendingBrowserRequestId !== response.id &&
|
||||
this.activeBrowserRequestId !== response.id
|
||||
) {
|
||||
this.sendBrowserUnsubscribe(result.subscriptionId)
|
||||
this.remove(response.id)
|
||||
return
|
||||
}
|
||||
this.pendingBrowserRequestId = null
|
||||
this.activeBrowserRequestId = response.id
|
||||
}
|
||||
}
|
||||
if (isTerminalSubscribedResult(result)) {
|
||||
this.terminalRouter.register(response.id, result.streamId, stream.listener)
|
||||
}
|
||||
if (!stream.cancelled) {
|
||||
stream.listener(result)
|
||||
}
|
||||
}
|
||||
|
||||
private dispose(id: string): void {
|
||||
const stream = this.streams.get(id)
|
||||
if (stream?.method === 'browser.screencast') {
|
||||
stream.cancelled = true
|
||||
this.clearBrowserRequest(id)
|
||||
this.disposeServerSubscription(id, stream)
|
||||
return
|
||||
}
|
||||
if (stream?.method === 'runtime.clientEvents.subscribe') {
|
||||
this.disposeServerSubscription(id, stream)
|
||||
return
|
||||
}
|
||||
if (stream?.method === 'terminal.subscribe') {
|
||||
const params = buildTerminalUnsubscribeParams(stream.params)
|
||||
if (params) {
|
||||
this.sendRpc('terminal.unsubscribe', params)
|
||||
}
|
||||
} else {
|
||||
const unsubscribe = buildStreamUnsubscribe(stream?.method, stream?.params)
|
||||
if (unsubscribe) {
|
||||
this.sendRpc(unsubscribe.method, unsubscribe.params)
|
||||
}
|
||||
}
|
||||
this.remove(id)
|
||||
}
|
||||
|
||||
private replaceBrowserStream(id: string): void {
|
||||
if (this.activeBrowserRequestId && this.activeBrowserRequestId !== id) {
|
||||
this.dispose(this.activeBrowserRequestId)
|
||||
}
|
||||
if (this.pendingBrowserRequestId && this.pendingBrowserRequestId !== id) {
|
||||
this.dispose(this.pendingBrowserRequestId)
|
||||
}
|
||||
this.pendingBrowserRequestId = id
|
||||
this.activeBrowserRequestId = null
|
||||
}
|
||||
|
||||
private disposeServerSubscription(id: string, stream: StreamRequest): void {
|
||||
stream.cancelled = true
|
||||
if (stream.subscriptionId) {
|
||||
this.sendServerSubscriptionUnsubscribe(stream)
|
||||
this.remove(id)
|
||||
} else if (!stream.sent) {
|
||||
this.remove(id)
|
||||
}
|
||||
}
|
||||
|
||||
private sendServerSubscriptionUnsubscribe(stream: StreamRequest): void {
|
||||
if (!stream.subscriptionId) {
|
||||
return
|
||||
}
|
||||
const unsubscribe = buildReadyStreamUnsubscribe(stream.method, stream.subscriptionId)
|
||||
if (unsubscribe) {
|
||||
this.sendRpc(unsubscribe.method, unsubscribe.params)
|
||||
}
|
||||
}
|
||||
|
||||
private sendBrowserUnsubscribe(subscriptionId: string): void {
|
||||
this.sendRpc('browser.screencast.unsubscribe', { subscriptionId })
|
||||
}
|
||||
|
||||
private sendRpc(method: string, params: unknown): boolean {
|
||||
return this.options.sendEncrypted({
|
||||
id: this.options.nextId(),
|
||||
deviceToken: this.options.deviceToken,
|
||||
method,
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
private send(id: string, stream: StreamRequest): boolean {
|
||||
return this.options.sendEncrypted({
|
||||
id,
|
||||
deviceToken: this.options.deviceToken,
|
||||
method: stream.method,
|
||||
params: stream.params
|
||||
})
|
||||
}
|
||||
|
||||
private remove(id: string): void {
|
||||
const stream = this.streams.get(id)
|
||||
this.streams.delete(id)
|
||||
this.clearBrowserRequest(id)
|
||||
this.resetTerminalRouting(id)
|
||||
if (stream?.method === 'browser.screencast') {
|
||||
stream.cancelled = true
|
||||
}
|
||||
}
|
||||
|
||||
private clearBrowserRequest(id: string): void {
|
||||
if (this.activeBrowserRequestId === id) {
|
||||
this.activeBrowserRequestId = null
|
||||
}
|
||||
if (this.pendingBrowserRequestId === id) {
|
||||
this.pendingBrowserRequestId = null
|
||||
}
|
||||
}
|
||||
|
||||
private resetTerminalRouting(id: string): void {
|
||||
this.terminalRouter.reset(id)
|
||||
}
|
||||
|
||||
private handleBrowserFrame(frame: BrowserScreencastFrame): void {
|
||||
if (!this.activeBrowserRequestId) {
|
||||
return
|
||||
}
|
||||
const stream = this.streams.get(this.activeBrowserRequestId)
|
||||
if (!stream || stream.cancelled || stream.method !== 'browser.screencast') {
|
||||
return
|
||||
}
|
||||
stream.onBinaryFrame?.(frame)
|
||||
}
|
||||
|
||||
private emitError(stream: StreamRequest, message: string, error?: unknown): void {
|
||||
if (!stream.cancelled) {
|
||||
stream.listener({ type: 'error', message, error })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
handleTerminalBinaryFrame,
|
||||
type TerminalSnapshotState
|
||||
} from './rpc-client-terminal-binary-frame'
|
||||
|
||||
type TerminalListener = (result: unknown) => void
|
||||
|
||||
export class RpcClientTerminalStreamRouter {
|
||||
private readonly listeners = new Map<number, TerminalListener>()
|
||||
private readonly idsByRequest = new Map<string, Set<number>>()
|
||||
private readonly snapshots = new Map<number, TerminalSnapshotState>()
|
||||
|
||||
register(requestId: string, streamId: number, listener: TerminalListener): void {
|
||||
const ids = this.idsByRequest.get(requestId) ?? new Set<number>()
|
||||
this.idsByRequest.set(requestId, ids)
|
||||
ids.add(streamId)
|
||||
this.listeners.set(streamId, listener)
|
||||
}
|
||||
|
||||
reset(requestId: string): void {
|
||||
const streamIds = this.idsByRequest.get(requestId)
|
||||
if (!streamIds) {
|
||||
return
|
||||
}
|
||||
for (const streamId of streamIds) {
|
||||
this.listeners.delete(streamId)
|
||||
this.snapshots.delete(streamId)
|
||||
}
|
||||
this.idsByRequest.delete(requestId)
|
||||
}
|
||||
|
||||
handle(bytes: Uint8Array): void {
|
||||
handleTerminalBinaryFrame(bytes, {
|
||||
terminalSnapshots: this.snapshots,
|
||||
getListener: (streamId) => this.listeners.get(streamId)
|
||||
})
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -28,6 +28,9 @@ function row(overrides: Partial<RuntimeWorktreeAgentRow> = {}): RuntimeWorktreeA
|
||||
describe('agentDotState', () => {
|
||||
it('maps known states through and unknown to idle', () => {
|
||||
expect(agentDotState(row({ state: 'working', updatedAt: 0 }), 0)).toBe('working')
|
||||
expect(
|
||||
agentDotState(row({ state: 'working', workingMode: 'monitoring', updatedAt: 0 }), 0)
|
||||
).toBe('monitoring')
|
||||
expect(agentDotState(row({ state: 'blocked', updatedAt: 0 }), 0)).toBe('blocked')
|
||||
expect(agentDotState(row({ state: 'waiting', updatedAt: 0 }), 0)).toBe('waiting')
|
||||
expect(agentDotState(row({ state: 'done', updatedAt: 0 }), 0)).toBe('done')
|
||||
@@ -65,6 +68,12 @@ describe('agentDisplayLabel', () => {
|
||||
expect(agentDisplayLabel(row({ state: 'working', prompt: '', updatedAt: 0 }), 0)).toBe(
|
||||
'Working'
|
||||
)
|
||||
expect(
|
||||
agentDisplayLabel(
|
||||
row({ state: 'working', workingMode: 'monitoring', prompt: '', updatedAt: 0 }),
|
||||
0
|
||||
)
|
||||
).toBe('Monitoring background tasks')
|
||||
})
|
||||
|
||||
it('falls back to the decayed state label when stale', () => {
|
||||
|
||||
@@ -9,23 +9,34 @@ export const AGENT_STATUS_STALE_AFTER_MS = 30 * 60 * 1000
|
||||
// Mirrors the desktop AgentStateDot vocabulary. The wire `state` is the agent
|
||||
// status state; 'blocked'/'waiting' read as attention states, 'done' as
|
||||
// complete, everything else idle.
|
||||
export type AgentDotState = 'working' | 'blocked' | 'waiting' | 'done' | 'idle' | 'interrupted'
|
||||
export type AgentDotState =
|
||||
| 'working'
|
||||
| 'monitoring'
|
||||
| 'blocked'
|
||||
| 'waiting'
|
||||
| 'done'
|
||||
| 'idle'
|
||||
| 'interrupted'
|
||||
|
||||
export function agentDotState(
|
||||
row: Pick<RuntimeWorktreeAgentRow, 'state' | 'interrupted' | 'updatedAt'>,
|
||||
row: Pick<RuntimeWorktreeAgentRow, 'state' | 'workingMode' | 'interrupted' | 'updatedAt'>,
|
||||
now: number
|
||||
): AgentDotState {
|
||||
if (row.interrupted) {
|
||||
return 'interrupted'
|
||||
}
|
||||
switch (row.state) {
|
||||
case 'working':
|
||||
case 'blocked':
|
||||
case 'waiting':
|
||||
// Why: an agent that exits without a final report would otherwise read as
|
||||
// active forever. Decay a stale active state to idle, matching desktop's
|
||||
// renderer-side staleness decay (worktree-agent-rows.ts).
|
||||
return now - row.updatedAt > AGENT_STATUS_STALE_AFTER_MS ? 'idle' : row.state
|
||||
case 'working':
|
||||
if (now - row.updatedAt > AGENT_STATUS_STALE_AFTER_MS) {
|
||||
return 'idle'
|
||||
}
|
||||
return row.workingMode === 'monitoring' ? 'monitoring' : 'working'
|
||||
case 'done':
|
||||
return 'done'
|
||||
}
|
||||
@@ -37,6 +48,8 @@ export function agentStateLabel(state: AgentDotState): string {
|
||||
switch (state) {
|
||||
case 'working':
|
||||
return 'Working'
|
||||
case 'monitoring':
|
||||
return 'Monitoring background tasks'
|
||||
case 'blocked':
|
||||
return 'Blocked'
|
||||
case 'waiting':
|
||||
|
||||
@@ -7,7 +7,10 @@ import {
|
||||
} from './home-resume-card'
|
||||
import type { HomeWorktreeSummary, HostWorktreeInfo } from './home-worktree-info'
|
||||
|
||||
const homeSource = readFileSync(new URL('../../app/index.tsx', import.meta.url), 'utf8')
|
||||
const resumeCardSource = readFileSync(
|
||||
new URL('../home/MobileHomeResumeCard.tsx', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
function worktree(worktreeId: string): HomeWorktreeSummary {
|
||||
return {
|
||||
@@ -120,17 +123,8 @@ describe('home resume card', () => {
|
||||
})
|
||||
|
||||
it('renders the home Resume card inert until its host connects', () => {
|
||||
const start = homeSource.indexOf('{/* ─── Resume card ─── */}')
|
||||
const end = homeSource.indexOf('{/* ─── Quick actions ─── */}', start)
|
||||
|
||||
// Assert the markers first: a renamed banner would otherwise slice garbage and report a
|
||||
// missing prop instead of the real cause.
|
||||
expect(start).toBeGreaterThanOrEqual(0)
|
||||
expect(end).toBeGreaterThan(start)
|
||||
|
||||
const resumeCard = homeSource.slice(start, end)
|
||||
expect(resumeCard).toContain('disabled={!resumeCard.actionable}')
|
||||
expect(resumeCard).toContain('!resumeCard.actionable && styles.cardDisabled')
|
||||
expect(resumeCardSource).toContain('disabled={!props.card.actionable}')
|
||||
expect(resumeCardSource).toContain('!props.card.actionable && styles.cardDisabled')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ExecutionHostId } from '../../../src/shared/execution-host'
|
||||
import type { AgentWorkingMode } from '../../../src/shared/agent-status-types'
|
||||
import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types'
|
||||
|
||||
export type Worktree = {
|
||||
@@ -45,6 +46,7 @@ export type Worktree = {
|
||||
linkedGitLabIssue?: number | null
|
||||
comment?: string
|
||||
status?: 'working' | 'active' | 'permission' | 'done' | 'inactive'
|
||||
workingMode?: AgentWorkingMode
|
||||
agents?: RuntimeWorktreeAgentRow[]
|
||||
}
|
||||
|
||||
|
||||
@@ -160,6 +160,20 @@ describe('areWorktreeListsEqual', () => {
|
||||
expect(areWorktreeListsEqual(first, second)).toBe(false)
|
||||
})
|
||||
|
||||
it('detects monitoring mode changes within working', () => {
|
||||
const first = [worktree({ agents: [agent({ state: 'working' })] })]
|
||||
const second = [worktree({ agents: [agent({ state: 'working', workingMode: 'monitoring' })] })]
|
||||
|
||||
expect(areWorktreeListsEqual(first, second)).toBe(false)
|
||||
})
|
||||
|
||||
it('detects workspace monitoring mode changes within working', () => {
|
||||
const first = [worktree({ status: 'working' })]
|
||||
const second = [worktree({ status: 'working', workingMode: 'monitoring' })]
|
||||
|
||||
expect(areWorktreeListsEqual(first, second)).toBe(false)
|
||||
})
|
||||
|
||||
it('treats missing and empty agent arrays as equivalent for rendering', () => {
|
||||
const first = [worktree({ agents: undefined })]
|
||||
const second = [worktree({ agents: [] })]
|
||||
|
||||
@@ -55,6 +55,7 @@ function areWorktreesEqual(left: Worktree, right: Worktree): boolean {
|
||||
(left.linkedGitLabIssue ?? null) === (right.linkedGitLabIssue ?? null) &&
|
||||
(left.comment ?? '') === (right.comment ?? '') &&
|
||||
(left.status ?? null) === (right.status ?? null) &&
|
||||
(left.workingMode ?? null) === (right.workingMode ?? null) &&
|
||||
arePullRequestsEqual(left.linkedPR, right.linkedPR) &&
|
||||
areAgentRowsEqual(left.agents ?? [], right.agents ?? [])
|
||||
)
|
||||
@@ -102,6 +103,7 @@ function areAgentRowsEqual(
|
||||
a.paneKey !== b.paneKey ||
|
||||
a.parentPaneKey !== b.parentPaneKey ||
|
||||
a.state !== b.state ||
|
||||
a.workingMode !== b.workingMode ||
|
||||
a.agentType !== b.agentType ||
|
||||
a.prompt !== b.prompt ||
|
||||
a.lastAssistantMessage !== b.lastAssistantMessage ||
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"test:e2e:posix-profile-index-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/golden-posix-fresh-startup.spec.ts tests/e2e/golden-posix-profile-index-fsync.spec.ts --grep @posix-profile-index-golden --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"test:e2e:windows-fresh-startup-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/golden-windows-fresh-startup.spec.ts --grep @windows-fresh-startup-golden --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"test:e2e:agent-tui-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/golden-agent-tui-launch.spec.ts tests/e2e/golden-shell-after-agent-exit.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"test:e2e:tab-bar-agent-launch-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/golden-tab-bar-agent-launch.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"test:e2e:terminal-rendering-release-evidence": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-opencode-emoji-table-rendering.spec.ts tests/e2e/terminal-long-table-scroll-restore.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=2",
|
||||
"test:e2e:terminal-perf": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-typing-latency.spec.ts tests/e2e/terminal-foreground-redraw-freeze.spec.ts tests/e2e/terminal-output-scheduler.spec.ts tests/e2e/terminal-hidden-tui-visual-restore.spec.ts tests/e2e/artificial-opencode-terminal-load.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=2",
|
||||
"test:e2e:terminal-perf:scale": "pnpm run ensure:electron-runtime && node config/scripts/run-terminal-scale-perf-e2e.mjs",
|
||||
@@ -114,6 +115,7 @@
|
||||
"test:e2e:source-control-scale": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/source-control-large-file-count.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"win-update-e2e": "node tests/tools/win-update-e2e/run.mjs",
|
||||
"win-crash-survival-e2e": "node tests/tools/win-crash-survival-e2e/run.mjs",
|
||||
"smoke:windows-pty-native-capability": "node tests/tools/windows-pty-native-capability-smoke/run.mjs",
|
||||
"test:e2e:ssh-codex-artifacts-repro": "node config/scripts/run-ssh-codex-artifacts-repro-e2e.mjs",
|
||||
"test:e2e:headful": "pnpm run ensure:electron-runtime && npx playwright test --config tests/playwright.config.ts --project electron-headful",
|
||||
"test:e2e:terminal-ime-native": "node config/scripts/run-terminal-ibus-hangul-e2e.mjs",
|
||||
|
||||
Generated
+3
-3
@@ -27,7 +27,7 @@ patchedDependencies:
|
||||
hash: 7333b3837f80a7fbd045964db6d76ba4fc118e49134bdbabb00585b6b7b60673
|
||||
path: config/patches/lint-staged@16.4.0.patch
|
||||
node-pty@1.1.0:
|
||||
hash: 710d5c74d1b738c41316e997e7cb860b61147604f98380ccf4f5bb20b6ed0ac5
|
||||
hash: 9c665d9e41571341cb8986607d531b953a7f32c0dd60e1e74e633c098d80f2c1
|
||||
path: config/patches/node-pty@1.1.0.patch
|
||||
|
||||
importers:
|
||||
@@ -69,7 +69,7 @@ importers:
|
||||
version: 3.3.1
|
||||
node-pty:
|
||||
specifier: ^1.1.0
|
||||
version: 1.1.0(patch_hash=710d5c74d1b738c41316e997e7cb860b61147604f98380ccf4f5bb20b6ed0ac5)
|
||||
version: 1.1.0(patch_hash=9c665d9e41571341cb8986607d531b953a7f32c0dd60e1e74e633c098d80f2c1)
|
||||
posthog-node:
|
||||
specifier: ^5.33.3
|
||||
version: 5.33.3
|
||||
@@ -12046,7 +12046,7 @@ snapshots:
|
||||
|
||||
node-int64@0.4.0: {}
|
||||
|
||||
node-pty@1.1.0(patch_hash=710d5c74d1b738c41316e997e7cb860b61147604f98380ccf4f5bb20b6ed0ac5):
|
||||
node-pty@1.1.0(patch_hash=9c665d9e41571341cb8986607d531b953a7f32c0dd60e1e74e633c098d80f2c1):
|
||||
dependencies:
|
||||
node-addon-api: 7.1.1
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type * as NodeCliCommandResolutionModule from '../../shared/node-cli-command-resolution'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { existsSync } from 'node:fs'
|
||||
@@ -12,6 +13,7 @@ const {
|
||||
resolveCliCommandMock,
|
||||
rmSyncMock,
|
||||
spawnMock,
|
||||
stdioForWindowsInteractiveChildMock,
|
||||
writeKeychainMock
|
||||
} = vi.hoisted(() => ({
|
||||
deleteKeychainMock: vi.fn(),
|
||||
@@ -20,6 +22,7 @@ const {
|
||||
resolveCliCommandMock: vi.fn(),
|
||||
rmSyncMock: vi.fn(),
|
||||
spawnMock: vi.fn(),
|
||||
stdioForWindowsInteractiveChildMock: vi.fn(),
|
||||
writeKeychainMock: vi.fn()
|
||||
}))
|
||||
|
||||
@@ -41,10 +44,16 @@ vi.mock('../../main/claude-accounts/keychain', () => ({
|
||||
readActiveClaudeKeychainCredentialsStrict: readKeychainMock,
|
||||
writeActiveClaudeKeychainCredentials: writeKeychainMock
|
||||
}))
|
||||
vi.mock('../../shared/node-cli-command-resolution', () => ({
|
||||
// Why importOriginal: withCliRuntimeOnPath is a pure filesystem-probing helper,
|
||||
// and the PATH assertions below are only meaningful against the real one.
|
||||
vi.mock('../../shared/node-cli-command-resolution', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof NodeCliCommandResolutionModule>()),
|
||||
getVersionManagerBinPaths: getVersionManagerBinPathsMock,
|
||||
resolveCliCommand: resolveCliCommandMock
|
||||
}))
|
||||
vi.mock('../../shared/windows-console-input', () => ({
|
||||
stdioForWindowsInteractiveChild: stdioForWindowsInteractiveChildMock
|
||||
}))
|
||||
|
||||
import { ACCOUNT_HANDLERS } from './account'
|
||||
import type { HandlerContext } from '../dispatch'
|
||||
@@ -105,6 +114,10 @@ describe('account CLI handlers', () => {
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(process, 'platform', originalPlatform)
|
||||
spawnMock.mockReset().mockImplementation(() => successfulChild())
|
||||
stdioForWindowsInteractiveChildMock.mockReset().mockImplementation((json: boolean) => ({
|
||||
stdio: ['inherit', json ? process.stderr : 'inherit', 'inherit'],
|
||||
dispose: vi.fn()
|
||||
}))
|
||||
resolveCliCommandMock.mockReset().mockImplementation((command: string) => command)
|
||||
getVersionManagerBinPathsMock.mockReset().mockReturnValue([])
|
||||
readKeychainMock.mockReset().mockResolvedValue(null)
|
||||
@@ -159,6 +172,56 @@ describe('account CLI handlers', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('passes Windows console device handles to the login child instead of inheriting Electron stdio', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
const dispose = vi.fn()
|
||||
stdioForWindowsInteractiveChildMock.mockReturnValue({
|
||||
stdio: [11, 'inherit', 'inherit'],
|
||||
dispose
|
||||
})
|
||||
|
||||
await ACCOUNT_HANDLERS['account add'](context('codex'))
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
'codex',
|
||||
['login', '--device-auth'],
|
||||
expect.objectContaining({ stdio: [11, 'inherit', 'inherit'] })
|
||||
)
|
||||
expect(dispose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('disposes the Windows console input fd when spawn throws synchronously', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
const dispose = vi.fn()
|
||||
stdioForWindowsInteractiveChildMock.mockReturnValue({
|
||||
stdio: [11, 'inherit', 'inherit'],
|
||||
dispose
|
||||
})
|
||||
spawnMock.mockImplementationOnce(() => {
|
||||
throw new Error('invalid stdio')
|
||||
})
|
||||
|
||||
await expect(ACCOUNT_HANDLERS['account add'](context('codex'))).rejects.toThrow('invalid stdio')
|
||||
|
||||
expect(dispose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps JSON login prompts off the CLI stdout envelope when console fds are attached', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
stdioForWindowsInteractiveChildMock.mockReturnValue({
|
||||
stdio: [11, process.stderr, 'inherit'],
|
||||
dispose: vi.fn()
|
||||
})
|
||||
|
||||
await ACCOUNT_HANDLERS['account add'](context('codex', true))
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
'codex',
|
||||
['login', '--device-auth'],
|
||||
expect.objectContaining({ stdio: [11, process.stderr, 'inherit'] })
|
||||
)
|
||||
})
|
||||
|
||||
it('routes Windows package-manager shims through the safe cmd launcher', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
resolveCliCommandMock.mockReturnValue('C:\\tools\\codex.cmd')
|
||||
|
||||
@@ -14,13 +14,15 @@ import {
|
||||
} from '../../main/claude-accounts/keychain'
|
||||
import {
|
||||
getVersionManagerBinPaths,
|
||||
resolveCliCommand
|
||||
resolveCliCommand,
|
||||
withCliRuntimeOnPath
|
||||
} from '../../shared/node-cli-command-resolution'
|
||||
import {
|
||||
getSpawnArgsForWindows,
|
||||
UnsafeWindowsBatchArgumentsError,
|
||||
WINDOWS_BATCH_UNSAFE_CHARACTERS_LABEL
|
||||
} from '../../shared/windows-batch-spawn'
|
||||
import { stdioForWindowsInteractiveChild } from '../../shared/windows-console-input'
|
||||
import { ACCOUNT_IMPORT_RUNTIME_CAPABILITY } from '../../shared/protocol-version'
|
||||
import type { RuntimeStatus } from '../../shared/runtime-types'
|
||||
import type {
|
||||
@@ -112,13 +114,25 @@ async function runAgentLoginInTerminal(
|
||||
)
|
||||
return
|
||||
}
|
||||
const env = addAgentNodePaths({ ...stripElectronRunAsNode(process.env), ...extraEnv })
|
||||
const child = spawn(spawnCmd, spawnArgs, {
|
||||
// Why: JSON mode reserves stdout for the response envelope while keeping
|
||||
// the interactive login attached to the user's terminal via stderr.
|
||||
stdio: ['inherit', json ? process.stderr : 'inherit', 'inherit'],
|
||||
env
|
||||
})
|
||||
// Why paired after the seed: addAgentNodePaths prepends the *newest* version
|
||||
// manager bin, which is not necessarily where this CLI lives. Pairing last puts
|
||||
// the CLI's own node in front of that seed (stablyai/orca#10932).
|
||||
const env = withCliRuntimeOnPath(
|
||||
resolvedCommand,
|
||||
addAgentNodePaths({ ...stripElectronRunAsNode(process.env), ...extraEnv })
|
||||
)
|
||||
const consoleStdio = stdioForWindowsInteractiveChild(json)
|
||||
let child: ReturnType<typeof spawn>
|
||||
try {
|
||||
child = spawn(spawnCmd, spawnArgs, {
|
||||
// Why: JSON mode reserves stdout for the response envelope while keeping
|
||||
// the interactive login attached to the user's terminal via stderr.
|
||||
stdio: consoleStdio.stdio,
|
||||
env
|
||||
})
|
||||
} finally {
|
||||
consoleStdio.dispose()
|
||||
}
|
||||
session.child = child
|
||||
child.once('error', (error) =>
|
||||
rejectPromise(
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../format', () => ({ printResult: vi.fn() }))
|
||||
|
||||
import { printResult } from '../format'
|
||||
import { HANDLER_GROUPS } from '../handler-group-manifest'
|
||||
import { ORCHESTRATION_HANDLERS } from './orchestration'
|
||||
import {
|
||||
ORCHESTRATION_DISPATCH_HANDLER,
|
||||
ORCHESTRATION_DISPATCH_INSPECTION_HANDLERS
|
||||
} from './orchestration/dispatch-handlers'
|
||||
import { getOptionalStructuredMessagePayload } from './orchestration/message-payload'
|
||||
import { getOptionalPositiveIntegerValueFlag } from './orchestration/numeric-flags'
|
||||
import { formatWorkerRead, formatWorkerRelease } from './orchestration/worker-output'
|
||||
|
||||
describe('extracted orchestration flag parsing', () => {
|
||||
it('serializes phase and omits empty CSV entries', () => {
|
||||
const payload = getOptionalStructuredMessagePayload(
|
||||
new Map([
|
||||
['phase', 'verification'],
|
||||
['files-modified', ' src/a.ts, ,src/b.ts, ']
|
||||
])
|
||||
)
|
||||
|
||||
expect(JSON.parse(payload ?? '')).toEqual({
|
||||
filesModified: ['src/a.ts', 'src/b.ts'],
|
||||
phase: 'verification'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects unsafe positive integers before dispatch', () => {
|
||||
expect(() =>
|
||||
getOptionalPositiveIntegerValueFlag(
|
||||
new Map([['timeout-ms', '9007199254740992']]),
|
||||
'timeout-ms'
|
||||
)
|
||||
).toThrow('Invalid positive safe integer for --timeout-ms')
|
||||
})
|
||||
})
|
||||
|
||||
it('composes handlers in the canonical command order', () => {
|
||||
const declared = HANDLER_GROUPS.find((group) => group.name === 'orchestration')?.keys
|
||||
expect(Object.keys(ORCHESTRATION_HANDLERS)).toEqual(declared)
|
||||
})
|
||||
|
||||
describe('extracted orchestration worker formatting', () => {
|
||||
it('renders terminal tails without changing line boundaries', () => {
|
||||
expect(
|
||||
formatWorkerRead({
|
||||
dispatchId: 'dispatch_1',
|
||||
terminal: { tail: ['first', '', 'third'] }
|
||||
} as never)
|
||||
).toBe('first\n\nthird')
|
||||
})
|
||||
|
||||
it('renders transcript blocks and survives unserializable tool input', () => {
|
||||
const circular: { self?: unknown } = {}
|
||||
circular.self = circular
|
||||
|
||||
expect(
|
||||
formatWorkerRead({
|
||||
source: 'transcript',
|
||||
transcript: {
|
||||
messages: [
|
||||
{
|
||||
id: 'message_1',
|
||||
role: 'assistant',
|
||||
blocks: [
|
||||
{ type: 'text', text: 'working' },
|
||||
{ type: 'tool-call', name: 'inspect', input: circular },
|
||||
{ type: 'tool-result', output: 'failed', isError: true },
|
||||
{ type: 'image-ref', url: 'https://example.test/proof.png' }
|
||||
],
|
||||
timestamp: null,
|
||||
source: 'transcript'
|
||||
}
|
||||
]
|
||||
}
|
||||
} as never)
|
||||
).toBe(
|
||||
'[assistant] working\n[tool inspect] [unserializable input]\n[tool result error] failed\n[image] https://example.test/proof.png'
|
||||
)
|
||||
})
|
||||
|
||||
it('renders release evidence in receipt order', () => {
|
||||
expect(
|
||||
formatWorkerRelease({
|
||||
dispatchId: 'dispatch_1',
|
||||
state: 'release_unknown',
|
||||
reason: 'host_unreachable',
|
||||
processAction: 'unverifiable',
|
||||
archive: { source: 'transcript', status: 'saved' },
|
||||
lastError: 'transport closed',
|
||||
recovery: 'Reconnect and retry.'
|
||||
})
|
||||
).toBe(
|
||||
'Worker dispatch_1 terminal [release_unknown] reason=host_unreachable process=unverifiable\n' +
|
||||
'archive transcript [saved]\ntransport closed\nReconnect and retry.'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('extracted orchestration dispatch handlers', () => {
|
||||
const call = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
call.mockReset()
|
||||
vi.mocked(printResult).mockReset()
|
||||
})
|
||||
|
||||
it('allows a dry run without a recipient and returns only its preamble', async () => {
|
||||
call.mockResolvedValue({
|
||||
result: { dispatch: null, dryRun: true, preamble: 'preview preamble' }
|
||||
})
|
||||
|
||||
await ORCHESTRATION_DISPATCH_HANDLER['orchestration dispatch']({
|
||||
flags: new Map<string, string | boolean>([
|
||||
['task', 'task_1'],
|
||||
['from', 'terminal_coordinator'],
|
||||
['dry-run', true],
|
||||
['return-preamble', true]
|
||||
]),
|
||||
client: { call },
|
||||
cwd: '/repo',
|
||||
json: false
|
||||
} as never)
|
||||
|
||||
expect(call).toHaveBeenCalledWith('orchestration.dispatch', {
|
||||
task: 'task_1',
|
||||
run: undefined,
|
||||
to: undefined,
|
||||
from: 'terminal_coordinator',
|
||||
inject: undefined,
|
||||
dryRun: true,
|
||||
returnPreamble: true,
|
||||
devMode: false
|
||||
})
|
||||
expect(renderedValue({ dispatch: null, dryRun: true, preamble: 'preview preamble' })).toBe(
|
||||
'preview preamble'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not resolve coordinator identity for a non-preamble dispatch lookup', async () => {
|
||||
call.mockResolvedValue({ result: { dispatch: null } })
|
||||
|
||||
await ORCHESTRATION_DISPATCH_INSPECTION_HANDLERS['orchestration dispatch-show']({
|
||||
flags: new Map([['task', 'task_1']]),
|
||||
client: { call },
|
||||
cwd: '/repo',
|
||||
json: false
|
||||
} as never)
|
||||
|
||||
expect(call).toHaveBeenCalledWith('orchestration.dispatchShow', {
|
||||
task: 'task_1',
|
||||
preamble: undefined,
|
||||
from: undefined,
|
||||
devMode: false
|
||||
})
|
||||
expect(renderedValue({ dispatch: null })).toBe('No dispatch context found.')
|
||||
})
|
||||
})
|
||||
|
||||
function renderedValue(value: unknown): string {
|
||||
const formatter = vi.mocked(printResult).mock.calls[0]?.[2] as (result: unknown) => string
|
||||
return formatter(value)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user