mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 08:03:14 +00:00
Merge origin/main (v1.4.127-rc.0) into orca-performance
46 commits from main. Conflict resolutions (branch structure wins, main semantics grafted): - pending-backlog cap (#7630): main's capPendingPtyData/droppedBacklog 2MB tail-trim EXCISED — this branch's droppedOutput sentinel mechanism (O(1) drop + query salvage + settings-scaled cap + restore marker) already bounds the unsent backlog and strictly supersedes it; carrying both would race two cap policies over one buffer. Kept main's makePtyDataPayload helper and exit-time pending flush (adapted to the sentinel). Main's duplicate never-ACK cap test removed (covered by the existing sentinel cap tests); its reload-gate tests kept with the runtime mock extended. - replay guard (#7661): main's blind REPLAY_GUARD_RELEASE_FALLBACK_MS release dropped — this branch's probe-certified stall release covers the same cold-restore reattach lockout without the auto-reply leak a timed release can cause in agent TUIs. Main's three regression tests KEPT, adapted to probe timing (explicit stallCheckMs). - hidden-pressure scenario: kept this branch's STRICT 1500ms restore budget (the background keep-tail global budget earns it; main relaxed to 4s for CI drain overhead) + grafted main's timer-drift gate const. - ssh-docker-relay-perf: kept this branch's ACK-stalled background PTY test AND grafted main's new file-streams+git-churn test (4 tests). - preload/renderer: droppedBacklog threading excised with the mechanism; main's unrelated changes (ssh importConfig reAdopt, etc.) kept. Gates: typecheck clean; ipc+daemon+terminal-pane+pane-manager suites 4,396 passed / 14 skipped. Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -69,6 +69,14 @@ jobs:
|
||||
- name: Check reliability gate manifest
|
||||
run: pnpm run check:reliability-gates
|
||||
|
||||
# Why: oxlint fails any file over max-lines that is NOT suppressed, so this
|
||||
# ratchet forbids ADDING a new suppression (inline disable or mobile max
|
||||
# bump). Existing oversized files are grandfathered in
|
||||
# config/max-lines-baseline.txt, which may only shrink — new bypasses fail
|
||||
# here with a clear message instead of silently growing the debt.
|
||||
- name: Enforce max-lines ratchet (no new bypasses)
|
||||
run: pnpm run check:max-lines-ratchet
|
||||
|
||||
# Why: project-owned type declarations must live in .ts so tsc
|
||||
# actually checks them. TypeScript's skipLibCheck: true (inherited
|
||||
# from @electron-toolkit/tsconfig) silently widens unresolved names
|
||||
|
||||
@@ -12,7 +12,9 @@ Keep comments short — one or two lines. Capture only the non-obvious reason (s
|
||||
|
||||
## Lint Rules: Do Not Disable Max Lines
|
||||
|
||||
Never add a `max-lines` disable (`eslint-disable max-lines`, `oxlint-disable max-lines`, or line-specific variants). Split the file, extract focused modules, move fixtures/builders into named files, or otherwise reduce the counted lines instead.
|
||||
Never add a `max-lines` disable (`eslint-disable max-lines`, `oxlint-disable max-lines`, or line-specific variants), and never add a per-file `max-lines` bump in `mobile/.oxlintrc.json`. Split the file, extract focused modules, move fixtures/builders into named files, or otherwise reduce the counted lines instead.
|
||||
|
||||
This is enforced in CI by `pnpm check:max-lines-ratchet` (`config/scripts/check-max-lines-ratchet.mjs`). Files already over the limit are grandfathered in `config/max-lines-baseline.txt`; that list may only **shrink**. Adding a new suppression fails the build. If you remove a suppression (great — split it!), run `pnpm check:max-lines-ratchet --prune` to drop its baseline line.
|
||||
|
||||
## File and Module Naming
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere.
|
||||
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.22](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.24](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.24/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
|
||||
Pair with your desktop app to monitor and steer your agents from your phone.
|
||||
|
||||
- **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) or [join TestFlight](https://testflight.apple.com/join/YjeGMQBA)
|
||||
- **Android:** [Download APK 0.0.22](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk)
|
||||
- **Android:** [Download APK 0.0.24](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.24/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
# Files/globs currently allowed to exceed the oxlint `max-lines` budget.
|
||||
# This is a RATCHET: the list may only SHRINK. Do NOT add entries to get CI green —
|
||||
# split the oversized file instead (AGENTS.md → "Do Not Disable Max Lines").
|
||||
# Regenerate/prune: pnpm check:max-lines-ratchet --prune (removes stale entries only)
|
||||
inline mobile/src/constants/marine-creatures.ts
|
||||
inline src/cli/handlers/automations.ts
|
||||
inline src/cli/handlers/orchestration.ts
|
||||
inline src/cli/help.ts
|
||||
inline src/cli/index.test.ts
|
||||
inline src/main/agent-hooks/server.test.ts
|
||||
inline src/main/agent-hooks/server.ts
|
||||
inline src/main/amp/hook-service.ts
|
||||
inline src/main/antigravity/hook-service.ts
|
||||
inline src/main/attribution/terminal-attribution.ts
|
||||
inline src/main/automations/external-manager.ts
|
||||
inline src/main/automations/hermes-cron-output.ts
|
||||
inline src/main/browser/agent-browser-bridge.test.ts
|
||||
inline src/main/browser/agent-browser-bridge.ts
|
||||
inline src/main/browser/browser-cookie-import.ts
|
||||
inline src/main/browser/browser-guest-ui.ts
|
||||
inline src/main/browser/browser-manager-grab.test.ts
|
||||
inline src/main/browser/browser-manager.test.ts
|
||||
inline src/main/browser/browser-manager.ts
|
||||
inline src/main/browser/browser-screencast-stream.ts
|
||||
inline src/main/browser/browser-session-registry.ts
|
||||
inline src/main/browser/cdp-bridge.ts
|
||||
inline src/main/browser/cdp-ws-proxy.ts
|
||||
inline src/main/browser/grab-guest-script.ts
|
||||
inline src/main/browser/snapshot-engine.ts
|
||||
inline src/main/claude-accounts/runtime-auth-service.test.ts
|
||||
inline src/main/claude-accounts/runtime-auth-service.ts
|
||||
inline src/main/claude-accounts/service.test.ts
|
||||
inline src/main/claude-accounts/service.ts
|
||||
inline src/main/claude-usage/scanner.ts
|
||||
inline src/main/claude-usage/store.ts
|
||||
inline src/main/cli/cli-installer.test.ts
|
||||
inline src/main/cli/cli-installer.ts
|
||||
inline src/main/cli/wsl-cli-installer.ts
|
||||
inline src/main/codex-accounts/runtime-home-service.test.ts
|
||||
inline src/main/codex-accounts/runtime-home-service.ts
|
||||
inline src/main/codex-accounts/service.test.ts
|
||||
inline src/main/codex-accounts/service.ts
|
||||
inline src/main/codex-usage/scanner.ts
|
||||
inline src/main/codex-usage/store.test.ts
|
||||
inline src/main/codex-usage/store.ts
|
||||
inline src/main/codex/config-toml-trust.test.ts
|
||||
inline src/main/codex/config-toml-trust.ts
|
||||
inline src/main/codex/hook-service.test.ts
|
||||
inline src/main/codex/hook-service.ts
|
||||
inline src/main/copilot/hook-service.ts
|
||||
inline src/main/daemon/client.ts
|
||||
inline src/main/daemon/daemon-health.ts
|
||||
inline src/main/daemon/daemon-init.test.ts
|
||||
inline src/main/daemon/daemon-init.ts
|
||||
inline src/main/daemon/daemon-pty-adapter.test.ts
|
||||
inline src/main/daemon/daemon-pty-adapter.ts
|
||||
inline src/main/daemon/daemon-server.ts
|
||||
inline src/main/daemon/pty-subprocess.test.ts
|
||||
inline src/main/daemon/pty-subprocess.ts
|
||||
inline src/main/daemon/session.ts
|
||||
inline src/main/daemon/shell-ready.ts
|
||||
inline src/main/git/remove-worktree.test.ts
|
||||
inline src/main/git/repo.ts
|
||||
inline src/main/git/runner.ts
|
||||
inline src/main/git/status.test.ts
|
||||
inline src/main/git/status.ts
|
||||
inline src/main/git/worktree.test.ts
|
||||
inline src/main/git/worktree.ts
|
||||
inline src/main/github/client.test.ts
|
||||
inline src/main/github/client.ts
|
||||
inline src/main/github/issues.ts
|
||||
inline src/main/github/pr-refresh-coordinator.test.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-mr.test.ts
|
||||
inline src/main/gitlab/client.ts
|
||||
inline src/main/gitlab/issues.ts
|
||||
inline src/main/gitlab/work-item-details.ts
|
||||
inline src/main/hermes/hook-service.ts
|
||||
inline src/main/hooks.test.ts
|
||||
inline src/main/hooks.ts
|
||||
inline src/main/index.ts
|
||||
inline src/main/ipc/browser.ts
|
||||
inline src/main/ipc/crash-reporting.ts
|
||||
inline src/main/ipc/filesystem-auth.ts
|
||||
inline src/main/ipc/filesystem-mutations.ts
|
||||
inline src/main/ipc/filesystem-watcher.ts
|
||||
inline src/main/ipc/filesystem.test.ts
|
||||
inline src/main/ipc/filesystem.ts
|
||||
inline src/main/ipc/github.test.ts
|
||||
inline src/main/ipc/github.ts
|
||||
inline src/main/ipc/gitlab.ts
|
||||
inline src/main/ipc/linear.ts
|
||||
inline src/main/ipc/notifications.test.ts
|
||||
inline src/main/ipc/notifications.ts
|
||||
inline src/main/ipc/pet.ts
|
||||
inline src/main/ipc/preflight.test.ts
|
||||
inline src/main/ipc/pty.test.ts
|
||||
inline src/main/ipc/pty.ts
|
||||
inline src/main/ipc/remote-workspace.ts
|
||||
inline src/main/ipc/repos-remote.test.ts
|
||||
inline src/main/ipc/repos.ts
|
||||
inline src/main/ipc/runtime-environments.test.ts
|
||||
inline src/main/ipc/ssh.test.ts
|
||||
inline src/main/ipc/ssh.ts
|
||||
inline src/main/ipc/worktree-remote.ts
|
||||
inline src/main/ipc/worktrees.test.ts
|
||||
inline src/main/ipc/worktrees.ts
|
||||
inline src/main/jira/client.ts
|
||||
inline src/main/jira/issues.ts
|
||||
inline src/main/keybindings/keybinding-file.ts
|
||||
inline src/main/linear/client.ts
|
||||
inline src/main/linear/issues.ts
|
||||
inline src/main/linear/projects.ts
|
||||
inline src/main/memory/collector.ts
|
||||
inline src/main/opencode-usage/scanner.ts
|
||||
inline src/main/opencode-usage/store.ts
|
||||
inline src/main/opencode/hook-service.ts
|
||||
inline src/main/persistence.test.ts
|
||||
inline src/main/persistence.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.test.ts
|
||||
inline src/main/providers/local-pty-provider.ts
|
||||
inline src/main/providers/local-pty-shell-ready.test.ts
|
||||
inline src/main/providers/local-pty-shell-ready.ts
|
||||
inline src/main/providers/ssh-git-provider.test.ts
|
||||
inline src/main/providers/ssh-git-provider.ts
|
||||
inline src/main/rate-limits/claude-fetcher.test.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
|
||||
inline src/main/rate-limits/service.test.ts
|
||||
inline src/main/rate-limits/service.ts
|
||||
inline src/main/runtime/orca-runtime-browser.ts
|
||||
inline src/main/runtime/orca-runtime-files.test.ts
|
||||
inline src/main/runtime/orca-runtime-files.ts
|
||||
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/orchestration/coordinator.ts
|
||||
inline src/main/runtime/orchestration/db.ts
|
||||
inline src/main/runtime/rpc/methods/files.ts
|
||||
inline src/main/runtime/rpc/methods/git.ts
|
||||
inline src/main/runtime/rpc/methods/github.ts
|
||||
inline src/main/runtime/rpc/methods/orchestration.test.ts
|
||||
inline src/main/runtime/rpc/methods/orchestration.ts
|
||||
inline src/main/runtime/rpc/methods/terminal.ts
|
||||
inline src/main/runtime/rpc/terminal-multiplex.test.ts
|
||||
inline src/main/runtime/runtime-rpc.test.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.test.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/star-nag/service.test.ts
|
||||
inline src/main/text-generation/commit-message-text-generation.test.ts
|
||||
inline src/main/text-generation/commit-message-text-generation.ts
|
||||
inline src/main/updater.test.ts
|
||||
inline src/main/updater.ts
|
||||
inline src/main/window/attach-main-window-services.ts
|
||||
inline src/main/window/createMainWindow.test.ts
|
||||
inline src/main/window/createMainWindow.ts
|
||||
inline src/main/workspace-space-analysis.ts
|
||||
inline src/preload/api-types.ts
|
||||
inline src/preload/index.ts
|
||||
inline src/relay/agent-hook-server.ts
|
||||
inline src/relay/dispatcher.ts
|
||||
inline src/relay/external-automations-handler.ts
|
||||
inline src/relay/fs-handler.ts
|
||||
inline src/relay/git-handler.test.ts
|
||||
inline src/relay/git-handler.ts
|
||||
inline src/relay/pty-handler.test.ts
|
||||
inline src/relay/pty-handler.ts
|
||||
inline src/relay/relay.ts
|
||||
inline src/relay/workspace-space-scan.ts
|
||||
inline src/renderer/src/App.tsx
|
||||
inline src/renderer/src/components/GitHubItemDialog.tsx
|
||||
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/PullRequestPage.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.test.ts
|
||||
inline src/renderer/src/components/activity/ActivityPrototypePage.tsx
|
||||
inline src/renderer/src/components/automations/AutomationsPage.tsx
|
||||
inline src/renderer/src/components/browser-pane/BrowserPane.tsx
|
||||
inline src/renderer/src/components/diff-comments/useDiffCommentDecorator.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/editor/MonacoEditor.tsx
|
||||
inline src/renderer/src/components/editor/editor-autosave-controller.ts
|
||||
inline src/renderer/src/components/editor/ipynb-parse.ts
|
||||
inline src/renderer/src/components/editor/useEditorPanelContentState.ts
|
||||
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.test.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/FileExplorer.test.tsx
|
||||
inline src/renderer/src/components/right-sidebar/FileExplorer.tsx
|
||||
inline src/renderer/src/components/right-sidebar/FileExplorerRow.tsx
|
||||
inline src/renderer/src/components/right-sidebar/PortsPanel.tsx
|
||||
inline src/renderer/src/components/right-sidebar/SourceControl.tsx
|
||||
inline src/renderer/src/components/right-sidebar/checks-panel-content.tsx
|
||||
inline src/renderer/src/components/right-sidebar/index.tsx
|
||||
inline src/renderer/src/components/right-sidebar/source-control-dropdown-items.ts
|
||||
inline src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts
|
||||
inline src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts
|
||||
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/settings/SettingsFormControls.tsx
|
||||
inline src/renderer/src/components/sidebar/RemoteFileBrowser.tsx
|
||||
inline src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx
|
||||
inline src/renderer/src/components/sidebar/WorktreeCard.tsx
|
||||
inline src/renderer/src/components/sidebar/WorktreeContextMenu.tsx
|
||||
inline src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts
|
||||
inline src/renderer/src/components/sidebar/WorktreeList.tsx
|
||||
inline src/renderer/src/components/sidebar/use-workspace-kanban-area-selection.ts
|
||||
inline src/renderer/src/components/sidebar/worktree-list-groups.test.ts
|
||||
inline src/renderer/src/components/sidebar/worktree-list-groups.ts
|
||||
inline src/renderer/src/components/stats/usage-overview-model.ts
|
||||
inline src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx
|
||||
inline src/renderer/src/components/status-bar/StatusBar.tsx
|
||||
inline src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx
|
||||
inline src/renderer/src/components/tab-bar/TabBar.tsx
|
||||
inline src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts
|
||||
inline src/renderer/src/components/tab-group/useTabDragSplit.ts
|
||||
inline src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts
|
||||
inline src/renderer/src/components/task-page-cache-selectors.ts
|
||||
inline src/renderer/src/components/terminal-pane/TerminalPane.tsx
|
||||
inline src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts
|
||||
inline src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts
|
||||
inline src/renderer/src/components/terminal-pane/keyboard-handlers.ts
|
||||
inline src/renderer/src/components/terminal-pane/pty-connection.test.ts
|
||||
inline src/renderer/src/components/terminal-pane/pty-connection.ts
|
||||
inline src/renderer/src/components/terminal-pane/pty-transport.test.ts
|
||||
inline src/renderer/src/components/terminal-pane/pty-transport.ts
|
||||
inline src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts
|
||||
inline src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts
|
||||
inline src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts
|
||||
inline src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts
|
||||
inline src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts
|
||||
inline src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts
|
||||
inline src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx
|
||||
inline src/renderer/src/hooks/useAutomationDispatchEvents.ts
|
||||
inline src/renderer/src/hooks/useComposerState.ts
|
||||
inline src/renderer/src/hooks/useEditorExternalWatch.ts
|
||||
inline src/renderer/src/hooks/useIpcEvents.test.ts
|
||||
inline src/renderer/src/hooks/useIpcEvents.ts
|
||||
inline src/renderer/src/hooks/useIssueMetadata.ts
|
||||
inline src/renderer/src/hooks/useSettingsNavigationMetadata.ts
|
||||
inline src/renderer/src/lib/active-agent-note-send.test.ts
|
||||
inline src/renderer/src/lib/file-type-icons.ts
|
||||
inline src/renderer/src/lib/pane-manager/pane-manager.ts
|
||||
inline src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts
|
||||
inline src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts
|
||||
inline src/renderer/src/lib/pane-manager/pane-tree-ops.ts
|
||||
inline src/renderer/src/lib/terminal-links.ts
|
||||
inline src/renderer/src/lib/worktree-activation.test.ts
|
||||
inline src/renderer/src/lib/worktree-activation.ts
|
||||
inline src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts
|
||||
inline src/renderer/src/runtime/runtime-file-client.test.ts
|
||||
inline src/renderer/src/runtime/runtime-file-client.ts
|
||||
inline src/renderer/src/runtime/runtime-git-client.ts
|
||||
inline src/renderer/src/runtime/runtime-linear-client.ts
|
||||
inline src/renderer/src/runtime/sync-runtime-graph.test.ts
|
||||
inline src/renderer/src/runtime/sync-runtime-graph.ts
|
||||
inline src/renderer/src/runtime/web-runtime-session.test.ts
|
||||
inline src/renderer/src/runtime/web-runtime-session.ts
|
||||
inline src/renderer/src/runtime/web-session-tabs-sync.test.ts
|
||||
inline src/renderer/src/runtime/web-session-tabs-sync.ts
|
||||
inline src/renderer/src/store/slices/agent-status.test.ts
|
||||
inline src/renderer/src/store/slices/agent-status.ts
|
||||
inline src/renderer/src/store/slices/browser.test.ts
|
||||
inline src/renderer/src/store/slices/browser.ts
|
||||
inline src/renderer/src/store/slices/diffComments.ts
|
||||
inline src/renderer/src/store/slices/editor.test.ts
|
||||
inline src/renderer/src/store/slices/editor.ts
|
||||
inline src/renderer/src/store/slices/github.test.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.test.ts
|
||||
inline src/renderer/src/store/slices/linear.ts
|
||||
inline src/renderer/src/store/slices/repos.ts
|
||||
inline src/renderer/src/store/slices/store-cascades.test.ts
|
||||
inline src/renderer/src/store/slices/store-session-cascades.test.ts
|
||||
inline src/renderer/src/store/slices/tabs.test.ts
|
||||
inline src/renderer/src/store/slices/tabs.ts
|
||||
inline src/renderer/src/store/slices/terminals.ts
|
||||
inline src/renderer/src/store/slices/ui.test.ts
|
||||
inline src/renderer/src/store/slices/ui.ts
|
||||
inline src/renderer/src/store/slices/workspace-cleanup.ts
|
||||
inline src/renderer/src/store/slices/worktrees.test.ts
|
||||
inline src/renderer/src/store/slices/worktrees.ts
|
||||
inline src/renderer/src/web/web-preload-api.test.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.test.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
|
||||
inline src/shared/github-project-types.ts
|
||||
inline src/shared/keybindings.test.ts
|
||||
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 src/shared/types.ts
|
||||
inline tests/e2e/helpers/terminal.ts
|
||||
inline tests/e2e/terminal-panes.spec.ts
|
||||
mobile-config app/h/*/files/*.tsx
|
||||
mobile-config app/h/*/index.tsx
|
||||
mobile-config app/h/*/session/*.tsx
|
||||
mobile-config app/h/*/source-control/*.tsx
|
||||
mobile-config app/h/*/tasks.tsx
|
||||
mobile-config app/index.tsx
|
||||
mobile-config app/pair-scan.tsx
|
||||
mobile-config app/terminal-settings.tsx
|
||||
mobile-config app/troubleshoot.tsx
|
||||
mobile-config scripts/mock-server.ts
|
||||
mobile-config scripts/repro-terminal-colors.ts
|
||||
mobile-config scripts/repro-worktree-startup-stream.ts
|
||||
mobile-config src/browser/MobileBrowserPane.tsx
|
||||
mobile-config src/components/CustomKeyModal.tsx
|
||||
mobile-config src/components/NewWorktreeModal.tsx
|
||||
mobile-config src/components/mobile-rich-markdown-editor-html.ts
|
||||
mobile-config src/terminal/terminal-accessory-keys.ts
|
||||
mobile-config src/terminal/terminal-webview-html.ts
|
||||
mobile-config src/transport/rpc-client.ts
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"updatedAt": "2026-07-03",
|
||||
"updatedAt": "2026-07-07",
|
||||
"policy": {
|
||||
"maturityLevels": [
|
||||
"experimental",
|
||||
@@ -1273,7 +1273,7 @@
|
||||
"macos"
|
||||
],
|
||||
"coveredProviders": [],
|
||||
"coverageNotes": "Local macOS evidence on main@1282f5c2d; identical files to the pending stack. Real OS IME automation, Windows ConPTY post-agent reset, and the CJK/Vietnamese/Arabic matrix remain registered gaps.",
|
||||
"coverageNotes": "Local macOS evidence on main@1282f5c2d, deterministic renderer-unit coverage for the Linux/Sogou candidate-key policy, and a local Electron/CDP live-PTY Sogou-style repro. Real Linux/Sogou OS IME automation, Windows ConPTY post-agent reset, and the CJK/Vietnamese/Arabic matrix remain registered gaps.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/pull/6699",
|
||||
"https://github.com/stablyai/orca/pull/6682",
|
||||
@@ -1281,14 +1281,20 @@
|
||||
"https://github.com/stablyai/orca/pull/6999"
|
||||
],
|
||||
"invariant": "Composition, native text forwarding, synthetic input, paste, and platform keyboard bypass paths must not send preedit/control bytes before commit and must commit text exactly once to the intended PTY.",
|
||||
"oracle": "The current renderer-unit slice asserts native text commits route to the intended PTY once, composition/preedit bookkeeping does not leak premature text, input-source classification handles synthetic/native paths, and paste/runtime forwarding avoids duplicate terminal payloads for covered fixtures. Real IME paths and the full CJK/Vietnamese/Arabic/JIS-yen matrix run in follow-up platform soak where automation is possible.",
|
||||
"oracle": "The current renderer-unit slice asserts native text commits route to the intended PTY once, composition/preedit bookkeeping does not leak premature text, input-source classification handles synthetic/native paths, paste/runtime forwarding avoids duplicate terminal payloads for covered fixtures, and Linux/Sogou candidate Space/digit selectors do not leak keydown/keypress/keyup while ordinary post-IME typing remains available. The Electron/CDP live-PTY repro verifies Sogou-style Space and digit candidate selectors submit only the committed Chinese text. Real IME paths and the full CJK/Vietnamese/Arabic/JIS-yen matrix run in follow-up platform soak where automation is possible.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts"
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts",
|
||||
"pnpm run test:e2e -- tests/e2e/chinese-ime-chat-input-repro.spec.ts"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts"
|
||||
"src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts",
|
||||
"tests/e2e/chinese-ime-chat-input-repro.spec.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
@@ -1309,34 +1315,80 @@
|
||||
"assertions": [
|
||||
"paste/runtime forwarding avoids duplicate terminal payloads"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts",
|
||||
"assertions": [
|
||||
"empty Sogou-style compositionupdate keeps composition active",
|
||||
"stale composition state expires so editing keys cannot stay suppressed indefinitely",
|
||||
"post-composition candidate guard is armed only by Sogou-style empty updates and clears after real typing"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts",
|
||||
"assertions": [
|
||||
"suppressed candidate keydown arms only the matching keypress/keyup release guard",
|
||||
"held-key repeat keydowns stay guarded until their keyup, even past expiry",
|
||||
"fresh keydowns, other keys, modified keys, and expired keypresses are not guarded, and a fresh press drops a stale pending release"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts",
|
||||
"assertions": [
|
||||
"standalone Linux 229 keydowns reach xterm while Windows 229 keydowns stay suppressed",
|
||||
"candidate Space/digit selectors are suppressed only while candidate guards are active"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts",
|
||||
"assertions": [
|
||||
"macOS standalone Process key behavior and composition-owned key suppression stay intact"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/chinese-ime-chat-input-repro.spec.ts",
|
||||
"assertions": [
|
||||
"Sogou-style Space candidate selection submits only the committed Chinese character",
|
||||
"Sogou-style digit candidate selection submits only the committed Chinese phrase",
|
||||
"Post-composition Sogou-style digit selection stays out of the PTY after compositionend"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-07-03",
|
||||
"date": "2026-07-07",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 0.9,
|
||||
"summary": "3 test file(s) passed, 63 tests passed on main@1282f5c2d in a clean checkout."
|
||||
"durationSeconds": 0.7,
|
||||
"summary": "7 test file(s) passed, 144 tests passed on the Linux/Sogou candidate-key branch (includes held-key repeat guard coverage)."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-07",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm run test:e2e -- tests/e2e/chinese-ime-chat-input-repro.spec.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 72.0,
|
||||
"summary": "Electron/CDP IME repro passed: Sogou candidate selection, post-composition candidate selection, and existing Chinese IME harness passed; real Codex IME test was skipped behind ORCA_E2E_REAL_CODEX_IME."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 30,
|
||||
"scope": "renderer unit plus platform soak"
|
||||
"p95Seconds": 90,
|
||||
"scope": "renderer unit plus focused Electron/CDP IME repro"
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "unknown",
|
||||
"evidence": "Focused renderer input tests are now registered; needs soak history and platform IME evidence before promotion."
|
||||
"evidence": "Focused renderer input tests and the Electron/CDP Sogou-style repro are now registered; needs soak history and true platform IME evidence before promotion."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "partial",
|
||||
"evidence": "Focused tests cover existing native-text, input-source, and paste/runtime forwarding contracts. Needs intentional-break proof for duplicate native text forwarding and composition preedit leakage, plus the broader language/platform matrix."
|
||||
"evidence": "Focused tests cover existing native-text, input-source, paste/runtime forwarding, Linux/Sogou candidate selector, post-composition guard contracts, and live-PTY Sogou-style candidate commits. Needs intentional-break proof for duplicate native text forwarding and composition preedit leakage, plus the broader language/platform matrix."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": false,
|
||||
"evidence": "Required if input forwarding adds polling, repeated event listeners, or renderer hot-path work."
|
||||
"required": true,
|
||||
"evidence": "Terminal IME hot-path audit clean on 2026-07-07: candidate guard adds O(1) boolean checks plus a bounded Space/digit release map; composition listeners are disposed with pane lifecycle; no polling, IPC fan-out, subprocess work, or SSH/remote transport cost added."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Cover deterministic byte/cell oracles first.",
|
||||
@@ -1344,7 +1396,7 @@
|
||||
"Pair with Windows ConPTY keyboard reset for Windows standard-key behavior."
|
||||
],
|
||||
"knownGaps": [
|
||||
"Current command is a renderer-unit slice; real OS IME automation may need manual or soak evidence.",
|
||||
"Current commands include renderer-unit coverage and a CDP-driven Electron repro; real OS IME automation may need manual or soak evidence.",
|
||||
"Backspace/Enter during composition, JIS yen, Arabic/RTL, paste edge cases, and Windows ConPTY post-agent key reset still need representative gate coverage."
|
||||
],
|
||||
"demotionRule": "Cannot promote if success is based only on DOM text without PTY byte/cell evidence."
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
// Ratchet gate for the oxlint `max-lines` rule.
|
||||
//
|
||||
// oxlint already fails any file that exceeds max-lines WITHOUT a suppression, so
|
||||
// the only way a file grows past the budget is by adding an `eslint/oxlint-disable
|
||||
// max-lines` comment or a per-file `max-lines` bump in mobile/.oxlintrc.json. This
|
||||
// check freezes the set of files currently allowed to do that (the baseline) and
|
||||
// fails CI when a NEW bypass appears — the existing over-limit files are
|
||||
// grandfathered; new ones must split instead. The baseline may only shrink.
|
||||
|
||||
const BASELINE_PATH = 'config/max-lines-baseline.txt'
|
||||
const MOBILE_CONFIG_PATH = 'mobile/.oxlintrc.json'
|
||||
// These two files legitimately contain the directive text as data (regex, fixtures),
|
||||
// so scanning them would self-flag. The ratchet does not police itself.
|
||||
const SELF_FILES = new Set([
|
||||
'config/scripts/check-max-lines-ratchet.mjs',
|
||||
'config/scripts/check-max-lines-ratchet.test.mjs'
|
||||
])
|
||||
|
||||
// Default max-lines budgets from .oxlintrc.json (counted lines).
|
||||
export function defaultLimitForPath(p) {
|
||||
if (/\.(test|spec)\.(ts|tsx)$/.test(p)) {
|
||||
return 800
|
||||
}
|
||||
if (p.endsWith('.tsx')) {
|
||||
return 400
|
||||
}
|
||||
if (p.endsWith('.mjs')) {
|
||||
return 600
|
||||
}
|
||||
return 300
|
||||
}
|
||||
|
||||
// True if the source contains an eslint/oxlint disable directive listing `max-lines`
|
||||
// (block or line, bare or compound, with or without a `-- Why:` reason).
|
||||
export function hasMaxLinesDisable(sourceText) {
|
||||
const re = /(?:eslint|oxlint)-disable(?:-next-line|-line)?\b([^\n]*)/g
|
||||
let m
|
||||
while ((m = re.exec(sourceText)) !== null) {
|
||||
let rules = m[1]
|
||||
rules = rules.split('--')[0] // strip the reason
|
||||
const close = rules.indexOf('*/')
|
||||
if (close !== -1) {
|
||||
rules = rules.slice(0, close) // strip block-comment tail
|
||||
}
|
||||
if (/\bmax-lines\b/.test(rules)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Per-file `max-lines` bumps in mobile/.oxlintrc.json whose `max` exceeds the
|
||||
// default for that glob (a lower `max` is stricter, not a bypass).
|
||||
export function collectMobileBumps(configText) {
|
||||
const cfg = JSON.parse(configText)
|
||||
const bumps = []
|
||||
for (const override of cfg.overrides ?? []) {
|
||||
const rule = override.rules?.['max-lines']
|
||||
if (!Array.isArray(rule) || typeof rule[1]?.max !== 'number') {
|
||||
continue
|
||||
}
|
||||
for (const glob of override.files ?? []) {
|
||||
if (rule[1].max > defaultLimitForPath(glob)) {
|
||||
bumps.push(`mobile-config ${glob}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
return bumps
|
||||
}
|
||||
|
||||
export function parseBaseline(text) {
|
||||
return new Set(
|
||||
text
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l && !l.startsWith('#'))
|
||||
)
|
||||
}
|
||||
|
||||
export function diffBaseline(current, baseline) {
|
||||
const cur = new Set(current)
|
||||
const base = baseline instanceof Set ? baseline : new Set(baseline)
|
||||
const added = [...cur].filter((e) => !base.has(e)).sort()
|
||||
const stale = [...base].filter((e) => !cur.has(e)).sort()
|
||||
return { added, stale }
|
||||
}
|
||||
|
||||
// Collect every current suppression entry from the tracked tree.
|
||||
export function collectCurrentSuppressions(root = process.cwd()) {
|
||||
const tracked = execFileSync('git', ['ls-files', '*.ts', '*.tsx', '*.mjs'], {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 64 * 1024 * 1024
|
||||
})
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.filter((f) => !SELF_FILES.has(f))
|
||||
|
||||
const entries = []
|
||||
for (const rel of tracked) {
|
||||
let src
|
||||
try {
|
||||
src = fs.readFileSync(path.join(root, rel), 'utf8')
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
if (hasMaxLinesDisable(src)) {
|
||||
entries.push(`inline ${rel}`)
|
||||
}
|
||||
}
|
||||
|
||||
const mobileCfgPath = path.join(root, MOBILE_CONFIG_PATH)
|
||||
if (fs.existsSync(mobileCfgPath)) {
|
||||
entries.push(...collectMobileBumps(fs.readFileSync(mobileCfgPath, 'utf8')))
|
||||
}
|
||||
|
||||
return entries.sort()
|
||||
}
|
||||
|
||||
function printAddedFailure(added) {
|
||||
for (const entry of added) {
|
||||
console.error(`::error::New max-lines bypass not allowed: ${entry}`)
|
||||
}
|
||||
console.error('')
|
||||
console.error('╭────────────────────────────────────────────────────────────────────────────╮')
|
||||
console.error('│ ❌ max-lines ratchet failed — a NEW file is trying to exceed the line cap. │')
|
||||
console.error('╰────────────────────────────────────────────────────────────────────────────╯')
|
||||
console.error('')
|
||||
console.error(` ${added.length} file(s)/glob(s) newly bypass the oxlint \`max-lines\` rule:`)
|
||||
console.error('')
|
||||
for (const entry of added) {
|
||||
const [kind, ...rest] = entry.split(' ')
|
||||
const target = rest.join(' ')
|
||||
const how =
|
||||
kind === 'inline'
|
||||
? 'added an eslint/oxlint-disable max-lines comment'
|
||||
: 'added a per-file max-lines bump in mobile/.oxlintrc.json'
|
||||
console.error(` • ${target}\n ↳ ${how}`)
|
||||
}
|
||||
console.error('')
|
||||
console.error(' Orca caps file size (300 .ts / 400 .tsx / 600 .mjs / 800 test — non-blank,')
|
||||
console.error(
|
||||
' non-comment lines). Existing oversized files are grandfathered; NEW ones are not.'
|
||||
)
|
||||
console.error('')
|
||||
console.error(' ✅ Fix it: SPLIT the file into focused modules — do NOT suppress the rule.')
|
||||
console.error(' See AGENTS.md → "Lint Rules: Do Not Disable Max Lines".')
|
||||
console.error('')
|
||||
console.error(' (If you are intentionally, with reviewer sign-off, adding an unavoidable')
|
||||
console.error(` exception, add the exact line(s) above to ${BASELINE_PATH}.)`)
|
||||
console.error('')
|
||||
}
|
||||
|
||||
function printStaleFailure(stale) {
|
||||
for (const entry of stale) {
|
||||
console.error(`::error::Stale max-lines baseline entry (prune it): ${entry}`)
|
||||
}
|
||||
console.error('')
|
||||
console.error('╭────────────────────────────────────────────────────────────────────────────╮')
|
||||
console.error('│ ⚠️ max-lines baseline is out of date — nice work removing a bypass! │')
|
||||
console.error('╰────────────────────────────────────────────────────────────────────────────╯')
|
||||
console.error('')
|
||||
console.error(` ${stale.length} baseline entr(y/ies) no longer have a max-lines suppression.`)
|
||||
console.error(
|
||||
' The baseline may only shrink, so these must be removed to keep re-adding blocked:'
|
||||
)
|
||||
console.error('')
|
||||
for (const entry of stale) {
|
||||
console.error(` • ${entry}`)
|
||||
}
|
||||
console.error('')
|
||||
console.error(` ✅ Fix it (one command): pnpm check:max-lines-ratchet --prune`)
|
||||
console.error('')
|
||||
}
|
||||
|
||||
export function main(root = process.cwd()) {
|
||||
const baselineFile = path.join(root, BASELINE_PATH)
|
||||
if (!fs.existsSync(baselineFile)) {
|
||||
console.error(
|
||||
`::error::Missing ${BASELINE_PATH}. Generate it with: node config/scripts/check-max-lines-ratchet.mjs --init`
|
||||
)
|
||||
return 1
|
||||
}
|
||||
const baseline = parseBaseline(fs.readFileSync(baselineFile, 'utf8'))
|
||||
const current = collectCurrentSuppressions(root)
|
||||
const { added, stale } = diffBaseline(current, baseline)
|
||||
|
||||
if (added.length > 0) {
|
||||
printAddedFailure(added)
|
||||
if (stale.length > 0) {
|
||||
console.error(
|
||||
` (Also: ${stale.length} stale baseline entr(y/ies) can be pruned — see below.)`
|
||||
)
|
||||
printStaleFailure(stale)
|
||||
}
|
||||
return 1
|
||||
}
|
||||
if (stale.length > 0) {
|
||||
printStaleFailure(stale)
|
||||
return 1
|
||||
}
|
||||
console.log(
|
||||
`max-lines ratchet OK — ${current.length} grandfathered suppression(s), no new bypasses.`
|
||||
)
|
||||
return 0
|
||||
}
|
||||
|
||||
function writeBaseline(root, entries) {
|
||||
const header = [
|
||||
'# Files/globs currently allowed to exceed the oxlint `max-lines` budget.',
|
||||
'# This is a RATCHET: the list may only SHRINK. Do NOT add entries to get CI green —',
|
||||
'# split the oversized file instead (AGENTS.md → "Do Not Disable Max Lines").',
|
||||
'# Regenerate/prune: pnpm check:max-lines-ratchet --prune (removes stale entries only)',
|
||||
''
|
||||
].join('\n')
|
||||
fs.writeFileSync(path.join(root, BASELINE_PATH), `${header}${entries.join('\n')}\n`)
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
const root = process.cwd()
|
||||
const arg = process.argv[2]
|
||||
if (arg === '--init') {
|
||||
// One-time bootstrap: capture the current suppression set as the baseline.
|
||||
const entries = collectCurrentSuppressions(root)
|
||||
writeBaseline(root, entries)
|
||||
console.log(`Wrote ${BASELINE_PATH} with ${entries.length} entries.`)
|
||||
process.exit(0)
|
||||
}
|
||||
if (arg === '--prune') {
|
||||
// Remove baseline entries whose suppression is gone (shrink only; never adds).
|
||||
const current = new Set(collectCurrentSuppressions(root))
|
||||
const baseline = parseBaseline(fs.readFileSync(path.join(root, BASELINE_PATH), 'utf8'))
|
||||
const kept = [...baseline].filter((e) => current.has(e)).sort()
|
||||
const newlyAdded = [...current].filter((e) => !baseline.has(e))
|
||||
writeBaseline(root, kept)
|
||||
console.log(
|
||||
`Pruned baseline to ${kept.length} entries (removed ${baseline.size - kept.length}).`
|
||||
)
|
||||
if (newlyAdded.length > 0) {
|
||||
console.error(
|
||||
`::error::--prune does not add entries; ${newlyAdded.length} new bypass(es) remain — split those files.`
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
process.exit(0)
|
||||
}
|
||||
process.exit(main(root))
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
collectMobileBumps,
|
||||
defaultLimitForPath,
|
||||
diffBaseline,
|
||||
hasMaxLinesDisable,
|
||||
parseBaseline
|
||||
} from './check-max-lines-ratchet.mjs'
|
||||
|
||||
describe('hasMaxLinesDisable', () => {
|
||||
it('detects a bare block disable', () => {
|
||||
expect(hasMaxLinesDisable('/* eslint-disable max-lines */\nexport const a = 1\n')).toBe(true)
|
||||
})
|
||||
|
||||
it('detects the oxlint spelling', () => {
|
||||
expect(hasMaxLinesDisable('/* oxlint-disable max-lines */\n')).toBe(true)
|
||||
})
|
||||
|
||||
it('detects a disable with a -- Why reason', () => {
|
||||
expect(hasMaxLinesDisable('/* eslint-disable max-lines -- Why: one owner. */\n')).toBe(true)
|
||||
})
|
||||
|
||||
it('detects a multi-line block where the reason wraps', () => {
|
||||
const src =
|
||||
'/* eslint-disable max-lines -- Why: this contract is\n * intentionally centralized. */\nimport x from "y"\n'
|
||||
expect(hasMaxLinesDisable(src)).toBe(true)
|
||||
})
|
||||
|
||||
it('detects max-lines inside a compound rule list', () => {
|
||||
expect(hasMaxLinesDisable('/* eslint-disable no-control-regex, max-lines -- Why: x */\n')).toBe(
|
||||
true
|
||||
)
|
||||
expect(hasMaxLinesDisable('/* eslint-disable max-lines, no-control-regex */\n')).toBe(true)
|
||||
})
|
||||
|
||||
it('detects a line-scoped disable', () => {
|
||||
expect(hasMaxLinesDisable('const a = 1 // eslint-disable-line max-lines\n')).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores a disable for an unrelated rule', () => {
|
||||
expect(hasMaxLinesDisable('/* eslint-disable no-console */\n')).toBe(false)
|
||||
})
|
||||
|
||||
it('does not treat "max-lines" appearing only in the reason text as a suppression', () => {
|
||||
// max-lines is after the `--`, so it is prose, not a suppressed rule.
|
||||
expect(
|
||||
hasMaxLinesDisable('/* eslint-disable no-console -- we could hit max-lines later */\n')
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for ordinary source', () => {
|
||||
expect(hasMaxLinesDisable('export function f() {\n return 42\n}\n')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('defaultLimitForPath', () => {
|
||||
it('uses 800 for tests, 400 for tsx, 600 for mjs, 300 otherwise', () => {
|
||||
expect(defaultLimitForPath('a/b.test.ts')).toBe(800)
|
||||
expect(defaultLimitForPath('a/b.spec.tsx')).toBe(800)
|
||||
expect(defaultLimitForPath('a/b.tsx')).toBe(400)
|
||||
expect(defaultLimitForPath('a/b.mjs')).toBe(600)
|
||||
expect(defaultLimitForPath('a/b.ts')).toBe(300)
|
||||
})
|
||||
})
|
||||
|
||||
describe('collectMobileBumps', () => {
|
||||
it('captures only overrides whose max exceeds the default for the glob', () => {
|
||||
const cfg = JSON.stringify({
|
||||
overrides: [
|
||||
{ files: ['app/h/*/tasks.tsx'], rules: { 'max-lines': ['error', { max: 14682 }] } }, // bump (>400)
|
||||
{
|
||||
files: ['src/terminal/TerminalWebView.tsx'],
|
||||
rules: { 'max-lines': ['error', { max: 379 }] }
|
||||
}, // stricter (<400), skip
|
||||
{ files: ['scripts/mock-server.ts'], rules: { 'max-lines': ['error', { max: 407 }] } } // bump (>300)
|
||||
]
|
||||
})
|
||||
expect(collectMobileBumps(cfg)).toEqual([
|
||||
'mobile-config app/h/*/tasks.tsx',
|
||||
'mobile-config scripts/mock-server.ts'
|
||||
])
|
||||
})
|
||||
|
||||
it('ignores overrides without a max-lines rule', () => {
|
||||
const cfg = JSON.stringify({
|
||||
overrides: [{ files: ['a.tsx'], rules: { 'no-console': 'off' } }]
|
||||
})
|
||||
expect(collectMobileBumps(cfg)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseBaseline', () => {
|
||||
it('drops comments and blank lines', () => {
|
||||
const b = parseBaseline('# header\n\ninline a.ts\nmobile-config x/*.tsx\n')
|
||||
expect(b).toEqual(new Set(['inline a.ts', 'mobile-config x/*.tsx']))
|
||||
})
|
||||
})
|
||||
|
||||
describe('diffBaseline', () => {
|
||||
it('reports added and stale entries', () => {
|
||||
const { added, stale } = diffBaseline(
|
||||
['inline b.ts', 'inline c.ts'],
|
||||
new Set(['inline a.ts', 'inline b.ts'])
|
||||
)
|
||||
expect(added).toEqual(['inline c.ts']) // new bypass
|
||||
expect(stale).toEqual(['inline a.ts']) // suppression removed
|
||||
})
|
||||
|
||||
it('is clean when current matches baseline', () => {
|
||||
const { added, stale } = diffBaseline(['inline a.ts'], new Set(['inline a.ts']))
|
||||
expect(added).toEqual([])
|
||||
expect(stale).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 3.7m">
|
||||
<title>downloads: 3.7m</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 3.9m">
|
||||
<title>downloads: 3.9m</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="93.5" y="15" fill="#010101" fill-opacity=".3">3.7m</text>
|
||||
<text x="93.5" y="14">3.7m</text>
|
||||
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">3.9m</text>
|
||||
<text x="93.5" y="14">3.9m</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 943 B After Width: | Height: | Size: 943 B |
@@ -238,10 +238,6 @@ Conecte ao app desktop para monitorar e conduzir seus agentes pelo celular.
|
||||
|
||||
- **Discord:** Entre na comunidade no **[Discord](https://discord.gg/fzjDKHxv8Q)**.
|
||||
- **Twitter / X:** Siga **[@orca_build](https://x.com/orca_build)** para atualizações e anúncios.
|
||||
- **WeChat:** Escaneie o QR code para entrar na comunidade.
|
||||
|
||||
<img src="../assets/wechat-qr.png" alt="QR code do WeChat para a comunidade Orca" width="160" />
|
||||
|
||||
- **Feedback e ideias:** Lançamos rápido. Sentiu falta de algo? [Peça um novo recurso](https://github.com/stablyai/orca/issues).
|
||||
- **Privacidade:** Veja a [documentação de privacidade e telemetria](https://www.onorca.dev/docs/telemetry) para saber quais dados anônimos de uso o Orca coleta e como desativar.
|
||||
- **Mostre apoio:** Dê uma [estrela](https://github.com/stablyai/orca) neste repositório para acompanhar nossos lançamentos diários.
|
||||
|
||||
@@ -235,6 +235,11 @@ yay -S stably-orca-bin
|
||||
|
||||
- **Discord:** 加入 **[Discord](https://discord.gg/fzjDKHxv8Q)** 社区。
|
||||
- **Twitter / X:** 关注 **[@orca_build](https://x.com/orca_build)** 获取更新和公告。
|
||||
- **微信:** 扫描二维码加入社区。如果第一个群已满,请使用备用微信群。
|
||||
|
||||
<img src="../assets/wechat-qr.png" alt="Orca 社区微信群二维码" width="160" />
|
||||
<img src="../assets/wechat-qr-backup.jpg" alt="Orca 社区备用微信群二维码" width="160" />
|
||||
|
||||
- **反馈与想法:** 我们发布很快。缺少什么功能?[提交功能请求](https://github.com/stablyai/orca/issues)。
|
||||
- **隐私:** 查看[隐私与遥测文档](https://www.onorca.dev/docs/telemetry),了解 Orca 收集哪些匿名使用数据以及如何退出。
|
||||
- **支持我们:** 给这个仓库点 [Star](https://github.com/stablyai/orca),关注我们的日常发布。
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
# SSH Typing Latency Under Relay Load
|
||||
|
||||
Date: 2026-07-06
|
||||
|
||||
## Symptom
|
||||
|
||||
Typing in an SSH-host terminal can become extremely slow (hundreds of ms to
|
||||
seconds per echoed keystroke) while other SSH work is active — file previews,
|
||||
source-control refresh, search. A quiet SSH shell stays snappy, which made the
|
||||
reports look unreproducible.
|
||||
|
||||
This is distinct from the local warm-switch lag documented in
|
||||
`terminal-switch-typing-lag-investigation.md` (daemon `listSessions()`
|
||||
snapshot cost — already fixed; `TerminalHost.listSessions()` is now
|
||||
metadata-only via `getAppliedSize()`).
|
||||
|
||||
## Root Cause
|
||||
|
||||
The relay and the Electron client share ONE ordered SSH channel for all
|
||||
JSON-RPC traffic: PTY input/output, file streams, git responses, search, port
|
||||
scans. Two mechanisms turned bulk traffic into typing latency:
|
||||
|
||||
1. **Relay-side head-of-line blocking (primary).** The `fs.readFileStream`
|
||||
pump (`src/relay/fs-handler-file-read.ts`) wrote every 256KB chunk (~340KB
|
||||
framed) into the relay's stdout as fast as local disk reads completed,
|
||||
ignoring the `write() === false` backpressure signal. A 10MB preview
|
||||
enqueued ~13.6MB into the pipe at once; a `pty.data` echo emitted
|
||||
mid-stream queued behind ALL of it. At 2MB/s WAN that is multiple seconds
|
||||
of echo delay per open file. The reproduced measurement: 4,195,592 bytes
|
||||
(the entire remaining 3MB test file, framed) queued ahead of one echo
|
||||
frame.
|
||||
|
||||
2. **Client-side O(n²) frame buffering (secondary).** `FrameDecoder.feed()`
|
||||
in both `src/main/ssh/relay-protocol.ts` and `src/relay/protocol.ts` did
|
||||
`Buffer.concat([buffered, chunk])` per data event, re-copying the whole
|
||||
backlog for every ~32KB TCP chunk — ~2MB of memcpy per 340KB frame,
|
||||
~80MB per 10MB file — on the Electron main thread, delaying `pty:write`
|
||||
IPC dispatch and echo delivery to the renderer.
|
||||
|
||||
## Fix
|
||||
|
||||
- **Bulk lane with sink backpressure** (`RelayDispatcher.notifyBulk`):
|
||||
`fs.streamChunk` frames are serialized per client and each send waits for
|
||||
the sink's `drain` when `write()` returns `false`. Interactive frames
|
||||
(`pty.data`) still use plain `notify()` and are admitted immediately —
|
||||
because the bulk lane keeps the outbound buffer at ~1 frame, an echo jumps
|
||||
ahead of every not-yet-admitted chunk. Sinks (`relay.ts` stdout + Unix
|
||||
socket clients) surface `write()`'s boolean and a one-shot
|
||||
`waitWriteDrain`; every death path (EPIPE, stdin end, detach, dispose)
|
||||
flushes parked waiters so a pump can never hang on a dead pipe.
|
||||
|
||||
- **Credit-based flow control** (`fs.streamAck`): the client requests
|
||||
streams with `flowControl: 'ack'` and acks each processed chunk; the relay
|
||||
caps unacked chunks at `STREAM_ACK_WINDOW_CHUNKS = 4` (~1MB raw). This
|
||||
bounds in-flight bulk bytes even past the relay's own pipe (sshd/TCP
|
||||
buffers) and paces the relay's base64/JSON encode loop so incoming
|
||||
keystroke frames get event-loop turns. A parked pump wakes on ack, cancel,
|
||||
release, client detach, and a 1s staleness recheck.
|
||||
|
||||
- **Cross-version compatibility**: old client + new relay → no
|
||||
`flowControl` param → legacy unpaced pump (still drain-bounded, which is
|
||||
transparent). New client + old relay → `fs.streamAck` is an unknown
|
||||
notification and is silently ignored. `STREAM_CHUNK_SIZE` (256KB) is
|
||||
intentionally unchanged — both sides bake it into chunk offset math, so
|
||||
changing it would corrupt cross-version streams.
|
||||
|
||||
- **FrameDecoder rewrite** (both mirrored protocol files): chunk-list
|
||||
buffering; each frame is assembled exactly once (one copy) instead of
|
||||
re-concatenating the backlog per feed.
|
||||
|
||||
## What Stays Fast / Unchanged
|
||||
|
||||
- Relay PTY output batching (interactive echo fast path in
|
||||
`src/relay/pty-handler.ts`) is untouched.
|
||||
- No `listSessions()` / provider inventory was added anywhere near typing,
|
||||
focus, switch, resume, resize, or render paths.
|
||||
- Remote file streaming correctness: per-chunk length checks, chunk-count and
|
||||
byte-count invariants, cancel paths, and the 12MB round-trip integration
|
||||
test all pass unchanged.
|
||||
|
||||
## Regression Coverage
|
||||
|
||||
- `src/relay/fs-stream-pty-echo-backpressure.integration.test.ts` — real
|
||||
mux ↔ dispatcher ↔ FsHandler over a congestible in-memory pipe; asserts
|
||||
deterministic BYTE bounds (not wall-clock): echo queues behind < 2 framed
|
||||
chunks when congested (pre-fix: whole file), ack window caps in-flight
|
||||
chunks, legacy no-ack clients still get complete streams.
|
||||
- `src/relay/fs-handler-stream.test.ts` — pump parks at the ack window,
|
||||
resumes per ack, and releases its file handle when cancelled while parked.
|
||||
- `src/relay/dispatcher.test.ts` — notifyBulk semantics: drain gating,
|
||||
per-client targeting, dispose releases parked senders, interactive
|
||||
notify() not gated behind a stalled bulk lane.
|
||||
- `src/main/ssh/relay-protocol.test.ts` — decoder byte-at-a-time boundary
|
||||
straddling, oversized-frame resync across odd chunk sizes, and a
|
||||
no-`Buffer.concat`-during-feed guard that locks out the O(n²) shape.
|
||||
- `tests/e2e/ssh-docker-relay-perf.spec.ts` — new "busy relay" scenario:
|
||||
types while two 8MB remote file-read loops and a git.status loop run;
|
||||
budgets median < 500ms, worst < 2000ms. (On loopback Docker the pre-fix
|
||||
HOL is only tens of ms — the in-process byte-bound test is the
|
||||
authoritative red/green; the e2e guards the end-to-end wiring.)
|
||||
|
||||
## Residual Gaps
|
||||
|
||||
- **Large single-frame responses**: `git.*` responses (stdout capped at
|
||||
`MAX_GIT_BUFFER` = 10MB → ~13MB framed) and other request/response results
|
||||
are one atomic frame on the wire; a huge diff can still delay an echo by
|
||||
its own transfer time. Fixing this requires response chunking (a protocol
|
||||
change); bounded by the 16MB `MAX_MESSAGE_SIZE`.
|
||||
- **Outbound direction**: a large `fs.writeFile` request frame (client →
|
||||
relay) can queue ahead of keystroke `pty.data` frames on `channel.stdin`.
|
||||
Same shape, opposite direction, rarer trigger (saving a large remote file
|
||||
while typing).
|
||||
- `pty.ackData` flow control for PTY output remains unenforced
|
||||
(`src/relay/pty-handler.ts` — "not yet enforced"); PTY output floods are
|
||||
already paced by the relay's 8ms batch/16KB slice scheduler.
|
||||
@@ -102,6 +102,14 @@ export default function RootLayout() {
|
||||
return
|
||||
}
|
||||
handledNotificationIdsRef.current.add(notificationId)
|
||||
// Why: RootLayout never unmounts, so cap this tap-dedup set (FIFO) rather
|
||||
// than letting it grow one id per notification tapped for the app's life.
|
||||
if (handledNotificationIdsRef.current.size > 256) {
|
||||
const oldest = handledNotificationIdsRef.current.values().next().value
|
||||
if (oldest !== undefined) {
|
||||
handledNotificationIdsRef.current.delete(oldest)
|
||||
}
|
||||
}
|
||||
|
||||
const path = await getNavigationPath(response.notification.request.content.data)
|
||||
clearLastNotificationResponse()
|
||||
|
||||
@@ -21,6 +21,31 @@ const MOCK_FILE_LIST = [
|
||||
{ relativePath: 'dist/archive.zip', basename: 'archive.zip', kind: 'binary' }
|
||||
]
|
||||
|
||||
function readMockDirectory(relativePath: string): Array<{ name: string; isDirectory: boolean }> {
|
||||
const prefix = relativePath ? `${relativePath}/` : ''
|
||||
const children = new Map<string, boolean>()
|
||||
for (const file of MOCK_FILE_LIST) {
|
||||
if (!file.relativePath.startsWith(prefix)) {
|
||||
continue
|
||||
}
|
||||
const rest = file.relativePath.slice(prefix.length)
|
||||
if (!rest) {
|
||||
continue
|
||||
}
|
||||
const [name, ...descendants] = rest.split('/')
|
||||
if (!name) {
|
||||
continue
|
||||
}
|
||||
children.set(name, children.get(name) === true || descendants.length > 0)
|
||||
}
|
||||
return Array.from(children, ([name, isDirectory]) => ({ name, isDirectory })).sort((a, b) => {
|
||||
if (a.isDirectory !== b.isDirectory) {
|
||||
return a.isDirectory ? -1 : 1
|
||||
}
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
}
|
||||
|
||||
export function handleMockFilePreviewRequest(
|
||||
request: RpcRequest,
|
||||
respond: Respond,
|
||||
@@ -40,6 +65,10 @@ export function handleMockFilePreviewRequest(
|
||||
)
|
||||
return true
|
||||
|
||||
case 'files.readDir':
|
||||
respond(success(request.id, readMockDirectory(String(request.params?.relativePath ?? ''))))
|
||||
return true
|
||||
|
||||
case 'files.read': {
|
||||
const relativePath = String(request.params?.relativePath ?? '')
|
||||
const content = MOCK_FILE_CONTENT[relativePath]
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { MobileFileExplorerPanel } from './MobileFileExplorerPanel'
|
||||
import type { MobileDirEntry } from './file-tree'
|
||||
import type { RpcResponse } from '../transport/types'
|
||||
|
||||
type MockClient = {
|
||||
sendRequest: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
const mockTransport = vi.hoisted(() => ({
|
||||
client: null as MockClient | null,
|
||||
connectionState: 'connected',
|
||||
forceReconnect: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('react-native', async () => {
|
||||
const React = await import('react')
|
||||
return {
|
||||
ActivityIndicator: 'ActivityIndicator',
|
||||
FlatList: (props: {
|
||||
data: unknown[]
|
||||
keyExtractor: (item: unknown, index: number) => string
|
||||
renderItem: (info: {
|
||||
item: unknown
|
||||
index: number
|
||||
separators: Record<string, never>
|
||||
}) => unknown
|
||||
}) =>
|
||||
React.createElement(
|
||||
'FlatList',
|
||||
props,
|
||||
props.data.map((item, index) =>
|
||||
React.createElement(
|
||||
'FlatListItem',
|
||||
{ key: props.keyExtractor(item, index) },
|
||||
props.renderItem({ item, index, separators: {} })
|
||||
)
|
||||
)
|
||||
),
|
||||
Pressable: 'Pressable',
|
||||
StyleSheet: {
|
||||
create: (styles: unknown) => styles,
|
||||
hairlineWidth: 1
|
||||
},
|
||||
Text: 'Text',
|
||||
View: 'View'
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('react-native-safe-area-context', () => ({
|
||||
SafeAreaView: 'SafeAreaView'
|
||||
}))
|
||||
|
||||
vi.mock('expo-router', () => ({
|
||||
useRouter: () => ({
|
||||
back: vi.fn(),
|
||||
push: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('lucide-react-native', () => ({
|
||||
ChevronDown: 'ChevronDown',
|
||||
ChevronLeft: 'ChevronLeft',
|
||||
ChevronRight: 'ChevronRight',
|
||||
File: 'File',
|
||||
FileText: 'FileText',
|
||||
Folder: 'Folder',
|
||||
Image: 'Image',
|
||||
X: 'X'
|
||||
}))
|
||||
|
||||
vi.mock('../platform/haptics', () => ({
|
||||
triggerSelection: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../transport/client-context', () => ({
|
||||
useForceReconnect: () => mockTransport.forceReconnect,
|
||||
useHostClient: () => ({
|
||||
client: mockTransport.client,
|
||||
state: mockTransport.connectionState
|
||||
})
|
||||
}))
|
||||
|
||||
function suppressReactTestRendererDeprecationWarning(): () => void {
|
||||
const originalConsoleError = console.error
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
const firstArg = args[0]
|
||||
if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
originalConsoleError(...args)
|
||||
})
|
||||
return () => consoleErrorSpy.mockRestore()
|
||||
}
|
||||
|
||||
function entry(name: string, isDirectory = false): MobileDirEntry {
|
||||
return { name, isDirectory }
|
||||
}
|
||||
|
||||
function ok(result: MobileDirEntry[]): RpcResponse {
|
||||
return { id: 'response-id', ok: true, result, _meta: { runtimeId: 'runtime-id' } }
|
||||
}
|
||||
|
||||
function createMockClient(entriesByPath: Record<string, MobileDirEntry[]>): MockClient {
|
||||
return {
|
||||
sendRequest: vi.fn(async (_method: string, params: { relativePath: string }) => {
|
||||
return ok(entriesByPath[params.relativePath] ?? [])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function renderExplorer(): Promise<ReactTestRenderer> {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(MobileFileExplorerPanel, {
|
||||
hostId: 'host-a',
|
||||
worktreeId: 'worktree-a',
|
||||
name: 'Example Worktree',
|
||||
embedded: true
|
||||
})
|
||||
)
|
||||
})
|
||||
} finally {
|
||||
restoreConsoleError()
|
||||
}
|
||||
if (!renderer) {
|
||||
throw new Error('MobileFileExplorerPanel did not render')
|
||||
}
|
||||
return renderer
|
||||
}
|
||||
|
||||
async function updateExplorer(renderer: ReactTestRenderer): Promise<void> {
|
||||
await act(async () => {
|
||||
renderer.update(
|
||||
createElement(MobileFileExplorerPanel, {
|
||||
hostId: 'host-a',
|
||||
worktreeId: 'worktree-a',
|
||||
name: 'Example Worktree',
|
||||
embedded: true
|
||||
})
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async function pressByLabel(
|
||||
renderer: ReactTestRenderer,
|
||||
accessibilityLabel: string
|
||||
): Promise<void> {
|
||||
const pressable = renderer.root
|
||||
.findAllByType('Pressable')
|
||||
.find((node) => node.props.accessibilityLabel === accessibilityLabel)
|
||||
if (!pressable) {
|
||||
throw new Error(`Unable to find pressable: ${accessibilityLabel}`)
|
||||
}
|
||||
await act(async () => {
|
||||
pressable.props.onPress()
|
||||
})
|
||||
}
|
||||
|
||||
function renderedText(renderer: ReactTestRenderer): string {
|
||||
return renderer.root
|
||||
.findAllByType('Text')
|
||||
.flatMap((node) => node.props.children)
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
describe('MobileFileExplorerPanel', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
mockTransport.client = null
|
||||
mockTransport.connectionState = 'connected'
|
||||
mockTransport.forceReconnect = vi.fn()
|
||||
})
|
||||
|
||||
it('loads directories lazily and reuses cached children when reopened', async () => {
|
||||
const client = createMockClient({
|
||||
'': [entry('src', true), entry('README.md')],
|
||||
src: [entry('app.ts'), entry('components', true)]
|
||||
})
|
||||
mockTransport.client = client
|
||||
|
||||
const renderer = await renderExplorer()
|
||||
|
||||
expect(client.sendRequest).toHaveBeenCalledTimes(1)
|
||||
expect(client.sendRequest).toHaveBeenLastCalledWith('files.readDir', {
|
||||
worktree: 'id:worktree-a',
|
||||
relativePath: ''
|
||||
})
|
||||
|
||||
await pressByLabel(renderer, 'Open folder src')
|
||||
await vi.waitFor(() => expect(client.sendRequest).toHaveBeenCalledTimes(2))
|
||||
expect(client.sendRequest).toHaveBeenLastCalledWith('files.readDir', {
|
||||
worktree: 'id:worktree-a',
|
||||
relativePath: 'src'
|
||||
})
|
||||
expect(renderedText(renderer)).toContain('app.ts')
|
||||
|
||||
await pressByLabel(renderer, 'Open folder src')
|
||||
await pressByLabel(renderer, 'Open folder src')
|
||||
|
||||
expect(client.sendRequest).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('keeps the loaded tree visible during a transient disconnect', async () => {
|
||||
const client = createMockClient({
|
||||
'': [entry('src', true), entry('README.md')]
|
||||
})
|
||||
mockTransport.client = client
|
||||
|
||||
const renderer = await renderExplorer()
|
||||
expect(renderedText(renderer)).toContain('src')
|
||||
|
||||
mockTransport.client = null
|
||||
mockTransport.connectionState = 'disconnected'
|
||||
await updateExplorer(renderer)
|
||||
|
||||
expect(client.sendRequest).toHaveBeenCalledTimes(1)
|
||||
expect(renderedText(renderer)).toContain('src')
|
||||
expect(renderedText(renderer)).toContain('README.md')
|
||||
expect(renderedText(renderer)).not.toContain('Waiting for desktop...')
|
||||
})
|
||||
|
||||
it('refreshes the root in the background after reconnect without blanking the tree', async () => {
|
||||
const client = createMockClient({
|
||||
'': [entry('src', true), entry('README.md')]
|
||||
})
|
||||
mockTransport.client = client
|
||||
|
||||
const renderer = await renderExplorer()
|
||||
expect(renderedText(renderer)).toContain('README.md')
|
||||
|
||||
mockTransport.client = null
|
||||
mockTransport.connectionState = 'disconnected'
|
||||
await updateExplorer(renderer)
|
||||
|
||||
let resolveReload: ((response: RpcResponse) => void) | undefined
|
||||
const reconnectedClient: MockClient = {
|
||||
sendRequest: vi.fn(
|
||||
() =>
|
||||
new Promise<RpcResponse>((resolve) => {
|
||||
resolveReload = resolve
|
||||
})
|
||||
)
|
||||
}
|
||||
mockTransport.client = reconnectedClient
|
||||
mockTransport.connectionState = 'connected'
|
||||
await updateExplorer(renderer)
|
||||
|
||||
expect(reconnectedClient.sendRequest).toHaveBeenCalledTimes(1)
|
||||
expect(renderedText(renderer)).toContain('src')
|
||||
expect(renderedText(renderer)).toContain('README.md')
|
||||
|
||||
await act(async () => {
|
||||
resolveReload?.(ok([entry('src', true), entry('README.md'), entry('CHANGELOG.md')]))
|
||||
})
|
||||
expect(renderedText(renderer)).toContain('CHANGELOG.md')
|
||||
})
|
||||
|
||||
it('keeps the cached tree when a post-reconnect root refresh fails', async () => {
|
||||
const client = createMockClient({
|
||||
'': [entry('src', true), entry('README.md')]
|
||||
})
|
||||
mockTransport.client = client
|
||||
|
||||
const renderer = await renderExplorer()
|
||||
expect(renderedText(renderer)).toContain('README.md')
|
||||
|
||||
mockTransport.client = null
|
||||
mockTransport.connectionState = 'disconnected'
|
||||
await updateExplorer(renderer)
|
||||
|
||||
const failingClient: MockClient = {
|
||||
sendRequest: vi.fn(async () => {
|
||||
throw new Error('refresh failed')
|
||||
})
|
||||
}
|
||||
mockTransport.client = failingClient
|
||||
mockTransport.connectionState = 'connected'
|
||||
await updateExplorer(renderer)
|
||||
|
||||
expect(failingClient.sendRequest).toHaveBeenCalledTimes(1)
|
||||
expect(renderedText(renderer)).toContain('src')
|
||||
expect(renderedText(renderer)).toContain('README.md')
|
||||
expect(renderedText(renderer)).not.toContain('refresh failed')
|
||||
})
|
||||
|
||||
it('falls back to the capped files.list against desktops without files.readDir', async () => {
|
||||
const legacyClient: MockClient = {
|
||||
sendRequest: vi.fn(async (method: string): Promise<RpcResponse> => {
|
||||
if (method === 'files.readDir') {
|
||||
return {
|
||||
id: 'response-id',
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'forbidden',
|
||||
message: "Method 'files.readDir' is not available to mobile clients"
|
||||
},
|
||||
_meta: { runtimeId: 'runtime-id' }
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: 'response-id',
|
||||
ok: true,
|
||||
result: {
|
||||
files: [
|
||||
{ relativePath: 'src/app.ts', basename: 'app.ts', kind: 'text' },
|
||||
{ relativePath: 'README.md', basename: 'README.md', kind: 'text' }
|
||||
],
|
||||
totalCount: 2,
|
||||
truncated: false
|
||||
},
|
||||
_meta: { runtimeId: 'runtime-id' }
|
||||
}
|
||||
})
|
||||
}
|
||||
mockTransport.client = legacyClient
|
||||
|
||||
const renderer = await renderExplorer()
|
||||
|
||||
expect(legacyClient.sendRequest).toHaveBeenCalledWith('files.list', {
|
||||
worktree: 'id:worktree-a'
|
||||
})
|
||||
expect(renderedText(renderer)).toContain('src')
|
||||
expect(renderedText(renderer)).toContain('README.md')
|
||||
|
||||
await pressByLabel(renderer, 'Open folder src')
|
||||
expect(renderedText(renderer)).toContain('app.ts')
|
||||
// Every directory comes from the synthesized cache: one readDir attempt
|
||||
// plus one files.list call total, no per-directory RPCs afterwards.
|
||||
expect(legacyClient.sendRequest).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('surfaces the legacy cap note when the files.list fallback is truncated', async () => {
|
||||
const legacyClient: MockClient = {
|
||||
sendRequest: vi.fn(async (method: string): Promise<RpcResponse> => {
|
||||
if (method === 'files.readDir') {
|
||||
return {
|
||||
id: 'response-id',
|
||||
ok: false,
|
||||
error: { code: 'method_not_found', message: 'Unknown method' },
|
||||
_meta: { runtimeId: 'runtime-id' }
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: 'response-id',
|
||||
ok: true,
|
||||
result: {
|
||||
files: [{ relativePath: 'README.md', basename: 'README.md', kind: 'text' }],
|
||||
totalCount: 6000,
|
||||
truncated: true
|
||||
},
|
||||
_meta: { runtimeId: 'runtime-id' }
|
||||
}
|
||||
})
|
||||
}
|
||||
mockTransport.client = legacyClient
|
||||
|
||||
const renderer = await renderExplorer()
|
||||
|
||||
expect(renderedText(renderer)).toContain('README.md')
|
||||
expect(renderedText(renderer)).toContain('Showing first 5000')
|
||||
})
|
||||
|
||||
it('reports the files.list failure when the fallback itself fails', async () => {
|
||||
const legacyClient: MockClient = {
|
||||
sendRequest: vi.fn(async (method: string): Promise<RpcResponse> => {
|
||||
if (method === 'files.readDir') {
|
||||
return {
|
||||
id: 'response-id',
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'forbidden',
|
||||
message: "Method 'files.readDir' is not available to mobile clients"
|
||||
},
|
||||
_meta: { runtimeId: 'runtime-id' }
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: 'response-id',
|
||||
ok: false,
|
||||
error: { code: 'internal', message: 'legacy list failed' },
|
||||
_meta: { runtimeId: 'runtime-id' }
|
||||
}
|
||||
})
|
||||
}
|
||||
mockTransport.client = legacyClient
|
||||
|
||||
const renderer = await renderExplorer()
|
||||
|
||||
expect(renderedText(renderer)).toContain('legacy list failed')
|
||||
expect(renderedText(renderer)).not.toContain('not available to mobile clients')
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
ActivityIndicator,
|
||||
FlatList,
|
||||
@@ -9,34 +9,33 @@ import {
|
||||
} from 'react-native'
|
||||
import { SafeAreaView } from 'react-native-safe-area-context'
|
||||
import { useRouter } from 'expo-router'
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
File,
|
||||
FileText,
|
||||
Folder,
|
||||
Image as ImageIcon,
|
||||
X
|
||||
} from 'lucide-react-native'
|
||||
import { ChevronLeft, X } from 'lucide-react-native'
|
||||
import { useHostClient, useForceReconnect } from '../transport/client-context'
|
||||
import { getWorktreeLabel } from '../session/worktree-label'
|
||||
import {
|
||||
buildTree,
|
||||
flattenTree,
|
||||
isMarkdownPath,
|
||||
type FilesListResult,
|
||||
type MobileFileEntry,
|
||||
type TreeNode
|
||||
flattenDirectoryCache,
|
||||
getDirectoryCacheState,
|
||||
type DirectoryCache,
|
||||
type FileExplorerRow,
|
||||
type MobileDirEntry
|
||||
} from './file-tree'
|
||||
import type { RpcSuccess } from '../transport/types'
|
||||
import { triggerSelection } from '../platform/haptics'
|
||||
import { colors, spacing } from '../theme/mobile-theme'
|
||||
import { fileExplorerStyles as styles } from './mobile-file-explorer-styles'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import {
|
||||
canPreviewMobileFileRow,
|
||||
navigateToMobileFilePreview
|
||||
} from './mobile-file-preview-navigation'
|
||||
beginDirectoryLoad,
|
||||
createDirectoryLoadRevisions,
|
||||
isCurrentDirectoryLoad,
|
||||
resetDirectoryLoadRevisions,
|
||||
type DirectoryLoadRevisions
|
||||
} from './directory-load-revisions'
|
||||
import {
|
||||
directoryCacheFromFileList,
|
||||
isMobileMethodUnavailableError,
|
||||
type LegacyFilesListResult
|
||||
} from './file-list-fallback'
|
||||
import { fileExplorerStyles as styles } from './mobile-file-explorer-styles'
|
||||
import { MobileFileExplorerRow } from './mobile-file-explorer-row'
|
||||
import { navigateToMobileFilePreview } from './mobile-file-preview-navigation'
|
||||
|
||||
export function MobileFileExplorerPanel(props: {
|
||||
hostId: string
|
||||
@@ -49,58 +48,217 @@ export function MobileFileExplorerPanel(props: {
|
||||
const router = useRouter()
|
||||
const { client, state: connState } = useHostClient(hostId)
|
||||
const forceReconnect = useForceReconnect()
|
||||
const [files, setFiles] = useState<MobileFileEntry[]>([])
|
||||
const scopeRef = useRef('')
|
||||
const scope = `${hostId}:${worktreeId}`
|
||||
scopeRef.current = scope
|
||||
const directoryLoadRevisionsRef = useRef<DirectoryLoadRevisions>(createDirectoryLoadRevisions())
|
||||
const pendingDirectoryRetriesRef = useRef<Set<string>>(new Set())
|
||||
const directoryCacheRef = useRef<DirectoryCache>({})
|
||||
const [directoryCache, setDirectoryCache] = useState<DirectoryCache>({})
|
||||
const [expanded, setExpanded] = useState<Set<string>>(() => new Set())
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [truncated, setTruncated] = useState(false)
|
||||
const [legacyListTruncated, setLegacyListTruncated] = useState(false)
|
||||
const worktreeLabel = getWorktreeLabel(name, worktreeId)
|
||||
|
||||
const loadFiles = useCallback(async () => {
|
||||
if (!client || connState !== 'connected') {
|
||||
setLoading(false)
|
||||
setError(connState === 'connected' ? 'Connecting to desktop...' : 'Waiting for desktop...')
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const response = await client.sendRequest('files.list', { worktree: `id:${worktreeId}` })
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error?.message || 'Unable to load files')
|
||||
const loadDirectory = useCallback(
|
||||
async (relativePath: string) => {
|
||||
const scope = scopeRef.current
|
||||
const loadToken = beginDirectoryLoad(directoryLoadRevisionsRef.current, scope, relativePath)
|
||||
const rootLoad = relativePath === ''
|
||||
|
||||
if (!client || connState !== 'connected') {
|
||||
const message =
|
||||
connState === 'connected' ? 'Connecting to desktop...' : 'Waiting for desktop...'
|
||||
if (rootLoad) {
|
||||
const hasLoadedRoot =
|
||||
(getDirectoryCacheState(directoryCacheRef.current, '')?.entries.length ?? 0) > 0
|
||||
setLoading(false)
|
||||
// Why: transient reconnects should not blank an already browsable tree.
|
||||
setError(hasLoadedRoot ? null : message)
|
||||
} else {
|
||||
setDirectoryCache((prev) => ({
|
||||
...prev,
|
||||
[relativePath]: {
|
||||
entries: getDirectoryCacheState(prev, relativePath)?.entries ?? [],
|
||||
error: message
|
||||
}
|
||||
}))
|
||||
}
|
||||
return
|
||||
}
|
||||
const result = (response as RpcSuccess).result as FilesListResult
|
||||
setFiles(result.files)
|
||||
setTruncated(result.truncated)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unable to load files')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [client, connState, worktreeId])
|
||||
|
||||
const hadLoadedRoot =
|
||||
rootLoad && (getDirectoryCacheState(directoryCacheRef.current, '')?.entries.length ?? 0) > 0
|
||||
if (rootLoad) {
|
||||
// Why: a reconnect refresh must not blank an already browsable tree —
|
||||
// the full-screen spinner unmounts the list and resets scroll.
|
||||
if (!hadLoadedRoot) {
|
||||
setLoading(true)
|
||||
}
|
||||
setError(null)
|
||||
}
|
||||
setDirectoryCache((prev) => ({
|
||||
...prev,
|
||||
[relativePath]: {
|
||||
entries: getDirectoryCacheState(prev, relativePath)?.entries ?? [],
|
||||
loading: true
|
||||
}
|
||||
}))
|
||||
|
||||
try {
|
||||
const response = await client.sendRequest('files.readDir', {
|
||||
worktree: `id:${worktreeId}`,
|
||||
relativePath
|
||||
})
|
||||
if (!response.ok) {
|
||||
// Why: desktops that predate the files.readDir mobile allowlist
|
||||
// entry still serve the capped files.list; fall back so the Files
|
||||
// tab keeps working until the desktop updates.
|
||||
if (
|
||||
rootLoad &&
|
||||
isMobileMethodUnavailableError(response.error?.code, response.error?.message)
|
||||
) {
|
||||
const legacy = await client.sendRequest('files.list', {
|
||||
worktree: `id:${worktreeId}`
|
||||
})
|
||||
if (legacy.ok) {
|
||||
if (
|
||||
!isCurrentDirectoryLoad(
|
||||
directoryLoadRevisionsRef.current,
|
||||
scopeRef.current,
|
||||
loadToken
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
const legacyResult = (legacy as RpcSuccess).result as LegacyFilesListResult
|
||||
setDirectoryCache(directoryCacheFromFileList(legacyResult.files))
|
||||
// Why: the capped list silently omits files past the cap — keep
|
||||
// the legacy explorer's "Showing first 5000" note.
|
||||
setLegacyListTruncated(legacyResult.truncated)
|
||||
return
|
||||
}
|
||||
throw new Error(
|
||||
legacy.error?.message || response.error?.message || 'Unable to load files'
|
||||
)
|
||||
}
|
||||
throw new Error(response.error?.message || 'Unable to load files')
|
||||
}
|
||||
if (
|
||||
!isCurrentDirectoryLoad(directoryLoadRevisionsRef.current, scopeRef.current, loadToken)
|
||||
) {
|
||||
return
|
||||
}
|
||||
const entries = (response as RpcSuccess).result as MobileDirEntry[]
|
||||
if (rootLoad) {
|
||||
setLegacyListTruncated(false)
|
||||
}
|
||||
setDirectoryCache((prev) => ({
|
||||
...prev,
|
||||
[relativePath]: { entries }
|
||||
}))
|
||||
} catch (err) {
|
||||
if (
|
||||
!isCurrentDirectoryLoad(directoryLoadRevisionsRef.current, scopeRef.current, loadToken)
|
||||
) {
|
||||
return
|
||||
}
|
||||
const message = err instanceof Error ? err.message : 'Unable to load files'
|
||||
if (rootLoad) {
|
||||
// Why: a failed background refresh keeps the cached tree browsable;
|
||||
// only a cold load surfaces the full-screen error.
|
||||
setError(hadLoadedRoot ? null : message)
|
||||
} else {
|
||||
setDirectoryCache((prev) => ({
|
||||
...prev,
|
||||
[relativePath]: {
|
||||
entries: getDirectoryCacheState(prev, relativePath)?.entries ?? [],
|
||||
error: message
|
||||
}
|
||||
}))
|
||||
}
|
||||
} finally {
|
||||
if (
|
||||
rootLoad &&
|
||||
isCurrentDirectoryLoad(directoryLoadRevisionsRef.current, scopeRef.current, loadToken)
|
||||
) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
},
|
||||
[client, connState, worktreeId]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
void loadFiles()
|
||||
}, [loadFiles])
|
||||
scopeRef.current = scope
|
||||
resetDirectoryLoadRevisions(directoryLoadRevisionsRef.current)
|
||||
pendingDirectoryRetriesRef.current.clear()
|
||||
directoryCacheRef.current = {}
|
||||
setDirectoryCache({})
|
||||
setExpanded(new Set())
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setLegacyListTruncated(false)
|
||||
}, [scope])
|
||||
|
||||
const rows = useMemo(() => flattenTree(buildTree(files), expanded), [expanded, files])
|
||||
useEffect(() => {
|
||||
directoryCacheRef.current = directoryCache
|
||||
}, [directoryCache])
|
||||
|
||||
const toggleDirectory = useCallback((relativePath: string) => {
|
||||
triggerSelection()
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(relativePath)) {
|
||||
next.delete(relativePath)
|
||||
} else {
|
||||
next.add(relativePath)
|
||||
useEffect(() => {
|
||||
void loadDirectory('')
|
||||
}, [hostId, loadDirectory])
|
||||
|
||||
useEffect(() => {
|
||||
if (connState !== 'connected' || pendingDirectoryRetriesRef.current.size === 0) {
|
||||
return
|
||||
}
|
||||
const pending = [...pendingDirectoryRetriesRef.current]
|
||||
pendingDirectoryRetriesRef.current.clear()
|
||||
for (const relativePath of pending) {
|
||||
void loadDirectory(relativePath)
|
||||
}
|
||||
}, [connState, loadDirectory])
|
||||
|
||||
const rows = useMemo(
|
||||
() => flattenDirectoryCache(directoryCache, expanded),
|
||||
[directoryCache, expanded]
|
||||
)
|
||||
|
||||
const toggleDirectory = useCallback(
|
||||
(relativePath: string) => {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(relativePath)) {
|
||||
next.delete(relativePath)
|
||||
} else {
|
||||
next.add(relativePath)
|
||||
}
|
||||
return next
|
||||
})
|
||||
const state = getDirectoryCacheState(directoryCache, relativePath)
|
||||
if (!expanded.has(relativePath) && !state?.loading && (!state?.entries || state.error)) {
|
||||
void loadDirectory(relativePath)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
},
|
||||
[directoryCache, expanded, loadDirectory]
|
||||
)
|
||||
|
||||
const retryDirectory = useCallback(
|
||||
(relativePath: string) => {
|
||||
if (connState !== 'connected' && hostId) {
|
||||
pendingDirectoryRetriesRef.current.add(relativePath)
|
||||
void forceReconnect(hostId)
|
||||
return
|
||||
}
|
||||
void loadDirectory(relativePath)
|
||||
},
|
||||
[connState, forceReconnect, hostId, loadDirectory]
|
||||
)
|
||||
|
||||
const previewFile = useCallback(
|
||||
(relativePath: string, displayName: string) => {
|
||||
triggerSelection()
|
||||
navigateToMobileFilePreview(
|
||||
router,
|
||||
{
|
||||
@@ -116,66 +274,15 @@ export function MobileFileExplorerPanel(props: {
|
||||
[embedded, hostId, name, onRequestClose, router, worktreeId]
|
||||
)
|
||||
|
||||
const renderItem: ListRenderItem<TreeNode> = ({ item }) => {
|
||||
const isDirectory = item.kind === 'directory'
|
||||
const isExpanded = expanded.has(item.relativePath)
|
||||
// Images render in the mobile viewer (via files.readPreview), so a binary
|
||||
// image is openable; only non-previewable binaries are unavailable.
|
||||
const previewable =
|
||||
item.kind !== 'directory' &&
|
||||
canPreviewMobileFileRow({ kind: item.kind, relativePath: item.relativePath })
|
||||
const isImage = item.kind === 'binary' && previewable
|
||||
const disabled = item.kind === 'binary' && !previewable
|
||||
const markdown = item.kind === 'text' && isMarkdownPath(item.relativePath)
|
||||
const renderItem: ListRenderItem<FileExplorerRow> = ({ item }) => {
|
||||
return (
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.row,
|
||||
{ paddingLeft: spacing.lg + item.depth * 18 },
|
||||
pressed && !disabled && styles.rowPressed,
|
||||
disabled && styles.rowDisabled
|
||||
]}
|
||||
disabled={disabled}
|
||||
onPress={() => {
|
||||
if (isDirectory) {
|
||||
toggleDirectory(item.relativePath)
|
||||
} else if (!disabled) {
|
||||
previewFile(item.relativePath, item.name)
|
||||
}
|
||||
}}
|
||||
accessibilityLabel={
|
||||
isDirectory
|
||||
? `Open folder ${item.name}`
|
||||
: disabled
|
||||
? `${item.name} unavailable on mobile`
|
||||
: `Preview file ${item.name}`
|
||||
}
|
||||
>
|
||||
{isDirectory ? (
|
||||
isExpanded ? (
|
||||
<ChevronDown size={16} color={colors.textSecondary} />
|
||||
) : (
|
||||
<ChevronRight size={16} color={colors.textSecondary} />
|
||||
)
|
||||
) : (
|
||||
<View style={styles.chevronSpacer} />
|
||||
)}
|
||||
{isDirectory ? (
|
||||
<Folder size={17} color={colors.textSecondary} />
|
||||
) : markdown ? (
|
||||
<FileText size={17} color={disabled ? colors.textMuted : colors.textSecondary} />
|
||||
) : isImage ? (
|
||||
<ImageIcon size={17} color={colors.textSecondary} />
|
||||
) : (
|
||||
<File size={17} color={disabled ? colors.textMuted : colors.textSecondary} />
|
||||
)}
|
||||
<View style={styles.rowTextBlock}>
|
||||
<Text style={[styles.rowTitle, disabled && styles.rowTitleDisabled]} numberOfLines={1}>
|
||||
{item.name}
|
||||
</Text>
|
||||
{disabled ? <Text style={styles.rowMeta}>Unavailable on mobile</Text> : null}
|
||||
</View>
|
||||
</Pressable>
|
||||
<MobileFileExplorerRow
|
||||
item={item}
|
||||
expanded={expanded}
|
||||
onPreviewFile={previewFile}
|
||||
onRetryDirectory={retryDirectory}
|
||||
onToggleDirectory={toggleDirectory}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -206,7 +313,7 @@ export function MobileFileExplorerPanel(props: {
|
||||
</Text>
|
||||
<Text style={styles.meta} numberOfLines={1}>
|
||||
{worktreeLabel}
|
||||
{truncated ? ' - Showing first 5000' : ''}
|
||||
{legacyListTruncated ? ' - Showing first 5000' : ''}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
@@ -220,12 +327,12 @@ export function MobileFileExplorerPanel(props: {
|
||||
<View style={styles.state}>
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
{/* Why: while disconnected, re-sending the request is useless — revive
|
||||
the parked transport instead (issue #5049); loadFiles re-runs via
|
||||
the parked transport instead (issue #5049); loadDirectory re-runs via
|
||||
its effect once the new client connects. */}
|
||||
<Pressable
|
||||
style={styles.retryButton}
|
||||
onPress={() =>
|
||||
connState !== 'connected' && hostId ? void forceReconnect(hostId) : void loadFiles()
|
||||
connState !== 'connected' && hostId ? void forceReconnect(hostId) : void loadDirectory('')
|
||||
}
|
||||
>
|
||||
<Text style={styles.retryText}>Retry</Text>
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
beginDirectoryLoad,
|
||||
createDirectoryLoadRevisions,
|
||||
isCurrentDirectoryLoad,
|
||||
resetDirectoryLoadRevisions,
|
||||
type DirectoryLoadRevisions
|
||||
} from './directory-load-revisions'
|
||||
|
||||
describe('directory-load-revisions', () => {
|
||||
it('ignores stale duplicate loads for the same directory and scope', () => {
|
||||
const revisions: DirectoryLoadRevisions = createDirectoryLoadRevisions()
|
||||
const older = beginDirectoryLoad(revisions, 'host-a:worktree-a', 'src')
|
||||
const newer = beginDirectoryLoad(revisions, 'host-a:worktree-a', 'src')
|
||||
|
||||
expect(isCurrentDirectoryLoad(revisions, 'host-a:worktree-a', newer)).toBe(true)
|
||||
expect(isCurrentDirectoryLoad(revisions, 'host-a:worktree-a', older)).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores loads from an old host/worktree scope', () => {
|
||||
const revisions: DirectoryLoadRevisions = createDirectoryLoadRevisions()
|
||||
const oldScopeLoad = beginDirectoryLoad(revisions, 'host-a:worktree-a', '')
|
||||
|
||||
expect(isCurrentDirectoryLoad(revisions, 'host-b:worktree-b', oldScopeLoad)).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores loads from an old reset generation in the same scope', () => {
|
||||
const revisions: DirectoryLoadRevisions = createDirectoryLoadRevisions()
|
||||
const oldLoad = beginDirectoryLoad(revisions, 'host-a:worktree-a', '')
|
||||
|
||||
resetDirectoryLoadRevisions(revisions)
|
||||
const newLoad = beginDirectoryLoad(revisions, 'host-a:worktree-a', '')
|
||||
|
||||
expect(isCurrentDirectoryLoad(revisions, 'host-a:worktree-a', newLoad)).toBe(true)
|
||||
expect(isCurrentDirectoryLoad(revisions, 'host-a:worktree-a', oldLoad)).toBe(false)
|
||||
})
|
||||
|
||||
it('tracks directory names that overlap object prototype keys', () => {
|
||||
const revisions: DirectoryLoadRevisions = createDirectoryLoadRevisions()
|
||||
const load = beginDirectoryLoad(revisions, 'host-a:worktree-a', '__proto__')
|
||||
|
||||
expect(isCurrentDirectoryLoad(revisions, 'host-a:worktree-a', load)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
export type DirectoryLoadRevisions = {
|
||||
generation: number
|
||||
revisionsByPath: Map<string, number>
|
||||
}
|
||||
|
||||
export type DirectoryLoadToken = {
|
||||
generation: number
|
||||
relativePath: string
|
||||
revision: number
|
||||
scope: string
|
||||
}
|
||||
|
||||
export function createDirectoryLoadRevisions(): DirectoryLoadRevisions {
|
||||
return { generation: 0, revisionsByPath: new Map() }
|
||||
}
|
||||
|
||||
export function resetDirectoryLoadRevisions(revisions: DirectoryLoadRevisions): void {
|
||||
revisions.generation += 1
|
||||
revisions.revisionsByPath.clear()
|
||||
}
|
||||
|
||||
export function beginDirectoryLoad(
|
||||
revisions: DirectoryLoadRevisions,
|
||||
scope: string,
|
||||
relativePath: string
|
||||
): DirectoryLoadToken {
|
||||
const revision = (revisions.revisionsByPath.get(relativePath) ?? 0) + 1
|
||||
revisions.revisionsByPath.set(relativePath, revision)
|
||||
return { generation: revisions.generation, relativePath, revision, scope }
|
||||
}
|
||||
|
||||
export function isCurrentDirectoryLoad(
|
||||
revisions: DirectoryLoadRevisions,
|
||||
currentScope: string,
|
||||
token: DirectoryLoadToken
|
||||
): boolean {
|
||||
return (
|
||||
currentScope === token.scope &&
|
||||
revisions.generation === token.generation &&
|
||||
revisions.revisionsByPath.get(token.relativePath) === token.revision
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { directoryCacheFromFileList, isMobileMethodUnavailableError } from './file-list-fallback'
|
||||
import { getDirectoryCacheState } from './file-tree'
|
||||
|
||||
describe('isMobileMethodUnavailableError', () => {
|
||||
it('detects old-desktop allowlist and missing-method failures', () => {
|
||||
expect(isMobileMethodUnavailableError('forbidden', undefined)).toBe(true)
|
||||
expect(isMobileMethodUnavailableError('method_not_found', undefined)).toBe(true)
|
||||
expect(
|
||||
isMobileMethodUnavailableError(
|
||||
'some_code',
|
||||
"Method 'files.readDir' is not available to mobile clients"
|
||||
)
|
||||
).toBe(true)
|
||||
expect(isMobileMethodUnavailableError('internal', 'boom')).toBe(false)
|
||||
expect(isMobileMethodUnavailableError(undefined, undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('directoryCacheFromFileList', () => {
|
||||
it('synthesizes every ancestor directory from flat paths', () => {
|
||||
const cache = directoryCacheFromFileList([
|
||||
{ relativePath: 'src/lib/util.ts', basename: 'util.ts', kind: 'text' },
|
||||
{ relativePath: 'src/app.ts', basename: 'app.ts', kind: 'text' },
|
||||
{ relativePath: 'README.md', basename: 'README.md', kind: 'text' }
|
||||
])
|
||||
expect(cache['']?.entries).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ name: 'src', isDirectory: true },
|
||||
{ name: 'README.md', isDirectory: false }
|
||||
])
|
||||
)
|
||||
expect(cache['src']?.entries).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ name: 'lib', isDirectory: true },
|
||||
{ name: 'app.ts', isDirectory: false }
|
||||
])
|
||||
)
|
||||
expect(cache['src/lib']?.entries).toEqual([{ name: 'util.ts', isDirectory: false }])
|
||||
})
|
||||
|
||||
it('keeps a name a directory when it appears as both file and dir prefix', () => {
|
||||
const cache = directoryCacheFromFileList([
|
||||
{ relativePath: 'src', basename: 'src', kind: 'text' },
|
||||
{ relativePath: 'src/app.ts', basename: 'app.ts', kind: 'text' }
|
||||
])
|
||||
expect(cache['']?.entries).toEqual([{ name: 'src', isDirectory: true }])
|
||||
})
|
||||
|
||||
it('returns an empty root for an empty list', () => {
|
||||
const cache = directoryCacheFromFileList([])
|
||||
expect(cache['']?.entries).toEqual([])
|
||||
})
|
||||
|
||||
it('stores a __proto__ directory as an own key instead of mutating the prototype', () => {
|
||||
const cache = directoryCacheFromFileList([
|
||||
{ relativePath: '__proto__/pollute.js', basename: 'pollute.js', kind: 'text' }
|
||||
])
|
||||
expect(Object.getPrototypeOf(cache)).toBe(Object.prototype)
|
||||
expect(cache['']?.entries).toEqual([{ name: '__proto__', isDirectory: true }])
|
||||
expect(getDirectoryCacheState(cache, '__proto__')?.entries).toEqual([
|
||||
{ name: 'pollute.js', isDirectory: false }
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
// Fallback for desktops that predate files.readDir in the mobile RPC
|
||||
// allowlist: synthesize the lazy directory cache from the flat, capped
|
||||
// files.list result so the Files tab stays browsable against old desktops.
|
||||
import type { DirectoryCache, MobileDirEntry } from './file-tree'
|
||||
|
||||
export type LegacyMobileFileEntry = {
|
||||
relativePath: string
|
||||
basename: string
|
||||
kind: 'text' | 'binary'
|
||||
}
|
||||
|
||||
export type LegacyFilesListResult = {
|
||||
files: LegacyMobileFileEntry[]
|
||||
totalCount: number
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
// Same detection shape as isMobileGitUnavailable in mobile-git-status.ts:
|
||||
// 'forbidden' = method exists but is not mobile-allowlisted on the old
|
||||
// desktop; 'method_not_found' = desktop predates the method entirely.
|
||||
export function isMobileMethodUnavailableError(
|
||||
code: string | undefined,
|
||||
message: string | undefined
|
||||
): boolean {
|
||||
return (
|
||||
code === 'forbidden' ||
|
||||
code === 'method_not_found' ||
|
||||
message?.includes('not available to mobile clients') === true
|
||||
)
|
||||
}
|
||||
|
||||
export function directoryCacheFromFileList(files: LegacyMobileFileEntry[]): DirectoryCache {
|
||||
const childrenByDir = new Map<string, Map<string, boolean>>()
|
||||
const ensureDir = (path: string): Map<string, boolean> => {
|
||||
let children = childrenByDir.get(path)
|
||||
if (!children) {
|
||||
children = new Map()
|
||||
childrenByDir.set(path, children)
|
||||
}
|
||||
return children
|
||||
}
|
||||
ensureDir('')
|
||||
for (const file of files) {
|
||||
const parts = file.relativePath.split('/').filter(Boolean)
|
||||
let parentPath = ''
|
||||
parts.forEach((name, index) => {
|
||||
const isDirectory = index < parts.length - 1
|
||||
const children = ensureDir(parentPath)
|
||||
children.set(name, children.get(name) === true || isDirectory)
|
||||
parentPath = parentPath ? `${parentPath}/${name}` : name
|
||||
if (isDirectory) {
|
||||
ensureDir(parentPath)
|
||||
}
|
||||
})
|
||||
}
|
||||
// Why: plain `cache[path] = ...` with a '__proto__' path segment mutates the
|
||||
// object's prototype instead of storing the directory; fromEntries always
|
||||
// creates own keys.
|
||||
return Object.fromEntries(
|
||||
Array.from(childrenByDir, ([path, children]) => [
|
||||
path,
|
||||
{
|
||||
entries: Array.from(
|
||||
children,
|
||||
([name, isDirectory]): MobileDirEntry => ({
|
||||
name,
|
||||
isDirectory
|
||||
})
|
||||
)
|
||||
}
|
||||
])
|
||||
)
|
||||
}
|
||||
@@ -1,32 +1,109 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildTree, flattenTree, isMarkdownPath, type MobileFileEntry } from './file-tree'
|
||||
import {
|
||||
flattenDirectoryCache,
|
||||
getMobileFileKind,
|
||||
isMarkdownPath,
|
||||
shouldIncludeMobileFileExplorerEntry,
|
||||
type DirectoryCache,
|
||||
type MobileDirEntry
|
||||
} from './file-tree'
|
||||
|
||||
function entry(relativePath: string, kind: 'text' | 'binary' = 'text'): MobileFileEntry {
|
||||
return { relativePath, basename: relativePath.split('/').pop() ?? relativePath, kind }
|
||||
function entry(name: string, isDirectory = false, isSymlink = false): MobileDirEntry {
|
||||
return { name, isDirectory, isSymlink }
|
||||
}
|
||||
|
||||
describe('file-tree', () => {
|
||||
it('nests files under their directories', () => {
|
||||
const root = buildTree([entry('src/app.ts'), entry('src/lib/util.ts'), entry('readme.md')])
|
||||
expect(root.files.map((f) => f.relativePath)).toEqual(['readme.md'])
|
||||
expect(root.directories.get('src')?.directories.get('lib')?.files[0]?.relativePath).toBe(
|
||||
'src/lib/util.ts'
|
||||
)
|
||||
it('flattens cached directories lazily with directories before files', () => {
|
||||
const cache: DirectoryCache = {
|
||||
'': { entries: [entry('zeta.txt'), entry('src', true), entry('readme.md')] },
|
||||
src: { entries: [entry('app.ts'), entry('lib', true)] },
|
||||
'src/lib': { entries: [entry('util.ts')] }
|
||||
}
|
||||
|
||||
const collapsed = flattenDirectoryCache(cache, new Set())
|
||||
expect(collapsed.map((row) => row.id)).toEqual(['dir:src', 'file:readme.md', 'file:zeta.txt'])
|
||||
|
||||
const expanded = flattenDirectoryCache(cache, new Set(['src', 'src/lib']))
|
||||
expect(expanded.map((row) => row.id)).toEqual([
|
||||
'dir:src',
|
||||
'dir:src/lib',
|
||||
'file:src/lib/util.ts',
|
||||
'file:src/app.ts',
|
||||
'file:readme.md',
|
||||
'file:zeta.txt'
|
||||
])
|
||||
})
|
||||
|
||||
it('flattens with directories before files and only expands open dirs', () => {
|
||||
const root = buildTree([entry('src/app.ts'), entry('zeta.txt')])
|
||||
const collapsed = flattenTree(root, new Set())
|
||||
expect(collapsed.map((r) => r.id)).toEqual(['dir:src', 'file:zeta.txt'])
|
||||
it('mirrors desktop default browse exclusions while keeping dotfiles visible', () => {
|
||||
const cache: DirectoryCache = {
|
||||
'': {
|
||||
entries: [
|
||||
entry('.git', true),
|
||||
entry('.env'),
|
||||
entry('node_modules', true),
|
||||
entry('src', true)
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const expanded = flattenTree(root, new Set(['src']))
|
||||
expect(expanded.map((r) => r.id)).toEqual(['dir:src', 'file:src/app.ts', 'file:zeta.txt'])
|
||||
expect(flattenDirectoryCache(cache, new Set()).map((row) => row.id)).toEqual([
|
||||
'dir:src',
|
||||
'file:.env'
|
||||
])
|
||||
expect(shouldIncludeMobileFileExplorerEntry(entry('.config', true))).toBe(true)
|
||||
})
|
||||
|
||||
it('preserves the binary kind on flattened rows', () => {
|
||||
const root = buildTree([entry('assets/logo.png', 'binary')])
|
||||
const rows = flattenTree(root, new Set(['assets']))
|
||||
expect(rows.find((r) => r.id === 'file:assets/logo.png')?.kind).toBe('binary')
|
||||
it('renders inline loading and error rows under expanded directories', () => {
|
||||
const cache: DirectoryCache = {
|
||||
'': { entries: [entry('loading-dir', true), entry('error-dir', true)] },
|
||||
'loading-dir': { entries: [], loading: true },
|
||||
'error-dir': { entries: [], error: 'permission denied' }
|
||||
}
|
||||
|
||||
const rows = flattenDirectoryCache(cache, new Set(['loading-dir', 'error-dir']))
|
||||
expect(rows.map((row) => row.id)).toEqual([
|
||||
'dir:error-dir',
|
||||
'error:error-dir',
|
||||
'dir:loading-dir',
|
||||
'loading:loading-dir'
|
||||
])
|
||||
expect(rows.find((row) => row.id === 'error:error-dir')).toMatchObject({
|
||||
kind: 'error',
|
||||
message: 'permission denied'
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves symlink metadata for user-initiated activation', () => {
|
||||
const cache: DirectoryCache = {
|
||||
'': { entries: [entry('linked-docs', false, true)] }
|
||||
}
|
||||
|
||||
expect(flattenDirectoryCache(cache, new Set())).toEqual([
|
||||
{
|
||||
id: 'file:linked-docs',
|
||||
name: 'linked-docs',
|
||||
relativePath: 'linked-docs',
|
||||
depth: 0,
|
||||
kind: 'text',
|
||||
isSymlink: true
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('does not treat inherited object keys as loaded directories', () => {
|
||||
const cache: DirectoryCache = {
|
||||
'': { entries: [entry('constructor', true), entry('__proto__', true)] },
|
||||
['__proto__']: { entries: [entry('file.txt')] }
|
||||
}
|
||||
|
||||
expect(
|
||||
flattenDirectoryCache(cache, new Set(['constructor', '__proto__'])).map((row) => row.id)
|
||||
).toEqual(['dir:__proto__', 'file:__proto__/file.txt', 'dir:constructor'])
|
||||
})
|
||||
|
||||
it('classifies binary file paths for existing mobile preview behavior', () => {
|
||||
expect(getMobileFileKind('assets/logo.png')).toBe('binary')
|
||||
expect(getMobileFileKind('src/app.ts')).toBe('text')
|
||||
})
|
||||
|
||||
it('detects markdown paths', () => {
|
||||
|
||||
+130
-69
@@ -1,91 +1,152 @@
|
||||
// Pure tree model for the mobile file explorer: turns the flat files.list
|
||||
// result into a nested directory structure and flattens it into renderable
|
||||
// rows. Kept out of the screen component so the screen stays under its line cap.
|
||||
// Pure tree projection for the mobile file explorer. Mobile mirrors desktop
|
||||
// browse semantics by flattening cached files.readDir results as folders open.
|
||||
|
||||
export type MobileFileEntry = {
|
||||
relativePath: string
|
||||
basename: string
|
||||
kind: 'text' | 'binary'
|
||||
export type MobileDirEntry = {
|
||||
name: string
|
||||
isDirectory: boolean
|
||||
isSymlink?: boolean
|
||||
}
|
||||
|
||||
export type FilesListResult = {
|
||||
files: MobileFileEntry[]
|
||||
totalCount: number
|
||||
truncated: boolean
|
||||
}
|
||||
export type MobileFileKind = 'text' | 'binary'
|
||||
|
||||
export type TreeNode = {
|
||||
id: string
|
||||
name: string
|
||||
relativePath: string
|
||||
depth: number
|
||||
kind: 'directory' | 'text' | 'binary'
|
||||
kind: 'directory' | MobileFileKind
|
||||
isSymlink?: boolean
|
||||
}
|
||||
|
||||
export type DirectoryNode = {
|
||||
name: string
|
||||
export type DirectoryState = {
|
||||
entries: MobileDirEntry[]
|
||||
loading?: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
export type DirectoryCache = Record<string, DirectoryState | undefined>
|
||||
|
||||
export type InlineStatusNode = {
|
||||
id: string
|
||||
relativePath: string
|
||||
directories: Map<string, DirectoryNode>
|
||||
files: MobileFileEntry[]
|
||||
depth: number
|
||||
kind: 'loading' | 'error'
|
||||
message?: string
|
||||
}
|
||||
|
||||
function createDirectoryNode(name: string, relativePath: string): DirectoryNode {
|
||||
return { name, relativePath, directories: new Map(), files: [] }
|
||||
}
|
||||
export type FileExplorerRow = TreeNode | InlineStatusNode
|
||||
|
||||
export function buildTree(files: MobileFileEntry[]): DirectoryNode {
|
||||
const root = createDirectoryNode('', '')
|
||||
for (const file of files) {
|
||||
const parts = file.relativePath.split('/').filter(Boolean)
|
||||
let current = root
|
||||
for (let index = 0; index < parts.length - 1; index += 1) {
|
||||
const name = parts[index]!
|
||||
const relativePath = parts.slice(0, index + 1).join('/')
|
||||
let child = current.directories.get(name)
|
||||
if (!child) {
|
||||
child = createDirectoryNode(name, relativePath)
|
||||
current.directories.set(name, child)
|
||||
}
|
||||
current = child
|
||||
}
|
||||
current.files.push(file)
|
||||
}
|
||||
return root
|
||||
}
|
||||
const DESKTOP_EXCLUDED_NAMES = new Set(['.git', 'node_modules'])
|
||||
const BINARY_EXTENSIONS = new Set([
|
||||
'.avif',
|
||||
'.bmp',
|
||||
'.gif',
|
||||
'.heic',
|
||||
'.ico',
|
||||
'.jpeg',
|
||||
'.jpg',
|
||||
'.mov',
|
||||
'.mp3',
|
||||
'.mp4',
|
||||
'.pdf',
|
||||
'.png',
|
||||
'.webp',
|
||||
'.zip'
|
||||
])
|
||||
|
||||
export function flattenTree(root: DirectoryNode, expanded: ReadonlySet<string>): TreeNode[] {
|
||||
const rows: TreeNode[] = []
|
||||
const visit = (directory: DirectoryNode, depth: number): void => {
|
||||
const dirs = Array.from(directory.directories.values()).sort((a, b) =>
|
||||
a.name.localeCompare(b.name)
|
||||
)
|
||||
for (const child of dirs) {
|
||||
rows.push({
|
||||
id: `dir:${child.relativePath}`,
|
||||
name: child.name,
|
||||
relativePath: child.relativePath,
|
||||
depth,
|
||||
kind: 'directory'
|
||||
})
|
||||
if (expanded.has(child.relativePath)) {
|
||||
visit(child, depth + 1)
|
||||
}
|
||||
}
|
||||
const files = [...directory.files].sort((a, b) => a.basename.localeCompare(b.basename))
|
||||
for (const file of files) {
|
||||
rows.push({
|
||||
id: `file:${file.relativePath}`,
|
||||
name: file.basename,
|
||||
relativePath: file.relativePath,
|
||||
depth,
|
||||
kind: file.kind
|
||||
})
|
||||
}
|
||||
}
|
||||
visit(root, 0)
|
||||
export function flattenDirectoryCache(
|
||||
cache: DirectoryCache,
|
||||
expanded: ReadonlySet<string>
|
||||
): FileExplorerRow[] {
|
||||
const rows: FileExplorerRow[] = []
|
||||
visitDirectory('', 0, cache, expanded, rows)
|
||||
return rows
|
||||
}
|
||||
|
||||
function visitDirectory(
|
||||
relativePath: string,
|
||||
depth: number,
|
||||
cache: DirectoryCache,
|
||||
expanded: ReadonlySet<string>,
|
||||
rows: FileExplorerRow[]
|
||||
): void {
|
||||
const state = getDirectoryCacheState(cache, relativePath)
|
||||
const entries = state?.entries ?? []
|
||||
const visibleEntries = entries
|
||||
.filter(shouldIncludeMobileFileExplorerEntry)
|
||||
.sort(compareDirectoryEntries)
|
||||
|
||||
for (const entry of visibleEntries) {
|
||||
const childPath = joinRelativePath(relativePath, entry.name)
|
||||
rows.push(toTreeNode(entry, childPath, depth))
|
||||
if (entry.isDirectory && expanded.has(childPath)) {
|
||||
const childState = getDirectoryCacheState(cache, childPath)
|
||||
if (childState?.loading) {
|
||||
rows.push({
|
||||
id: `loading:${childPath}`,
|
||||
relativePath: childPath,
|
||||
depth: depth + 1,
|
||||
kind: 'loading'
|
||||
})
|
||||
} else if (childState?.error) {
|
||||
rows.push({
|
||||
id: `error:${childPath}`,
|
||||
relativePath: childPath,
|
||||
depth: depth + 1,
|
||||
kind: 'error',
|
||||
message: childState.error
|
||||
})
|
||||
} else {
|
||||
visitDirectory(childPath, depth + 1, cache, expanded, rows)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toTreeNode(entry: MobileDirEntry, relativePath: string, depth: number): TreeNode {
|
||||
return {
|
||||
id: `${entry.isDirectory ? 'dir' : 'file'}:${relativePath}`,
|
||||
name: entry.name,
|
||||
relativePath,
|
||||
depth,
|
||||
kind: entry.isDirectory ? 'directory' : getMobileFileKind(relativePath),
|
||||
isSymlink: entry.isSymlink
|
||||
}
|
||||
}
|
||||
|
||||
function compareDirectoryEntries(a: MobileDirEntry, b: MobileDirEntry): number {
|
||||
if (a.isDirectory !== b.isDirectory) {
|
||||
return a.isDirectory ? -1 : 1
|
||||
}
|
||||
return a.name.localeCompare(b.name)
|
||||
}
|
||||
|
||||
export function shouldIncludeMobileFileExplorerEntry(entry: MobileDirEntry): boolean {
|
||||
return !DESKTOP_EXCLUDED_NAMES.has(entry.name)
|
||||
}
|
||||
|
||||
export function getDirectoryCacheState(
|
||||
cache: DirectoryCache,
|
||||
relativePath: string
|
||||
): DirectoryState | undefined {
|
||||
// Why: repository paths are arbitrary object keys; inherited keys like
|
||||
// "constructor" must not masquerade as loaded directory state.
|
||||
return Object.prototype.hasOwnProperty.call(cache, relativePath) ? cache[relativePath] : undefined
|
||||
}
|
||||
|
||||
export function joinRelativePath(parentPath: string, name: string): string {
|
||||
return parentPath ? `${parentPath}/${name}` : name
|
||||
}
|
||||
|
||||
export function getMobileFileKind(relativePath: string): MobileFileKind {
|
||||
const basename = relativePath.split('/').pop() ?? relativePath
|
||||
const dotIndex = basename.lastIndexOf('.')
|
||||
if (dotIndex <= 0) {
|
||||
return 'text'
|
||||
}
|
||||
return BINARY_EXTENSIONS.has(basename.slice(dotIndex).toLowerCase()) ? 'binary' : 'text'
|
||||
}
|
||||
|
||||
export function isMarkdownPath(relativePath: string): boolean {
|
||||
return /\.(md|mdx|markdown)$/i.test(relativePath)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { ActivityIndicator, Pressable, Text, View } from 'react-native'
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
File,
|
||||
FileText,
|
||||
Folder,
|
||||
Image as ImageIcon
|
||||
} from 'lucide-react-native'
|
||||
import { triggerSelection } from '../platform/haptics'
|
||||
import { colors, spacing } from '../theme/mobile-theme'
|
||||
import { type FileExplorerRow, isMarkdownPath, type TreeNode } from './file-tree'
|
||||
import { fileExplorerStyles as styles } from './mobile-file-explorer-styles'
|
||||
import { canPreviewMobileFileRow } from './mobile-file-preview-navigation'
|
||||
|
||||
type Props = {
|
||||
item: FileExplorerRow
|
||||
expanded: ReadonlySet<string>
|
||||
onPreviewFile: (relativePath: string, displayName: string) => void
|
||||
onRetryDirectory: (relativePath: string) => void
|
||||
onToggleDirectory: (relativePath: string) => void
|
||||
}
|
||||
|
||||
export function MobileFileExplorerRow(props: Props) {
|
||||
const { item, expanded, onPreviewFile, onRetryDirectory, onToggleDirectory } = props
|
||||
|
||||
if (item.kind === 'loading') {
|
||||
return (
|
||||
<View style={[styles.inlineStatusRow, { paddingLeft: spacing.lg + item.depth * 18 }]}>
|
||||
<View style={styles.chevronSpacer} />
|
||||
<ActivityIndicator size="small" color={colors.textSecondary} />
|
||||
<Text style={styles.inlineStatusText}>Loading...</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
if (item.kind === 'error') {
|
||||
return (
|
||||
<View style={[styles.inlineStatusRow, { paddingLeft: spacing.lg + item.depth * 18 }]}>
|
||||
<View style={styles.chevronSpacer} />
|
||||
<Text style={styles.inlineErrorText} numberOfLines={1}>
|
||||
{item.message || 'Unable to load folder'}
|
||||
</Text>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.inlineRetryButton, pressed && styles.rowPressed]}
|
||||
onPress={() => {
|
||||
triggerSelection()
|
||||
onRetryDirectory(item.relativePath)
|
||||
}}
|
||||
accessibilityLabel={`Retry loading ${item.relativePath}`}
|
||||
>
|
||||
<Text style={styles.inlineRetryText}>Retry</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
if (isTreeNode(item)) {
|
||||
return (
|
||||
<TreeRow
|
||||
item={item}
|
||||
expanded={expanded}
|
||||
onPreviewFile={onPreviewFile}
|
||||
onToggleDirectory={onToggleDirectory}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function isTreeNode(item: FileExplorerRow): item is TreeNode {
|
||||
return item.kind === 'directory' || item.kind === 'text' || item.kind === 'binary'
|
||||
}
|
||||
|
||||
function TreeRow(props: {
|
||||
item: TreeNode
|
||||
expanded: ReadonlySet<string>
|
||||
onPreviewFile: (relativePath: string, displayName: string) => void
|
||||
onToggleDirectory: (relativePath: string) => void
|
||||
}) {
|
||||
const { item, expanded, onPreviewFile, onToggleDirectory } = props
|
||||
const isDirectory = item.kind === 'directory'
|
||||
const isExpanded = expanded.has(item.relativePath)
|
||||
// Images render in the mobile viewer (via files.readPreview), so a binary
|
||||
// image is openable; only non-previewable binaries are unavailable.
|
||||
const previewable =
|
||||
item.kind !== 'directory' &&
|
||||
canPreviewMobileFileRow({ kind: item.kind, relativePath: item.relativePath })
|
||||
const isImage = item.kind === 'binary' && previewable
|
||||
const disabled = item.kind === 'binary' && !previewable
|
||||
const markdown = item.kind === 'text' && isMarkdownPath(item.relativePath)
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.row,
|
||||
{ paddingLeft: spacing.lg + item.depth * 18 },
|
||||
pressed && !disabled && styles.rowPressed,
|
||||
disabled && styles.rowDisabled
|
||||
]}
|
||||
disabled={disabled}
|
||||
onPress={() => {
|
||||
triggerSelection()
|
||||
if (isDirectory) {
|
||||
onToggleDirectory(item.relativePath)
|
||||
} else if (!disabled) {
|
||||
onPreviewFile(item.relativePath, item.name)
|
||||
}
|
||||
}}
|
||||
accessibilityLabel={
|
||||
isDirectory
|
||||
? `Open folder ${item.name}`
|
||||
: disabled
|
||||
? `${item.name} unavailable on mobile`
|
||||
: `Preview file ${item.name}`
|
||||
}
|
||||
>
|
||||
{isDirectory ? (
|
||||
isExpanded ? (
|
||||
<ChevronDown size={16} color={colors.textSecondary} />
|
||||
) : (
|
||||
<ChevronRight size={16} color={colors.textSecondary} />
|
||||
)
|
||||
) : (
|
||||
<View style={styles.chevronSpacer} />
|
||||
)}
|
||||
{isDirectory ? (
|
||||
<Folder size={17} color={colors.textSecondary} />
|
||||
) : markdown ? (
|
||||
<FileText size={17} color={disabled ? colors.textMuted : colors.textSecondary} />
|
||||
) : isImage ? (
|
||||
<ImageIcon size={17} color={colors.textSecondary} />
|
||||
) : (
|
||||
<File size={17} color={disabled ? colors.textMuted : colors.textSecondary} />
|
||||
)}
|
||||
<View style={styles.rowTextBlock}>
|
||||
<Text style={[styles.rowTitle, disabled && styles.rowTitleDisabled]} numberOfLines={1}>
|
||||
{item.name}
|
||||
</Text>
|
||||
{disabled ? <Text style={styles.rowMeta}>Unavailable on mobile</Text> : null}
|
||||
</View>
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
@@ -78,6 +78,37 @@ export const fileExplorerStyles = StyleSheet.create({
|
||||
color: colors.textMuted,
|
||||
fontSize: 11
|
||||
},
|
||||
inlineStatusRow: {
|
||||
minHeight: 36,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingRight: spacing.md
|
||||
},
|
||||
inlineStatusText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.metaSize
|
||||
},
|
||||
inlineErrorText: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
color: colors.statusRed,
|
||||
fontSize: typography.metaSize
|
||||
},
|
||||
inlineRetryButton: {
|
||||
minHeight: 28,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: radii.button,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.borderSubtle,
|
||||
paddingHorizontal: spacing.md
|
||||
},
|
||||
inlineRetryText: {
|
||||
color: colors.textPrimary,
|
||||
fontSize: typography.metaSize,
|
||||
fontWeight: '600'
|
||||
},
|
||||
state: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import * as Notifications from 'expo-notifications'
|
||||
import { subscribeToDesktopNotifications } from './mobile-notifications'
|
||||
import {
|
||||
setScheduledNotificationsMaxForTests,
|
||||
subscribeToDesktopNotifications
|
||||
} from './mobile-notifications'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { loadPushNotificationsEnabled } from '../storage/preferences'
|
||||
|
||||
@@ -268,4 +271,48 @@ describe('subscribeToDesktopNotifications', () => {
|
||||
|
||||
expect(Notifications.dismissNotificationAsync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Why: notificationId is unique per completion, so the map grew unbounded when
|
||||
// the desktop never sent a dismiss (the remote-mobile case). It is now capped.
|
||||
it('evicts the oldest scheduled entry once the cap is exceeded', async () => {
|
||||
setScheduledNotificationsMaxForTests(1)
|
||||
try {
|
||||
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
|
||||
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
|
||||
status: 'granted',
|
||||
canAskAgain: true
|
||||
} as never)
|
||||
vi.mocked(Notifications.scheduleNotificationAsync)
|
||||
.mockResolvedValueOnce('scheduled-old')
|
||||
.mockResolvedValueOnce('scheduled-new')
|
||||
vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined)
|
||||
let onEvent: ((data: unknown) => void) | null = null
|
||||
const client = {
|
||||
subscribe: vi.fn((_method, _params, callback: (data: unknown) => void) => {
|
||||
onEvent = callback
|
||||
return vi.fn()
|
||||
}),
|
||||
getState: vi.fn(() => 'connected'),
|
||||
sendRequest: vi.fn()
|
||||
} as unknown as RpcClient
|
||||
|
||||
subscribeToDesktopNotifications(client, 'host-1')
|
||||
onEvent?.({ type: 'notification', title: 't', body: 'b', notificationId: 'agent:old' })
|
||||
await flushAsync()
|
||||
onEvent?.({ type: 'notification', title: 't', body: 'b', notificationId: 'agent:new' })
|
||||
await flushAsync()
|
||||
|
||||
// The older entry was evicted by the cap: dismissing it is a no-op...
|
||||
onEvent?.({ type: 'dismiss', notificationId: 'agent:old' })
|
||||
await flushAsync()
|
||||
expect(Notifications.dismissNotificationAsync).not.toHaveBeenCalledWith('scheduled-old')
|
||||
|
||||
// ...while the most-recent entry is retained and still dismissable.
|
||||
onEvent?.({ type: 'dismiss', notificationId: 'agent:new' })
|
||||
await flushAsync()
|
||||
expect(Notifications.dismissNotificationAsync).toHaveBeenCalledWith('scheduled-new')
|
||||
} finally {
|
||||
setScheduledNotificationsMaxForTests()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -31,10 +31,42 @@ type ScheduledNotificationState = {
|
||||
|
||||
const scheduledNotificationsByHostAndNotificationId = new Map<string, ScheduledNotificationState>()
|
||||
|
||||
// Why: notificationId embeds a per-completion timestamp (buildAgentNotificationId),
|
||||
// so every agent-task-complete inserts a new, never-reused key. Entries are only
|
||||
// removed when the desktop sends a matching dismiss — which a remote mobile user
|
||||
// (not at the desktop) frequently never gets — so the map grew for the app's whole
|
||||
// life. Bound it; a settled entry only retains a small identifier used for later
|
||||
// programmatic dismissal, unnecessary for long-past completions.
|
||||
const MAX_SCHEDULED_NOTIFICATIONS = 256
|
||||
let maxScheduledNotifications = MAX_SCHEDULED_NOTIFICATIONS
|
||||
|
||||
function getStoredNotificationKey(hostId: string, notificationId: string): string {
|
||||
return `${encodeURIComponent(hostId)}:${encodeURIComponent(notificationId)}`
|
||||
}
|
||||
|
||||
// Evict the oldest SETTLED entries (never one mid-schedule) until within the cap.
|
||||
// Map iteration is insertion order, so the first match is the oldest.
|
||||
function boundScheduledNotifications(): void {
|
||||
while (scheduledNotificationsByHostAndNotificationId.size > maxScheduledNotifications) {
|
||||
let evicted = false
|
||||
for (const [key, state] of scheduledNotificationsByHostAndNotificationId) {
|
||||
if (!state.pending) {
|
||||
scheduledNotificationsByHostAndNotificationId.delete(key)
|
||||
evicted = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!evicted) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Test-only: override the cap (pass no arg to restore the default). */
|
||||
export function setScheduledNotificationsMaxForTests(max?: number): void {
|
||||
maxScheduledNotifications = max ?? MAX_SCHEDULED_NOTIFICATIONS
|
||||
}
|
||||
|
||||
export type NotificationPermissionState = {
|
||||
granted: boolean
|
||||
status: string
|
||||
@@ -155,6 +187,7 @@ async function showLocalNotification(event: NotificationEvent, hostId: string):
|
||||
return
|
||||
}
|
||||
notificationState.identifier = scheduledIdentifier
|
||||
boundScheduledNotifications()
|
||||
} finally {
|
||||
if (notificationState.pending === pending) {
|
||||
notificationState.pending = undefined
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "orca",
|
||||
"version": "1.4.126-rc.0",
|
||||
"version": "1.4.127-rc.0",
|
||||
"description": "Next-gen IDE for parallel agentic development",
|
||||
"homepage": "https://github.com/stablyai/orca",
|
||||
"author": "stablyai",
|
||||
@@ -11,7 +11,7 @@
|
||||
"main": "./out/main/index.js",
|
||||
"scripts": {
|
||||
"format": "oxfmt --write .",
|
||||
"lint": "oxlint && pnpm run lint:switch-exhaustiveness && node config/scripts/check-styled-scrollbars.mjs && pnpm run check:reliability-gates && pnpm run verify:localization-catalog && pnpm run verify:localization-coverage",
|
||||
"lint": "oxlint && pnpm run lint:switch-exhaustiveness && node config/scripts/check-styled-scrollbars.mjs && pnpm run check:reliability-gates && pnpm run check:max-lines-ratchet && pnpm run verify:localization-catalog && pnpm run verify:localization-coverage",
|
||||
"lint:react-doctor": "oxlint --config config/oxlint-react-doctor.json",
|
||||
"lint:react-doctor:changed": "node config/scripts/lint-react-doctor-changed.mjs",
|
||||
"lint:switch-exhaustiveness": "oxlint --type-aware --config config/oxlint-switch-exhaustiveness.json src/main src/preload src/shared src/relay src/cli src/renderer/src config tests --quiet",
|
||||
@@ -19,6 +19,7 @@
|
||||
"test": "node config/scripts/ensure-native-runtime.mjs --runtime=node && vitest run --config config/vitest.config.ts",
|
||||
"check:styled-scrollbars": "node config/scripts/check-styled-scrollbars.mjs",
|
||||
"check:reliability-gates": "node config/scripts/check-reliability-gates.mjs",
|
||||
"check:max-lines-ratchet": "node config/scripts/check-max-lines-ratchet.mjs",
|
||||
"check:feature-wall-assets": "node config/scripts/check-feature-wall-assets.mjs",
|
||||
"verify:macos-entitlements": "node config/scripts/verify-macos-entitlements.mjs",
|
||||
"vendor:feature-wall-assets": "node config/scripts/vendor-feature-wall-assets.mjs",
|
||||
|
||||
@@ -13,7 +13,12 @@ import {
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { AgentHookServer, agentHookServer, _internals } from './server'
|
||||
import {
|
||||
AgentHookServer,
|
||||
agentHookServer,
|
||||
CLOSED_AGENT_STATUS_TAB_IDS_MAX,
|
||||
_internals
|
||||
} from './server'
|
||||
import {
|
||||
AGENT_STATUS_MAX_FIELD_LENGTH,
|
||||
AGENT_STATUS_STALE_AFTER_MS,
|
||||
@@ -6848,3 +6853,23 @@ describe('AgentHookServer ingestTerminalStatus', () => {
|
||||
expect(server.getStatusSnapshot()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('AgentHookServer closed-tab suppression bound', () => {
|
||||
it('bounds closedAgentStatusTabIds with LRU eviction as tabs close', () => {
|
||||
const server = new AgentHookServer()
|
||||
const internals = server as unknown as {
|
||||
markTabClosedForAgentStatus: (tabId: string) => void
|
||||
closedAgentStatusTabIds: Set<string>
|
||||
}
|
||||
|
||||
const total = CLOSED_AGENT_STATUS_TAB_IDS_MAX + 200
|
||||
for (let i = 0; i < total; i += 1) {
|
||||
internals.markTabClosedForAgentStatus(`closed-tab-${i}`)
|
||||
}
|
||||
|
||||
// Set stays bounded; oldest ids are evicted, most-recent are retained.
|
||||
expect(internals.closedAgentStatusTabIds.size).toBe(CLOSED_AGENT_STATUS_TAB_IDS_MAX)
|
||||
expect(internals.closedAgentStatusTabIds.has('closed-tab-0')).toBe(false)
|
||||
expect(internals.closedAgentStatusTabIds.has(`closed-tab-${total - 1}`)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -119,6 +119,12 @@ const AGENT_PROMPT_SENT_AGENT_KINDS = new Set<AgentKind>(AGENT_KIND_VALUES)
|
||||
// not resurrect on hydrate.
|
||||
const HYDRATE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
|
||||
|
||||
// Why: closed-tab suppression only needs to cover recently closed tabs — a
|
||||
// status event for a long-closed tab cannot arrive once its process/hooks are
|
||||
// gone. Bound the set so it can't grow one entry per tab close for the whole
|
||||
// session (it is otherwise only cleared at app quit).
|
||||
export const CLOSED_AGENT_STATUS_TAB_IDS_MAX = 1024
|
||||
|
||||
type LastStatusFile = {
|
||||
version: number
|
||||
entries: Record<string, EnrichedAgentHookEventPayload>
|
||||
@@ -596,7 +602,17 @@ export class AgentHookServer {
|
||||
}
|
||||
|
||||
private markTabClosedForAgentStatus(tabId: string): void {
|
||||
// Delete-then-add keeps recently closed tabs most-recent so eviction only
|
||||
// sheds the oldest ids, which can no longer receive status events.
|
||||
this.closedAgentStatusTabIds.delete(tabId)
|
||||
this.closedAgentStatusTabIds.add(tabId)
|
||||
while (this.closedAgentStatusTabIds.size > CLOSED_AGENT_STATUS_TAB_IDS_MAX) {
|
||||
const oldest = this.closedAgentStatusTabIds.keys().next().value
|
||||
if (oldest === undefined) {
|
||||
break
|
||||
}
|
||||
this.closedAgentStatusTabIds.delete(oldest)
|
||||
}
|
||||
}
|
||||
|
||||
private shouldSuppressClosedTabStatus(paneKey: string): boolean {
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
parseHermesSessionContent
|
||||
} from './session-scanner-secondary-parsers'
|
||||
import type { FileWithMtime } from './session-scanner-types'
|
||||
import { normalizePiSessionsDir } from './session-scanner-values'
|
||||
import { normalizeAgentSessionsDir } from './session-scanner-values'
|
||||
import { remoteCodexIndexTitles } from './remote-session-scanner-codex-index'
|
||||
import type {
|
||||
RemoteParserOptions,
|
||||
@@ -83,6 +83,7 @@ export function remoteSessionSources(
|
||||
parseDevinSessionContent
|
||||
),
|
||||
jsonlSource('pi', remoteHome, hostPlatform, remotePiSessionsSegments(), piParser),
|
||||
jsonlSource('omp', remoteHome, hostPlatform, remoteOmpSessionsSegments(), ompParser),
|
||||
jsonlSource(
|
||||
'droid',
|
||||
remoteHome,
|
||||
@@ -203,6 +204,15 @@ function piParser(
|
||||
return parseMessageGraphSessionContent('pi', file, content, platform, options)
|
||||
}
|
||||
|
||||
function ompParser(
|
||||
file: FileWithMtime,
|
||||
content: string,
|
||||
platform: NodeJS.Platform,
|
||||
options: RemoteParserOptions
|
||||
): Promise<AiVaultSession | null> {
|
||||
return parseMessageGraphSessionContent('omp', file, content, platform, options)
|
||||
}
|
||||
|
||||
function openClawParser(
|
||||
file: FileWithMtime,
|
||||
content: string,
|
||||
@@ -217,5 +227,9 @@ function remotePathSegments(path: string): string[] {
|
||||
}
|
||||
|
||||
function remotePiSessionsSegments(): string[] {
|
||||
return normalizePiSessionsDir('/.pi/agent/sessions').split('/').filter(Boolean)
|
||||
return normalizeAgentSessionsDir('/.pi/agent/sessions', '.pi').split('/').filter(Boolean)
|
||||
}
|
||||
|
||||
function remoteOmpSessionsSegments(): string[] {
|
||||
return normalizeAgentSessionsDir('/.omp/agent/sessions', '.omp').split('/').filter(Boolean)
|
||||
}
|
||||
|
||||
@@ -113,6 +113,7 @@ export function finalizeSession(
|
||||
resumeCommand: buildAiVaultResumeCommand({
|
||||
agent: accumulator.agent,
|
||||
sessionId,
|
||||
resumeFilePath: accumulator.filePath,
|
||||
cwd: accumulator.cwd,
|
||||
platform,
|
||||
codexHome: options.codexHome
|
||||
|
||||
@@ -64,6 +64,8 @@ export async function parseAgentSessionFile(
|
||||
return parseMessageGraphSessionFile('openclaw', candidate.file, platform)
|
||||
case 'pi':
|
||||
return parseMessageGraphSessionFile('pi', candidate.file, platform)
|
||||
case 'omp':
|
||||
return parseMessageGraphSessionFile('omp', candidate.file, platform)
|
||||
case 'droid':
|
||||
return parseDroidSessionFile(candidate.file, platform)
|
||||
case 'devin':
|
||||
|
||||
@@ -117,6 +117,7 @@ describe('scanAiVaultSessions Codex worker sessions', () => {
|
||||
openclawStateDir: join(root, 'openclaw-state'),
|
||||
openclawLegacyStateDir: join(root, 'openclaw-legacy-state'),
|
||||
piSessionsDir: join(root, 'pi-sessions'),
|
||||
ompSessionsDir: join(root, 'omp-sessions'),
|
||||
droidSessionsDir: join(root, 'droid-sessions'),
|
||||
droidProjectsDir: join(root, 'droid-projects'),
|
||||
kimiSessionsDir: join(root, 'kimi-sessions'),
|
||||
|
||||
@@ -163,8 +163,12 @@ export function rovoPartsText(parts: unknown[], role: 'user' | 'assistant'): str
|
||||
return extractContentText(textParts)
|
||||
}
|
||||
|
||||
// Agents whose transcripts are append-only message-graph JSONL (session +
|
||||
// model_change + message records). OMP is a Pi fork and shares the format.
|
||||
export type MessageGraphAgent = 'openclaw' | 'pi' | 'omp'
|
||||
|
||||
export async function parseMessageGraphSessionFile(
|
||||
agent: 'openclaw' | 'pi',
|
||||
agent: MessageGraphAgent,
|
||||
file: FileWithMtime,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): Promise<AiVaultSession | null> {
|
||||
@@ -176,7 +180,7 @@ export async function parseMessageGraphSessionFile(
|
||||
}
|
||||
|
||||
export async function parseMessageGraphSessionContent(
|
||||
agent: 'openclaw' | 'pi',
|
||||
agent: MessageGraphAgent,
|
||||
file: FileWithMtime,
|
||||
content: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
@@ -206,7 +210,10 @@ function consumeMessageGraphRecordLine(accumulator: SessionAccumulator, line: st
|
||||
return
|
||||
}
|
||||
if (record.type === 'model_change') {
|
||||
accumulator.model = extractString(record.modelId) ?? accumulator.model
|
||||
// Pi writes `modelId`; OMP writes `model`. Prefer either so an in-progress
|
||||
// session shows its model before the first assistant reply lands.
|
||||
accumulator.model =
|
||||
extractString(record.modelId) ?? extractString(record.model) ?? accumulator.model
|
||||
return
|
||||
}
|
||||
if (record.type !== 'message') {
|
||||
@@ -227,7 +234,7 @@ function consumeMessageGraphRecordLine(accumulator: SessionAccumulator, line: st
|
||||
}
|
||||
|
||||
export function createMessageGraphSessionResumeState(
|
||||
agent: 'openclaw' | 'pi',
|
||||
agent: MessageGraphAgent,
|
||||
file: FileWithMtime
|
||||
): ResumableSessionParseState {
|
||||
return accumulatorFoldResumeState(
|
||||
@@ -237,7 +244,7 @@ export function createMessageGraphSessionResumeState(
|
||||
}
|
||||
|
||||
async function parseMessageGraphSessionLines(args: {
|
||||
agent: 'openclaw' | 'pi'
|
||||
agent: MessageGraphAgent
|
||||
file: FileWithMtime
|
||||
lines: AsyncIterable<string> | Iterable<string>
|
||||
platform: NodeJS.Platform
|
||||
|
||||
@@ -188,6 +188,56 @@ export function piFixture(): IncrementalAgentFixture {
|
||||
}
|
||||
}
|
||||
|
||||
// OMP is a Pi fork sharing the message-graph format, but keys the model on
|
||||
// `model` (not Pi's `modelId`); its own fixture exercises the registry's 'omp'
|
||||
// branch and that model-key difference. (The session `title` is included for
|
||||
// realism only — the parser derives the title from the first user message.)
|
||||
export function ompFixture(): IncrementalAgentFixture {
|
||||
return {
|
||||
agent: 'omp',
|
||||
fileName: '2026-05-01T10-00-00-000Z_cccccccc-dddd-4eee-8fff-000000000000.jsonl',
|
||||
seedLines: [
|
||||
JSON.stringify({
|
||||
type: 'session',
|
||||
version: 3,
|
||||
id: 'cccccccc-dddd-4eee-8fff-000000000000',
|
||||
cwd: '/repo/app',
|
||||
title: 'OMP session title',
|
||||
timestamp: '2026-05-01T10:00:00.000Z'
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'model_change',
|
||||
model: 'gpt-5.4-mini',
|
||||
timestamp: '2026-05-01T10:00:01.000Z'
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'message',
|
||||
message: { role: 'user', content: [{ type: 'text', text: 'omp seed question' }] },
|
||||
timestamp: '2026-05-01T10:00:05.000Z'
|
||||
})
|
||||
],
|
||||
appendLines: [
|
||||
JSON.stringify({
|
||||
type: 'message',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'omp incremental answer' }],
|
||||
model: 'gpt-5.4-mini',
|
||||
usage: { input: 40, output: 20, totalTokens: 60 }
|
||||
},
|
||||
timestamp: '2026-05-01T10:01:00.000Z'
|
||||
})
|
||||
],
|
||||
truncatedLines: [
|
||||
JSON.stringify({
|
||||
type: 'session',
|
||||
id: 'cccccccc-dddd-4eee-8fff-000000000000',
|
||||
timestamp: '2026-05-01T10:00:00.000Z'
|
||||
})
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
export function geminiJsonlFixture(): IncrementalAgentFixture {
|
||||
return {
|
||||
agent: 'gemini',
|
||||
@@ -235,6 +285,7 @@ export function allIncrementalAgentFixtures(): IncrementalAgentFixture[] {
|
||||
droidFixture(),
|
||||
openclawFixture(),
|
||||
piFixture(),
|
||||
ompFixture(),
|
||||
geminiJsonlFixture()
|
||||
]
|
||||
}
|
||||
|
||||
@@ -36,7 +36,8 @@ function isolatedScanRoots(root: string) {
|
||||
piSessionsDir: join(root, 'pi-sessions'),
|
||||
droidSessionsDir: join(root, 'droid-sessions'),
|
||||
droidProjectsDir: join(root, 'droid-projects'),
|
||||
kimiSessionsDir: join(root, 'kimi-sessions')
|
||||
kimiSessionsDir: join(root, 'kimi-sessions'),
|
||||
ompSessionsDir: join(root, 'omp-sessions')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,7 +59,8 @@ function resumableStateFactoryFor(
|
||||
case 'droid':
|
||||
return () => createDroidSessionResumeState(candidate.file)
|
||||
case 'openclaw':
|
||||
case 'pi': {
|
||||
case 'pi':
|
||||
case 'omp': {
|
||||
const agent = candidate.agent
|
||||
return () => createMessageGraphSessionResumeState(agent, candidate.file)
|
||||
}
|
||||
@@ -67,7 +68,12 @@ function resumableStateFactoryFor(
|
||||
return candidate.file.path.endsWith('.jsonl')
|
||||
? () => createGeminiJsonlSessionResumeState(candidate.file)
|
||||
: null
|
||||
default:
|
||||
case 'devin':
|
||||
case 'grok':
|
||||
case 'hermes':
|
||||
case 'kimi':
|
||||
case 'opencode':
|
||||
case 'rovo':
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -103,7 +109,7 @@ function storeEntry(path: string, entry: SessionParseCacheEntry): void {
|
||||
/**
|
||||
* Parse a session file, reusing prior work where the file is provably
|
||||
* unchanged (mtime+size) and, for append-only JSONL transcripts (Claude,
|
||||
* Codex, Cursor, Copilot, Droid, OpenClaw/Pi, Gemini-JSONL), resuming the
|
||||
* Codex, Cursor, Copilot, Droid, OpenClaw/Pi/OMP, Gemini-JSONL), resuming the
|
||||
* parse from the last consumed byte when the file only grew. This is what
|
||||
* keeps the renderer's ~5s forced rescans from re-reading gigabytes of
|
||||
* transcripts (STA-1278/STA-1417: main process pegging one core during
|
||||
|
||||
@@ -6,7 +6,7 @@ import { discoverFiles, discoverOpenClawFiles } from './session-scanner-discover
|
||||
import { droidDiscoveries, kimiDiscoveries } from './session-scanner-droid-kimi-sources'
|
||||
import { opencodeDiscoveries } from './session-scanner-opencode-sources'
|
||||
import type { AiVaultScanOptions, SessionFileDiscovery } from './session-scanner-types'
|
||||
import { normalizePiSessionsDir } from './session-scanner-values'
|
||||
import { normalizeAgentSessionsDir } from './session-scanner-values'
|
||||
|
||||
const CLAUDE_PROJECTS_DIR = join(homedir(), '.claude', 'projects')
|
||||
export const DEFAULT_CODEX_HOME_DIR = join(homedir(), '.codex')
|
||||
@@ -25,8 +25,13 @@ const GROK_SESSIONS_DIR = join(
|
||||
const HERMES_SESSIONS_DIR = join(homedir(), '.hermes', 'sessions')
|
||||
const ROVO_SESSIONS_DIR = join(homedir(), '.rovodev', 'sessions')
|
||||
const OPENCLAW_STATE_DIR = process.env.OPENCLAW_STATE_DIR?.trim() || join(homedir(), '.openclaw')
|
||||
const PI_SESSIONS_DIR = normalizePiSessionsDir(
|
||||
process.env.PI_CODING_AGENT_DIR?.trim() || join(homedir(), '.pi', 'agent', 'sessions')
|
||||
const PI_SESSIONS_DIR = normalizeAgentSessionsDir(
|
||||
process.env.PI_CODING_AGENT_DIR?.trim() || join(homedir(), '.pi', 'agent', 'sessions'),
|
||||
'.pi'
|
||||
)
|
||||
const OMP_SESSIONS_DIR = normalizeAgentSessionsDir(
|
||||
process.env.OMP_CODING_AGENT_DIR?.trim() || join(homedir(), '.omp', 'agent', 'sessions'),
|
||||
'.omp'
|
||||
)
|
||||
// Why: Devin ATIF transcripts are stored under <DEVIN_HOME>/transcripts.
|
||||
const DEVIN_TRANSCRIPTS_DIR = join(
|
||||
@@ -124,7 +129,8 @@ function standardDiscoveries(
|
||||
...devinDiscoveries(options, wslHomeDirs, limit, issues),
|
||||
...hermesDiscoveries(options, wslHomeDirs, limit, issues),
|
||||
...rovoDiscoveries(options, wslHomeDirs, limit, issues),
|
||||
...piDiscoveries(options, wslHomeDirs, limit, issues)
|
||||
...piDiscoveries(options, wslHomeDirs, limit, issues),
|
||||
...ompDiscoveries(options, wslHomeDirs, limit, issues)
|
||||
]
|
||||
}
|
||||
|
||||
@@ -244,6 +250,21 @@ function piDiscoveries(
|
||||
)
|
||||
}
|
||||
|
||||
function ompDiscoveries(
|
||||
options: AiVaultScanOptions,
|
||||
wslHomeDirs: readonly string[],
|
||||
limit: number,
|
||||
issues: AiVaultScanIssue[]
|
||||
): Promise<SessionFileDiscovery>[] {
|
||||
return sessionRootDirs(options.ompSessionsDir ?? OMP_SESSIONS_DIR, wslHomeDirs, [
|
||||
'.omp',
|
||||
'agent',
|
||||
'sessions'
|
||||
]).map((rootDir) =>
|
||||
discoverFiles({ rootDir, limit, agent: 'omp', issues, extensions: ['.jsonl'] })
|
||||
)
|
||||
}
|
||||
|
||||
function openClawDiscovery(
|
||||
options: AiVaultScanOptions,
|
||||
wslHomeDirs: readonly string[],
|
||||
|
||||
@@ -19,6 +19,7 @@ export function isolatedScanRoots(root: string) {
|
||||
openclawStateDir: join(root, 'openclaw-state'),
|
||||
openclawLegacyStateDir: join(root, 'openclaw-legacy-state'),
|
||||
piSessionsDir: join(root, 'pi-sessions'),
|
||||
ompSessionsDir: join(root, 'omp-sessions'),
|
||||
droidSessionsDir: join(root, 'droid-sessions'),
|
||||
droidProjectsDir: join(root, 'droid-projects'),
|
||||
kimiSessionsDir: join(root, 'kimi-sessions')
|
||||
|
||||
@@ -25,6 +25,7 @@ export type AiVaultScanOptions = {
|
||||
openclawStateDir?: string
|
||||
openclawLegacyStateDir?: string
|
||||
piSessionsDir?: string
|
||||
ompSessionsDir?: string
|
||||
droidSessionsDir?: string
|
||||
droidProjectsDir?: string
|
||||
kimiSessionsDir?: string
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
extractPreviewContentText,
|
||||
normalizeAgentSessionsDir,
|
||||
normalizePreviewText,
|
||||
normalizeTitleText
|
||||
} from './session-scanner-values'
|
||||
@@ -51,4 +52,20 @@ describe('AI Vault session scanner text values', () => {
|
||||
|
||||
expect(result).toBe(`${'a'.repeat(216)}...`)
|
||||
})
|
||||
|
||||
it('expands Pi and OMP agent homes to their session directories', () => {
|
||||
expect(normalizeAgentSessionsDir('/agents/.pi', '.pi')).toBe('/agents/.pi/agent/sessions')
|
||||
expect(normalizeAgentSessionsDir('/agents/.pi/agent', '.pi')).toBe('/agents/.pi/agent/sessions')
|
||||
expect(normalizeAgentSessionsDir('/agents/.pi/agent/sessions', '.pi')).toBe(
|
||||
'/agents/.pi/agent/sessions'
|
||||
)
|
||||
|
||||
expect(normalizeAgentSessionsDir('/agents/.omp', '.omp')).toBe('/agents/.omp/agent/sessions')
|
||||
expect(normalizeAgentSessionsDir('/agents/.omp/agent', '.omp')).toBe(
|
||||
'/agents/.omp/agent/sessions'
|
||||
)
|
||||
expect(normalizeAgentSessionsDir('/agents/.omp/agent/sessions', '.omp')).toBe(
|
||||
'/agents/.omp/agent/sessions'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -126,10 +126,15 @@ export function findOpenCodeStorageRoot(filePath: string): string | null {
|
||||
return dirname(sessionRoot)
|
||||
}
|
||||
|
||||
export function normalizePiSessionsDir(rawValue: string): string {
|
||||
// Pi and OMP (a Pi fork) both store transcripts under
|
||||
// <home>/<agentHomeDirName>/agent/sessions; accept any prefix of that path.
|
||||
export function normalizeAgentSessionsDir(
|
||||
rawValue: string,
|
||||
agentHomeDirName: '.pi' | '.omp'
|
||||
): string {
|
||||
const trimmed = rawValue.trim()
|
||||
if (!trimmed) {
|
||||
return join(homedir(), '.pi', 'agent', 'sessions')
|
||||
return join(homedir(), agentHomeDirName, 'agent', 'sessions')
|
||||
}
|
||||
const normalized = trimmed.replace(/[\\/]+$/, '')
|
||||
const leaf = basename(normalized)
|
||||
@@ -139,7 +144,7 @@ export function normalizePiSessionsDir(rawValue: string): string {
|
||||
if (leaf === 'agent') {
|
||||
return join(normalized, 'sessions')
|
||||
}
|
||||
if (leaf === '.pi') {
|
||||
if (leaf === agentHomeDirName) {
|
||||
return join(normalized, 'agent', 'sessions')
|
||||
}
|
||||
return normalized
|
||||
|
||||
@@ -2,7 +2,7 @@ import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AI_VAULT_AGENTS, buildAiVaultResumeCommand } from '../../shared/ai-vault-types'
|
||||
import { AI_VAULT_AGENTS } from '../../shared/ai-vault-types'
|
||||
import { scanAiVaultSessions } from './session-scanner'
|
||||
import { isolatedScanRoots, jsonLines } from './session-scanner-test-fixtures'
|
||||
|
||||
@@ -584,6 +584,44 @@ describe('scanAiVaultSessions', () => {
|
||||
])
|
||||
)
|
||||
|
||||
const ompSessionFile = join(roots.ompSessionsDir, 'omp-session.jsonl')
|
||||
await mkdir(roots.ompSessionsDir, { recursive: true })
|
||||
await writeFile(
|
||||
ompSessionFile,
|
||||
jsonLines([
|
||||
{
|
||||
type: 'session',
|
||||
version: 3,
|
||||
id: 'omp-session',
|
||||
title: 'OMP session title',
|
||||
timestamp: '2026-05-01T10:08:30.000Z',
|
||||
cwd: '/tmp/omp'
|
||||
},
|
||||
{
|
||||
type: 'model_change',
|
||||
model: 'gpt-5.4-mini',
|
||||
timestamp: '2026-05-01T10:08:30.500Z'
|
||||
},
|
||||
{
|
||||
type: 'message',
|
||||
timestamp: '2026-05-01T10:08:31.000Z',
|
||||
message: { role: 'user', content: [{ type: 'text', text: 'OMP title' }] }
|
||||
},
|
||||
{
|
||||
type: 'message',
|
||||
timestamp: '2026-05-01T10:08:32.000Z',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'OMP answer' }],
|
||||
model: 'gpt-5.4-mini',
|
||||
// totalTokens deliberately != input+output so the assertion proves
|
||||
// the explicit-total field is read, not an input/output sum.
|
||||
usage: { input: 10, output: 5, totalTokens: 160 }
|
||||
}
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
await mkdir(roots.devinTranscriptsDir, { recursive: true })
|
||||
await writeFile(
|
||||
join(roots.devinTranscriptsDir, 'devin-session.json'),
|
||||
@@ -719,11 +757,48 @@ describe('scanAiVaultSessions', () => {
|
||||
"cd '/tmp/openclaw' && openclaw --resume 'openclaw-session'"
|
||||
)
|
||||
expect(commandByAgent.get('pi')).toBe("cd '/tmp/pi' && pi --session 'pi-session'")
|
||||
// OMP resumes by absolute transcript path, not by internal session id.
|
||||
expect(commandByAgent.get('omp')).toBe(`cd '/tmp/omp' && omp --resume '${ompSessionFile}'`)
|
||||
expect(commandByAgent.get('devin')).toBe("cd '/tmp/devin' && devin --resume 'devin-session'")
|
||||
expect(commandByAgent.get('droid')).toBe("cd '/tmp/droid' && droid --resume 'droid-session'")
|
||||
expect(commandByAgent.get('kimi')).toBe(
|
||||
"cd '/tmp/kimi' && kimi --session 'session_kimi-session'"
|
||||
)
|
||||
|
||||
const ompSession = result.sessions.find((session) => session.agent === 'omp')
|
||||
expect(ompSession?.model).toBe('gpt-5.4-mini')
|
||||
expect(ompSession?.totalTokens).toBe(160)
|
||||
})
|
||||
|
||||
it('captures an in-progress OMP model from model_change before any assistant reply', async () => {
|
||||
// OMP writes the model on `model_change.model` (not Pi's `modelId`). With no
|
||||
// assistant message yet, the model must still come through — proving the
|
||||
// model_change fallback rather than assistant-message capture.
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-omp-mc-'))
|
||||
tempRoots.push(root)
|
||||
const roots = isolatedScanRoots(root)
|
||||
await mkdir(roots.ompSessionsDir, { recursive: true })
|
||||
await writeFile(
|
||||
join(roots.ompSessionsDir, 'omp-in-progress.jsonl'),
|
||||
jsonLines([
|
||||
{
|
||||
type: 'session',
|
||||
id: 'omp-in-progress',
|
||||
timestamp: '2026-05-01T10:00:00.000Z',
|
||||
cwd: '/tmp/omp'
|
||||
},
|
||||
{ type: 'model_change', model: 'omp-mc-only-model', timestamp: '2026-05-01T10:00:01.000Z' },
|
||||
{
|
||||
type: 'message',
|
||||
timestamp: '2026-05-01T10:00:02.000Z',
|
||||
message: { role: 'user', content: [{ type: 'text', text: 'first prompt' }] }
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
const result = await scanAiVaultSessions({ ...roots, platform: 'darwin', limit: 5 })
|
||||
const session = result.sessions.find((s) => s.agent === 'omp')
|
||||
expect(session?.model).toBe('omp-mc-only-model')
|
||||
})
|
||||
|
||||
it('strips newline-heavy Grok user_query envelopes without regex matching', async () => {
|
||||
@@ -769,42 +844,3 @@ describe('scanAiVaultSessions', () => {
|
||||
expect(usedGrokWrapperMatch).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildAiVaultResumeCommand', () => {
|
||||
it('wraps Windows cwd changes in cmd so PowerShell and cmd launch the same resume command', () => {
|
||||
expect(
|
||||
buildAiVaultResumeCommand({
|
||||
agent: 'codex',
|
||||
sessionId: 'session-1',
|
||||
cwd: 'C:\\Users\\Ada Lovelace\\repo',
|
||||
platform: 'win32'
|
||||
})
|
||||
).toBe('cmd /d /s /c "cd /d ""C:\\Users\\Ada Lovelace\\repo"" && codex resume ""session-1"""')
|
||||
})
|
||||
|
||||
it('carries non-default Codex homes in copied resume commands', () => {
|
||||
expect(
|
||||
buildAiVaultResumeCommand({
|
||||
agent: 'codex',
|
||||
sessionId: 'session-1',
|
||||
cwd: '/repo/app',
|
||||
platform: 'darwin',
|
||||
codexHome: '/Users/ada/Library/Application Support/Orca/codex-runtime-home/home'
|
||||
})
|
||||
).toBe(
|
||||
"cd '/repo/app' && CODEX_HOME='/Users/ada/Library/Application Support/Orca/codex-runtime-home/home' codex resume 'session-1'"
|
||||
)
|
||||
|
||||
expect(
|
||||
buildAiVaultResumeCommand({
|
||||
agent: 'codex',
|
||||
sessionId: 'session-1',
|
||||
cwd: 'C:\\Users\\Ada Lovelace\\repo',
|
||||
platform: 'win32',
|
||||
codexHome: 'C:\\Users\\Ada\\AppData\\Roaming\\Orca\\codex-runtime-home\\home'
|
||||
})
|
||||
).toBe(
|
||||
'cmd /d /s /c "cd /d ""C:\\Users\\Ada Lovelace\\repo"" && set ""CODEX_HOME=C:\\Users\\Ada\\AppData\\Roaming\\Orca\\codex-runtime-home\\home"" && codex resume ""session-1"""'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -533,6 +533,56 @@ describe('CodexRuntimeHomeService', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('bridges WSL history from a configured per-distro source-home override', async () => {
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
const startWslCodexSessionBridgeInBackground = vi.fn(() => Promise.resolve())
|
||||
vi.doMock('../codex/wsl-codex-session-bridge', () => ({
|
||||
startWslCodexSessionBridgeInBackground
|
||||
}))
|
||||
const wslHome = join(testState.userDataDir, 'wsl-home')
|
||||
vi.doMock('../wsl', () => ({
|
||||
getDefaultWslDistro: () => 'Ubuntu',
|
||||
getWslHome: () => wslHome
|
||||
}))
|
||||
const store = createStore(
|
||||
createSettings({
|
||||
activeCodexManagedAccountId: null,
|
||||
activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: null } },
|
||||
// Why: the override is a Linux path inside the distro, not <wslHome>/.codex.
|
||||
codexSessionSourceHome: { wsl: { Ubuntu: '/home/me/.config/codex' } }
|
||||
})
|
||||
)
|
||||
|
||||
try {
|
||||
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
|
||||
const service = new CodexRuntimeHomeService(store as never)
|
||||
const wslRuntimeHomePath = join(
|
||||
wslHome,
|
||||
'.local',
|
||||
'share',
|
||||
'orca',
|
||||
'codex-runtime-home',
|
||||
'home'
|
||||
)
|
||||
|
||||
service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })
|
||||
|
||||
expect(startWslCodexSessionBridgeInBackground).toHaveBeenCalledTimes(1)
|
||||
expect(startWslCodexSessionBridgeInBackground).toHaveBeenCalledWith({
|
||||
distro: 'Ubuntu',
|
||||
systemCodexHomePath: '/home/me/.config/codex',
|
||||
managedCodexHomePath: wslRuntimeHomePath
|
||||
})
|
||||
} finally {
|
||||
vi.doUnmock('../codex/wsl-codex-session-bridge')
|
||||
vi.doUnmock('../wsl')
|
||||
if (originalPlatform) {
|
||||
Object.defineProperty(process, 'platform', originalPlatform)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('starts WSL session bridging for the distro used by the materialized runtime home', async () => {
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
|
||||
@@ -40,6 +40,10 @@ import {
|
||||
syncSystemCodexResourcesIntoManagedHome
|
||||
} from '../codex/codex-home-paths'
|
||||
import { startSystemCodexSessionBridgeInBackground } from '../codex/codex-session-bridge'
|
||||
import {
|
||||
resolveHostCodexSessionSourceHome,
|
||||
resolveWslCodexSessionSourceHome
|
||||
} from '../codex/codex-session-source-home'
|
||||
import { startWslCodexSessionBridgeInBackground } from '../codex/wsl-codex-session-bridge'
|
||||
import {
|
||||
prepareSystemConfigForFreshRuntimeMirror,
|
||||
@@ -146,7 +150,10 @@ export class CodexRuntimeHomeService {
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
// Why: historical Codex sessions can be large; bridge them after launch
|
||||
// setup so starting a fresh Codex TUI never waits on a full tree walk.
|
||||
void startSystemCodexSessionBridgeInBackground()
|
||||
void startSystemCodexSessionBridgeInBackground(
|
||||
{},
|
||||
resolveHostCodexSessionSourceHome(this.store.getSettings())
|
||||
)
|
||||
return this.getRuntimeHomePath()
|
||||
}
|
||||
|
||||
@@ -162,10 +169,11 @@ export class CodexRuntimeHomeService {
|
||||
if (!distro) {
|
||||
return
|
||||
}
|
||||
const systemCodexHomePath = this.getWslSystemCodexHomePath({
|
||||
runtime: 'wsl',
|
||||
wslDistro: distro
|
||||
})
|
||||
// Why: history-only override lets custom-CODEX_HOME users bridge from their
|
||||
// real home; falls back to <wslHome>/.codex, which auth/config still use.
|
||||
const systemCodexHomePath =
|
||||
resolveWslCodexSessionSourceHome(this.store.getSettings(), distro) ??
|
||||
this.getWslSystemCodexHomePath({ runtime: 'wsl', wslDistro: distro })
|
||||
if (!systemCodexHomePath || systemCodexHomePath === runtimeHomePath) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -215,6 +215,51 @@ describe('syncSystemCodexSessionsIntoManagedHome', () => {
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('bridges from a custom source home override instead of ~/.codex', () => {
|
||||
// Why: users with a custom CODEX_HOME point history discovery at that
|
||||
// folder; the default ~/.codex must be ignored when an override is given.
|
||||
const customSourceHome = join(fakeHomeDir, 'custom-codex')
|
||||
const customSessionPath = join(
|
||||
customSourceHome,
|
||||
'sessions',
|
||||
'2026',
|
||||
'05',
|
||||
'26',
|
||||
'rollout-custom.jsonl'
|
||||
)
|
||||
mkdirSync(dirname(customSessionPath), { recursive: true })
|
||||
writeFileSync(customSessionPath, '{"id":"custom"}\n', 'utf-8')
|
||||
|
||||
// A session under the default ~/.codex should NOT be bridged when overridden.
|
||||
const defaultSessionPath = join(
|
||||
getSystemCodexHomePath(),
|
||||
'sessions',
|
||||
'2026',
|
||||
'05',
|
||||
'26',
|
||||
'rollout-default.jsonl'
|
||||
)
|
||||
mkdirSync(dirname(defaultSessionPath), { recursive: true })
|
||||
writeFileSync(defaultSessionPath, '{"id":"default"}\n', 'utf-8')
|
||||
|
||||
syncSystemCodexSessionsIntoManagedHome(customSourceHome)
|
||||
|
||||
const bridgedCustomPath = join(
|
||||
getRuntimeCodexHomePath(),
|
||||
'sessions',
|
||||
'2026',
|
||||
'05',
|
||||
'26',
|
||||
'rollout-custom.jsonl'
|
||||
)
|
||||
expect(readFileSync(bridgedCustomPath, 'utf-8')).toBe('{"id":"custom"}\n')
|
||||
expect(
|
||||
existsSync(
|
||||
join(getRuntimeCodexHomePath(), 'sessions', '2026', '05', '26', 'rollout-default.jsonl')
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('falls back to symlinks when hardlinks are unavailable', () => {
|
||||
fsMockState.failLink = true
|
||||
const systemSessionPath = join(
|
||||
|
||||
@@ -42,9 +42,12 @@ let backgroundSessionBridgeTask: Promise<void> | null = null
|
||||
|
||||
/**
|
||||
* Synchronously mirrors system session files into the managed runtime home.
|
||||
*
|
||||
* `sourceCodexHomePath` overrides the default ~/.codex history source for users
|
||||
* who run Codex with a custom CODEX_HOME; it only affects history discovery.
|
||||
*/
|
||||
export function syncSystemCodexSessionsIntoManagedHome(): void {
|
||||
const systemSessionsRoot = join(getSystemCodexHomePath(), 'sessions')
|
||||
export function syncSystemCodexSessionsIntoManagedHome(sourceCodexHomePath?: string): void {
|
||||
const systemSessionsRoot = join(sourceCodexHomePath || getSystemCodexHomePath(), 'sessions')
|
||||
if (!existsSync(systemSessionsRoot)) {
|
||||
return
|
||||
}
|
||||
@@ -62,12 +65,13 @@ export function syncSystemCodexSessionsIntoManagedHome(): void {
|
||||
* background bridging without starting duplicate directory walks.
|
||||
*/
|
||||
export function startSystemCodexSessionBridgeInBackground(
|
||||
options: CodexSessionBridgeIncrementalOptions = {}
|
||||
options: CodexSessionBridgeIncrementalOptions = {},
|
||||
sourceCodexHomePath?: string
|
||||
): Promise<void> {
|
||||
if (backgroundSessionBridgeTask) {
|
||||
return backgroundSessionBridgeTask
|
||||
}
|
||||
const task = syncSystemCodexSessionsIntoManagedHomeIncrementally(options)
|
||||
const task = syncSystemCodexSessionsIntoManagedHomeIncrementally(options, sourceCodexHomePath)
|
||||
.catch((error: unknown) => {
|
||||
console.warn('[codex-session-bridge] Background session bridge failed:', error)
|
||||
})
|
||||
@@ -88,9 +92,10 @@ export function startSystemCodexSessionBridgeInBackground(
|
||||
* bridge operation equivalent to the synchronous path.
|
||||
*/
|
||||
export async function syncSystemCodexSessionsIntoManagedHomeIncrementally(
|
||||
options: CodexSessionBridgeIncrementalOptions = {}
|
||||
options: CodexSessionBridgeIncrementalOptions = {},
|
||||
sourceCodexHomePath?: string
|
||||
): Promise<CodexSessionBridgeSummary> {
|
||||
const systemSessionsRoot = join(getSystemCodexHomePath(), 'sessions')
|
||||
const systemSessionsRoot = join(sourceCodexHomePath || getSystemCodexHomePath(), 'sessions')
|
||||
if (!existsSync(systemSessionsRoot)) {
|
||||
return { scannedFiles: 0, linkedFiles: 0 }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
resolveHostCodexSessionSourceHome,
|
||||
resolveWslCodexSessionSourceHome
|
||||
} from './codex-session-source-home'
|
||||
|
||||
describe('resolveHostCodexSessionSourceHome', () => {
|
||||
it('returns undefined when no override is configured', () => {
|
||||
expect(resolveHostCodexSessionSourceHome({})).toBeUndefined()
|
||||
expect(resolveHostCodexSessionSourceHome({ codexSessionSourceHome: {} })).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined for blank/whitespace overrides so the default is kept', () => {
|
||||
expect(
|
||||
resolveHostCodexSessionSourceHome({ codexSessionSourceHome: { host: ' ' } })
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns the trimmed host override path', () => {
|
||||
expect(
|
||||
resolveHostCodexSessionSourceHome({ codexSessionSourceHome: { host: ' /custom/codex ' } })
|
||||
).toBe('/custom/codex')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveWslCodexSessionSourceHome', () => {
|
||||
it('returns undefined when no per-distro override exists', () => {
|
||||
expect(resolveWslCodexSessionSourceHome({}, 'Ubuntu')).toBeUndefined()
|
||||
expect(
|
||||
resolveWslCodexSessionSourceHome(
|
||||
{ codexSessionSourceHome: { wsl: { Debian: '/home/me/.codex' } } },
|
||||
'Ubuntu'
|
||||
)
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('resolves a per-distro override for the matching distro', () => {
|
||||
expect(
|
||||
resolveWslCodexSessionSourceHome(
|
||||
{ codexSessionSourceHome: { wsl: { Ubuntu: '/home/me/.config/codex' } } },
|
||||
'Ubuntu'
|
||||
)
|
||||
).toBe('/home/me/.config/codex')
|
||||
})
|
||||
|
||||
it('matches distro names case-insensitively, mirroring WSL', () => {
|
||||
expect(
|
||||
resolveWslCodexSessionSourceHome(
|
||||
{ codexSessionSourceHome: { wsl: { Ubuntu: '/home/me/.config/codex' } } },
|
||||
'ubuntu'
|
||||
)
|
||||
).toBe('/home/me/.config/codex')
|
||||
})
|
||||
|
||||
it('ignores blank per-distro overrides so the default is kept', () => {
|
||||
expect(
|
||||
resolveWslCodexSessionSourceHome(
|
||||
{ codexSessionSourceHome: { wsl: { Ubuntu: ' ' } } },
|
||||
'Ubuntu'
|
||||
)
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not leak the host override into WSL resolution', () => {
|
||||
expect(
|
||||
resolveWslCodexSessionSourceHome(
|
||||
{ codexSessionSourceHome: { host: '/custom/codex' } },
|
||||
'Ubuntu'
|
||||
)
|
||||
).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { GlobalSettings } from '../../shared/types'
|
||||
|
||||
/**
|
||||
* Resolves the user-configured Codex *session history* source home, if any.
|
||||
*
|
||||
* Why: Orca relocates CODEX_HOME to a managed home, then bridges history from
|
||||
* the user's real Codex home so /resume finds it. That source defaults to
|
||||
* ~/.codex, but users who run Codex with a custom CODEX_HOME need to point
|
||||
* history discovery at that folder. This override affects history only; auth,
|
||||
* config, and hooks continue to read from ~/.codex.
|
||||
*/
|
||||
|
||||
/** Host override; returns undefined to keep the default ~/.codex source. */
|
||||
export function resolveHostCodexSessionSourceHome(
|
||||
settings: Pick<GlobalSettings, 'codexSessionSourceHome'>
|
||||
): string | undefined {
|
||||
return normalizeSourceHome(settings.codexSessionSourceHome?.host)
|
||||
}
|
||||
|
||||
/** Per-distro WSL override; returns undefined to keep the default <wslHome>/.codex source. */
|
||||
export function resolveWslCodexSessionSourceHome(
|
||||
settings: Pick<GlobalSettings, 'codexSessionSourceHome'>,
|
||||
distro: string
|
||||
): string | undefined {
|
||||
const perDistro = settings.codexSessionSourceHome?.wsl
|
||||
if (!perDistro) {
|
||||
return undefined
|
||||
}
|
||||
// Why: distro keys are matched case-insensitively so "Ubuntu" and "ubuntu"
|
||||
// resolve the same override, mirroring how WSL treats distro names.
|
||||
const normalizedDistro = distro.trim().toLowerCase()
|
||||
for (const [key, value] of Object.entries(perDistro)) {
|
||||
if (key.trim().toLowerCase() === normalizedDistro) {
|
||||
return normalizeSourceHome(value)
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function normalizeSourceHome(value: string | undefined): string | undefined {
|
||||
const trimmed = value?.trim()
|
||||
return trimmed ? trimmed : undefined
|
||||
}
|
||||
@@ -122,7 +122,7 @@ import {
|
||||
_resetMergeQueueCacheForTests,
|
||||
__resetTrackedUpstreamBranchCacheForTests
|
||||
} from './client'
|
||||
import { __resetPRConflictSummaryGitCapabilityCacheForTests } from './conflict-summary'
|
||||
import { __resetPRConflictSummaryCachesForTests } from './conflict-summary'
|
||||
import { resetMergedPRCommitMembershipCacheForTest } from './merged-pr-commit-membership'
|
||||
|
||||
describe('checkOrcaStarred', () => {
|
||||
@@ -194,7 +194,7 @@ describe('getPRForBranch', () => {
|
||||
_resetOwnerRepoCache()
|
||||
_resetMergeQueueCacheForTests()
|
||||
__resetTrackedUpstreamBranchCacheForTests()
|
||||
__resetPRConflictSummaryGitCapabilityCacheForTests()
|
||||
__resetPRConflictSummaryCachesForTests()
|
||||
resetMergedPRCommitMembershipCacheForTest()
|
||||
})
|
||||
|
||||
@@ -2987,11 +2987,16 @@ describe('getPRForBranch', () => {
|
||||
baseRefOid: 'base-oid',
|
||||
headRefOid: 'head-oid'
|
||||
}
|
||||
// Why a second head OID: identical inputs now hit the summary result
|
||||
// cache outright; a pushed head re-derives and must still skip the
|
||||
// unsupported --merge-base retry via the capability cache.
|
||||
const pushedBranchLookup = { ...branchLookup, head: { ref: 'feature/test', sha: 'head-oid-2' } }
|
||||
const pushedExactLookup = { ...exactLookup, headRefOid: 'head-oid-2' }
|
||||
ghExecFileAsyncMock
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify([branchLookup]) })
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify(exactLookup) })
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify([branchLookup]) })
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify(exactLookup) })
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify([pushedBranchLookup]) })
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify(pushedExactLookup) })
|
||||
gitExecFileAsyncMock
|
||||
.mockResolvedValueOnce({ stdout: '' })
|
||||
.mockResolvedValueOnce({ stdout: 'latest-base-oid\n' })
|
||||
@@ -2999,8 +3004,6 @@ describe('getPRForBranch', () => {
|
||||
.mockResolvedValueOnce({ stdout: '2\n' })
|
||||
.mockRejectedValueOnce({ stderr: "error: unknown option `merge-base'" })
|
||||
.mockRejectedValueOnce({ stdout: 'result-tree-oid\u0000src/conflict.ts\u0000' })
|
||||
.mockResolvedValueOnce({ stdout: '' })
|
||||
.mockResolvedValueOnce({ stdout: 'latest-base-oid\n' })
|
||||
.mockResolvedValueOnce({ stdout: 'merge-base-oid\n' })
|
||||
.mockResolvedValueOnce({ stdout: '2\n' })
|
||||
.mockRejectedValueOnce({ stdout: 'result-tree-oid\u0000src/conflict.ts\u0000' })
|
||||
@@ -3567,6 +3570,7 @@ describe('GitHub GraphQL rate-limit guard', () => {
|
||||
acquireMock.mockResolvedValue(undefined)
|
||||
_resetOwnerRepoCache()
|
||||
_resetMergeQueueCacheForTests()
|
||||
__resetPRConflictSummaryCachesForTests()
|
||||
})
|
||||
|
||||
it('skips PR review-thread GraphQL fetch while preserving REST comments', async () => {
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import type { PRConflictSummary } from '../../shared/types'
|
||||
|
||||
// Why 60s: the hottest coordinator cadences that re-derive a CONFLICTING PR
|
||||
// (10s mergeability-pending, 2.5s manual-pending) previously each ran a
|
||||
// network fetch; one fetch per base branch per minute matches the 60s minimum
|
||||
// background refresh cadence, so the tracked base tip is never staler than the
|
||||
// PR data around it. Manual refresh intentionally shares the window: GitHub's
|
||||
// own mergeability recompute is async too, and the card self-corrects within
|
||||
// a minute.
|
||||
export const CONFLICT_SUMMARY_BASE_FETCH_WINDOW_MS = 60_000
|
||||
|
||||
// Why bounded: the main process is long-lived; cap the maps so repos and PRs
|
||||
// that stop refreshing can't accumulate entries forever.
|
||||
const BASE_OID_CACHE_MAX = 64
|
||||
const SUMMARY_CACHE_MAX = 128
|
||||
|
||||
export type FreshBaseTipResolution =
|
||||
| { kind: 'resolved'; oid: string }
|
||||
| { kind: 'fallback-unresolved' }
|
||||
|
||||
type CachedBaseTipResolution = {
|
||||
oid: string | null
|
||||
resolvedAt: number
|
||||
}
|
||||
|
||||
type CachedSummary = {
|
||||
value: PRConflictSummary | undefined
|
||||
// Why: a successful summary is a pure function of two immutable commit OIDs
|
||||
// and never goes stale; a failed derivation depends on which objects exist
|
||||
// locally, which the next fetch window can change, so it carries an expiry.
|
||||
staleAt: number | null
|
||||
}
|
||||
|
||||
const baseOidCache = new Map<string, CachedBaseTipResolution>()
|
||||
const summaryCache = new Map<string, CachedSummary>()
|
||||
const inFlightBaseOidResolves = new Map<string, Promise<FreshBaseTipResolution>>()
|
||||
const inFlightSummaryDerivations = new Map<string, Promise<PRConflictSummary | undefined>>()
|
||||
|
||||
// Why: WSL distros have their own git binary, filesystem view, and remote
|
||||
// access, so cached state must never leak across the host/distro boundary.
|
||||
export function getConflictSummaryGitRuntimeKey(wslDistro: string | undefined): string {
|
||||
return wslDistro ? `wsl:${wslDistro}` : 'local:host'
|
||||
}
|
||||
|
||||
// Why JSON: repo paths and git ref names may contain any printable joiner
|
||||
// character (git allows `|` in branch names), so a delimiter-joined key could
|
||||
// alias distinct identities onto one cache entry.
|
||||
export function buildConflictSummaryCacheKey(...parts: string[]): string {
|
||||
return JSON.stringify(parts)
|
||||
}
|
||||
|
||||
export function readFreshBaseTipResolution(baseKey: string): FreshBaseTipResolution | null {
|
||||
const entry = baseOidCache.get(baseKey)
|
||||
if (!entry) {
|
||||
return null
|
||||
}
|
||||
if (Date.now() - entry.resolvedAt >= CONFLICT_SUMMARY_BASE_FETCH_WINDOW_MS) {
|
||||
baseOidCache.delete(baseKey)
|
||||
return null
|
||||
}
|
||||
return entry.oid ? { kind: 'resolved', oid: entry.oid } : { kind: 'fallback-unresolved' }
|
||||
}
|
||||
|
||||
export function storeResolvedBaseTip(baseKey: string, oid: string): void {
|
||||
setBoundedMapEntry(baseOidCache, baseKey, { oid, resolvedAt: Date.now() }, BASE_OID_CACHE_MAX)
|
||||
}
|
||||
|
||||
export function rememberUnresolvedBaseTip(baseKey: string): void {
|
||||
setBoundedMapEntry(
|
||||
baseOidCache,
|
||||
baseKey,
|
||||
{ oid: null, resolvedAt: Date.now() },
|
||||
BASE_OID_CACHE_MAX
|
||||
)
|
||||
}
|
||||
|
||||
export function readCachedSummary(summaryKey: string): CachedSummary | null {
|
||||
const entry = summaryCache.get(summaryKey)
|
||||
if (!entry) {
|
||||
return null
|
||||
}
|
||||
if (entry.staleAt !== null && Date.now() >= entry.staleAt) {
|
||||
summaryCache.delete(summaryKey)
|
||||
return null
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
export function storeCachedSummary(summaryKey: string, value: PRConflictSummary | undefined): void {
|
||||
setBoundedMapEntry(
|
||||
summaryCache,
|
||||
summaryKey,
|
||||
{
|
||||
value,
|
||||
staleAt: value === undefined ? Date.now() + CONFLICT_SUMMARY_BASE_FETCH_WINDOW_MS : null
|
||||
},
|
||||
SUMMARY_CACHE_MAX
|
||||
)
|
||||
}
|
||||
|
||||
export function dedupeBaseOidResolve(
|
||||
key: string,
|
||||
factory: () => Promise<FreshBaseTipResolution>
|
||||
): Promise<FreshBaseTipResolution> {
|
||||
return dedupeInFlight(inFlightBaseOidResolves, key, factory)
|
||||
}
|
||||
|
||||
export function dedupeSummaryDerivation(
|
||||
key: string,
|
||||
factory: () => Promise<PRConflictSummary | undefined>
|
||||
): Promise<PRConflictSummary | undefined> {
|
||||
return dedupeInFlight(inFlightSummaryDerivations, key, factory)
|
||||
}
|
||||
|
||||
function dedupeInFlight<T>(
|
||||
map: Map<string, Promise<T>>,
|
||||
key: string,
|
||||
factory: () => Promise<T>
|
||||
): Promise<T> {
|
||||
const existing = map.get(key)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
const promise = factory().finally(() => {
|
||||
map.delete(key)
|
||||
})
|
||||
map.set(key, promise)
|
||||
return promise
|
||||
}
|
||||
|
||||
function setBoundedMapEntry<K, V>(map: Map<K, V>, key: K, value: V, maxEntries: number): void {
|
||||
if (map.has(key)) {
|
||||
map.delete(key)
|
||||
}
|
||||
map.set(key, value)
|
||||
while (map.size > maxEntries) {
|
||||
const oldest = map.keys().next()
|
||||
if (oldest.done) {
|
||||
return
|
||||
}
|
||||
map.delete(oldest.value)
|
||||
}
|
||||
}
|
||||
|
||||
export function __resetPRConflictSummaryDerivationCachesForTests(): void {
|
||||
baseOidCache.clear()
|
||||
summaryCache.clear()
|
||||
inFlightBaseOidResolves.clear()
|
||||
inFlightSummaryDerivations.clear()
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// Why these tests exist: the conflict-summary derivation used to re-run a
|
||||
// network `git fetch` plus a four-subprocess chain on every PR refresh tick
|
||||
// (measured ~490 full derivations in 2.1h on one machine). They pin the
|
||||
// regression contract by counting subprocess spawns: unchanged inputs must
|
||||
// cost zero subprocesses, and the base fetch must run at most once per
|
||||
// throttle window.
|
||||
|
||||
const gitExecFileAsyncMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('../git/runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock }))
|
||||
|
||||
import { CONFLICT_SUMMARY_BASE_FETCH_WINDOW_MS } from './conflict-summary-cache'
|
||||
import { __resetPRConflictSummaryCachesForTests, getPRConflictSummary } from './conflict-summary'
|
||||
|
||||
type GitResult = { stdout: string }
|
||||
type GitHandler = (argv: string[]) => Promise<GitResult>
|
||||
|
||||
const defaultHandlers: Record<string, GitHandler> = {
|
||||
fetch: async () => ({ stdout: '' }),
|
||||
'rev-parse': async () => ({ stdout: 'base-tip-1\n' }),
|
||||
'merge-base': async () => ({ stdout: 'merge-base-1\n' }),
|
||||
'rev-list': async () => ({ stdout: '3\n' }),
|
||||
'merge-tree': async () => ({ stdout: 'tree-oid\u0000src/conflict.ts\u0000' })
|
||||
}
|
||||
|
||||
function mockGitDispatch(overrides: Record<string, GitHandler> = {}): void {
|
||||
gitExecFileAsyncMock.mockImplementation((argv: string[]) => {
|
||||
const handler = overrides[argv[0]] ?? defaultHandlers[argv[0]]
|
||||
if (!handler) {
|
||||
return Promise.reject(new Error(`unexpected git command: ${argv.join(' ')}`))
|
||||
}
|
||||
return handler(argv)
|
||||
})
|
||||
}
|
||||
|
||||
function spawnCount(command?: string): number {
|
||||
const calls = gitExecFileAsyncMock.mock.calls
|
||||
if (!command) {
|
||||
return calls.length
|
||||
}
|
||||
return calls.filter(([argv]) => Array.isArray(argv) && argv[0] === command).length
|
||||
}
|
||||
|
||||
const expectedSummary = {
|
||||
baseRef: 'main',
|
||||
baseCommit: 'base-ti',
|
||||
commitsBehind: 3,
|
||||
files: ['src/conflict.ts']
|
||||
}
|
||||
|
||||
function deriveSummary(headRefOid = 'head-oid-1', wslDistro?: string) {
|
||||
return getPRConflictSummary(
|
||||
'/repo-root',
|
||||
'main',
|
||||
'github-base-oid',
|
||||
headRefOid,
|
||||
wslDistro ? { wslDistro } : {}
|
||||
)
|
||||
}
|
||||
|
||||
describe('getPRConflictSummary caching', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(1_750_000_000_000)
|
||||
gitExecFileAsyncMock.mockReset()
|
||||
__resetPRConflictSummaryCachesForTests()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('runs exactly one fetch and one derivation chain, then zero subprocesses on repeat calls', async () => {
|
||||
mockGitDispatch()
|
||||
|
||||
const first = await deriveSummary()
|
||||
expect(first).toEqual(expectedSummary)
|
||||
expect(spawnCount('fetch')).toBe(1)
|
||||
expect(spawnCount()).toBe(5)
|
||||
|
||||
const second = await deriveSummary()
|
||||
const third = await deriveSummary()
|
||||
expect(second).toEqual(expectedSummary)
|
||||
expect(third).toEqual(expectedSummary)
|
||||
expect(spawnCount()).toBe(5)
|
||||
})
|
||||
|
||||
it('re-derives when headRefOid changes without re-fetching inside the throttle window', async () => {
|
||||
mockGitDispatch()
|
||||
|
||||
await deriveSummary('head-oid-1')
|
||||
const afterPush = await deriveSummary('head-oid-2')
|
||||
|
||||
expect(afterPush).toEqual(expectedSummary)
|
||||
expect(spawnCount('fetch')).toBe(1)
|
||||
expect(spawnCount('rev-parse')).toBe(1)
|
||||
expect(spawnCount('merge-base')).toBe(2)
|
||||
expect(spawnCount('rev-list')).toBe(2)
|
||||
expect(spawnCount('merge-tree')).toBe(2)
|
||||
})
|
||||
|
||||
it('fetches again after the throttle window and skips derivation when the base tip is unchanged', async () => {
|
||||
mockGitDispatch()
|
||||
|
||||
await deriveSummary()
|
||||
vi.setSystemTime(1_750_000_000_000 + CONFLICT_SUMMARY_BASE_FETCH_WINDOW_MS + 1_000)
|
||||
const afterExpiry = await deriveSummary()
|
||||
|
||||
expect(afterExpiry).toEqual(expectedSummary)
|
||||
expect(spawnCount('fetch')).toBe(2)
|
||||
expect(spawnCount('rev-parse')).toBe(2)
|
||||
// Why: same (headRefOid, latestBaseOid) pair — the summary cache still hits.
|
||||
expect(spawnCount('merge-base')).toBe(1)
|
||||
expect(spawnCount('rev-list')).toBe(1)
|
||||
expect(spawnCount('merge-tree')).toBe(1)
|
||||
})
|
||||
|
||||
it('re-derives after the throttle window when the base tip moved', async () => {
|
||||
mockGitDispatch()
|
||||
await deriveSummary()
|
||||
|
||||
vi.setSystemTime(1_750_000_000_000 + CONFLICT_SUMMARY_BASE_FETCH_WINDOW_MS + 1_000)
|
||||
mockGitDispatch({
|
||||
'rev-parse': async () => ({ stdout: 'base-tip-2\n' }),
|
||||
'rev-list': async () => ({ stdout: '5\n' })
|
||||
})
|
||||
const afterBaseMove = await deriveSummary()
|
||||
|
||||
expect(afterBaseMove).toEqual({ ...expectedSummary, baseCommit: 'base-ti', commitsBehind: 5 })
|
||||
expect(spawnCount('fetch')).toBe(2)
|
||||
expect(spawnCount('merge-base')).toBe(2)
|
||||
expect(spawnCount('rev-list')).toBe(2)
|
||||
expect(spawnCount('merge-tree')).toBe(2)
|
||||
})
|
||||
|
||||
it('dedupes concurrent identical calls onto one in-flight subprocess chain', async () => {
|
||||
let releaseFetch: ((value: GitResult) => void) | undefined
|
||||
mockGitDispatch({
|
||||
fetch: () =>
|
||||
new Promise<GitResult>((resolve) => {
|
||||
releaseFetch = resolve
|
||||
})
|
||||
})
|
||||
|
||||
const firstCall = deriveSummary()
|
||||
const secondCall = deriveSummary()
|
||||
releaseFetch?.({ stdout: '' })
|
||||
const [first, second] = await Promise.all([firstCall, secondCall])
|
||||
|
||||
expect(first).toEqual(expectedSummary)
|
||||
expect(second).toEqual(expectedSummary)
|
||||
expect(spawnCount()).toBe(5)
|
||||
})
|
||||
|
||||
it('dedupes overlapping derivations after resolving the same live base tip', async () => {
|
||||
let releaseFetch: ((value: GitResult) => void) | undefined
|
||||
mockGitDispatch({
|
||||
fetch: () =>
|
||||
new Promise<GitResult>((resolve) => {
|
||||
releaseFetch = resolve
|
||||
})
|
||||
})
|
||||
|
||||
const backgroundRefresh = getPRConflictSummary(
|
||||
'/repo-root',
|
||||
'main',
|
||||
'older-github-base-oid',
|
||||
'head-oid-1',
|
||||
{}
|
||||
)
|
||||
const manualRefresh = getPRConflictSummary(
|
||||
'/repo-root',
|
||||
'main',
|
||||
'newer-github-base-oid',
|
||||
'head-oid-1',
|
||||
{}
|
||||
)
|
||||
releaseFetch?.({ stdout: '' })
|
||||
const [backgroundSummary, manualSummary] = await Promise.all([backgroundRefresh, manualRefresh])
|
||||
|
||||
expect(backgroundSummary).toEqual(expectedSummary)
|
||||
expect(manualSummary).toEqual(expectedSummary)
|
||||
expect(spawnCount('fetch')).toBe(1)
|
||||
expect(spawnCount('rev-parse')).toBe(1)
|
||||
expect(spawnCount('merge-base')).toBe(1)
|
||||
expect(spawnCount('rev-list')).toBe(1)
|
||||
expect(spawnCount('merge-tree')).toBe(1)
|
||||
})
|
||||
|
||||
it('shares one base fetch across concurrent PRs on the same base branch', async () => {
|
||||
let releaseFetch: ((value: GitResult) => void) | undefined
|
||||
mockGitDispatch({
|
||||
fetch: () =>
|
||||
new Promise<GitResult>((resolve) => {
|
||||
releaseFetch = resolve
|
||||
})
|
||||
})
|
||||
|
||||
const prA = deriveSummary('head-oid-a')
|
||||
const prB = deriveSummary('head-oid-b')
|
||||
releaseFetch?.({ stdout: '' })
|
||||
const [summaryA, summaryB] = await Promise.all([prA, prB])
|
||||
|
||||
expect(summaryA).toEqual(expectedSummary)
|
||||
expect(summaryB).toEqual(expectedSummary)
|
||||
expect(spawnCount('fetch')).toBe(1)
|
||||
expect(spawnCount('rev-parse')).toBe(1)
|
||||
expect(spawnCount('merge-base')).toBe(2)
|
||||
})
|
||||
|
||||
it('keeps concurrent fallback base OIDs isolated per PR when the base ref is unavailable', async () => {
|
||||
let releaseFetch: ((value: GitResult) => void) | undefined
|
||||
mockGitDispatch({
|
||||
fetch: () =>
|
||||
new Promise<GitResult>((resolve) => {
|
||||
releaseFetch = resolve
|
||||
}),
|
||||
'rev-parse': () => Promise.reject(new Error('missing remote-tracking ref')),
|
||||
'rev-list': async (argv) => ({
|
||||
stdout: argv[2]?.includes('bbbb2222-base') ? '7\n' : '4\n'
|
||||
}),
|
||||
'merge-tree': async (argv) => ({
|
||||
stdout: argv.includes('bbbb2222-base')
|
||||
? 'tree-oid\u0000src/b.ts\u0000'
|
||||
: 'tree-oid\u0000src/a.ts\u0000'
|
||||
})
|
||||
})
|
||||
|
||||
const prA = getPRConflictSummary('/repo-root', 'main', 'aaaa1111-base', 'head-oid-a', {})
|
||||
const prB = getPRConflictSummary('/repo-root', 'main', 'bbbb2222-base', 'head-oid-b', {})
|
||||
releaseFetch?.({ stdout: '' })
|
||||
const [summaryA, summaryB] = await Promise.all([prA, prB])
|
||||
|
||||
expect(summaryA).toEqual({
|
||||
baseRef: 'main',
|
||||
baseCommit: 'aaaa111',
|
||||
commitsBehind: 4,
|
||||
files: ['src/a.ts']
|
||||
})
|
||||
expect(summaryB).toEqual({
|
||||
baseRef: 'main',
|
||||
baseCommit: 'bbbb222',
|
||||
commitsBehind: 7,
|
||||
files: ['src/b.ts']
|
||||
})
|
||||
expect(spawnCount('fetch')).toBe(1)
|
||||
expect(spawnCount('merge-base')).toBe(2)
|
||||
})
|
||||
|
||||
it('falls back to the local remote-tracking ref when fetch fails', async () => {
|
||||
mockGitDispatch({
|
||||
fetch: () => Promise.reject(new Error('offline'))
|
||||
})
|
||||
|
||||
const summary = await deriveSummary()
|
||||
|
||||
expect(summary).toEqual(expectedSummary)
|
||||
expect(spawnCount('fetch')).toBe(1)
|
||||
|
||||
// Why: a failed fetch attempt must also be throttled — offline machines
|
||||
// were the worst case, paying the 10s fetch timeout on every tick.
|
||||
const repeat = await deriveSummary()
|
||||
expect(repeat).toEqual(expectedSummary)
|
||||
expect(spawnCount('fetch')).toBe(1)
|
||||
expect(spawnCount()).toBe(5)
|
||||
})
|
||||
|
||||
it("falls back to GitHub's baseRefOid when fetch and remote-tracking refs are unavailable", async () => {
|
||||
mockGitDispatch({
|
||||
fetch: () => Promise.reject(new Error('offline')),
|
||||
'rev-parse': () => Promise.reject(new Error('unknown revision'))
|
||||
})
|
||||
|
||||
const summary = await deriveSummary()
|
||||
|
||||
expect(summary).toEqual({ ...expectedSummary, baseCommit: 'github-' })
|
||||
expect(
|
||||
gitExecFileAsyncMock.mock.calls.some(
|
||||
([argv]) => argv[0] === 'merge-base' && argv.includes('github-base-oid')
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps WSL-distro derivations isolated from host derivations', async () => {
|
||||
mockGitDispatch()
|
||||
|
||||
await deriveSummary()
|
||||
await deriveSummary('head-oid-1', 'Ubuntu')
|
||||
|
||||
expect(spawnCount('fetch')).toBe(2)
|
||||
expect(
|
||||
gitExecFileAsyncMock.mock.calls.filter(
|
||||
([argv, options]) =>
|
||||
argv[0] === 'fetch' && (options as { wslDistro?: string })?.wslDistro === 'Ubuntu'
|
||||
)
|
||||
).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keeps identities distinct when paths or ref names contain a joiner character', async () => {
|
||||
mockGitDispatch()
|
||||
|
||||
// Why: these two calls alias under naive pipe-joined keys — the segment
|
||||
// boundary shifts between repoPath and baseRefName.
|
||||
await getPRConflictSummary('/repo|x', 'main', 'github-base-oid', 'head-oid-1', {})
|
||||
await getPRConflictSummary('/repo', 'x|main', 'github-base-oid', 'head-oid-1', {})
|
||||
|
||||
expect(spawnCount('fetch')).toBe(2)
|
||||
expect(spawnCount('merge-base')).toBe(2)
|
||||
})
|
||||
|
||||
it('caches failed derivations only until the throttle window expires', async () => {
|
||||
mockGitDispatch({
|
||||
'merge-base': () => Promise.reject(new Error('bad object'))
|
||||
})
|
||||
|
||||
await expect(deriveSummary()).resolves.toBeUndefined()
|
||||
const failureSpawns = spawnCount()
|
||||
|
||||
// Within the window the failure is negative-cached: zero subprocesses.
|
||||
await expect(deriveSummary()).resolves.toBeUndefined()
|
||||
expect(spawnCount()).toBe(failureSpawns)
|
||||
|
||||
// After expiry the derivation retries (a fetch may have brought objects in).
|
||||
vi.setSystemTime(1_750_000_000_000 + CONFLICT_SUMMARY_BASE_FETCH_WINDOW_MS + 1_000)
|
||||
mockGitDispatch()
|
||||
await expect(deriveSummary()).resolves.toEqual(expectedSummary)
|
||||
expect(spawnCount('merge-base')).toBe(2)
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,17 @@
|
||||
import type { PRConflictSummary } from '../../shared/types'
|
||||
import { gitExecFileAsync } from '../git/runner'
|
||||
import {
|
||||
__resetPRConflictSummaryDerivationCachesForTests,
|
||||
buildConflictSummaryCacheKey,
|
||||
dedupeBaseOidResolve,
|
||||
dedupeSummaryDerivation,
|
||||
getConflictSummaryGitRuntimeKey,
|
||||
readCachedSummary,
|
||||
readFreshBaseTipResolution,
|
||||
rememberUnresolvedBaseTip,
|
||||
storeResolvedBaseTip,
|
||||
storeCachedSummary
|
||||
} from './conflict-summary-cache'
|
||||
|
||||
type LocalGitExecOptions = {
|
||||
wslDistro?: string
|
||||
@@ -7,8 +19,9 @@ type LocalGitExecOptions = {
|
||||
|
||||
const mergeTreeMergeBaseUnsupportedRuntimes = new Set<string>()
|
||||
|
||||
export function __resetPRConflictSummaryGitCapabilityCacheForTests(): void {
|
||||
export function __resetPRConflictSummaryCachesForTests(): void {
|
||||
mergeTreeMergeBaseUnsupportedRuntimes.clear()
|
||||
__resetPRConflictSummaryDerivationCachesForTests()
|
||||
}
|
||||
|
||||
export async function getPRConflictSummary(
|
||||
@@ -18,45 +31,122 @@ export async function getPRConflictSummary(
|
||||
headRefOid: string,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): Promise<PRConflictSummary | undefined> {
|
||||
try {
|
||||
// Why: the renderer only needs a read-only merge-conflict snapshot. We
|
||||
// derive it from local git state so the PR card can show GitHub-style
|
||||
// detail without spending additional gh API calls on every refresh. We use
|
||||
// GitHub's head OID directly because the registered repo path may not have
|
||||
// a matching local branch name for the PR head. For the base side, prefer a
|
||||
// freshly-fetched remote-tracking ref so Orca matches GitHub's portal,
|
||||
// which compares against the latest base branch tip rather than the PR's
|
||||
// older pinned baseRefOid snapshot.
|
||||
const latestBaseOid = await resolveLatestBaseOid(
|
||||
// Why: the renderer only needs a read-only merge-conflict snapshot. We
|
||||
// derive it from local git state so the PR card can show GitHub-style
|
||||
// detail without spending additional gh API calls on every refresh. We use
|
||||
// GitHub's head OID directly because the registered repo path may not have
|
||||
// a matching local branch name for the PR head. For the base side, prefer a
|
||||
// freshly-fetched remote-tracking ref so Orca matches GitHub's portal,
|
||||
// which compares against the latest base branch tip rather than the PR's
|
||||
// older pinned baseRefOid snapshot.
|
||||
const latestBaseOid = await resolveLatestBaseOidThrottled(
|
||||
repoPath,
|
||||
baseRefName,
|
||||
baseRefOid,
|
||||
localGitOptions
|
||||
)
|
||||
// Why: the summary is a pure function of the two commit OIDs, so a key hit
|
||||
// can skip the whole merge-base/rev-list/merge-tree subprocess chain.
|
||||
const runtimeKey = getConflictSummaryGitRuntimeKey(localGitOptions.wslDistro)
|
||||
const summaryKey = buildConflictSummaryCacheKey(
|
||||
runtimeKey,
|
||||
repoPath,
|
||||
baseRefName,
|
||||
headRefOid,
|
||||
latestBaseOid
|
||||
)
|
||||
const cached = readCachedSummary(summaryKey)
|
||||
if (cached) {
|
||||
return cached.value
|
||||
}
|
||||
|
||||
// Why: different GitHub reads can report different pinned baseRefOid values
|
||||
// while still resolving to the same live base tip; dedupe the expensive
|
||||
// local derivation on the actual summary identity.
|
||||
return dedupeSummaryDerivation(summaryKey, () =>
|
||||
derivePRConflictSummary(
|
||||
repoPath,
|
||||
baseRefName,
|
||||
baseRefOid,
|
||||
headRefOid,
|
||||
latestBaseOid,
|
||||
summaryKey,
|
||||
localGitOptions
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
async function derivePRConflictSummary(
|
||||
repoPath: string,
|
||||
baseRefName: string,
|
||||
headRefOid: string,
|
||||
latestBaseOid: string,
|
||||
summaryKey: string,
|
||||
localGitOptions: LocalGitExecOptions
|
||||
): Promise<PRConflictSummary | undefined> {
|
||||
const cached = readCachedSummary(summaryKey)
|
||||
if (cached) {
|
||||
return cached.value
|
||||
}
|
||||
|
||||
try {
|
||||
const mergeBase = await resolveMergeBase(repoPath, headRefOid, latestBaseOid, localGitOptions)
|
||||
const [commitsBehind, files] = await Promise.all([
|
||||
countCommits(repoPath, `${headRefOid}..${latestBaseOid}`, localGitOptions),
|
||||
loadConflictingFiles(repoPath, mergeBase, headRefOid, latestBaseOid, localGitOptions)
|
||||
])
|
||||
|
||||
return {
|
||||
const summary = {
|
||||
baseRef: baseRefName,
|
||||
baseCommit: latestBaseOid.slice(0, 7),
|
||||
commitsBehind,
|
||||
files,
|
||||
...(files.length === 0 ? { localMergeState: 'clean' as const } : {})
|
||||
}
|
||||
storeCachedSummary(summaryKey, summary)
|
||||
return summary
|
||||
} catch {
|
||||
storeCachedSummary(summaryKey, undefined)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveLatestBaseOid(
|
||||
async function resolveLatestBaseOidThrottled(
|
||||
repoPath: string,
|
||||
baseRefName: string,
|
||||
fallbackBaseOid: string,
|
||||
localGitOptions: LocalGitExecOptions
|
||||
): Promise<string> {
|
||||
const runtimeKey = getConflictSummaryGitRuntimeKey(localGitOptions.wslDistro)
|
||||
const baseKey = buildConflictSummaryCacheKey(runtimeKey, repoPath, baseRefName)
|
||||
const cachedResolution = readFreshBaseTipResolution(baseKey)
|
||||
if (cachedResolution) {
|
||||
return cachedResolution.kind === 'resolved' ? cachedResolution.oid : fallbackBaseOid
|
||||
}
|
||||
return dedupeBaseOidResolve(baseKey, async () => {
|
||||
// Why re-check inside the dedupe slot: a sibling caller may have finished
|
||||
// resolving between our cache read and this factory starting.
|
||||
const freshResolution = readFreshBaseTipResolution(baseKey)
|
||||
if (freshResolution) {
|
||||
return freshResolution
|
||||
}
|
||||
const oid = await resolveLatestBaseOid(repoPath, baseRefName, localGitOptions)
|
||||
if (oid) {
|
||||
storeResolvedBaseTip(baseKey, oid)
|
||||
return { kind: 'resolved', oid }
|
||||
}
|
||||
// Why cache the unresolved probe, not the caller fallback: the fetch
|
||||
// attempt is branch-wide expensive work, but GitHub's baseRefOid is
|
||||
// PR-specific and must not leak to sibling PRs on the same base branch.
|
||||
rememberUnresolvedBaseTip(baseKey)
|
||||
return { kind: 'fallback-unresolved' }
|
||||
}).then((resolution) => (resolution.kind === 'resolved' ? resolution.oid : fallbackBaseOid))
|
||||
}
|
||||
|
||||
async function resolveLatestBaseOid(
|
||||
repoPath: string,
|
||||
baseRefName: string,
|
||||
localGitOptions: LocalGitExecOptions
|
||||
): Promise<string | null> {
|
||||
const remoteName = 'origin'
|
||||
|
||||
try {
|
||||
@@ -88,7 +178,7 @@ async function resolveLatestBaseOid(
|
||||
}
|
||||
}
|
||||
|
||||
return fallbackBaseOid
|
||||
return null
|
||||
}
|
||||
|
||||
async function resolveMergeBase(
|
||||
@@ -123,7 +213,7 @@ async function loadConflictingFiles(
|
||||
baseOid: string,
|
||||
localGitOptions: LocalGitExecOptions
|
||||
): Promise<string[]> {
|
||||
const capabilityKey = getMergeTreeCapabilityKey(localGitOptions)
|
||||
const capabilityKey = getConflictSummaryGitRuntimeKey(localGitOptions.wslDistro)
|
||||
const modernArgs = [
|
||||
'merge-tree',
|
||||
'--write-tree',
|
||||
@@ -193,10 +283,6 @@ async function loadConflictingFilesWithLegacyMergeTree(
|
||||
}
|
||||
}
|
||||
|
||||
function getMergeTreeCapabilityKey(localGitOptions: LocalGitExecOptions): string {
|
||||
return localGitOptions.wslDistro ? `wsl:${localGitOptions.wslDistro}` : 'local:host'
|
||||
}
|
||||
|
||||
function parseMergeTreeNameOnlyOutput(stdout: string): string[] {
|
||||
const entries = stdout.split('\0').filter(Boolean)
|
||||
if (entries.length === 0) {
|
||||
|
||||
@@ -3,14 +3,19 @@ request timestamps, and follow-up scheduling against shared module state. */
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { GitHubPRRefreshCandidate, PRInfo } from '../../shared/types'
|
||||
|
||||
const { sendMock, getAllWebContentsMock, getPRForBranchOutcomeMock, getRateLimitMock } = vi.hoisted(
|
||||
() => ({
|
||||
sendMock: vi.fn(),
|
||||
getAllWebContentsMock: vi.fn(),
|
||||
getPRForBranchOutcomeMock: vi.fn(),
|
||||
getRateLimitMock: vi.fn()
|
||||
})
|
||||
)
|
||||
const {
|
||||
sendMock,
|
||||
getAllWebContentsMock,
|
||||
getPRForBranchOutcomeMock,
|
||||
getRateLimitMock,
|
||||
rateLimitGuardMock
|
||||
} = vi.hoisted(() => ({
|
||||
sendMock: vi.fn(),
|
||||
getAllWebContentsMock: vi.fn(),
|
||||
getPRForBranchOutcomeMock: vi.fn(),
|
||||
getRateLimitMock: vi.fn(),
|
||||
rateLimitGuardMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
webContents: {
|
||||
@@ -25,7 +30,7 @@ vi.mock('./client', () => ({
|
||||
vi.mock('./rate-limit', () => ({
|
||||
getRateLimit: getRateLimitMock,
|
||||
noteRateLimitSpend: vi.fn(),
|
||||
rateLimitGuard: vi.fn(() => ({ blocked: false }))
|
||||
rateLimitGuard: rateLimitGuardMock
|
||||
}))
|
||||
|
||||
function makeCandidate(
|
||||
@@ -80,6 +85,8 @@ describe('pr-refresh-coordinator', () => {
|
||||
getAllWebContentsMock.mockReset()
|
||||
getPRForBranchOutcomeMock.mockReset()
|
||||
getRateLimitMock.mockReset()
|
||||
rateLimitGuardMock.mockReset()
|
||||
rateLimitGuardMock.mockReturnValue({ blocked: false })
|
||||
getAllWebContentsMock.mockReturnValue([
|
||||
{
|
||||
id: 1,
|
||||
@@ -505,13 +512,45 @@ describe('pr-refresh-coordinator', () => {
|
||||
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(5)
|
||||
})
|
||||
|
||||
it('proceeds with background refreshes when the budget probe fails (fail open)', async () => {
|
||||
const { enqueuePRRefresh } = await import('./pr-refresh-coordinator')
|
||||
// Why: regression for #7553 — GHES with rate limiting disabled 404s every
|
||||
// probe; an unreadable budget must not pause refreshes.
|
||||
getRateLimitMock.mockResolvedValue({
|
||||
ok: false,
|
||||
error: 'HTTP 404: Rate limiting is not enabled.'
|
||||
})
|
||||
getPRForBranchOutcomeMock.mockResolvedValue({
|
||||
kind: 'no-pr',
|
||||
fetchedAt: Date.now()
|
||||
})
|
||||
|
||||
enqueuePRRefresh(makeCandidate(), 'active', 80, 1)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(1)
|
||||
const pausedEvents = sendMock.mock.calls
|
||||
.map(([, event]) => event)
|
||||
.filter((event) => event.status === 'paused' && event.skippedReason === 'rate-limit')
|
||||
expect(pausedEvents).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does not consume active burst slots for rate-limit pauses', async () => {
|
||||
const { enqueuePRRefresh } = await import('./pr-refresh-coordinator')
|
||||
getRateLimitMock
|
||||
.mockResolvedValueOnce({ ok: false })
|
||||
.mockResolvedValueOnce({ ok: false })
|
||||
.mockResolvedValueOnce({ ok: false })
|
||||
.mockResolvedValue({ ok: true })
|
||||
// Why: keyed on drained items (one getRateLimit call each) rather than
|
||||
// guard-call counts, so the guard's per-bucket evaluation order stays free
|
||||
// to change without breaking the slot-accounting assertion.
|
||||
let drainedItems = 0
|
||||
getRateLimitMock.mockImplementation(async () => {
|
||||
drainedItems += 1
|
||||
return { ok: true }
|
||||
})
|
||||
rateLimitGuardMock.mockImplementation(() =>
|
||||
drainedItems <= 3
|
||||
? { blocked: true, remaining: 0, limit: 5000, resetAt: 61 }
|
||||
: { blocked: false }
|
||||
)
|
||||
getPRForBranchOutcomeMock.mockResolvedValue({
|
||||
kind: 'upstream-error',
|
||||
errorType: 'unknown',
|
||||
|
||||
@@ -708,20 +708,11 @@ async function drainQueue(): Promise<void> {
|
||||
)
|
||||
|
||||
if (isBackground(next.reason)) {
|
||||
const rateLimit = await getRateLimit()
|
||||
if (!rateLimit.ok) {
|
||||
const retryAt = Date.now() + 30_000
|
||||
queue.set(next.key, { ...next, dueAt: retryAt })
|
||||
broadcast({
|
||||
aliases,
|
||||
reason: next.reason,
|
||||
status: 'paused',
|
||||
pausedUntil: retryAt,
|
||||
skippedReason: 'rate-limit'
|
||||
})
|
||||
scheduleDrain(30_000)
|
||||
continue
|
||||
}
|
||||
// Why: the probe only warms rateLimitGuard's cached snapshot, so a
|
||||
// failed probe must fail open — GHES with rate limiting disabled 404s
|
||||
// every probe (#7553). A genuinely broken gh surfaces per-key as typed
|
||||
// fetch outcomes instead (visible keys additionally back off).
|
||||
await getRateLimit()
|
||||
const buckets = backgroundRefreshBuckets()
|
||||
const blockedGuard = buckets
|
||||
.map((bucket) => rateLimitGuard(bucket))
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { ghExecFileAsyncMock } = vi.hoisted(() => ({
|
||||
ghExecFileAsyncMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../git/runner', () => ({
|
||||
ghExecFileAsync: ghExecFileAsyncMock
|
||||
}))
|
||||
|
||||
vi.mock('./gh-utils', () => ({
|
||||
acquire: vi.fn(),
|
||||
release: vi.fn()
|
||||
}))
|
||||
|
||||
import { getRateLimit, rateLimitGuard, _resetRateLimitCache } from './rate-limit'
|
||||
|
||||
const PAYLOAD = JSON.stringify({
|
||||
resources: {
|
||||
core: { limit: 5000, remaining: 4200, reset: 1_700_000_000 },
|
||||
search: { limit: 30, remaining: 28, reset: 1_700_000_000 },
|
||||
graphql: { limit: 5000, remaining: 4900, reset: 1_700_000_000 }
|
||||
}
|
||||
})
|
||||
|
||||
describe('getRateLimit', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(1_000)
|
||||
ghExecFileAsyncMock.mockReset()
|
||||
_resetRateLimitCache()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('parses bucket counts from gh api rate_limit', async () => {
|
||||
ghExecFileAsyncMock.mockResolvedValue({ stdout: PAYLOAD })
|
||||
|
||||
const result = await getRateLimit()
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
snapshot: {
|
||||
core: { limit: 5000, remaining: 4200, resetAt: 1_700_000_000 },
|
||||
search: { limit: 30, remaining: 28, resetAt: 1_700_000_000 },
|
||||
graphql: { limit: 5000, remaining: 4900, resetAt: 1_700_000_000 },
|
||||
fetchedAt: 1_000
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('caches a failed probe for the TTL instead of re-spawning gh', async () => {
|
||||
// Why: regression for #7553 — GHES with rate limiting disabled fails every
|
||||
// probe. Refreshes fail open past the failure, so without a negative cache
|
||||
// each queued refresh would spawn its own doomed gh subprocess.
|
||||
ghExecFileAsyncMock.mockRejectedValue(new Error('HTTP 404: Rate limiting is not enabled.'))
|
||||
|
||||
const first = await getRateLimit()
|
||||
const second = await getRateLimit()
|
||||
|
||||
expect(first).toEqual({ ok: false, error: 'HTTP 404: Rate limiting is not enabled.' })
|
||||
expect(second).toEqual(first)
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('re-probes after the failure TTL elapses and recovers on success', async () => {
|
||||
ghExecFileAsyncMock.mockRejectedValueOnce(new Error('boom'))
|
||||
ghExecFileAsyncMock.mockResolvedValue({ stdout: PAYLOAD })
|
||||
|
||||
expect((await getRateLimit()).ok).toBe(false)
|
||||
expect((await getRateLimit()).ok).toBe(false)
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
vi.setSystemTime(1_000 + 30_000)
|
||||
|
||||
expect((await getRateLimit()).ok).toBe(true)
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('bypasses the failure cache when forced', async () => {
|
||||
ghExecFileAsyncMock.mockRejectedValueOnce(new Error('boom'))
|
||||
ghExecFileAsyncMock.mockResolvedValue({ stdout: PAYLOAD })
|
||||
|
||||
expect((await getRateLimit()).ok).toBe(false)
|
||||
expect((await getRateLimit({ force: true })).ok).toBe(true)
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('rateLimitGuard', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(1_000)
|
||||
ghExecFileAsyncMock.mockReset()
|
||||
_resetRateLimitCache()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
async function cacheExhaustedSnapshot(resetEpochSeconds: number): Promise<void> {
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
resources: {
|
||||
core: { limit: 5000, remaining: 3, reset: resetEpochSeconds },
|
||||
search: { limit: 30, remaining: 28, reset: resetEpochSeconds },
|
||||
graphql: { limit: 5000, remaining: 4900, reset: resetEpochSeconds }
|
||||
}
|
||||
})
|
||||
})
|
||||
expect((await getRateLimit()).ok).toBe(true)
|
||||
}
|
||||
|
||||
it('blocks below the floor while resetAt is in the future', async () => {
|
||||
await cacheExhaustedSnapshot(61)
|
||||
|
||||
expect(rateLimitGuard('core')).toEqual({
|
||||
blocked: true,
|
||||
remaining: 3,
|
||||
limit: 5000,
|
||||
resetAt: 61
|
||||
})
|
||||
})
|
||||
|
||||
it('fails open once the blocking bucket resetAt has elapsed', async () => {
|
||||
// Why: probes can fail indefinitely (sleep past resetAt, network blip), so
|
||||
// a stale exhausted snapshot must not block once GitHub has already reset
|
||||
// the budget — a past-due pause would spin the coordinator drain loop.
|
||||
await cacheExhaustedSnapshot(61)
|
||||
|
||||
vi.setSystemTime(61_000)
|
||||
|
||||
expect(rateLimitGuard('core')).toEqual({ blocked: false })
|
||||
})
|
||||
})
|
||||
@@ -35,6 +35,11 @@ import {
|
||||
// 1-per-second "is it safe now?" polling pattern UIs tend to fall into.
|
||||
const RATE_LIMIT_CACHE_TTL_MS = 30_000
|
||||
let cached: GitHubRateLimitSnapshot | null = null
|
||||
// Why: failed probes are cached for the same TTL as successes. Refreshes fail
|
||||
// open past a failed probe, so on a host that can never report a budget (GHES
|
||||
// with rate limiting disabled 404s every probe) an uncached failure would cost
|
||||
// a gh subprocess per queued refresh.
|
||||
let probeFailure: { at: number; error: string } | null = null
|
||||
|
||||
type GhRateLimitPayload = {
|
||||
resources?: {
|
||||
@@ -66,6 +71,7 @@ function parseBucket(
|
||||
/** @internal — test-only */
|
||||
export function _resetRateLimitCache(): void {
|
||||
cached = null
|
||||
probeFailure = null
|
||||
}
|
||||
|
||||
// Why: hard-stop thresholds for the circuit breaker. We refuse to issue a new
|
||||
@@ -196,6 +202,9 @@ export async function getRateLimit(options?: { force?: boolean }): Promise<GetRa
|
||||
if (!options?.force && cached && Date.now() - cached.fetchedAt < RATE_LIMIT_CACHE_TTL_MS) {
|
||||
return { ok: true, snapshot: cached }
|
||||
}
|
||||
if (!options?.force && probeFailure && Date.now() - probeFailure.at < RATE_LIMIT_CACHE_TTL_MS) {
|
||||
return { ok: false, error: probeFailure.error }
|
||||
}
|
||||
if (!options?.force && probeInFlight) {
|
||||
return probeInFlight
|
||||
}
|
||||
@@ -222,9 +231,11 @@ async function fetchRateLimitSnapshot(): Promise<GetRateLimitResult> {
|
||||
fetchedAt: Date.now()
|
||||
}
|
||||
cached = snapshot
|
||||
probeFailure = null
|
||||
return { ok: true, snapshot }
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
probeFailure = { at: Date.now(), error: message }
|
||||
return { ok: false, error: message }
|
||||
} finally {
|
||||
release()
|
||||
|
||||
@@ -8,8 +8,11 @@ import type * as RepoWorktrees from '../repo-worktrees'
|
||||
import { listRepoWorktrees } from '../repo-worktrees'
|
||||
import type { FolderWorkspace, GitWorktreeInfo, ProjectGroup, Repo } from '../../shared/types'
|
||||
import {
|
||||
AUTHORIZED_EXTERNAL_PATHS_MAX,
|
||||
authorizeExternalPath,
|
||||
invalidateAuthorizedRootsCache,
|
||||
isDescendantOrEqual,
|
||||
isPathAllowed,
|
||||
rebuildAuthorizedRootsCache,
|
||||
resolveAuthorizedPath,
|
||||
resolveRegisteredWorktreePath,
|
||||
@@ -336,3 +339,42 @@ describe('filesystem-auth path containment', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('filesystem-auth authorized external path bound', () => {
|
||||
// Empty allow-list store, so a path is allowed only if it (or an ancestor) is
|
||||
// in the session-authorized external-path set.
|
||||
const emptyStore = makeStore([])
|
||||
const flood = (n: number): string =>
|
||||
resolve(`/leak-audit-ext/flood-${String(n).padStart(6, '0')}`)
|
||||
|
||||
it('bounds the authorized external path set with LRU eviction', () => {
|
||||
const keep = resolve('/leak-audit-ext/keep')
|
||||
authorizeExternalPath(keep)
|
||||
|
||||
// Flood past the cap with distinct external paths, re-authorizing `keep`
|
||||
// periodically so LRU keeps it hot.
|
||||
const total = AUTHORIZED_EXTERNAL_PATHS_MAX + 200
|
||||
for (let i = 0; i < total; i += 1) {
|
||||
authorizeExternalPath(flood(i))
|
||||
if (i % 250 === 0) {
|
||||
authorizeExternalPath(keep)
|
||||
}
|
||||
}
|
||||
|
||||
// The oldest never-re-touched entries fell out of the bounded set...
|
||||
expect(isPathAllowed(flood(0), emptyStore)).toBe(false)
|
||||
// ...while the periodically re-authorized path and the most recent survive.
|
||||
expect(isPathAllowed(keep, emptyStore)).toBe(true)
|
||||
expect(isPathAllowed(flood(total - 1), emptyStore)).toBe(true)
|
||||
})
|
||||
|
||||
it('re-authorizes an evicted path on next use (self-healing)', () => {
|
||||
const path = resolve('/leak-audit-ext/evicted-then-reused')
|
||||
for (let i = 0; i < AUTHORIZED_EXTERNAL_PATHS_MAX + 50; i += 1) {
|
||||
authorizeExternalPath(flood(100_000 + i))
|
||||
}
|
||||
expect(isPathAllowed(path, emptyStore)).toBe(false)
|
||||
authorizeExternalPath(path)
|
||||
expect(isPathAllowed(path, emptyStore)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,6 +13,14 @@ import type { FolderWorkspace, ProjectGroup, Repo } from '../../shared/types'
|
||||
|
||||
export const PATH_ACCESS_DENIED_MESSAGE =
|
||||
'Access denied: path resolves outside allowed directories. If this blocks a legitimate workflow, please file a GitHub issue.'
|
||||
// Why: authorized external paths accumulate for the whole session (file drops,
|
||||
// terminal link opens, editor/composer/notebook opens). Bound the set with LRU
|
||||
// eviction — mirrors rememberUnwatchableRoot in filesystem-watcher.ts — so a long
|
||||
// session touching many distinct external files cannot grow it (or the O(n) auth
|
||||
// scan in isPathAllowed) without limit. Every caller re-authorizes a path right
|
||||
// before operating on it, so evicting a stale entry is self-healing and does not
|
||||
// weaken the security boundary.
|
||||
export const AUTHORIZED_EXTERNAL_PATHS_MAX = 4096
|
||||
const authorizedExternalPaths = new Set<string>()
|
||||
const registeredWorktreeRoots = new Set<string>()
|
||||
const registeredWorktreeRootsByRepo = new Map<string, Set<string>>()
|
||||
@@ -23,12 +31,26 @@ const AUTHORIZED_ROOTS_REBUILD_CONCURRENCY = 8
|
||||
type FolderScopeStore = Pick<Store, 'getRepos'> &
|
||||
Partial<Pick<Store, 'getProjectGroups' | 'getFolderWorkspaces'>>
|
||||
|
||||
function rememberAuthorizedExternalPath(path: string): void {
|
||||
// Delete-then-add keeps re-authorized (actively used) paths most-recent so
|
||||
// eviction only sheds the oldest never-re-touched entries.
|
||||
authorizedExternalPaths.delete(path)
|
||||
authorizedExternalPaths.add(path)
|
||||
while (authorizedExternalPaths.size > AUTHORIZED_EXTERNAL_PATHS_MAX) {
|
||||
const oldest = authorizedExternalPaths.keys().next().value
|
||||
if (oldest === undefined) {
|
||||
break
|
||||
}
|
||||
authorizedExternalPaths.delete(oldest)
|
||||
}
|
||||
}
|
||||
|
||||
export function authorizeExternalPath(targetPath: string): void {
|
||||
const resolvedTarget = resolve(targetPath)
|
||||
authorizedExternalPaths.add(resolvedTarget)
|
||||
rememberAuthorizedExternalPath(resolvedTarget)
|
||||
try {
|
||||
// Why: macOS canonicalizes /tmp to /private/tmp during read authorization.
|
||||
authorizedExternalPaths.add(realpathSync(resolvedTarget))
|
||||
rememberAuthorizedExternalPath(realpathSync(resolvedTarget))
|
||||
} catch {}
|
||||
}
|
||||
|
||||
|
||||
@@ -3381,6 +3381,54 @@ describe('registerPtyHandlers', () => {
|
||||
}
|
||||
})
|
||||
|
||||
// Why: the cap and its flag must never fire in the common case (renderer keeps
|
||||
// up), so ordinary small output carries no droppedBacklog.
|
||||
it('does not flag droppedBacklog for ordinary small output under the cap', async () => {
|
||||
vi.useFakeTimers()
|
||||
const runtime = {
|
||||
setPtyController: vi.fn(),
|
||||
registerPty: vi.fn(),
|
||||
onPtySpawned: vi.fn(),
|
||||
onPtyExit: vi.fn(),
|
||||
onPtyData: vi.fn(() => 12),
|
||||
createPreAllocatedTerminalHandle: vi.fn(() => 'terminal-handle-small'),
|
||||
registerPreAllocatedHandleForPty: vi.fn()
|
||||
}
|
||||
try {
|
||||
registerPtyHandlers(
|
||||
mainWindow as never,
|
||||
runtime as never,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{ awaitLocalPtyStartup: () => Promise.resolve() }
|
||||
)
|
||||
const pendingSpawn = handlers.get('pty:spawn')!(null, {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
sessionId: 'small-output-session'
|
||||
}) as Promise<{ id: string }>
|
||||
await Promise.resolve()
|
||||
const daemon = installObservableDaemonTestProvider()
|
||||
rebindLocalProviderListeners()
|
||||
const result = await pendingSpawn
|
||||
|
||||
daemon.emitData(result.id, 'small output')
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
|
||||
const dataSends = mainWindow.webContents.send.mock.calls.filter(
|
||||
(call) => call[0] === 'pty:data' && (call[1] as { id: string }).id === result.id
|
||||
)
|
||||
expect(dataSends.length).toBeGreaterThan(0)
|
||||
for (const call of dataSends) {
|
||||
expect((call[1] as { droppedBacklog?: boolean }).droppedBacklog).toBeUndefined()
|
||||
}
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('waits for the desktop startup barrier before runtime local spawns resolve the provider', async () => {
|
||||
const barrier = makeDeferred()
|
||||
const runtime = {
|
||||
@@ -8380,6 +8428,7 @@ describe('registerPtyHandlers', () => {
|
||||
onPtyExit: vi.fn(),
|
||||
onPtyData: vi.fn(() => 42),
|
||||
getPtyOutputSequence: vi.fn(() => 42),
|
||||
hasRemoteTerminalViewSubscriber: vi.fn(() => false),
|
||||
createPreAllocatedTerminalHandle: vi.fn(() => 'terminal-handle-1'),
|
||||
registerPreAllocatedHandleForPty: vi.fn()
|
||||
}
|
||||
@@ -8797,6 +8846,7 @@ describe('registerPtyHandlers', () => {
|
||||
onPtyExit: vi.fn(),
|
||||
onPtyData: vi.fn(() => 42),
|
||||
getPtyOutputSequence: vi.fn(() => 42),
|
||||
hasRemoteTerminalViewSubscriber: vi.fn(() => false),
|
||||
createPreAllocatedTerminalHandle: vi.fn(() => 'terminal-handle-1'),
|
||||
registerPreAllocatedHandleForPty: vi.fn()
|
||||
}
|
||||
@@ -8853,6 +8903,7 @@ describe('registerPtyHandlers', () => {
|
||||
onPtyExit: vi.fn(),
|
||||
onPtyData: vi.fn(() => 42),
|
||||
getPtyOutputSequence: vi.fn(() => 42),
|
||||
hasRemoteTerminalViewSubscriber: vi.fn(() => false),
|
||||
createPreAllocatedTerminalHandle: vi.fn(() => 'terminal-handle-1'),
|
||||
registerPreAllocatedHandleForPty: vi.fn()
|
||||
}
|
||||
@@ -8916,6 +8967,7 @@ describe('registerPtyHandlers', () => {
|
||||
onPtyExit: vi.fn(),
|
||||
onPtyData: vi.fn(() => 42),
|
||||
getPtyOutputSequence: vi.fn(() => 42),
|
||||
hasRemoteTerminalViewSubscriber: vi.fn(() => false),
|
||||
createPreAllocatedTerminalHandle: vi.fn(() => 'terminal-handle-1'),
|
||||
registerPreAllocatedHandleForPty: vi.fn()
|
||||
}
|
||||
@@ -10332,6 +10384,7 @@ describe('registerPtyHandlers', () => {
|
||||
onPtyData: vi.fn(),
|
||||
preAllocateHandleForPty: vi.fn(() => null),
|
||||
getPtyOutputSequence: vi.fn(() => 2_472),
|
||||
hasRemoteTerminalViewSubscriber: vi.fn(() => false),
|
||||
serializeHiddenOutputRecoveryBuffer: vi.fn().mockResolvedValue({
|
||||
data: 'snapshot\r\n',
|
||||
cols: 100,
|
||||
|
||||
+27
-9
@@ -1504,6 +1504,11 @@ export function registerPtyHandlers(
|
||||
data: string
|
||||
startSeq?: number
|
||||
containsBackgroundOutput?: boolean
|
||||
// Why droppedOutput (not main's droppedBacklog trim): this branch bounds
|
||||
// the unsent backlog with the O(1) drop-to-sentinel + query-salvage +
|
||||
// snapshot-restore mechanism below, which strictly supersedes main's
|
||||
// #7630 keep-2MB-tail trim — carrying both would race two cap policies
|
||||
// over the same buffer.
|
||||
droppedOutput?: true
|
||||
}
|
||||
|
||||
@@ -1552,7 +1557,9 @@ export function registerPtyHandlers(
|
||||
const PTY_RENDERER_TOTAL_IN_FLIGHT_HIGH_WATER_CHARS = 8 * 1024 * 1024
|
||||
// Why: while the renderer cannot receive (frozen, starved, mid-reload), a
|
||||
// chatty PTY used to grow its pendingData string without bound — main-process
|
||||
// heap ballooning that a renderer reload cannot clear. Beyond this cap the
|
||||
// heap ballooning that a renderer reload cannot clear (main's #7630 fixed the
|
||||
// same Win/Linux background-throttled-renderer leak with a 2MB tail trim;
|
||||
// this branch's sentinel mechanism supersedes it). Beyond this cap the
|
||||
// buffered bytes are dropped and the pane heals from the main-owned buffer
|
||||
// snapshot via the droppedOutput sentinel (renderer hidden-output restore).
|
||||
// Why read settings live: the cap scales with the user's scrollback setting
|
||||
@@ -2339,15 +2346,25 @@ export function registerPtyHandlers(
|
||||
// tears down the terminal on pty:exit before the batch timer fires.
|
||||
const remaining = pendingData.get(payload.id)
|
||||
if (remaining) {
|
||||
sendPtyDataToRenderer(
|
||||
payload.id,
|
||||
makePtyDataPayload(
|
||||
if (remaining.droppedOutput === true) {
|
||||
// Sentinel entry: only salvaged query bytes remain; keep the flag so
|
||||
// the renderer knows the span was dropped (same as the flush loop).
|
||||
sendPtyDataToRenderer(payload.id, {
|
||||
id: payload.id,
|
||||
data: remaining.data,
|
||||
droppedOutput: true
|
||||
})
|
||||
} else {
|
||||
sendPtyDataToRenderer(
|
||||
payload.id,
|
||||
remaining.data,
|
||||
remaining.startSeq,
|
||||
remaining.containsBackgroundOutput
|
||||
makePtyDataPayload(
|
||||
payload.id,
|
||||
remaining.data,
|
||||
remaining.startSeq,
|
||||
remaining.containsBackgroundOutput
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
pendingData.delete(payload.id)
|
||||
}
|
||||
// Why: exit drops this PTY's bookkeeping; resume (no-op on a dead PTY)
|
||||
@@ -2514,7 +2531,8 @@ export function registerPtyHandlers(
|
||||
...(typeof pending.startSeq === 'number'
|
||||
? { seq: pending.startSeq + nextData.length, rawLength: nextData.length }
|
||||
: {}),
|
||||
...(pending.containsBackgroundOutput === true ? { background: true } : {})
|
||||
...(pending.containsBackgroundOutput === true ? { background: true } : {}),
|
||||
...(pending.droppedOutput === true ? { droppedOutput: true } : {})
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -25,6 +25,16 @@ function createMockChannel(): EventEmitter & { stderr: EventEmitter } {
|
||||
})
|
||||
}
|
||||
|
||||
// Recover the PowerShell script from a `powershell.exe ... -EncodedCommand <b64>`
|
||||
// command so tests can assert on the actual (UTF-16LE) payload sent to the host.
|
||||
function decodeEncodedCommand(command: string): string {
|
||||
const match = /-EncodedCommand (\S+)/.exec(command)
|
||||
if (!match) {
|
||||
throw new Error(`no -EncodedCommand in: ${command}`)
|
||||
}
|
||||
return Buffer.from(match[1], 'base64').toString('utf16le')
|
||||
}
|
||||
|
||||
describe('registerSshBrowseHandler', () => {
|
||||
let handler: BrowseHandler
|
||||
|
||||
@@ -88,6 +98,275 @@ describe('registerSshBrowseHandler', () => {
|
||||
expect(exec).toHaveBeenCalledWith("cd '/tmp/it'\\''s here' && pwd && command ls -1Ap")
|
||||
})
|
||||
|
||||
it('falls back to PowerShell when a Windows SSH shell rejects POSIX exec', async () => {
|
||||
const posixChannel = createMockChannel()
|
||||
const windowsChannel = createMockChannel()
|
||||
const exec = vi.fn().mockResolvedValueOnce(posixChannel).mockResolvedValueOnce(windowsChannel)
|
||||
const getConnectionManager = () => ({
|
||||
getConnection: () => ({ exec })
|
||||
})
|
||||
registerSshBrowseHandler(getConnectionManager as never)
|
||||
|
||||
const resultPromise = handler(null, { targetId: 'ssh-1', dirPath: 'C:/Users/alice' })
|
||||
await Promise.resolve()
|
||||
posixChannel.stderr.emit(
|
||||
'data',
|
||||
Buffer.from('"exec" no se reconoce como un comando interno o externo')
|
||||
)
|
||||
posixChannel.emit('exit', 1)
|
||||
posixChannel.emit('close')
|
||||
await vi.waitFor(() => {
|
||||
expect(windowsChannel.listenerCount('close')).toBe(1)
|
||||
})
|
||||
// Windows OpenSSH exec emits CRLF; the parser must strip \r so directories
|
||||
// aren't misclassified as files with a stray carriage return in the name.
|
||||
// The script emits a forward-slash resolvedPath (the -replace '\\','/' line)
|
||||
// so the renderer's parentPath/joinPath, which only split on `/`, still work.
|
||||
windowsChannel.emit('data', Buffer.from('C:/Users/alice\r\nDesktop/\r\nnotes.txt\r\n'))
|
||||
windowsChannel.emit('exit', 0)
|
||||
windowsChannel.emit('close')
|
||||
|
||||
await expect(resultPromise).resolves.toEqual({
|
||||
resolvedPath: 'C:/Users/alice',
|
||||
entries: [
|
||||
{ name: 'Desktop', isDirectory: true },
|
||||
{ name: 'notes.txt', isDirectory: false }
|
||||
]
|
||||
})
|
||||
expect(exec).toHaveBeenCalledTimes(2)
|
||||
expect(exec).toHaveBeenNthCalledWith(1, "cd 'C:/Users/alice' && pwd && command ls -1Ap")
|
||||
expect(exec.mock.calls[1]?.[0]).toMatch(/^powershell\.exe /)
|
||||
expect(exec.mock.calls[1]?.[1]).toEqual({ wrapCommand: false })
|
||||
|
||||
// Decode the -EncodedCommand payload so an accidental switch from the
|
||||
// single-quote-escaped PowerShell literal to raw interpolation (an injection
|
||||
// regression) is caught, and to lock in the UTF-8 output pin.
|
||||
const script = decodeEncodedCommand(exec.mock.calls[1]?.[0] ?? '')
|
||||
expect(script).toContain('[Console]::OutputEncoding = [System.Text.Encoding]::UTF8')
|
||||
expect(script).toContain("$dir = 'C:/Users/alice'")
|
||||
expect(script).toContain('Get-ChildItem -LiteralPath $resolved -Force')
|
||||
// resolvedPath must be emitted with forward slashes so the renderer's
|
||||
// parentPath/joinPath (which only split on `/`) keep working on Windows.
|
||||
expect(script).toContain("Write-Output ($resolved -replace '\\\\', '/')")
|
||||
})
|
||||
|
||||
it('falls back for a non-English cmd.exe reject (exit 1, localized stderr)', async () => {
|
||||
// Regression: real Windows OpenSSH + cmd.exe forwards exit 1 (not 9009) with
|
||||
// localized stderr. The old 9009/English-string trigger silently missed this,
|
||||
// so German/Japanese/etc. hosts never fell back. Keying off the non-zero exit
|
||||
// of a RemoteBrowseError fixes it regardless of OS display language.
|
||||
const posixChannel = createMockChannel()
|
||||
const windowsChannel = createMockChannel()
|
||||
const exec = vi.fn().mockResolvedValueOnce(posixChannel).mockResolvedValueOnce(windowsChannel)
|
||||
const getConnectionManager = () => ({
|
||||
getConnection: () => ({ exec })
|
||||
})
|
||||
registerSshBrowseHandler(getConnectionManager as never)
|
||||
|
||||
const resultPromise = handler(null, { targetId: 'ssh-1', dirPath: 'C:/Users' })
|
||||
await Promise.resolve()
|
||||
// Japanese cmd.exe "not recognized" text — matches none of the removed English
|
||||
// /Spanish substrings, and exit 1 is not the removed 9009 sentinel.
|
||||
posixChannel.stderr.emit(
|
||||
'data',
|
||||
Buffer.from("'exec' は、内部コマンドとして認識されていません。")
|
||||
)
|
||||
posixChannel.emit('exit', 1)
|
||||
posixChannel.emit('close')
|
||||
await vi.waitFor(() => {
|
||||
expect(windowsChannel.listenerCount('close')).toBe(1)
|
||||
})
|
||||
windowsChannel.emit('data', Buffer.from('C:/Users\r\nAdmin/\r\n'))
|
||||
windowsChannel.emit('exit', 0)
|
||||
windowsChannel.emit('close')
|
||||
|
||||
await expect(resultPromise).resolves.toEqual({
|
||||
resolvedPath: 'C:/Users',
|
||||
entries: [{ name: 'Admin', isDirectory: true }]
|
||||
})
|
||||
expect(exec).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('escapes single quotes in the PowerShell literal path', async () => {
|
||||
const posixChannel = createMockChannel()
|
||||
const windowsChannel = createMockChannel()
|
||||
const exec = vi.fn().mockResolvedValueOnce(posixChannel).mockResolvedValueOnce(windowsChannel)
|
||||
const getConnectionManager = () => ({
|
||||
getConnection: () => ({ exec })
|
||||
})
|
||||
registerSshBrowseHandler(getConnectionManager as never)
|
||||
|
||||
const resultPromise = handler(null, { targetId: 'ssh-1', dirPath: "C:/O'Brien" })
|
||||
await Promise.resolve()
|
||||
// A cmd.exe reject exits 1 over SSH (its 9009 ERRORLEVEL never crosses the
|
||||
// process boundary) with localized German stderr. The fallback must still fire,
|
||||
// since it keys off the non-zero exit, not the (localized, unmatchable) text.
|
||||
posixChannel.stderr.emit('data', Buffer.from('Der Befehl "exec" ist falsch geschrieben'))
|
||||
posixChannel.emit('exit', 1)
|
||||
posixChannel.emit('close')
|
||||
await vi.waitFor(() => {
|
||||
expect(windowsChannel.listenerCount('close')).toBe(1)
|
||||
})
|
||||
windowsChannel.emit('data', Buffer.from("C:/O'Brien\r\n"))
|
||||
windowsChannel.emit('exit', 0)
|
||||
windowsChannel.emit('close')
|
||||
|
||||
await expect(resultPromise).resolves.toEqual({
|
||||
resolvedPath: "C:/O'Brien",
|
||||
entries: []
|
||||
})
|
||||
const script = decodeEncodedCommand(exec.mock.calls[1]?.[0] ?? '')
|
||||
// Single quote must be doubled inside the PowerShell literal, not passed raw.
|
||||
expect(script).toContain("$dir = 'C:/O''Brien'")
|
||||
})
|
||||
|
||||
it('expands ~ to $HOME in the PowerShell fallback (the default browse path)', async () => {
|
||||
const posixChannel = createMockChannel()
|
||||
const windowsChannel = createMockChannel()
|
||||
const exec = vi.fn().mockResolvedValueOnce(posixChannel).mockResolvedValueOnce(windowsChannel)
|
||||
const getConnectionManager = () => ({
|
||||
getConnection: () => ({ exec })
|
||||
})
|
||||
registerSshBrowseHandler(getConnectionManager as never)
|
||||
|
||||
const resultPromise = handler(null, { targetId: 'ssh-1', dirPath: '~' })
|
||||
await Promise.resolve()
|
||||
posixChannel.stderr.emit('data', Buffer.from('"exec" is not recognized'))
|
||||
posixChannel.emit('exit', 1)
|
||||
posixChannel.emit('close')
|
||||
await vi.waitFor(() => {
|
||||
expect(windowsChannel.listenerCount('close')).toBe(1)
|
||||
})
|
||||
windowsChannel.emit('data', Buffer.from('C:/Users/alice\r\n'))
|
||||
windowsChannel.emit('exit', 0)
|
||||
windowsChannel.emit('close')
|
||||
|
||||
await expect(resultPromise).resolves.toEqual({ resolvedPath: 'C:/Users/alice', entries: [] })
|
||||
const script = decodeEncodedCommand(exec.mock.calls[1]?.[0] ?? '')
|
||||
// ~ must expand to $HOME, not be passed literally to Set-Location.
|
||||
expect(script).toContain('$dir = $HOME')
|
||||
})
|
||||
|
||||
// The renderer rebuilds forward-slash Windows paths with POSIX helpers: the
|
||||
// breadcrumb prepends a spurious leading '/' before the drive, and "Up" from a
|
||||
// first-level dir yields a bare drive letter. Both must be rooted for
|
||||
// Set-Location, or navigation lands in the drive-relative cwd / errors.
|
||||
it.each([
|
||||
{ dirPath: '/C:/Users', expected: "$dir = 'C:/Users'" },
|
||||
{ dirPath: 'C:', expected: "$dir = 'C:/'" },
|
||||
// Combined strip + root, so a future refactor can't break the ordering.
|
||||
{ dirPath: '/C:', expected: "$dir = 'C:/'" }
|
||||
])(
|
||||
'roots the Windows drive path $dirPath in the PowerShell fallback',
|
||||
async ({ dirPath, expected }) => {
|
||||
const posixChannel = createMockChannel()
|
||||
const windowsChannel = createMockChannel()
|
||||
const exec = vi.fn().mockResolvedValueOnce(posixChannel).mockResolvedValueOnce(windowsChannel)
|
||||
const getConnectionManager = () => ({
|
||||
getConnection: () => ({ exec })
|
||||
})
|
||||
registerSshBrowseHandler(getConnectionManager as never)
|
||||
|
||||
const resultPromise = handler(null, { targetId: 'ssh-1', dirPath })
|
||||
await Promise.resolve()
|
||||
posixChannel.stderr.emit('data', Buffer.from('"exec" is not recognized'))
|
||||
posixChannel.emit('exit', 1)
|
||||
posixChannel.emit('close')
|
||||
await vi.waitFor(() => {
|
||||
expect(windowsChannel.listenerCount('close')).toBe(1)
|
||||
})
|
||||
windowsChannel.emit('data', Buffer.from('C:/Users\r\n'))
|
||||
windowsChannel.emit('exit', 0)
|
||||
windowsChannel.emit('close')
|
||||
|
||||
await expect(resultPromise).resolves.toEqual({ resolvedPath: 'C:/Users', entries: [] })
|
||||
const script = decodeEncodedCommand(exec.mock.calls[1]?.[0] ?? '')
|
||||
expect(script).toContain(expected)
|
||||
}
|
||||
)
|
||||
|
||||
it('surfaces the original POSIX failure when the PowerShell retry shows the host is not Windows', async () => {
|
||||
const posixChannel = createMockChannel()
|
||||
const windowsChannel = createMockChannel()
|
||||
const exec = vi.fn().mockResolvedValueOnce(posixChannel).mockResolvedValueOnce(windowsChannel)
|
||||
const getConnectionManager = () => ({
|
||||
getConnection: () => ({ exec })
|
||||
})
|
||||
registerSshBrowseHandler(getConnectionManager as never)
|
||||
|
||||
const resultPromise = handler(null, { targetId: 'ssh-1', dirPath: '/root/secret' })
|
||||
await Promise.resolve()
|
||||
// A genuine POSIX permission failure exits non-zero; it's indistinguishable
|
||||
// from a Windows shell reject without probing, so the fallback is attempted...
|
||||
posixChannel.stderr.emit('data', Buffer.from('ls: /root/secret: Permission denied'))
|
||||
posixChannel.emit('exit', 1)
|
||||
posixChannel.emit('close')
|
||||
await vi.waitFor(() => {
|
||||
expect(windowsChannel.listenerCount('close')).toBe(1)
|
||||
})
|
||||
// ...but on a POSIX host the login shell can't find powershell.exe (exit 127),
|
||||
// so the original permission error is surfaced, never masked by the retry.
|
||||
windowsChannel.stderr.emit('data', Buffer.from('bash: powershell.exe: command not found'))
|
||||
windowsChannel.emit('exit', 127)
|
||||
windowsChannel.emit('close')
|
||||
|
||||
await expect(resultPromise).rejects.toThrow('Permission denied')
|
||||
expect(exec).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('surfaces the PowerShell error when the Windows fallback runs but fails', async () => {
|
||||
const posixChannel = createMockChannel()
|
||||
const windowsChannel = createMockChannel()
|
||||
const exec = vi.fn().mockResolvedValueOnce(posixChannel).mockResolvedValueOnce(windowsChannel)
|
||||
const getConnectionManager = () => ({
|
||||
getConnection: () => ({ exec })
|
||||
})
|
||||
registerSshBrowseHandler(getConnectionManager as never)
|
||||
|
||||
const resultPromise = handler(null, { targetId: 'ssh-1', dirPath: 'C:/missing' })
|
||||
await Promise.resolve()
|
||||
posixChannel.stderr.emit('data', Buffer.from('"exec" is not recognized'))
|
||||
posixChannel.emit('exit', 1)
|
||||
posixChannel.emit('close')
|
||||
await vi.waitFor(() => {
|
||||
expect(windowsChannel.listenerCount('close')).toBe(1)
|
||||
})
|
||||
windowsChannel.stderr.emit('data', Buffer.from('Cannot find path C:/missing'))
|
||||
windowsChannel.emit('exit', 1)
|
||||
windowsChannel.emit('close')
|
||||
|
||||
// PowerShell exited non-127, so it genuinely ran on a Windows host — its error
|
||||
// is the real cause. Surface it rather than the cmd.exe "not recognized" prose.
|
||||
await expect(resultPromise).rejects.toThrow('Cannot find path')
|
||||
})
|
||||
|
||||
it('surfaces the original POSIX error when the fallback shows powershell.exe is missing', async () => {
|
||||
const posixChannel = createMockChannel()
|
||||
const windowsChannel = createMockChannel()
|
||||
const exec = vi.fn().mockResolvedValueOnce(posixChannel).mockResolvedValueOnce(windowsChannel)
|
||||
const getConnectionManager = () => ({
|
||||
getConnection: () => ({ exec })
|
||||
})
|
||||
registerSshBrowseHandler(getConnectionManager as never)
|
||||
|
||||
const resultPromise = handler(null, { targetId: 'ssh-1', dirPath: '/opt/exec' })
|
||||
await Promise.resolve()
|
||||
// A non-zero POSIX failure triggers the fallback probe on any host...
|
||||
posixChannel.stderr.emit('data', Buffer.from('exec: command not found'))
|
||||
posixChannel.emit('exit', 127)
|
||||
posixChannel.emit('close')
|
||||
await vi.waitFor(() => {
|
||||
expect(windowsChannel.listenerCount('close')).toBe(1)
|
||||
})
|
||||
windowsChannel.stderr.emit('data', Buffer.from('sh: powershell.exe: not found'))
|
||||
windowsChannel.emit('exit', 127)
|
||||
windowsChannel.emit('close')
|
||||
|
||||
// The original POSIX failure is the real one — don't mask it with the
|
||||
// misleading "powershell.exe: not found" from the doomed retry.
|
||||
await expect(resultPromise).rejects.toThrow('exec: command not found')
|
||||
})
|
||||
|
||||
it('rejects and detaches listeners when the browse channel errors', async () => {
|
||||
const channel = createMockChannel()
|
||||
const exec = vi.fn().mockResolvedValue(channel)
|
||||
@@ -101,6 +380,9 @@ describe('registerSshBrowseHandler', () => {
|
||||
channel.emit('error', new Error('remote disconnected'))
|
||||
|
||||
await expect(resultPromise).rejects.toThrow('remote disconnected')
|
||||
// A transport failure is not a RemoteBrowseError, so it must not trigger a
|
||||
// pointless PowerShell retry.
|
||||
expect(exec).toHaveBeenCalledTimes(1)
|
||||
expect(channel.listenerCount('data')).toBe(0)
|
||||
expect(channel.listenerCount('exit')).toBe(0)
|
||||
expect(channel.listenerCount('close')).toBe(0)
|
||||
|
||||
+267
-144
@@ -1,5 +1,7 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import type { SshConnectionManager } from '../ssh/ssh-connection'
|
||||
import type { SshExecOptions } from '../ssh/ssh-connection-utils'
|
||||
import { powerShellCommand, powerShellLiteral } from '../ssh/ssh-remote-powershell'
|
||||
|
||||
export type RemoteDirEntry = {
|
||||
name: string
|
||||
@@ -8,6 +10,33 @@ export type RemoteDirEntry = {
|
||||
|
||||
const SSH_BROWSE_TIMEOUT_MS = 15_000
|
||||
|
||||
// Why: a POSIX login shell that can't find powershell.exe exits 127 (the POSIX
|
||||
// "command not found" convention, identical across sh/bash/zsh and locales). It's
|
||||
// the locale-independent signal that the Windows fallback never actually ran, so
|
||||
// the original POSIX failure — not the doomed retry — is the real error.
|
||||
//
|
||||
// Note: cmd.exe's ERRORLEVEL for an unrecognized command is 9009, but that value
|
||||
// never crosses cmd.exe's process boundary. sshd forwards cmd.exe's *process* exit
|
||||
// code, which is 1 — verified on real Windows OpenSSH + cmd.exe over both the ssh2
|
||||
// and system-ssh transports. So a Windows host rejecting Orca's POSIX `exec`
|
||||
// wrapper is detected by "the remote command ran and exited non-zero"
|
||||
// (RemoteBrowseError), not by a magic exit code or localized stderr text.
|
||||
const POSIX_COMMAND_NOT_FOUND_EXIT = 127
|
||||
|
||||
// Carries the raw exit code so the fallback can (a) recognize that the remote
|
||||
// command actually ran and failed — the locale-independent trigger for the
|
||||
// Windows retry — and (b) tell a POSIX "powershell.exe not found" (127) apart
|
||||
// from a genuine PowerShell error, without parsing localized shell prose.
|
||||
class RemoteBrowseError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly exitCode: number | null
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'RemoteBrowseError'
|
||||
}
|
||||
}
|
||||
|
||||
// Why: the relay's fs.readDir enforces workspace root ACLs, which aren't
|
||||
// registered until a repo is added. This handler uses a raw SSH exec channel
|
||||
// to list directories, allowing the user to browse the remote filesystem
|
||||
@@ -32,156 +61,229 @@ export function registerSshBrowseHandler(
|
||||
throw new Error(`SSH connection "${args.targetId}" not found`)
|
||||
}
|
||||
|
||||
// Why: using one line per entry preserves filenames containing spaces.
|
||||
// `command ls` bypasses user aliases/functions like `ls='eza ...'`.
|
||||
// The -1 flag outputs one entry per line. The -p flag appends / to directories.
|
||||
// We resolve ~ and get the absolute path via `cd <path> && pwd`.
|
||||
// `cd` and `ls` are chained with `&&` so a failing `ls` (e.g. permission
|
||||
// denied after a readable `cd ... && pwd`) propagates as a non-zero exit
|
||||
// code rather than being indistinguishable from an empty directory.
|
||||
const command = `cd ${shellEscape(args.dirPath)} && pwd && command ls -1Ap`
|
||||
const channel = await conn.exec(command)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
let exitCode: number | null = null
|
||||
let settled = false
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const cleanup = (): void => {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
timeout = null
|
||||
}
|
||||
channel.off('data', onStdoutData)
|
||||
channel.stderr.off('data', onStderrData)
|
||||
channel.off('exit', onExit)
|
||||
channel.off('close', onClose)
|
||||
channel.off('error', onError)
|
||||
channel.stderr.off('error', onError)
|
||||
try {
|
||||
return await browseWithPosixShell(conn, args.dirPath)
|
||||
} catch (posixError) {
|
||||
// Why: a Windows login shell (cmd.exe/PowerShell) rejects Orca's POSIX
|
||||
// `exec` wrapper, and the only locale-independent signal for that is "the
|
||||
// remote command executed and exited non-zero" (RemoteBrowseError). Its
|
||||
// stderr prose is localized, and cmd.exe's 9009 ERRORLEVEL never reaches
|
||||
// us (sshd forwards process exit 1). Transport errors/timeouts aren't
|
||||
// RemoteBrowseErrors, so a dropped connection is never retried as Windows.
|
||||
if (!(posixError instanceof RemoteBrowseError)) {
|
||||
throw posixError
|
||||
}
|
||||
const rejectOnce = (error: Error): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
cleanup()
|
||||
reject(error)
|
||||
try {
|
||||
return await browseWithWindowsPowerShell(conn, args.dirPath)
|
||||
} catch (fallbackError) {
|
||||
// Why: if the login shell couldn't find powershell.exe (exit 127) the
|
||||
// host isn't Windows — surface the original POSIX failure rather than a
|
||||
// misleading "powershell.exe: not found". Otherwise PowerShell genuinely
|
||||
// ran and its error (e.g. "Cannot find path") is the real cause.
|
||||
throw isPosixCommandNotFound(fallbackError) ? posixError : fallbackError
|
||||
}
|
||||
const closeChannel = (): void => {
|
||||
const closable = channel as { close?: () => void; destroy?: () => void }
|
||||
try {
|
||||
if (typeof closable.close === 'function') {
|
||||
closable.close()
|
||||
} else if (typeof closable.destroy === 'function') {
|
||||
closable.destroy()
|
||||
}
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
}
|
||||
const onTimeout = (): void => {
|
||||
// Why: remote browsing runs before a relay workspace root exists, so
|
||||
// it cannot rely on relay request deadlines. Bound this raw exec
|
||||
// channel directly to keep Add Remote Project from hanging forever.
|
||||
rejectOnce(new Error('Remote directory listing timed out'))
|
||||
closeChannel()
|
||||
}
|
||||
const resolveOnce = (result: { entries: RemoteDirEntry[]; resolvedPath: string }): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
cleanup()
|
||||
resolve(result)
|
||||
}
|
||||
|
||||
const onStdoutData = (data: Buffer): void => {
|
||||
stdout += data.toString()
|
||||
}
|
||||
const onStderrData = (data: Buffer): void => {
|
||||
stderr += data.toString()
|
||||
}
|
||||
// `exit` fires before `close`; capture the code so we can distinguish
|
||||
// a failed `ls` that still produced `pwd` output from an empty listing.
|
||||
const onExit = (code: number | null): void => {
|
||||
exitCode = code
|
||||
}
|
||||
const onError = (error: Error): void => {
|
||||
rejectOnce(error)
|
||||
}
|
||||
const onClose = (): void => {
|
||||
// A null exitCode means the server closed the channel without
|
||||
// sending an exit-status message (or signalled termination). We
|
||||
// can't assume success — falling back to "empty stdout = empty
|
||||
// directory" is exactly the bug the exit-code branch was added to
|
||||
// fix. Treat any non-zero OR null exit as a failure when stderr
|
||||
// has content, and otherwise require stdout to contain at least
|
||||
// the resolved `pwd` line before accepting the result.
|
||||
if (exitCode !== 0) {
|
||||
const msg =
|
||||
stderr.trim() ||
|
||||
(exitCode === null
|
||||
? 'Remote listing failed (channel closed without exit status)'
|
||||
: `Remote listing failed (exit ${exitCode})`)
|
||||
rejectOnce(new Error(msg))
|
||||
return
|
||||
}
|
||||
if (stderr.trim() && !stdout.trim()) {
|
||||
rejectOnce(new Error(stderr.trim()))
|
||||
return
|
||||
}
|
||||
|
||||
const lines = stdout.trim().split('\n')
|
||||
if (lines.length === 0) {
|
||||
rejectOnce(new Error('Empty response from remote'))
|
||||
return
|
||||
}
|
||||
|
||||
const resolvedPath = lines[0]
|
||||
const entries: RemoteDirEntry[] = []
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i]
|
||||
if (!line || line === './' || line === '../') {
|
||||
continue
|
||||
}
|
||||
if (line.endsWith('/')) {
|
||||
entries.push({ name: line.slice(0, -1), isDirectory: true })
|
||||
} else {
|
||||
entries.push({ name: line, isDirectory: false })
|
||||
}
|
||||
}
|
||||
|
||||
// Sort: directories first, then alphabetical
|
||||
entries.sort((a, b) => {
|
||||
if (a.isDirectory !== b.isDirectory) {
|
||||
return a.isDirectory ? -1 : 1
|
||||
}
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
|
||||
resolveOnce({ entries, resolvedPath })
|
||||
}
|
||||
|
||||
channel.on('data', onStdoutData)
|
||||
channel.stderr.on('data', onStderrData)
|
||||
channel.on('exit', onExit)
|
||||
channel.on('close', onClose)
|
||||
// Why: SSH exec streams emit `error` on transport loss; without a
|
||||
// scoped listener, a disappearing remote can become process-fatal.
|
||||
channel.on('error', onError)
|
||||
channel.stderr.on('error', onError)
|
||||
timeout = setTimeout(onTimeout, SSH_BROWSE_TIMEOUT_MS)
|
||||
if (typeof timeout.unref === 'function') {
|
||||
timeout.unref()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
type SshBrowseConnection = NonNullable<ReturnType<SshConnectionManager['getConnection']>>
|
||||
|
||||
function browseWithPosixShell(
|
||||
conn: SshBrowseConnection,
|
||||
dirPath: string
|
||||
): Promise<{ entries: RemoteDirEntry[]; resolvedPath: string }> {
|
||||
// Why: using one line per entry preserves filenames containing spaces.
|
||||
// `command ls` bypasses user aliases/functions like `ls='eza ...'`.
|
||||
// The -1 flag outputs one entry per line. The -p flag appends / to directories.
|
||||
// We resolve ~ and get the absolute path via `cd <path> && pwd`.
|
||||
// `cd` and `ls` are chained with `&&` so a failing `ls` (e.g. permission
|
||||
// denied after a readable `cd ... && pwd`) propagates as a non-zero exit
|
||||
// code rather than being indistinguishable from an empty directory.
|
||||
return runBrowseCommand(conn, `cd ${shellEscape(dirPath)} && pwd && command ls -1Ap`)
|
||||
}
|
||||
|
||||
function browseWithWindowsPowerShell(
|
||||
conn: SshBrowseConnection,
|
||||
dirPath: string
|
||||
): Promise<{ entries: RemoteDirEntry[]; resolvedPath: string }> {
|
||||
const script = [
|
||||
"$ErrorActionPreference = 'Stop'",
|
||||
// Why: Windows PowerShell 5.1 writes redirected stdout in the legacy OEM
|
||||
// code page, but runBrowseCommand decodes as UTF-8; pin UTF-8 output so
|
||||
// non-ASCII names (e.g. C:\Users\José, CJK, Cyrillic) don't come back mojibake.
|
||||
'[Console]::OutputEncoding = [System.Text.Encoding]::UTF8',
|
||||
`$dir = ${powerShellPathExpression(dirPath)}`,
|
||||
'Set-Location -LiteralPath $dir',
|
||||
'$resolved = (Get-Location).ProviderPath',
|
||||
// Why: the renderer's parentPath/joinPath only split on `/`, so a native
|
||||
// backslash path (C:\Users\alice) breaks "Up" and mixes separators. Emit a
|
||||
// forward-slash resolvedPath (matching the POSIX branch) while keeping the
|
||||
// native $resolved for Get-ChildItem -LiteralPath.
|
||||
"Write-Output ($resolved -replace '\\\\', '/')",
|
||||
'Get-ChildItem -LiteralPath $resolved -Force | ForEach-Object {',
|
||||
" if ($_.PSIsContainer) { Write-Output ($_.Name + '/') } else { Write-Output $_.Name }",
|
||||
'}'
|
||||
].join('; ')
|
||||
|
||||
return runBrowseCommand(conn, powerShellCommand(script), { wrapCommand: false })
|
||||
}
|
||||
|
||||
async function runBrowseCommand(
|
||||
conn: SshBrowseConnection,
|
||||
command: string,
|
||||
options?: SshExecOptions
|
||||
): Promise<{ entries: RemoteDirEntry[]; resolvedPath: string }> {
|
||||
const channel = options ? await conn.exec(command, options) : await conn.exec(command)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
let exitCode: number | null = null
|
||||
let settled = false
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const cleanup = (): void => {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
timeout = null
|
||||
}
|
||||
channel.off('data', onStdoutData)
|
||||
channel.stderr.off('data', onStderrData)
|
||||
channel.off('exit', onExit)
|
||||
channel.off('close', onClose)
|
||||
channel.off('error', onError)
|
||||
channel.stderr.off('error', onError)
|
||||
}
|
||||
const rejectOnce = (error: Error): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
cleanup()
|
||||
reject(error)
|
||||
}
|
||||
const closeChannel = (): void => {
|
||||
const closable = channel as { close?: () => void; destroy?: () => void }
|
||||
try {
|
||||
if (typeof closable.close === 'function') {
|
||||
closable.close()
|
||||
} else if (typeof closable.destroy === 'function') {
|
||||
closable.destroy()
|
||||
}
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
}
|
||||
const onTimeout = (): void => {
|
||||
// Why: remote browsing runs before a relay workspace root exists, so
|
||||
// it cannot rely on relay request deadlines. Bound this raw exec
|
||||
// channel directly to keep Add Remote Project from hanging forever.
|
||||
rejectOnce(new Error('Remote directory listing timed out'))
|
||||
closeChannel()
|
||||
}
|
||||
const resolveOnce = (result: { entries: RemoteDirEntry[]; resolvedPath: string }): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
cleanup()
|
||||
resolve(result)
|
||||
}
|
||||
|
||||
const onStdoutData = (data: Buffer): void => {
|
||||
stdout += data.toString()
|
||||
}
|
||||
const onStderrData = (data: Buffer): void => {
|
||||
stderr += data.toString()
|
||||
}
|
||||
// `exit` fires before `close`; capture the code so we can distinguish
|
||||
// a failed `ls` that still produced `pwd` output from an empty listing.
|
||||
const onExit = (code: number | null): void => {
|
||||
exitCode = code
|
||||
}
|
||||
const onError = (error: Error): void => {
|
||||
rejectOnce(error)
|
||||
}
|
||||
const onClose = (): void => {
|
||||
// A null exitCode means the server closed the channel without
|
||||
// sending an exit-status message (or signalled termination). We
|
||||
// can't assume success — falling back to "empty stdout = empty
|
||||
// directory" is exactly the bug the exit-code branch was added to
|
||||
// fix. Treat any non-zero OR null exit as a failure when stderr
|
||||
// has content, and otherwise require stdout to contain at least
|
||||
// the resolved `pwd` line before accepting the result.
|
||||
if (exitCode !== 0) {
|
||||
const msg =
|
||||
stderr.trim() ||
|
||||
(exitCode === null
|
||||
? 'Remote listing failed (channel closed without exit status)'
|
||||
: `Remote listing failed (exit ${exitCode})`)
|
||||
rejectOnce(new RemoteBrowseError(msg, exitCode))
|
||||
return
|
||||
}
|
||||
if (stderr.trim() && !stdout.trim()) {
|
||||
rejectOnce(new Error(stderr.trim()))
|
||||
return
|
||||
}
|
||||
|
||||
// Why: Windows OpenSSH exec emits CRLF, so split on \r?\n — otherwise a
|
||||
// trailing \r defeats the endsWith('/') dir check and leaves a stray CR
|
||||
// in every name.
|
||||
const lines = stdout.trim().split(/\r?\n/)
|
||||
if (lines.length === 0) {
|
||||
rejectOnce(new Error('Empty response from remote'))
|
||||
return
|
||||
}
|
||||
|
||||
const resolvedPath = lines[0]
|
||||
const entries: RemoteDirEntry[] = []
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i]
|
||||
if (!line || line === './' || line === '../') {
|
||||
continue
|
||||
}
|
||||
if (line.endsWith('/')) {
|
||||
entries.push({ name: line.slice(0, -1), isDirectory: true })
|
||||
} else {
|
||||
entries.push({ name: line, isDirectory: false })
|
||||
}
|
||||
}
|
||||
|
||||
// Sort: directories first, then alphabetical
|
||||
entries.sort((a, b) => {
|
||||
if (a.isDirectory !== b.isDirectory) {
|
||||
return a.isDirectory ? -1 : 1
|
||||
}
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
|
||||
resolveOnce({ entries, resolvedPath })
|
||||
}
|
||||
|
||||
channel.on('data', onStdoutData)
|
||||
channel.stderr.on('data', onStderrData)
|
||||
channel.on('exit', onExit)
|
||||
channel.on('close', onClose)
|
||||
// Why: SSH exec streams emit `error` on transport loss; without a
|
||||
// scoped listener, a disappearing remote can become process-fatal.
|
||||
channel.on('error', onError)
|
||||
channel.stderr.on('error', onError)
|
||||
timeout = setTimeout(onTimeout, SSH_BROWSE_TIMEOUT_MS)
|
||||
if (typeof timeout.unref === 'function') {
|
||||
timeout.unref()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Why: a POSIX login shell that can't find powershell.exe exits 127, marking the
|
||||
// Windows fallback as "never ran" — the host isn't Windows, so the original POSIX
|
||||
// failure, not the doomed retry, is the error worth surfacing.
|
||||
function isPosixCommandNotFound(error: unknown): boolean {
|
||||
return error instanceof RemoteBrowseError && error.exitCode === POSIX_COMMAND_NOT_FOUND_EXIT
|
||||
}
|
||||
|
||||
// Why: prevent shell injection in the directory path. Single-quote wrapping
|
||||
// with escaped internal single quotes is the safest approach for sh/bash.
|
||||
// Tilde must be expanded by the shell, so paths starting with ~ use $HOME
|
||||
@@ -199,3 +301,24 @@ function shellEscape(s: string): string {
|
||||
function shellEscapeRaw(s: string): string {
|
||||
return `'${s.replace(/'/g, "'\\''")}'`
|
||||
}
|
||||
|
||||
function powerShellPathExpression(s: string): string {
|
||||
if (s === '~') {
|
||||
return '$HOME'
|
||||
}
|
||||
if (s.startsWith('~/') || s.startsWith('~\\')) {
|
||||
return `Join-Path $HOME ${powerShellLiteral(s.slice(2))}`
|
||||
}
|
||||
return powerShellLiteral(normalizeWindowsDrivePath(s))
|
||||
}
|
||||
|
||||
// Why: browse emits forward-slash Windows paths, so the renderer rebuilds them
|
||||
// with POSIX helpers — the breadcrumb prepends a spurious leading '/' before the
|
||||
// drive (/C:/Users) and "Up" from a first-level dir yields a bare drive letter
|
||||
// (C:). Both are wrong for Set-Location: a leading '/' means the current drive's
|
||||
// root, and 'C:' is drive-relative (the process cwd), not 'C:\'. Normalize both
|
||||
// back to a rooted drive path here so navigation lands where the user clicked.
|
||||
function normalizeWindowsDrivePath(s: string): string {
|
||||
const stripped = s.replace(/^\/(?=[A-Za-z]:(?:[/\\]|$))/, '')
|
||||
return /^[A-Za-z]:$/.test(stripped) ? `${stripped}/` : stripped
|
||||
}
|
||||
|
||||
+2
-2
@@ -714,8 +714,8 @@ export function registerSshHandlers(
|
||||
await removeRegisteredSshTarget(args.id)
|
||||
})
|
||||
|
||||
ipcMain.handle('ssh:importConfig', () => {
|
||||
return sshStore!.importFromSshConfig()
|
||||
ipcMain.handle('ssh:importConfig', (_event, args?: { reAdopt?: boolean }) => {
|
||||
return sshStore!.importFromSshConfig(args)
|
||||
})
|
||||
|
||||
// ── Connection lifecycle ───────────────────────────────────────────
|
||||
|
||||
@@ -103,6 +103,7 @@ type CreateWorktreeArgsWithSystemProvenance = CreateWorktreeArgs & {
|
||||
}
|
||||
import { classifyWorkspaceCreateError } from './workspace-create-error-classifier'
|
||||
import { advertisedUrlWatcher } from '../ports/advertised-url-watcher'
|
||||
import { localhostWorktreeLabelProxy } from '../localhost-worktree-label-proxy'
|
||||
import {
|
||||
assertWorktreeDoesNotContainRegisteredWorktree,
|
||||
canCleanupUnregisteredOrcaLeftoverDirectory,
|
||||
@@ -161,6 +162,9 @@ function removeWorktreeMetadataAndTransientState(store: Store, worktreeId: strin
|
||||
// drop process-local caches before the same ID can point at a new workspace.
|
||||
store.removeWorktreeMeta(worktreeId)
|
||||
advertisedUrlWatcher.forgetWorktree(worktreeId)
|
||||
// Why: drop this worktree's localhost label routes so they don't accumulate
|
||||
// in the proxy's route maps for the rest of the session.
|
||||
localhostWorktreeLabelProxy.unregisterWorktree(worktreeId)
|
||||
deleteWorktreeHistoryDir(worktreeId)
|
||||
// Why: release the removed worktree's PR-refresh aliases so coalesced queue
|
||||
// entries do not retain it for the rest of the session (memory creep).
|
||||
|
||||
@@ -124,4 +124,33 @@ describe('localhost worktree label proxy', () => {
|
||||
|
||||
expect(result.status).toBe(404)
|
||||
})
|
||||
|
||||
it('drops a worktree label routes on unregisterWorktree, leaving others intact', async () => {
|
||||
const port = await startUpstream((_request, response) => {
|
||||
response.writeHead(200, { 'content-type': 'text/plain' })
|
||||
response.end('ok')
|
||||
})
|
||||
const proxy = new LocalhostWorktreeLabelProxy()
|
||||
const a = await proxy.registerRoute({
|
||||
targetUrl: `http://localhost:${port}/`,
|
||||
projectName: 'Snap Studio',
|
||||
worktreeName: 'feature-a',
|
||||
worktreeId: 'wt-a'
|
||||
})
|
||||
const b = await proxy.registerRoute({
|
||||
targetUrl: `http://localhost:${port}/`,
|
||||
projectName: 'Snap Studio',
|
||||
worktreeName: 'feature-b',
|
||||
worktreeId: 'wt-b'
|
||||
})
|
||||
|
||||
expect((await fetchThroughProxy(a.url)).status).toBe(200)
|
||||
expect((await fetchThroughProxy(b.url)).status).toBe(200)
|
||||
|
||||
proxy.unregisterWorktree('wt-a')
|
||||
|
||||
// The removed worktree's label no longer routes; the other worktree is unaffected.
|
||||
expect((await fetchThroughProxy(a.url)).status).toBe(404)
|
||||
expect((await fetchThroughProxy(b.url)).status).toBe(200)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -49,6 +49,18 @@ export class LocalhostWorktreeLabelProxy {
|
||||
}
|
||||
}
|
||||
|
||||
// Why: routes are added per (worktreeId, targetUrl) but were never removed, so
|
||||
// labels for deleted worktrees accumulated in both maps for the whole session.
|
||||
// Drop them when the worktree is torn down. The shared http.Server stays up.
|
||||
unregisterWorktree(worktreeId: string): void {
|
||||
for (const [label, route] of this.routes) {
|
||||
if (route.worktreeId === worktreeId) {
|
||||
this.routes.delete(label)
|
||||
this.routeKeys.delete(route.routeKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureServer(): Promise<void> {
|
||||
if (this.server && this.listenPort !== null) {
|
||||
return
|
||||
|
||||
@@ -20,7 +20,8 @@ vi.mock('./transcript-reader', async (importOriginal) => {
|
||||
import { isTextBlock } from '../../shared/native-chat-types'
|
||||
import {
|
||||
clearNativeChatTranscriptCache,
|
||||
readNativeChatTranscriptCached
|
||||
readNativeChatTranscriptCached,
|
||||
setNativeChatTranscriptCacheMaxBytesForTests
|
||||
} from './transcript-read-cache'
|
||||
|
||||
let tempRoots: string[] = []
|
||||
@@ -46,8 +47,26 @@ async function seedSession(sessionId: string, turns: number): Promise<string> {
|
||||
return filePath
|
||||
}
|
||||
|
||||
// Writes a transcript file at an explicit path whose on-disk size is ~`bytes`
|
||||
// (padded via one big user message), returning the path. Read it back by passing
|
||||
// the path as `transcriptPath` so resolution doesn't depend on process.env.HOME.
|
||||
async function seedBigFile(name: string, bytes: number): Promise<string> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-cache-bytes-'))
|
||||
tempRoots.push(root)
|
||||
const filePath = join(root, `${name}.jsonl`)
|
||||
const record = {
|
||||
type: 'user',
|
||||
uuid: `u-${name}`,
|
||||
timestamp: '2026-06-01T10:00:00.000Z',
|
||||
message: { role: 'user', content: 'x'.repeat(Math.max(1, bytes)) }
|
||||
}
|
||||
await writeFile(filePath, jsonLines([record]))
|
||||
return filePath
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
clearNativeChatTranscriptCache()
|
||||
setNativeChatTranscriptCacheMaxBytesForTests()
|
||||
readSpy.mockClear()
|
||||
})
|
||||
|
||||
@@ -148,4 +167,59 @@ describe('readNativeChatTranscriptCached', () => {
|
||||
// Distinct files must not share a cached parse object.
|
||||
expect(c).not.toBe(a)
|
||||
})
|
||||
|
||||
// Why: each cached entry is a FULL unwindowed transcript parse; heavy agent
|
||||
// sessions are tens of MB, so the count-only cap let 50 entries retain multiple
|
||||
// GB in the one process serving desktop + every paired client. The byte budget
|
||||
// evicts the oldest large entries while always keeping the most-recent.
|
||||
it('evicts the oldest entry once total cached bytes exceed the budget', async () => {
|
||||
// ~4 KB per file, budget 9 KB → holds 2, a third read evicts the oldest.
|
||||
setNativeChatTranscriptCacheMaxBytesForTests(9 * 1024)
|
||||
const fileA = await seedBigFile('big-a', 4 * 1024)
|
||||
const fileB = await seedBigFile('big-b', 4 * 1024)
|
||||
const fileC = await seedBigFile('big-c', 4 * 1024)
|
||||
|
||||
await readNativeChatTranscriptCached('claude', 'sa', fileA) // spy 1
|
||||
await readNativeChatTranscriptCached('claude', 'sb', fileB) // spy 2
|
||||
await readNativeChatTranscriptCached('claude', 'sc', fileC) // spy 3, evicts A
|
||||
expect(readSpy).toHaveBeenCalledTimes(3)
|
||||
|
||||
// C is the most-recent → still cached (no re-read).
|
||||
await readNativeChatTranscriptCached('claude', 'sc', fileC)
|
||||
expect(readSpy).toHaveBeenCalledTimes(3)
|
||||
|
||||
// A was evicted by the byte cap → this re-reads (and evicts B in turn).
|
||||
await readNativeChatTranscriptCached('claude', 'sa', fileA)
|
||||
expect(readSpy).toHaveBeenCalledTimes(4)
|
||||
|
||||
// B is now evicted → also re-reads.
|
||||
await readNativeChatTranscriptCached('claude', 'sb', fileB)
|
||||
expect(readSpy).toHaveBeenCalledTimes(5)
|
||||
})
|
||||
|
||||
it('keeps a single active transcript larger than the whole budget cached', async () => {
|
||||
// A lone entry over budget must NOT be dropped, else every read re-parses.
|
||||
setNativeChatTranscriptCacheMaxBytesForTests(1024)
|
||||
const big = await seedBigFile('huge', 8 * 1024)
|
||||
await readNativeChatTranscriptCached('claude', 'huge', big)
|
||||
await readNativeChatTranscriptCached('claude', 'huge', big)
|
||||
expect(readSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not evict small entries under the default budget (no regression for typical use)', async () => {
|
||||
const files = await Promise.all([
|
||||
seedBigFile('s1', 512),
|
||||
seedBigFile('s2', 512),
|
||||
seedBigFile('s3', 512)
|
||||
])
|
||||
for (const [i, file] of files.entries()) {
|
||||
await readNativeChatTranscriptCached('claude', `sess-${i}`, file)
|
||||
}
|
||||
expect(readSpy).toHaveBeenCalledTimes(3)
|
||||
// Re-read all three: every one is still cached (byte budget never triggered).
|
||||
for (const [i, file] of files.entries()) {
|
||||
await readNativeChatTranscriptCached('claude', `sess-${i}`, file)
|
||||
}
|
||||
expect(readSpy).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,6 +21,9 @@ type CachedTranscript = {
|
||||
result: ReadTranscriptResult
|
||||
/** mtime of the resolved file when cached; a newer mtime invalidates it. */
|
||||
mtimeMs: number
|
||||
/** On-disk byte size of the resolved file — a cheap, monotonic proxy for this
|
||||
* entry's parsed memory footprint, used to bound the cache by total bytes. */
|
||||
bytes: number
|
||||
}
|
||||
|
||||
const cache = new Map<string, CachedTranscript>()
|
||||
@@ -28,19 +31,36 @@ const cache = new Map<string, CachedTranscript>()
|
||||
// Why: cap the cache so a long-lived process browsing many sessions can't grow
|
||||
// it unbounded. Map preserves insertion order, so evicting the first key drops
|
||||
// the oldest entry (a simple LRU once re-inserts bump recency; see setCached).
|
||||
// Entry-count cap is fine for v1; a byte-aware cap is the follow-up if profiling
|
||||
// shows RSS pressure now that one process serves many remote clients.
|
||||
const MAX_CACHE_ENTRIES = 50
|
||||
// Why: a heavy Claude/Codex coding session's JSONL is routinely tens of MB (tool
|
||||
// results embed whole file contents, command output, and diffs), and each cached
|
||||
// entry is the full unwindowed parse. The count cap alone let 50 such entries
|
||||
// retain multiple GB in the one process that now serves desktop + every paired
|
||||
// web/mobile client. Bound total cached file bytes too; we always keep the most-
|
||||
// recent entry (see setCached) so an active transcript is never re-parsed on
|
||||
// every read, which caps the regression to extra re-parses only past this budget.
|
||||
const MAX_CACHE_BYTES = 128 * 1024 * 1024
|
||||
// Overridable only from tests so the byte-eviction path can be exercised without
|
||||
// writing hundreds of MB of fixtures; production always uses MAX_CACHE_BYTES.
|
||||
let maxCacheBytes = MAX_CACHE_BYTES
|
||||
|
||||
function setCached(key: string, value: CachedTranscript): void {
|
||||
// Re-insert moves the key to the most-recent position for LRU eviction.
|
||||
cache.delete(key)
|
||||
cache.set(key, value)
|
||||
while (cache.size > MAX_CACHE_ENTRIES) {
|
||||
let totalBytes = 0
|
||||
for (const entry of cache.values()) {
|
||||
totalBytes += entry.bytes
|
||||
}
|
||||
// Evict oldest until within BOTH caps, but never drop the most-recent entry
|
||||
// (cache.size > 1): a single active transcript larger than the whole budget
|
||||
// must stay cached or every read would re-parse the full file.
|
||||
while (cache.size > 1 && (cache.size > MAX_CACHE_ENTRIES || totalBytes > maxCacheBytes)) {
|
||||
const oldest = cache.keys().next().value
|
||||
if (oldest === undefined) {
|
||||
break
|
||||
}
|
||||
totalBytes -= cache.get(oldest)?.bytes ?? 0
|
||||
cache.delete(oldest)
|
||||
}
|
||||
}
|
||||
@@ -49,11 +69,12 @@ function cacheKey(agent: AgentType, filePath: string): string {
|
||||
return `${agent}:${filePath}`
|
||||
}
|
||||
|
||||
async function fileMtimeMs(filePath: string): Promise<number> {
|
||||
async function fileStat(filePath: string): Promise<{ mtimeMs: number; bytes: number }> {
|
||||
try {
|
||||
return (await stat(filePath)).mtimeMs
|
||||
const stats = await stat(filePath)
|
||||
return { mtimeMs: stats.mtimeMs, bytes: stats.size }
|
||||
} catch {
|
||||
return Number.NaN
|
||||
return { mtimeMs: Number.NaN, bytes: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +95,7 @@ export async function readNativeChatTranscriptCached(
|
||||
}
|
||||
|
||||
const key = cacheKey(agent, filePath)
|
||||
const mtimeMs = await fileMtimeMs(filePath)
|
||||
const { mtimeMs, bytes } = await fileStat(filePath)
|
||||
const cached = cache.get(key)
|
||||
if (cached && Number.isFinite(mtimeMs) && cached.mtimeMs === mtimeMs) {
|
||||
// Bump recency so a frequently-read session survives eviction.
|
||||
@@ -84,7 +105,7 @@ export async function readNativeChatTranscriptCached(
|
||||
|
||||
const result = await readNativeChatTranscript(agent, sessionId, { filePath })
|
||||
if (Number.isFinite(mtimeMs)) {
|
||||
setCached(key, { result, mtimeMs })
|
||||
setCached(key, { result, mtimeMs, bytes })
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -93,3 +114,8 @@ export async function readNativeChatTranscriptCached(
|
||||
export function clearNativeChatTranscriptCache(): void {
|
||||
cache.clear()
|
||||
}
|
||||
|
||||
/** Test-only: override the byte budget (pass no arg to restore the default). */
|
||||
export function setNativeChatTranscriptCacheMaxBytesForTests(bytes?: number): void {
|
||||
maxCacheBytes = bytes ?? MAX_CACHE_BYTES
|
||||
}
|
||||
|
||||
@@ -3339,6 +3339,11 @@ export class Store {
|
||||
defaults.workspaceSession
|
||||
),
|
||||
sshTargets: (parsed.sshTargets ?? []).map(normalizeSshTarget),
|
||||
deletedSshConfigAliases: Array.isArray(parsed.deletedSshConfigAliases)
|
||||
? parsed.deletedSshConfigAliases.filter(
|
||||
(alias): alias is string => typeof alias === 'string'
|
||||
)
|
||||
: [],
|
||||
sshRemotePtyLeases: (parsed.sshRemotePtyLeases ?? [])
|
||||
.map(normalizeSshRemotePtyLease)
|
||||
.filter((lease): lease is SshRemotePtyLease => lease !== null),
|
||||
@@ -5973,6 +5978,34 @@ export class Store {
|
||||
this.scheduleSave()
|
||||
}
|
||||
|
||||
getDeletedSshConfigAliases(): string[] {
|
||||
return [...(this.state.deletedSshConfigAliases ?? [])]
|
||||
}
|
||||
|
||||
addDeletedSshConfigAlias(alias: string): void {
|
||||
this.state.deletedSshConfigAliases ??= []
|
||||
if (!this.state.deletedSshConfigAliases.includes(alias)) {
|
||||
this.state.deletedSshConfigAliases.push(alias)
|
||||
this.scheduleSave()
|
||||
}
|
||||
}
|
||||
|
||||
removeDeletedSshConfigAlias(alias: string): void {
|
||||
const current = this.state.deletedSshConfigAliases
|
||||
if (!current || !current.includes(alias)) {
|
||||
return
|
||||
}
|
||||
this.state.deletedSshConfigAliases = current.filter((entry) => entry !== alias)
|
||||
this.scheduleSave()
|
||||
}
|
||||
|
||||
clearDeletedSshConfigAliases(): void {
|
||||
if (this.state.deletedSshConfigAliases && this.state.deletedSshConfigAliases.length > 0) {
|
||||
this.state.deletedSshConfigAliases = []
|
||||
this.scheduleSave()
|
||||
}
|
||||
}
|
||||
|
||||
// ── SSH Remote PTY Leases ──────────────────────────────────────────
|
||||
|
||||
getSshRemotePtyLeases(targetId?: string): SshRemotePtyLease[] {
|
||||
|
||||
@@ -80,7 +80,8 @@ describe('SshFilesystemProvider readFile streaming', () => {
|
||||
|
||||
const result = await provider.readFile('/home/user/file.txt')
|
||||
expect(mux.request).toHaveBeenCalledWith('fs.readFileStream', {
|
||||
filePath: '/home/user/file.txt'
|
||||
filePath: '/home/user/file.txt',
|
||||
flowControl: 'ack'
|
||||
})
|
||||
expect(result).toEqual({ content: text, isBinary: false })
|
||||
})
|
||||
|
||||
@@ -153,6 +153,7 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
|
||||
'files.openDiff',
|
||||
'files.read',
|
||||
'files.readChunk',
|
||||
'files.readDir',
|
||||
'files.readPreview',
|
||||
'files.readTerminalArtifact',
|
||||
'files.readTerminalArtifactPreview',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
HEADER_LENGTH,
|
||||
MessageType,
|
||||
@@ -160,6 +160,74 @@ describe('FrameDecoder', () => {
|
||||
expect(frames).toHaveLength(1)
|
||||
expect(frames[0].id).toBe(2)
|
||||
})
|
||||
|
||||
it('decodes frames fed one byte at a time (worst-case boundary straddling)', () => {
|
||||
const frames: DecodedFrame[] = []
|
||||
const decoder = new FrameDecoder((f) => frames.push(f))
|
||||
|
||||
const frame1 = encodeFrame(MessageType.Regular, 1, 0, Buffer.from('first payload'))
|
||||
const frame2 = encodeFrame(MessageType.Regular, 2, 1, Buffer.from('second'))
|
||||
const combined = Buffer.concat([frame1, frame2])
|
||||
|
||||
for (let i = 0; i < combined.length; i += 1) {
|
||||
decoder.feed(combined.subarray(i, i + 1))
|
||||
}
|
||||
|
||||
expect(frames).toHaveLength(2)
|
||||
expect(frames[0].payload.toString()).toBe('first payload')
|
||||
expect(frames[1].id).toBe(2)
|
||||
expect(frames[1].payload.toString()).toBe('second')
|
||||
})
|
||||
|
||||
it('skips an oversized frame fed in odd-sized chunks and resynchronizes', () => {
|
||||
const errors: Error[] = []
|
||||
const frames: DecodedFrame[] = []
|
||||
const decoder = new FrameDecoder(
|
||||
(f) => frames.push(f),
|
||||
(err) => errors.push(err)
|
||||
)
|
||||
|
||||
const oversizedLength = 17 * 1024 * 1024
|
||||
const header = Buffer.alloc(HEADER_LENGTH)
|
||||
header[0] = MessageType.Regular
|
||||
header.writeUInt32BE(1, 1)
|
||||
header.writeUInt32BE(0, 5)
|
||||
header.writeUInt32BE(oversizedLength, 9)
|
||||
const oversized = Buffer.concat([header, Buffer.alloc(oversizedLength)])
|
||||
const valid = encodeFrame(MessageType.Regular, 2, 0, Buffer.from('after'))
|
||||
const combined = Buffer.concat([oversized, valid])
|
||||
|
||||
const chunkSize = 1024 * 1024 - 7
|
||||
for (let i = 0; i < combined.length; i += chunkSize) {
|
||||
decoder.feed(combined.subarray(i, i + chunkSize))
|
||||
}
|
||||
|
||||
expect(errors).toHaveLength(1)
|
||||
expect(frames).toHaveLength(1)
|
||||
expect(frames[0].payload.toString()).toBe('after')
|
||||
})
|
||||
|
||||
it('never rebuilds the buffered stream per feed while assembling a large frame', () => {
|
||||
// Regression: feed() used Buffer.concat([buffered, chunk]) per data event,
|
||||
// re-copying the whole backlog for every TCP chunk — O(n²) memcpy on the
|
||||
// Electron main thread while fs.streamChunk frames arrive (SSH typing lag).
|
||||
const frames: DecodedFrame[] = []
|
||||
const decoder = new FrameDecoder((f) => frames.push(f))
|
||||
const frame = encodeFrame(MessageType.Regular, 1, 0, Buffer.alloc(512 * 1024, 0x61))
|
||||
|
||||
const concatSpy = vi.spyOn(Buffer, 'concat')
|
||||
try {
|
||||
for (let i = 0; i < frame.length; i += 32 * 1024) {
|
||||
decoder.feed(frame.subarray(i, i + 32 * 1024))
|
||||
}
|
||||
} finally {
|
||||
concatSpy.mockRestore()
|
||||
}
|
||||
|
||||
expect(frames).toHaveLength(1)
|
||||
expect(frames[0].payload.length).toBe(512 * 1024)
|
||||
expect(concatSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseJsonRpcMessage', () => {
|
||||
|
||||
@@ -127,7 +127,13 @@ export type DecodedFrame = {
|
||||
* Incremental frame parser. Feed it chunks of data; it emits complete frames.
|
||||
*/
|
||||
export class FrameDecoder {
|
||||
private buffer = Buffer.alloc(0)
|
||||
// Why: feed() runs on the Electron main thread for every SSH channel data
|
||||
// event. Rebuilding one contiguous buffer per feed (Buffer.concat) re-copies
|
||||
// every already-buffered byte for each incoming ~32KB TCP chunk — O(n²) per
|
||||
// large frame (a 340KB fs.streamChunk frame cost ~2MB of memcpy). A chunk
|
||||
// list assembles each frame exactly once instead.
|
||||
private chunks: Buffer[] = []
|
||||
private bufferedLength = 0
|
||||
private onFrame: (frame: DecodedFrame) => void
|
||||
private onError: ((err: Error) => void) | null
|
||||
|
||||
@@ -137,21 +143,31 @@ export class FrameDecoder {
|
||||
}
|
||||
|
||||
feed(chunk: Buffer | Uint8Array): void {
|
||||
this.buffer = Buffer.concat([this.buffer, chunk])
|
||||
const buf = Buffer.isBuffer(chunk)
|
||||
? chunk
|
||||
: Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength)
|
||||
if (buf.length > 0) {
|
||||
this.chunks.push(buf)
|
||||
this.bufferedLength += buf.length
|
||||
}
|
||||
|
||||
while (this.buffer.length >= HEADER_LENGTH) {
|
||||
const length = this.buffer.readUInt32BE(9)
|
||||
while (this.bufferedLength >= HEADER_LENGTH) {
|
||||
const header = this.peekBytes(HEADER_LENGTH)
|
||||
const length = header.readUInt32BE(9)
|
||||
const totalLength = HEADER_LENGTH + length
|
||||
|
||||
if (this.bufferedLength < totalLength) {
|
||||
// Not fully received yet (also holds oversized frames until they can
|
||||
// be skipped whole, keeping the decoder synchronized).
|
||||
break
|
||||
}
|
||||
|
||||
// Why: throwing here would leave the buffer in a partially consumed
|
||||
// state — subsequent feed() calls would try to parse leftover payload
|
||||
// bytes as a new header, corrupting every future frame. Instead we
|
||||
// skip the entire oversized frame so the decoder stays synchronized.
|
||||
if (length > MAX_MESSAGE_SIZE) {
|
||||
if (this.buffer.length < totalLength) {
|
||||
break
|
||||
}
|
||||
this.buffer = this.buffer.subarray(totalLength)
|
||||
this.discardBytes(totalLength)
|
||||
const err = new Error(`Frame payload too large: ${length} bytes — discarded`)
|
||||
if (this.onError) {
|
||||
this.onError(err)
|
||||
@@ -159,24 +175,83 @@ export class FrameDecoder {
|
||||
continue
|
||||
}
|
||||
|
||||
if (this.buffer.length < totalLength) {
|
||||
break
|
||||
}
|
||||
|
||||
const framed = this.takeBytes(totalLength)
|
||||
const frame: DecodedFrame = {
|
||||
type: this.buffer[0],
|
||||
id: this.buffer.readUInt32BE(1),
|
||||
ack: this.buffer.readUInt32BE(5),
|
||||
payload: this.buffer.subarray(HEADER_LENGTH, totalLength)
|
||||
type: framed[0],
|
||||
id: framed.readUInt32BE(1),
|
||||
ack: framed.readUInt32BE(5),
|
||||
payload: framed.subarray(HEADER_LENGTH, totalLength)
|
||||
}
|
||||
|
||||
this.buffer = this.buffer.subarray(totalLength)
|
||||
this.onFrame(frame)
|
||||
}
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.buffer = Buffer.alloc(0)
|
||||
this.chunks = []
|
||||
this.bufferedLength = 0
|
||||
}
|
||||
|
||||
/** View of the first `count` buffered bytes without consuming them. */
|
||||
private peekBytes(count: number): Buffer {
|
||||
const first = this.chunks[0]
|
||||
if (first.length >= count) {
|
||||
return first
|
||||
}
|
||||
const out = Buffer.allocUnsafe(count)
|
||||
let copied = 0
|
||||
for (const part of this.chunks) {
|
||||
copied += part.copy(out, copied, 0, Math.min(part.length, count - copied))
|
||||
if (copied >= count) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Consume and return the first `count` buffered bytes (single copy). */
|
||||
private takeBytes(count: number): Buffer {
|
||||
const first = this.chunks[0]
|
||||
if (first.length === count) {
|
||||
this.chunks.shift()
|
||||
this.bufferedLength -= count
|
||||
return first
|
||||
}
|
||||
if (first.length > count) {
|
||||
this.chunks[0] = first.subarray(count)
|
||||
this.bufferedLength -= count
|
||||
return first.subarray(0, count)
|
||||
}
|
||||
const out = Buffer.allocUnsafe(count)
|
||||
let copied = 0
|
||||
while (copied < count) {
|
||||
const part = this.chunks[0]
|
||||
const take = Math.min(part.length, count - copied)
|
||||
part.copy(out, copied, 0, take)
|
||||
copied += take
|
||||
if (take === part.length) {
|
||||
this.chunks.shift()
|
||||
} else {
|
||||
this.chunks[0] = part.subarray(take)
|
||||
}
|
||||
}
|
||||
this.bufferedLength -= count
|
||||
return out
|
||||
}
|
||||
|
||||
/** Consume the first `count` buffered bytes without assembling them. */
|
||||
private discardBytes(count: number): void {
|
||||
let remaining = count
|
||||
while (remaining > 0) {
|
||||
const part = this.chunks[0]
|
||||
if (part.length <= remaining) {
|
||||
this.chunks.shift()
|
||||
remaining -= part.length
|
||||
} else {
|
||||
this.chunks[0] = part.subarray(remaining)
|
||||
remaining = 0
|
||||
}
|
||||
}
|
||||
this.bufferedLength -= count
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ vi.mock('./ssh-config-parser', () => ({
|
||||
|
||||
function createMockStore() {
|
||||
const targets: SshTarget[] = []
|
||||
let deletedAliases: string[] = []
|
||||
|
||||
return {
|
||||
getSshTargets: vi.fn(() => [...targets]),
|
||||
@@ -32,6 +33,18 @@ function createMockStore() {
|
||||
if (idx !== -1) {
|
||||
targets.splice(idx, 1)
|
||||
}
|
||||
}),
|
||||
getDeletedSshConfigAliases: vi.fn(() => [...deletedAliases]),
|
||||
addDeletedSshConfigAlias: vi.fn((alias: string) => {
|
||||
if (!deletedAliases.includes(alias)) {
|
||||
deletedAliases.push(alias)
|
||||
}
|
||||
}),
|
||||
removeDeletedSshConfigAlias: vi.fn((alias: string) => {
|
||||
deletedAliases = deletedAliases.filter((entry) => entry !== alias)
|
||||
}),
|
||||
clearDeletedSshConfigAliases: vi.fn(() => {
|
||||
deletedAliases = []
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -324,4 +337,106 @@ describe('SshConnectionStore', () => {
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleted config host tombstones', () => {
|
||||
function candidate(overrides: Partial<SshTarget> & { configHost: string }): SshTarget {
|
||||
return {
|
||||
id: `tmp-${overrides.configHost}`,
|
||||
label: overrides.configHost,
|
||||
host: `${overrides.configHost}.example.com`,
|
||||
port: 22,
|
||||
username: '',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
// PRIMARY regression: deleting a config-sourced host must not let the next
|
||||
// ~/.ssh/config sync resurrect it.
|
||||
it('tombstones a deleted config-sourced host so re-import skips it', () => {
|
||||
mockStore.addSshTarget({
|
||||
id: 'ssh-1',
|
||||
label: 'mini',
|
||||
configHost: 'mini',
|
||||
host: 'mini.example.com',
|
||||
port: 22,
|
||||
username: 'ping',
|
||||
source: 'ssh-config'
|
||||
})
|
||||
|
||||
sshStore.removeTarget('ssh-1')
|
||||
expect(mockStore.addDeletedSshConfigAlias).toHaveBeenCalledWith('mini')
|
||||
|
||||
loadUserSshConfigMock.mockReturnValue([{ host: 'mini' }])
|
||||
sshConfigHostsToTargetsMock.mockReturnValue([candidate({ configHost: 'mini' })])
|
||||
|
||||
const result = sshStore.importFromSshConfig()
|
||||
|
||||
expect(mockStore.addSshTarget).toHaveBeenCalledTimes(1) // only the seed insert
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('does not tombstone a manual target on delete', () => {
|
||||
mockStore.addSshTarget({
|
||||
id: 'ssh-1',
|
||||
label: 'mini',
|
||||
configHost: 'mini',
|
||||
host: 'mini.example.com',
|
||||
port: 22,
|
||||
username: 'ping',
|
||||
source: 'manual'
|
||||
})
|
||||
|
||||
sshStore.removeTarget('ssh-1')
|
||||
expect(mockStore.addDeletedSshConfigAlias).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('re-adding a deleted host reclaims its alias so sync stops suppressing it', () => {
|
||||
mockStore.addDeletedSshConfigAlias('mini')
|
||||
|
||||
sshStore.addTarget({
|
||||
label: 'mini',
|
||||
configHost: 'mini',
|
||||
host: '10.0.0.2',
|
||||
port: 22,
|
||||
username: 'ping'
|
||||
})
|
||||
expect(mockStore.removeDeletedSshConfigAlias).toHaveBeenCalledWith('mini')
|
||||
|
||||
loadUserSshConfigMock.mockReturnValue([{ host: 'mini' }])
|
||||
sshConfigHostsToTargetsMock.mockReturnValue([candidate({ configHost: 'mini' })])
|
||||
// Alias reclaimed, but it is now a manual target — still not re-inserted.
|
||||
const result = sshStore.importFromSshConfig()
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('editing a target reclaims its alias from the deleted set', () => {
|
||||
mockStore.addSshTarget({
|
||||
id: 'ssh-1',
|
||||
label: 'mini',
|
||||
configHost: 'mini',
|
||||
host: 'mini.example.com',
|
||||
port: 22,
|
||||
username: 'ping',
|
||||
source: 'ssh-config'
|
||||
})
|
||||
mockStore.addDeletedSshConfigAlias('mini')
|
||||
|
||||
sshStore.updateTarget('ssh-1', { port: 2222, source: 'manual' })
|
||||
expect(mockStore.removeDeletedSshConfigAlias).toHaveBeenCalledWith('mini')
|
||||
})
|
||||
|
||||
it('reAdopt clears all tombstones and re-imports the deleted host', () => {
|
||||
mockStore.addDeletedSshConfigAlias('mini')
|
||||
loadUserSshConfigMock.mockReturnValue([{ host: 'mini' }])
|
||||
sshConfigHostsToTargetsMock.mockReturnValue([candidate({ configHost: 'mini' })])
|
||||
|
||||
const result = sshStore.importFromSshConfig({ reAdopt: true })
|
||||
|
||||
expect(mockStore.clearDeletedSshConfigAliases).toHaveBeenCalled()
|
||||
expect(mockStore.addSshTarget).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ configHost: 'mini', source: 'ssh-config' })
|
||||
)
|
||||
expect(result).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -23,6 +23,9 @@ export class SshConnectionStore {
|
||||
source: target.source ?? 'manual',
|
||||
id: `ssh-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
}
|
||||
// Why: re-adding a host the user previously deleted is an explicit intent to
|
||||
// keep it — lift any tombstone so config sync stops suppressing this alias.
|
||||
this.reclaimAlias(full.configHost ?? full.label)
|
||||
this.store.addSshTarget(full)
|
||||
return full
|
||||
}
|
||||
@@ -51,19 +54,49 @@ export class SshConnectionStore {
|
||||
}
|
||||
|
||||
updateTarget(id: string, updates: Partial<Omit<SshTarget, 'id'>>): SshTarget | null {
|
||||
return this.store.updateSshTarget(id, updates)
|
||||
const updated = this.store.updateSshTarget(id, updates)
|
||||
if (updated) {
|
||||
// Why: actively editing a target reclaims its alias from the deleted set,
|
||||
// so an edit can never leave the host tombstoned.
|
||||
this.reclaimAlias(updated.configHost ?? updated.label)
|
||||
}
|
||||
return updated
|
||||
}
|
||||
|
||||
removeTarget(id: string): void {
|
||||
const target = this.store.getSshTarget(id)
|
||||
// Why: deleting a config-managed target must record a tombstone; otherwise
|
||||
// the next ~/.ssh/config sync re-inserts it verbatim (the config entry still
|
||||
// exists on disk) and the host reappears. Manual targets need no tombstone —
|
||||
// sync never re-adds them.
|
||||
if (target && isConfigManagedTarget(target)) {
|
||||
const alias = target.configHost ?? target.label
|
||||
if (alias) {
|
||||
this.store.addDeletedSshConfigAlias(alias)
|
||||
}
|
||||
}
|
||||
this.store.removeSshTarget(id)
|
||||
}
|
||||
|
||||
private reclaimAlias(alias: string | undefined): void {
|
||||
if (alias) {
|
||||
this.store.removeDeletedSshConfigAlias(alias)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync targets from ~/.ssh/config: insert new hosts, update existing
|
||||
* config-sourced ones in place (so a rotated port takes effect), never touch
|
||||
* manual targets. Returns the inserted and updated targets.
|
||||
*/
|
||||
importFromSshConfig(): SshTarget[] {
|
||||
importFromSshConfig(options?: { reAdopt?: boolean }): SshTarget[] {
|
||||
// Why: the explicit Import action re-adopts every config host, so it clears
|
||||
// all tombstones first. The passive on-open sync passes no flag and keeps
|
||||
// deleted hosts suppressed.
|
||||
if (options?.reAdopt) {
|
||||
this.store.clearDeletedSshConfigAliases()
|
||||
}
|
||||
const deletedAliases = new Set(this.store.getDeletedSshConfigAliases())
|
||||
const configHosts = loadUserSshConfig()
|
||||
const existingTargets = this.store.getSshTargets()
|
||||
// Map config-managed targets (and legacy targets that strongly look like
|
||||
@@ -102,6 +135,11 @@ export class SshConnectionStore {
|
||||
// A manual target owns this alias — never clobber it.
|
||||
continue
|
||||
}
|
||||
if (deletedAliases.has(alias)) {
|
||||
// The user deleted this config host — stay deleted until they re-add it
|
||||
// or re-adopt config explicitly.
|
||||
continue
|
||||
}
|
||||
if (processedAliases.has(alias)) {
|
||||
continue
|
||||
}
|
||||
@@ -156,6 +194,16 @@ export function isRuntimeOwnedSshTarget(target: SshTarget): boolean {
|
||||
return target.owner?.type === 'on-demand-runtime'
|
||||
}
|
||||
|
||||
function isConfigManagedTarget(target: SshTarget): boolean {
|
||||
// Why: a target is subject to config sync (and therefore needs a tombstone on
|
||||
// delete) when it is explicitly config-sourced, or a legacy import that sync
|
||||
// still adopts. Manual targets are excluded — sync never re-adds them.
|
||||
return (
|
||||
target.source === 'ssh-config' ||
|
||||
(target.source === undefined && isLegacyConfigImportTarget(target))
|
||||
)
|
||||
}
|
||||
|
||||
function isLegacyConfigImportTarget(target: SshTarget): boolean {
|
||||
const alias = target.configHost ?? target.label
|
||||
// Why: legacy manual and imported targets both lack `source`. Only adopt the
|
||||
|
||||
@@ -148,6 +148,10 @@ export async function readFileViaStream(
|
||||
expectedSeq += 1
|
||||
receivedChunks += 1
|
||||
bytesReceived += decoded.length
|
||||
// Why: credit-based flow control — the relay caps unacked chunks so bulk
|
||||
// stream frames cannot queue unbounded ahead of interactive pty.data
|
||||
// frames on the shared SSH channel. Old relays ignore this notification.
|
||||
mux.notify('fs.streamAck', { streamId: id, seq })
|
||||
}
|
||||
|
||||
const handleEnd = (params: Record<string, unknown>): void => {
|
||||
@@ -260,7 +264,9 @@ export async function readFileViaStream(
|
||||
unsubscribers.push(onDispose)
|
||||
|
||||
void mux
|
||||
.request('fs.readFileStream', { filePath })
|
||||
// Why: flowControl declares this client acks each chunk, letting a new
|
||||
// relay pace the pump. Old relays ignore the extra param and flood.
|
||||
.request('fs.readFileStream', { filePath, flowControl: 'ack' })
|
||||
.then((rawMetadata) => {
|
||||
if (settled) {
|
||||
return
|
||||
|
||||
@@ -267,6 +267,27 @@ describe('SshPortForwardManager', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps only a bounded tail of a chatty forward stderr (memory-leak regression)', async () => {
|
||||
const onForwardClosed = vi.fn()
|
||||
manager.setCallbacks({ onForwardClosed })
|
||||
const forward = createFakeSystemSshForward()
|
||||
startSystemSshPortForwardProcessMock.mockReturnValue(forward)
|
||||
const conn = createSystemSshConn()
|
||||
|
||||
await manager.addForward('conn-1', conn as never, 3000, '127.0.0.1', 8080)
|
||||
// A long-lived forward against a chatty remote sshd: emit >64 KB of stderr.
|
||||
forward.process.stderr.emit('data', Buffer.from(`HEAD_MARKER${'x'.repeat(70 * 1024)}`))
|
||||
forward.process.stderr.emit('data', Buffer.from('TAIL_MARKER'))
|
||||
forward.process.emit('exit', 255)
|
||||
|
||||
const detail = onForwardClosed.mock.calls[0]?.[1]?.detail as string
|
||||
// The oldest bytes are trimmed; the most-recent tail is retained.
|
||||
expect(detail).toContain('TAIL_MARKER')
|
||||
expect(detail).not.toContain('HEAD_MARKER')
|
||||
// Bounded well below the ~70 KB produced.
|
||||
expect(detail.length).toBeLessThan(66 * 1024)
|
||||
})
|
||||
|
||||
it('lists forwards filtered by connectionId', async () => {
|
||||
const conn = createMockConn()
|
||||
await manager.addForward('conn-1', conn as never, 3000, 'localhost', 8080)
|
||||
|
||||
@@ -33,9 +33,17 @@ export class SystemSshPortForwardProvider implements SshPortForwardProvider {
|
||||
)
|
||||
|
||||
await forward.waitForStartup()
|
||||
// Why: this stderr stays attached for the forward's whole lifetime but is
|
||||
// only used to build the exit-error detail, so keep a bounded tail — a chatty
|
||||
// remote sshd could otherwise grow it unbounded on a long-lived forward
|
||||
// (mirrors MAX_RELAY_STARTUP_BUFFER_BYTES in ssh-relay-deploy-helpers).
|
||||
const MAX_STDERR_TAIL_BYTES = 64 * 1024
|
||||
let stderr = ''
|
||||
const onStderr = (chunk: Buffer): void => {
|
||||
stderr += chunk.toString('utf-8')
|
||||
if (stderr.length > MAX_STDERR_TAIL_BYTES) {
|
||||
stderr = stderr.slice(-MAX_STDERR_TAIL_BYTES)
|
||||
}
|
||||
}
|
||||
forward.process.stderr?.on('data', onStderr)
|
||||
|
||||
|
||||
@@ -9,16 +9,56 @@ vi.mock('child_process', () => ({
|
||||
execFile: execFileMock
|
||||
}))
|
||||
|
||||
function expectedFallbackFont(): string {
|
||||
if (process.platform === 'darwin') {
|
||||
function expectedFallbackFont(platform = process.platform): string {
|
||||
if (platform === 'darwin') {
|
||||
return 'SF Mono'
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
if (platform === 'win32') {
|
||||
return 'Cascadia Mono'
|
||||
}
|
||||
return 'JetBrains Mono'
|
||||
}
|
||||
|
||||
async function withPlatform<T>(platform: NodeJS.Platform, fn: () => Promise<T>): Promise<T> {
|
||||
const originalPlatform = process.platform
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: platform })
|
||||
try {
|
||||
return await fn()
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: originalPlatform
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function expectFontCommandTimeout(
|
||||
platform: NodeJS.Platform,
|
||||
timeoutMs: number
|
||||
): Promise<void> {
|
||||
await withPlatform(platform, async () => {
|
||||
vi.useFakeTimers()
|
||||
execFileMock.mockReturnValue({ kill: killMock })
|
||||
|
||||
const { listSystemFontFamilies } = await import('./system-fonts')
|
||||
const fontsPromise = listSystemFontFamilies()
|
||||
let resolvedFonts: string[] | null = null
|
||||
fontsPromise.then((fonts) => {
|
||||
resolvedFonts = fonts
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(timeoutMs - 1)
|
||||
|
||||
expect(resolvedFonts).toBeNull()
|
||||
expect(killMock).not.toHaveBeenCalled()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
|
||||
await expect(fontsPromise).resolves.toContain(expectedFallbackFont(platform))
|
||||
expect(killMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
}
|
||||
|
||||
describe('listSystemFontFamilies', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
@@ -44,4 +84,15 @@ describe('listSystemFontFamilies', () => {
|
||||
expect(resolvedFonts).toContain(expectedFallbackFont())
|
||||
expect(killMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('uses the longer timeout for macOS profiler scans', async () => {
|
||||
await expectFontCommandTimeout('darwin', 45_000)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['linux' as NodeJS.Platform, 15_000],
|
||||
['win32' as NodeJS.Platform, 15_000]
|
||||
])('keeps the %s font command timeout short', async (platform, timeoutMs) => {
|
||||
await expectFontCommandTimeout(platform, timeoutMs)
|
||||
})
|
||||
})
|
||||
|
||||
+28
-17
@@ -3,6 +3,9 @@ import { execFile } from 'node:child_process'
|
||||
let cachedFonts: string[] | null = null
|
||||
let fontsPromise: Promise<string[]> | null = null
|
||||
const SYSTEM_FONT_LIST_TIMEOUT_MS = 15_000
|
||||
// Why: large macOS font catalogs can make system_profiler exceed 15s even
|
||||
// when it is healthy; keep the longer wait scoped to that slow command.
|
||||
const MAC_SYSTEM_FONT_LIST_TIMEOUT_MS = 45_000
|
||||
|
||||
export async function listSystemFontFamilies(): Promise<string[]> {
|
||||
if (cachedFonts) {
|
||||
@@ -43,23 +46,26 @@ function loadSystemFontFamilies(): Promise<string[]> {
|
||||
}
|
||||
|
||||
function listMacFonts(): Promise<string[]> {
|
||||
return execFileText('system_profiler', ['SPFontsDataType', '-json'], 32 * 1024 * 1024).then(
|
||||
(output) => {
|
||||
const parsed = JSON.parse(output) as {
|
||||
SPFontsDataType?: {
|
||||
typefaces?: {
|
||||
family?: string
|
||||
}[]
|
||||
return execFileText(
|
||||
'system_profiler',
|
||||
['SPFontsDataType', '-json'],
|
||||
32 * 1024 * 1024,
|
||||
MAC_SYSTEM_FONT_LIST_TIMEOUT_MS
|
||||
).then((output) => {
|
||||
const parsed = JSON.parse(output) as {
|
||||
SPFontsDataType?: {
|
||||
typefaces?: {
|
||||
family?: string
|
||||
}[]
|
||||
}
|
||||
|
||||
return uniqueSorted(
|
||||
(parsed.SPFontsDataType ?? []).flatMap((font) =>
|
||||
(font.typefaces ?? []).map((typeface) => typeface.family)
|
||||
)
|
||||
)
|
||||
}[]
|
||||
}
|
||||
)
|
||||
|
||||
return uniqueSorted(
|
||||
(parsed.SPFontsDataType ?? []).flatMap((font) =>
|
||||
(font.typefaces ?? []).map((typeface) => typeface.family)
|
||||
)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function listLinuxFonts(): Promise<string[]> {
|
||||
@@ -95,7 +101,12 @@ $fonts.Families | ForEach-Object { $_.Name }
|
||||
)
|
||||
}
|
||||
|
||||
function execFileText(command: string, args: string[], maxBuffer: number): Promise<string> {
|
||||
function execFileText(
|
||||
command: string,
|
||||
args: string[],
|
||||
maxBuffer: number,
|
||||
timeoutMs = SYSTEM_FONT_LIST_TIMEOUT_MS
|
||||
): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
@@ -123,7 +134,7 @@ function execFileText(command: string, args: string[], maxBuffer: number): Promi
|
||||
// should fall back instead of keeping settings IPC pending forever.
|
||||
child.kill()
|
||||
reject(new Error(`Timed out listing system fonts with ${command}`))
|
||||
}, SYSTEM_FONT_LIST_TIMEOUT_MS)
|
||||
}, timeoutMs)
|
||||
if (typeof timer === 'object' && 'unref' in timer) {
|
||||
timer.unref()
|
||||
}
|
||||
|
||||
@@ -2879,7 +2879,7 @@ export type PreloadApi = {
|
||||
updates: Partial<Omit<SshTarget, 'id'>>
|
||||
}) => Promise<SshTarget>
|
||||
removeTarget: (args: { id: string }) => Promise<void>
|
||||
importConfig: () => Promise<SshTarget[]>
|
||||
importConfig: (args?: { reAdopt?: boolean }) => Promise<SshTarget[]>
|
||||
connect: (args: { targetId: string }) => Promise<SshConnectionState | null>
|
||||
disconnect: (args: { targetId: string }) => Promise<void>
|
||||
terminateSessions: (args: { targetId: string }) => Promise<void>
|
||||
|
||||
@@ -3947,7 +3947,8 @@ const api = {
|
||||
removeTarget: (args: { id: string }): Promise<void> =>
|
||||
ipcRenderer.invoke('ssh:removeTarget', args),
|
||||
|
||||
importConfig: (): Promise<SshTarget[]> => ipcRenderer.invoke('ssh:importConfig'),
|
||||
importConfig: (args?: { reAdopt?: boolean }): Promise<SshTarget[]> =>
|
||||
ipcRenderer.invoke('ssh:importConfig', args),
|
||||
|
||||
connect: (args: { targetId: string }): Promise<SshConnectionState | null> =>
|
||||
ipcRenderer.invoke('ssh:connect', args),
|
||||
|
||||
@@ -395,4 +395,119 @@ describe('RelayDispatcher', () => {
|
||||
|
||||
expect(listener).toHaveBeenCalledWith(1)
|
||||
})
|
||||
|
||||
describe('notifyBulk (bulk lane backpressure)', () => {
|
||||
it('resolves immediately when the sink accepts the frame', async () => {
|
||||
const frames: Buffer[] = []
|
||||
const bulkDispatcher = new RelayDispatcher((data) => {
|
||||
frames.push(Buffer.from(data))
|
||||
return true
|
||||
})
|
||||
try {
|
||||
await bulkDispatcher.notifyBulk('bulk.event', { seq: 0 })
|
||||
expect(frames).toHaveLength(1)
|
||||
const frame = decodeFirstFrame(frames[0])
|
||||
const msg = JSON.parse(frame.payload.toString()) as JsonRpcNotification
|
||||
expect(msg.method).toBe('bulk.event')
|
||||
} finally {
|
||||
bulkDispatcher.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('holds the next bulk frame until the saturated sink drains', async () => {
|
||||
const frames: Buffer[] = []
|
||||
const drainWaiters = new Set<() => void>()
|
||||
const bulkDispatcher = new RelayDispatcher(
|
||||
(data) => {
|
||||
frames.push(Buffer.from(data))
|
||||
return false
|
||||
},
|
||||
{ waitWriteDrain: (cb) => drainWaiters.add(cb) }
|
||||
)
|
||||
try {
|
||||
let firstSettled = false
|
||||
const first = bulkDispatcher.notifyBulk('bulk.event', { seq: 0 }).then(() => {
|
||||
firstSettled = true
|
||||
})
|
||||
void bulkDispatcher.notifyBulk('bulk.event', { seq: 1 })
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
// First frame written, but its send has not settled and the second
|
||||
// frame is not admitted while the sink stays saturated.
|
||||
expect(frames).toHaveLength(1)
|
||||
expect(firstSettled).toBe(false)
|
||||
|
||||
for (const cb of Array.from(drainWaiters)) {
|
||||
drainWaiters.delete(cb)
|
||||
cb()
|
||||
}
|
||||
await first
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(frames).toHaveLength(2)
|
||||
} finally {
|
||||
bulkDispatcher.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('interactive notify() frames are not gated behind a stalled bulk lane', async () => {
|
||||
const frames: Buffer[] = []
|
||||
const bulkDispatcher = new RelayDispatcher(
|
||||
(data) => {
|
||||
frames.push(Buffer.from(data))
|
||||
return false
|
||||
},
|
||||
{ waitWriteDrain: () => {} }
|
||||
)
|
||||
try {
|
||||
void bulkDispatcher.notifyBulk('bulk.event', { seq: 0 })
|
||||
void bulkDispatcher.notifyBulk('bulk.event', { seq: 1 })
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(frames).toHaveLength(1)
|
||||
|
||||
bulkDispatcher.notify('pty.data', { id: 'pty-1', data: 'x' })
|
||||
expect(frames).toHaveLength(2)
|
||||
const msg = JSON.parse(
|
||||
decodeFirstFrame(frames[1]).payload.toString()
|
||||
) as JsonRpcNotification
|
||||
expect(msg.method).toBe('pty.data')
|
||||
} finally {
|
||||
bulkDispatcher.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('releases a parked bulk send when the dispatcher is disposed', async () => {
|
||||
const bulkDispatcher = new RelayDispatcher(() => false, { waitWriteDrain: () => {} })
|
||||
const pending = bulkDispatcher.notifyBulk('bulk.event', { seq: 0 })
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
bulkDispatcher.dispose()
|
||||
await expect(pending).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('targets only the requested client and resolves for missing clients', async () => {
|
||||
const primaryFrames: Buffer[] = []
|
||||
const secondaryFrames: Buffer[] = []
|
||||
const bulkDispatcher = new RelayDispatcher((data) => {
|
||||
primaryFrames.push(Buffer.from(data))
|
||||
return true
|
||||
})
|
||||
try {
|
||||
const secondaryId = bulkDispatcher.attachClient((data) => {
|
||||
secondaryFrames.push(Buffer.from(data))
|
||||
return true
|
||||
})
|
||||
|
||||
await bulkDispatcher.notifyBulk('bulk.event', { seq: 0 }, { clientId: secondaryId })
|
||||
expect(primaryFrames).toHaveLength(0)
|
||||
expect(secondaryFrames).toHaveLength(1)
|
||||
|
||||
await expect(
|
||||
bulkDispatcher.notifyBulk('bulk.event', { seq: 1 }, { clientId: 999 })
|
||||
).resolves.toBeUndefined()
|
||||
expect(primaryFrames).toHaveLength(0)
|
||||
expect(secondaryFrames).toHaveLength(1)
|
||||
} finally {
|
||||
bulkDispatcher.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+126
-11
@@ -27,10 +27,28 @@ export type MethodHandler = (
|
||||
|
||||
export type NotificationHandler = (params: Record<string, unknown>, context: RequestContext) => void
|
||||
|
||||
/** Sink write. Returning literal `false` signals saturation (Node stream
|
||||
* semantics); `void`/`true` mean the frame was accepted. */
|
||||
export type RelayClientWrite = (data: Buffer) => boolean | void
|
||||
|
||||
export type RelayClientSinkOptions = {
|
||||
/** One-shot: invoke `cb` once when the sink can accept more data again
|
||||
* (stream 'drain'), or when the sink is permanently dead (error/close) so
|
||||
* bulk senders waiting on it never hang. */
|
||||
waitWriteDrain?: (cb: () => void) => void
|
||||
}
|
||||
|
||||
type RelayClient = {
|
||||
id: number
|
||||
decoder: FrameDecoder
|
||||
write: (data: Buffer) => void
|
||||
write: RelayClientWrite
|
||||
waitWriteDrain?: (cb: () => void) => void
|
||||
/** Pending resolvers for bulk sends stalled on sink saturation. Flushed on
|
||||
* drain, write failure, detach, setWrite, and dispose so no pump hangs. */
|
||||
drainWaiters: Set<() => void>
|
||||
/** Serializes bulk-lane sends per client so at most one bulk frame is
|
||||
* admitted past the sink's high-water mark at a time. */
|
||||
bulkChain: Promise<void>
|
||||
nextOutgoingSeq: number
|
||||
highestReceivedSeq: number
|
||||
generation: number
|
||||
@@ -58,8 +76,8 @@ export class RelayDispatcher {
|
||||
private nextClientId = 1
|
||||
private nextRequestId = 1
|
||||
|
||||
constructor(write: (data: Buffer) => void) {
|
||||
this.primaryClient = this.createClient(write)
|
||||
constructor(write: RelayClientWrite, sinkOptions?: RelayClientSinkOptions) {
|
||||
this.primaryClient = this.createClient(write, sinkOptions)
|
||||
this.clients.set(this.primaryClient.id, this.primaryClient)
|
||||
this.startKeepalive()
|
||||
}
|
||||
@@ -75,10 +93,14 @@ export class RelayDispatcher {
|
||||
// never acks the new client's frames until the new client's seq catches
|
||||
// up - causing the client's unacked-timeout checker to accumulate stale
|
||||
// timestamps that could eventually fire a false connection-dead signal.
|
||||
setWrite(write: (data: Buffer) => void): void {
|
||||
setWrite(write: RelayClientWrite, sinkOptions?: RelayClientSinkOptions): void {
|
||||
this.requestAborts.abortClient(this.primaryClient.id)
|
||||
this.primaryClient.write = write
|
||||
this.primaryClient.waitWriteDrain = sinkOptions?.waitWriteDrain
|
||||
this.primaryClient.closed = false
|
||||
// Why: the saturated sink the waiters were parked on no longer exists;
|
||||
// wake stalled bulk senders so they re-evaluate against the new sink.
|
||||
this.flushDrainWaiters(this.primaryClient)
|
||||
this.resetClient(this.primaryClient)
|
||||
}
|
||||
|
||||
@@ -89,14 +111,15 @@ export class RelayDispatcher {
|
||||
this.requestAborts.abortClient(this.primaryClient.id)
|
||||
this.primaryClient.generation++
|
||||
this.primaryClient.closed = true
|
||||
this.flushDrainWaiters(this.primaryClient)
|
||||
this.notifyClientDetached(this.primaryClient.id)
|
||||
}
|
||||
|
||||
// Why: synced remote workspaces can have more than one Orca client attached
|
||||
// to the same relay. Frame sequence numbers and JSON-RPC request ids are per
|
||||
// SSH channel, so each socket client needs independent protocol state.
|
||||
attachClient(write: (data: Buffer) => void): number {
|
||||
const client = this.createClient(write)
|
||||
attachClient(write: RelayClientWrite, sinkOptions?: RelayClientSinkOptions): number {
|
||||
const client = this.createClient(write, sinkOptions)
|
||||
this.clients.set(client.id, client)
|
||||
return client.id
|
||||
}
|
||||
@@ -109,6 +132,7 @@ export class RelayDispatcher {
|
||||
this.requestAborts.abortClient(clientId)
|
||||
client.generation++
|
||||
client.closed = true
|
||||
this.flushDrainWaiters(client)
|
||||
this.clients.delete(clientId)
|
||||
this.notifyClientDetached(clientId)
|
||||
}
|
||||
@@ -165,6 +189,90 @@ export class RelayDispatcher {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk-lane notification. Sends are serialized per client and the returned
|
||||
* promise resolves only after the sink accepted the frame without reporting
|
||||
* saturation (or the client went away). Bulk producers (file streams) await
|
||||
* this between frames so interactive frames (pty.data echo) never queue
|
||||
* behind an unbounded backlog on the shared SSH channel.
|
||||
*
|
||||
* With `clientId`, the frame goes only to that client — stream chunks have
|
||||
* exactly one consumer, and broadcasting them would let one slow secondary
|
||||
* client stall everyone. A missing/closed target resolves immediately; the
|
||||
* caller's staleness check owns aborting the stream.
|
||||
*/
|
||||
notifyBulk(
|
||||
method: string,
|
||||
params?: Record<string, unknown>,
|
||||
opts?: { clientId?: number }
|
||||
): Promise<void> {
|
||||
if (this.disposed) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
const msg: JsonRpcNotification = {
|
||||
jsonrpc: '2.0',
|
||||
method,
|
||||
...(params !== undefined ? { params } : {})
|
||||
}
|
||||
const targets =
|
||||
opts?.clientId !== undefined
|
||||
? [this.clients.get(opts.clientId)].filter((c): c is RelayClient => c !== undefined)
|
||||
: Array.from(this.clients.values())
|
||||
const waits: Promise<void>[] = []
|
||||
for (const client of targets) {
|
||||
if (client.closed) {
|
||||
continue
|
||||
}
|
||||
// Why: the frame is encoded inside the chain step, not at call time —
|
||||
// sequence numbers must be assigned in actual write order.
|
||||
const step = client.bulkChain.then(() => {
|
||||
if (this.disposed || client.closed) {
|
||||
return
|
||||
}
|
||||
const accepted = this.sendFrame(client, msg)
|
||||
if (accepted === false) {
|
||||
return this.waitForClientDrain(client)
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
client.bulkChain = step.catch(() => {})
|
||||
waits.push(step)
|
||||
}
|
||||
if (waits.length === 0) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return Promise.all(waits).then(() => {})
|
||||
}
|
||||
|
||||
private waitForClientDrain(client: RelayClient): Promise<void> {
|
||||
if (this.disposed || client.closed || !client.waitWriteDrain) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return new Promise<void>((resolve) => {
|
||||
let settled = false
|
||||
const finish = (): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
client.drainWaiters.delete(finish)
|
||||
resolve()
|
||||
}
|
||||
client.drainWaiters.add(finish)
|
||||
try {
|
||||
client.waitWriteDrain!(finish)
|
||||
} catch {
|
||||
finish()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private flushDrainWaiters(client: RelayClient): void {
|
||||
for (const waiter of Array.from(client.drainWaiters)) {
|
||||
waiter()
|
||||
}
|
||||
}
|
||||
|
||||
requestPrimary(
|
||||
method: string,
|
||||
params?: Record<string, unknown>,
|
||||
@@ -236,14 +344,20 @@ export class RelayDispatcher {
|
||||
// Why: dispose means this relay instance cannot send responses anymore;
|
||||
// abort in-flight request work so stale SSH-side scans/watchers release.
|
||||
this.requestAborts.abortAll()
|
||||
for (const client of this.clients.values()) {
|
||||
this.flushDrainWaiters(client)
|
||||
}
|
||||
}
|
||||
|
||||
private createClient(write: (data: Buffer) => void): RelayClient {
|
||||
private createClient(write: RelayClientWrite, sinkOptions?: RelayClientSinkOptions): RelayClient {
|
||||
const id = this.nextClientId++
|
||||
const client: RelayClient = {
|
||||
id,
|
||||
decoder: new FrameDecoder((frame) => this.handleFrame(client, frame)),
|
||||
write,
|
||||
waitWriteDrain: sinkOptions?.waitWriteDrain,
|
||||
drainWaiters: new Set(),
|
||||
bulkChain: Promise.resolve(),
|
||||
nextOutgoingSeq: 1,
|
||||
highestReceivedSeq: 0,
|
||||
generation: 0,
|
||||
@@ -387,13 +501,13 @@ export class RelayDispatcher {
|
||||
private sendFrame(
|
||||
client: RelayClient,
|
||||
msg: JsonRpcRequest | JsonRpcResponse | JsonRpcNotification
|
||||
): void {
|
||||
): boolean | void {
|
||||
if (this.disposed || client.closed) {
|
||||
return
|
||||
}
|
||||
const seq = client.nextOutgoingSeq++
|
||||
const frame = encodeJsonRpcFrame(msg, seq, client.highestReceivedSeq)
|
||||
this.writeFrame(client, frame)
|
||||
return this.writeFrame(client, frame)
|
||||
}
|
||||
|
||||
private startKeepalive(): void {
|
||||
@@ -416,12 +530,13 @@ export class RelayDispatcher {
|
||||
this.keepaliveTimer.unref()
|
||||
}
|
||||
|
||||
private writeFrame(client: RelayClient, frame: Buffer): void {
|
||||
private writeFrame(client: RelayClient, frame: Buffer): boolean | void {
|
||||
try {
|
||||
client.write(frame)
|
||||
return client.write(frame)
|
||||
} catch (err) {
|
||||
client.closed = true
|
||||
client.generation++
|
||||
this.flushDrainWaiters(client)
|
||||
if (client !== this.primaryClient) {
|
||||
this.clients.delete(client.id)
|
||||
this.notifyClientDetached(client.id)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { open, readFile, stat } from 'node:fs/promises'
|
||||
import { extname } from 'node:path'
|
||||
import type { RelayDispatcher, RequestContext } from './dispatcher'
|
||||
import { STREAM_CHUNK_SIZE, RelayErrorCode } from './protocol'
|
||||
import { STREAM_ACK_WINDOW_CHUNKS, STREAM_CHUNK_SIZE, RelayErrorCode } from './protocol'
|
||||
import type { RelayStreamRegistry, TooManyStreamsError } from './fs-stream-registry'
|
||||
import {
|
||||
BINARY_PROBE_BYTES,
|
||||
@@ -61,11 +61,21 @@ type StreamChunkReader = {
|
||||
): Promise<{ bytesRead: number }>
|
||||
}
|
||||
|
||||
export type StreamPumpOptions = {
|
||||
/** Client that requested the stream. Chunks go only to it — broadcasting
|
||||
* bulk frames would let one slow secondary client stall the requester. */
|
||||
clientId?: number
|
||||
/** True when the client declared `flowControl: 'ack'` — it sends
|
||||
* fs.streamAck per processed chunk and the pump caps unacked chunks. */
|
||||
paceWithAcks: boolean
|
||||
}
|
||||
|
||||
export async function readRelayFileStreamMetadata(
|
||||
filePath: string,
|
||||
dispatcher: RelayDispatcher,
|
||||
registry: RelayStreamRegistry,
|
||||
context: RequestContext
|
||||
context: RequestContext,
|
||||
pumpOptions?: StreamPumpOptions
|
||||
): Promise<StreamMetadata> {
|
||||
const stats = await stat(filePath)
|
||||
const mimeType = IMAGE_MIME_TYPES[extname(filePath).toLowerCase()]
|
||||
@@ -106,8 +116,9 @@ export async function readRelayFileStreamMetadata(
|
||||
// Why: pumpChunks owns its own try/finally for handle release; the outer
|
||||
// setImmediate kicks the pump off the metadata-response task so the client
|
||||
// sees the response before the first chunk frame.
|
||||
const resolvedPumpOptions = pumpOptions ?? { paceWithAcks: false }
|
||||
setImmediate(() => {
|
||||
void pumpChunks(streamId, stats.size, dispatcher, registry, context)
|
||||
void pumpChunks(streamId, stats.size, dispatcher, registry, context, resolvedPumpOptions)
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -126,7 +137,8 @@ async function pumpChunks(
|
||||
totalSize: number,
|
||||
dispatcher: RelayDispatcher,
|
||||
registry: RelayStreamRegistry,
|
||||
context: RequestContext
|
||||
context: RequestContext,
|
||||
pumpOptions: StreamPumpOptions
|
||||
): Promise<void> {
|
||||
const entry = registry.get(streamId)
|
||||
if (!entry) {
|
||||
@@ -150,6 +162,27 @@ async function pumpChunks(
|
||||
endReason = 'aborted'
|
||||
break
|
||||
}
|
||||
// Why: credit window — bulk chunks share one ordered SSH channel with
|
||||
// interactive pty.data frames. Waiting for client acks bounds how many
|
||||
// stream bytes a keystroke echo can queue behind, and yields the relay
|
||||
// event loop so incoming keystrokes are handled between chunks.
|
||||
if (pumpOptions.paceWithAcks) {
|
||||
while (
|
||||
seq - registry.ackedThroughSeq(streamId) > STREAM_ACK_WINDOW_CHUNKS &&
|
||||
!context.isStale() &&
|
||||
!registry.isAborted(streamId)
|
||||
) {
|
||||
await registry.waitForAck(streamId)
|
||||
}
|
||||
if (context.isStale()) {
|
||||
endReason = 'stale'
|
||||
break
|
||||
}
|
||||
if (registry.isAborted(streamId)) {
|
||||
endReason = 'aborted'
|
||||
break
|
||||
}
|
||||
}
|
||||
const want = Math.min(STREAM_CHUNK_SIZE, totalSize - offset)
|
||||
const bytesRead = await readFullStreamChunk(entry.handle, buffer, want, offset)
|
||||
if (bytesRead !== want) {
|
||||
@@ -167,7 +200,14 @@ async function pumpChunks(
|
||||
break
|
||||
}
|
||||
const data = buffer.subarray(0, bytesRead).toString('base64')
|
||||
dispatcher.notify('fs.streamChunk', { streamId, seq, data })
|
||||
// Why: the bulk lane waits out sink saturation, so a flood of chunk
|
||||
// frames cannot pile up in the outbound pipe ahead of interactive
|
||||
// pty.data frames written via plain notify().
|
||||
await dispatcher.notifyBulk(
|
||||
'fs.streamChunk',
|
||||
{ streamId, seq, data },
|
||||
pumpOptions.clientId !== undefined ? { clientId: pumpOptions.clientId } : undefined
|
||||
)
|
||||
offset += bytesRead
|
||||
seq += 1
|
||||
}
|
||||
|
||||
@@ -34,6 +34,9 @@ function createMockDispatcher() {
|
||||
notify: vi.fn((method: string, params?: Record<string, unknown>) => {
|
||||
notifications.push({ method, params })
|
||||
}),
|
||||
notifyBulk: vi.fn(async (method: string, params?: Record<string, unknown>): Promise<void> => {
|
||||
notifications.push({ method, params })
|
||||
}),
|
||||
_notifications: notifications,
|
||||
callRequest(
|
||||
method: string,
|
||||
@@ -278,6 +281,61 @@ describe('FsHandler readFileStream', () => {
|
||||
expect(err).toBeNull()
|
||||
})
|
||||
|
||||
it('parks the pump at the ack credit window and resumes on fs.streamAck', async () => {
|
||||
const filePath = path.join(tmpDir, 'paced.png')
|
||||
const content = Buffer.alloc(1536 * 1024, 0x42) // 6 chunks
|
||||
writeFileSync(filePath, content)
|
||||
|
||||
const meta = (await dispatcher.callRequest(
|
||||
'fs.readFileStream',
|
||||
{ filePath, flowControl: 'ack' },
|
||||
{ isStale: () => false }
|
||||
)) as { streamId: number }
|
||||
|
||||
// Window of 4 admits seqs 0..3; seq 4 must wait for an ack.
|
||||
await waitFor(() => collectStream(dispatcher).chunks.length === 4)
|
||||
await flush(10)
|
||||
expect(collectStream(dispatcher).chunks.length).toBe(4)
|
||||
expect(collectStream(dispatcher).end).toBeNull()
|
||||
|
||||
// Ack one chunk → exactly one more is admitted.
|
||||
dispatcher.callNotification('fs.streamAck', { streamId: meta.streamId, seq: 0 })
|
||||
await waitFor(() => collectStream(dispatcher).chunks.length === 5)
|
||||
await flush(10)
|
||||
expect(collectStream(dispatcher).chunks.length).toBe(5)
|
||||
|
||||
for (let seq = 1; seq < 6; seq += 1) {
|
||||
dispatcher.callNotification('fs.streamAck', { streamId: meta.streamId, seq })
|
||||
}
|
||||
await waitFor(() => collectStream(dispatcher).end !== null)
|
||||
|
||||
const { chunks, err } = collectStream(dispatcher)
|
||||
expect(err).toBeNull()
|
||||
const reassembled = Buffer.concat(chunks.map((c) => Buffer.from(c.data, 'base64')))
|
||||
expect(reassembled.equals(content)).toBe(true)
|
||||
})
|
||||
|
||||
it('releases a pump parked on the ack window when the stream is cancelled', async () => {
|
||||
const filePath = path.join(tmpDir, 'parked-cancel.png')
|
||||
writeFileSync(filePath, Buffer.alloc(4 * 1024 * 1024, 0x42)) // 16 chunks
|
||||
|
||||
const meta = (await dispatcher.callRequest(
|
||||
'fs.readFileStream',
|
||||
{ filePath, flowControl: 'ack' },
|
||||
{ isStale: () => false }
|
||||
)) as { streamId: number }
|
||||
|
||||
await waitFor(() => collectStream(dispatcher).chunks.length === 4)
|
||||
dispatcher.callNotification('fs.cancelStream', { streamId: meta.streamId })
|
||||
|
||||
const registry = (handler as unknown as { streamRegistry: { size(): number } }).streamRegistry
|
||||
await waitFor(() => registry.size() === 0)
|
||||
|
||||
const { end, err } = collectStream(dispatcher)
|
||||
expect(end).toBeNull()
|
||||
expect(err).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects the 17th concurrent stream with TooManyStreams', async () => {
|
||||
const paths: string[] = []
|
||||
for (let i = 0; i < 17; i++) {
|
||||
|
||||
+23
-2
@@ -86,7 +86,13 @@ export class FsHandler {
|
||||
constructor(dispatcher: RelayDispatcher, _context: RelayContext) {
|
||||
this.dispatcher = dispatcher
|
||||
this.registerHandlers()
|
||||
this.dispatcher.onClientDetached?.((clientId) => this.releaseClientWatches(clientId))
|
||||
this.dispatcher.onClientDetached?.((clientId) => {
|
||||
this.releaseClientWatches(clientId)
|
||||
// Why: a detached client's fs.streamAck frames will never arrive; wake
|
||||
// any pump parked on the ack window so it re-checks staleness and exits
|
||||
// instead of stranding its open file handle.
|
||||
this.streamRegistry.wakeAllAckWaiters()
|
||||
})
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
@@ -113,6 +119,7 @@ export class FsHandler {
|
||||
this.dispatcher.onRequest('fs.watch', (p, context) => this.watch(p, context))
|
||||
this.dispatcher.onNotification('fs.unwatch', (p, context) => this.unwatch(p, context))
|
||||
this.dispatcher.onNotification('fs.cancelStream', (p) => this.cancelStream(p))
|
||||
this.dispatcher.onNotification('fs.streamAck', (p) => this.streamAck(p))
|
||||
}
|
||||
|
||||
private async readDir(params: Record<string, unknown>) {
|
||||
@@ -148,7 +155,13 @@ export class FsHandler {
|
||||
private async readFileStream(params: Record<string, unknown>, context?: RequestContext) {
|
||||
const filePath = expandTilde(params.filePath as string)
|
||||
const ctx = context ?? { clientId: 0, isStale: () => false }
|
||||
return readRelayFileStreamMetadata(filePath, this.dispatcher, this.streamRegistry, ctx)
|
||||
return readRelayFileStreamMetadata(filePath, this.dispatcher, this.streamRegistry, ctx, {
|
||||
// Why: only target the requesting client when the dispatcher actually
|
||||
// routed this request (context present) — direct-call tests and legacy
|
||||
// paths keep broadcast semantics.
|
||||
...(context ? { clientId: context.clientId } : {}),
|
||||
paceWithAcks: params.flowControl === 'ack'
|
||||
})
|
||||
}
|
||||
|
||||
private async tempDir(): Promise<string> {
|
||||
@@ -162,6 +175,14 @@ export class FsHandler {
|
||||
}
|
||||
}
|
||||
|
||||
private streamAck(params: Record<string, unknown>): void {
|
||||
const streamId = params.streamId as number | undefined
|
||||
const seq = params.seq as number | undefined
|
||||
if (typeof streamId === 'number' && typeof seq === 'number') {
|
||||
this.streamRegistry.recordAck(streamId, seq)
|
||||
}
|
||||
}
|
||||
|
||||
private async writeFile(params: Record<string, unknown>) {
|
||||
const filePath = expandTilde(params.filePath as string)
|
||||
const content = params.content as string
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* Regression: SSH typing latency under bulk file-stream load.
|
||||
*
|
||||
* The relay and the client share ONE ordered SSH channel. If the relay
|
||||
* enqueues an entire file's fs.streamChunk frames into the outbound pipe
|
||||
* at once, an interactive pty.data echo emitted mid-stream queues behind
|
||||
* megabytes of bulk data and typing feels seconds-slow.
|
||||
*
|
||||
* These tests model the SSH channel as a congestible in-memory pipe and
|
||||
* assert deterministic byte bounds instead of wall-clock latency:
|
||||
* - with a saturated sink, the relay stalls the pump on write backpressure
|
||||
* so at most ~1 chunk frame sits ahead of a pty echo;
|
||||
* - with a fast sink but an unresponsive client, the fs.streamAck credit
|
||||
* window bounds the in-flight backlog;
|
||||
* - legacy clients that never ack still receive the full stream.
|
||||
*/
|
||||
import { describe, expect, it, beforeEach, afterEach } from 'vitest'
|
||||
import { mkdtempSync, writeFileSync } from 'node:fs'
|
||||
import { rm } from 'node:fs/promises'
|
||||
import * as path from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
|
||||
import {
|
||||
SshChannelMultiplexer,
|
||||
type MultiplexerTransport
|
||||
} from '../main/ssh/ssh-channel-multiplexer'
|
||||
import { readFileViaStream } from '../main/ssh/ssh-filesystem-stream-reader'
|
||||
|
||||
import { RelayDispatcher } from './dispatcher'
|
||||
import { RelayContext } from './context'
|
||||
import { FsHandler } from './fs-handler'
|
||||
import { STREAM_CHUNK_SIZE } from './protocol'
|
||||
|
||||
// One framed fs.streamChunk: 256KB raw → base64 (4/3) + JSON envelope + header.
|
||||
const FRAMED_CHUNK_BYTES = Math.ceil((STREAM_CHUNK_SIZE * 4) / 3) + 512
|
||||
// Node pipe/socket sinks report saturation via write() === false past the HWM.
|
||||
const SINK_HIGH_WATER_MARK = 64 * 1024
|
||||
|
||||
async function waitUntil(
|
||||
predicate: () => boolean,
|
||||
what: string,
|
||||
timeoutMs = 10_000
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (!predicate()) {
|
||||
if (Date.now() > deadline) {
|
||||
throw new Error(`waitUntil timed out: ${what}`)
|
||||
}
|
||||
await new Promise((r) => setImmediate(r))
|
||||
}
|
||||
}
|
||||
|
||||
/** Waits until `read()` stops changing for `stableTurns` macrotask turns. */
|
||||
async function waitUntilSettled(read: () => number, stableTurns = 25): Promise<void> {
|
||||
let last = read()
|
||||
let stable = 0
|
||||
while (stable < stableTurns) {
|
||||
await new Promise((r) => setImmediate(r))
|
||||
const current = read()
|
||||
if (current === last) {
|
||||
stable += 1
|
||||
} else {
|
||||
stable = 0
|
||||
last = current
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type Harness = {
|
||||
mux: SshChannelMultiplexer
|
||||
dispatcher: RelayDispatcher
|
||||
fsHandler: FsHandler
|
||||
queuedBytes: () => number
|
||||
/** Deliver every queued relay→client buffer to the client mux. */
|
||||
deliverAll: () => void
|
||||
/** Start delivering continuously on each macrotask turn. */
|
||||
startAutoDeliver: () => void
|
||||
dispose: () => void
|
||||
}
|
||||
|
||||
function createHarness(opts: { congested: boolean }): Harness {
|
||||
let relayFeed: ((data: Buffer) => void) | null = null
|
||||
const clientDataCallbacks: ((data: Buffer) => void)[] = []
|
||||
|
||||
const clientTransport: MultiplexerTransport = {
|
||||
write: (data: Buffer) => {
|
||||
// Client → relay: keystrokes and acks flow on the opposite direction of
|
||||
// the duplex channel; they are not blocked by relay→client congestion.
|
||||
setImmediate(() => relayFeed?.(data))
|
||||
},
|
||||
onData: (cb) => {
|
||||
clientDataCallbacks.push(cb)
|
||||
},
|
||||
onClose: () => {}
|
||||
}
|
||||
|
||||
const outQueue: Buffer[] = []
|
||||
let queuedBytes = 0
|
||||
const drainWaiters = new Set<() => void>()
|
||||
const fireDrainIfIdle = (): void => {
|
||||
if (queuedBytes > 0) {
|
||||
return
|
||||
}
|
||||
for (const cb of Array.from(drainWaiters)) {
|
||||
drainWaiters.delete(cb)
|
||||
cb()
|
||||
}
|
||||
}
|
||||
|
||||
const dispatcher = new RelayDispatcher(
|
||||
(data: Buffer) => {
|
||||
outQueue.push(data)
|
||||
queuedBytes += data.length
|
||||
if (!opts.congested) {
|
||||
return true
|
||||
}
|
||||
return queuedBytes < SINK_HIGH_WATER_MARK
|
||||
},
|
||||
{
|
||||
waitWriteDrain: (cb: () => void) => {
|
||||
drainWaiters.add(cb)
|
||||
fireDrainIfIdle()
|
||||
}
|
||||
}
|
||||
)
|
||||
relayFeed = (data: Buffer) => dispatcher.feed(data)
|
||||
|
||||
const deliverAll = (): void => {
|
||||
while (outQueue.length > 0) {
|
||||
const buf = outQueue.shift()!
|
||||
queuedBytes -= buf.length
|
||||
for (const cb of clientDataCallbacks) {
|
||||
cb(buf)
|
||||
}
|
||||
}
|
||||
fireDrainIfIdle()
|
||||
}
|
||||
|
||||
let autoDeliverTimer: ReturnType<typeof setInterval> | null = null
|
||||
const startAutoDeliver = (): void => {
|
||||
if (autoDeliverTimer) {
|
||||
return
|
||||
}
|
||||
autoDeliverTimer = setInterval(deliverAll, 1)
|
||||
}
|
||||
|
||||
const context = new RelayContext()
|
||||
const fsHandler = new FsHandler(dispatcher, context)
|
||||
const mux = new SshChannelMultiplexer(clientTransport)
|
||||
|
||||
return {
|
||||
mux,
|
||||
dispatcher,
|
||||
fsHandler,
|
||||
queuedBytes: () => queuedBytes,
|
||||
deliverAll,
|
||||
startAutoDeliver,
|
||||
dispose: () => {
|
||||
if (autoDeliverTimer) {
|
||||
clearInterval(autoDeliverTimer)
|
||||
}
|
||||
mux.dispose()
|
||||
dispatcher.dispose()
|
||||
fsHandler.dispose()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('fs.readFileStream vs pty.data echo head-of-line blocking', () => {
|
||||
let tmpDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-stream-hol-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('bounds bulk bytes queued ahead of a pty echo when the channel is congested', async () => {
|
||||
const harness = createHarness({ congested: true })
|
||||
try {
|
||||
const filePath = path.join(tmpDir, 'big.png')
|
||||
const original = randomBytes(3 * 1024 * 1024) // 12 chunks
|
||||
writeFileSync(filePath, original)
|
||||
|
||||
// Relay-side fake PTY: echoes input back immediately, mirroring
|
||||
// PtyHandler's interactive fast path (dispatcher.notify on echo).
|
||||
let queuedBytesAheadOfEcho = -1
|
||||
harness.dispatcher.onNotification('pty.data', (params) => {
|
||||
queuedBytesAheadOfEcho = harness.queuedBytes()
|
||||
harness.dispatcher.notify('pty.data', { id: params.id, data: params.data })
|
||||
})
|
||||
|
||||
const readPromise = readFileViaStream(harness.mux, filePath)
|
||||
// Deliver until the client has the stream metadata, then congest fully.
|
||||
await waitUntil(() => harness.queuedBytes() > 0, 'metadata response queued')
|
||||
harness.deliverAll()
|
||||
// Let the pump run to whatever bound it enforces (pre-fix: whole file).
|
||||
await waitUntil(() => harness.queuedBytes() > 0, 'first chunk queued')
|
||||
await waitUntilSettled(() => harness.queuedBytes())
|
||||
|
||||
// Type one key while the stream is congested.
|
||||
harness.mux.notify('pty.data', { id: 'pty-1', data: 'x' })
|
||||
await waitUntil(() => queuedBytesAheadOfEcho >= 0, 'echo emitted by relay')
|
||||
|
||||
// The echo must not sit behind an unbounded chunk backlog: at most one
|
||||
// in-flight bulk frame (the write that saturated the sink) plus slack.
|
||||
expect(queuedBytesAheadOfEcho).toBeLessThan(2 * FRAMED_CHUNK_BYTES)
|
||||
|
||||
// Un-congest: the stream must still complete with intact content.
|
||||
harness.startAutoDeliver()
|
||||
const result = await readPromise
|
||||
expect(Buffer.from(result.content, 'base64').equals(original)).toBe(true)
|
||||
} finally {
|
||||
harness.dispose()
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
it('caps in-flight chunks via the fs.streamAck credit window when the client stalls', async () => {
|
||||
const harness = createHarness({ congested: false })
|
||||
try {
|
||||
const filePath = path.join(tmpDir, 'big.png')
|
||||
writeFileSync(filePath, randomBytes(3 * 1024 * 1024)) // 12 chunks
|
||||
|
||||
const receivedSeqs: number[] = []
|
||||
harness.mux.onNotificationByMethod('fs.streamChunk', (params) => {
|
||||
receivedSeqs.push(params.seq as number)
|
||||
})
|
||||
let streamEnded = false
|
||||
harness.mux.onNotificationByMethod('fs.streamEnd', () => {
|
||||
streamEnded = true
|
||||
})
|
||||
|
||||
harness.startAutoDeliver()
|
||||
// Raw ack-capable request without sending any acks: models a client
|
||||
// whose main thread is too busy to process chunks.
|
||||
const metadata = (await harness.mux.request('fs.readFileStream', {
|
||||
filePath,
|
||||
flowControl: 'ack'
|
||||
})) as { streamId: number }
|
||||
|
||||
await waitUntil(() => receivedSeqs.length > 0, 'first chunk received')
|
||||
await waitUntilSettled(() => receivedSeqs.length)
|
||||
|
||||
// Without acks the relay must stop at the credit window, not flood
|
||||
// the remaining chunks.
|
||||
expect(receivedSeqs.length).toBeLessThanOrEqual(5)
|
||||
expect(streamEnded).toBe(false)
|
||||
|
||||
// Acking releases the window and the stream completes.
|
||||
const totalChunks = 12
|
||||
for (let seq = 0; seq < totalChunks; seq += 1) {
|
||||
harness.mux.notify('fs.streamAck', { streamId: metadata.streamId, seq })
|
||||
}
|
||||
await waitUntil(() => streamEnded, 'stream completed after acks')
|
||||
expect(receivedSeqs).toEqual(Array.from({ length: totalChunks }, (_, i) => i))
|
||||
} finally {
|
||||
harness.dispose()
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
it('still delivers the full stream to legacy clients that never ack', async () => {
|
||||
const harness = createHarness({ congested: false })
|
||||
try {
|
||||
const filePath = path.join(tmpDir, 'legacy.png')
|
||||
const original = randomBytes(1024 * 1024 + 12345)
|
||||
writeFileSync(filePath, original)
|
||||
|
||||
const chunks = new Map<number, Buffer>()
|
||||
let streamEnded = false
|
||||
harness.mux.onNotificationByMethod('fs.streamChunk', (params) => {
|
||||
chunks.set(params.seq as number, Buffer.from(params.data as string, 'base64'))
|
||||
})
|
||||
harness.mux.onNotificationByMethod('fs.streamEnd', () => {
|
||||
streamEnded = true
|
||||
})
|
||||
|
||||
harness.startAutoDeliver()
|
||||
// Legacy request shape: no flowControl param, and no acks ever sent.
|
||||
await harness.mux.request('fs.readFileStream', { filePath })
|
||||
await waitUntil(() => streamEnded, 'legacy stream completed')
|
||||
|
||||
const reassembled = Buffer.concat(
|
||||
Array.from(chunks.entries())
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([, buf]) => buf)
|
||||
)
|
||||
expect(reassembled.equals(original)).toBe(true)
|
||||
} finally {
|
||||
harness.dispose()
|
||||
}
|
||||
}, 30_000)
|
||||
})
|
||||
@@ -1,9 +1,14 @@
|
||||
import type { FileHandle } from 'node:fs/promises'
|
||||
import { MAX_CONCURRENT_STREAMS, RelayErrorCode } from './protocol'
|
||||
import { MAX_CONCURRENT_STREAMS, RelayErrorCode, STREAM_ACK_STALL_RECHECK_MS } from './protocol'
|
||||
|
||||
type StreamEntry = {
|
||||
handle: FileHandle
|
||||
aborted: boolean
|
||||
/** Highest chunk seq the client acknowledged (in-order; -1 = none yet). */
|
||||
ackedThroughSeq: number
|
||||
/** Pumps parked on the ack credit window. Woken by acks, abort, release,
|
||||
* and a periodic stall recheck so a vanished client cannot strand a pump. */
|
||||
ackWaiters: Set<() => void>
|
||||
}
|
||||
|
||||
export class TooManyStreamsError extends Error {
|
||||
@@ -22,7 +27,12 @@ export class RelayStreamRegistry {
|
||||
throw new TooManyStreamsError()
|
||||
}
|
||||
const streamId = this.nextId++
|
||||
this.streams.set(streamId, { handle, aborted: false })
|
||||
this.streams.set(streamId, {
|
||||
handle,
|
||||
aborted: false,
|
||||
ackedThroughSeq: -1,
|
||||
ackWaiters: new Set()
|
||||
})
|
||||
return streamId
|
||||
}
|
||||
|
||||
@@ -30,6 +40,7 @@ export class RelayStreamRegistry {
|
||||
const entry = this.streams.get(streamId)
|
||||
if (entry) {
|
||||
entry.aborted = true
|
||||
this.wakeAckWaiters(entry)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,11 +52,65 @@ export class RelayStreamRegistry {
|
||||
return this.streams.get(streamId)
|
||||
}
|
||||
|
||||
recordAck(streamId: number, seq: number): void {
|
||||
const entry = this.streams.get(streamId)
|
||||
if (!entry || typeof seq !== 'number' || !Number.isFinite(seq)) {
|
||||
return
|
||||
}
|
||||
if (seq > entry.ackedThroughSeq) {
|
||||
entry.ackedThroughSeq = seq
|
||||
}
|
||||
this.wakeAckWaiters(entry)
|
||||
}
|
||||
|
||||
ackedThroughSeq(streamId: number): number {
|
||||
return this.streams.get(streamId)?.ackedThroughSeq ?? Number.MAX_SAFE_INTEGER
|
||||
}
|
||||
|
||||
/** Resolves on the next ack/abort/release for this stream, or after the
|
||||
* stall-recheck interval so callers can re-evaluate staleness. */
|
||||
waitForAck(streamId: number): Promise<void> {
|
||||
const entry = this.streams.get(streamId)
|
||||
if (!entry || entry.aborted) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return new Promise<void>((resolve) => {
|
||||
let settled = false
|
||||
const timer = setTimeout(() => finish(), STREAM_ACK_STALL_RECHECK_MS)
|
||||
timer.unref?.()
|
||||
const finish = (): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
entry.ackWaiters.delete(finish)
|
||||
resolve()
|
||||
}
|
||||
entry.ackWaiters.add(finish)
|
||||
})
|
||||
}
|
||||
|
||||
/** Wake every parked pump (all streams) so it re-checks staleness — used
|
||||
* when a client detaches and its acks will never arrive. */
|
||||
wakeAllAckWaiters(): void {
|
||||
for (const entry of this.streams.values()) {
|
||||
this.wakeAckWaiters(entry)
|
||||
}
|
||||
}
|
||||
|
||||
private wakeAckWaiters(entry: StreamEntry): void {
|
||||
for (const waiter of Array.from(entry.ackWaiters)) {
|
||||
waiter()
|
||||
}
|
||||
}
|
||||
|
||||
async release(streamId: number): Promise<void> {
|
||||
const entry = this.streams.get(streamId)
|
||||
if (!entry) {
|
||||
return
|
||||
}
|
||||
this.wakeAckWaiters(entry)
|
||||
this.streams.delete(streamId)
|
||||
try {
|
||||
await entry.handle.close()
|
||||
|
||||
+107
-21
@@ -48,6 +48,17 @@ export const TIMEOUT_MS = 20_000
|
||||
export const STREAM_CHUNK_SIZE = 256 * 1024
|
||||
export const MAX_CONCURRENT_STREAMS = 16
|
||||
|
||||
/** Max unacked fs.streamChunk frames in flight per stream when the client
|
||||
* requested `flowControl: 'ack'`. Bounds how many bulk bytes an interactive
|
||||
* pty.data frame can queue behind on the shared SSH channel (~1MB raw) while
|
||||
* keeping the pipe full across one ack round-trip on fast links. */
|
||||
export const STREAM_ACK_WINDOW_CHUNKS = 4
|
||||
|
||||
/** Safety-valve poll interval for a pump stalled on acks: re-checks stream
|
||||
* abort/staleness so a client that vanished mid-stream cannot park the pump
|
||||
* (and its open file handle) forever. */
|
||||
export const STREAM_ACK_STALL_RECHECK_MS = 1_000
|
||||
|
||||
export const RelayErrorCode = {
|
||||
TooManyStreams: -33006,
|
||||
StreamProtocolError: -33007
|
||||
@@ -109,7 +120,12 @@ export function encodeKeepAliveFrame(id: number, ack: number): Buffer {
|
||||
}
|
||||
|
||||
export class FrameDecoder {
|
||||
private buffer = Buffer.alloc(0)
|
||||
// Why: feed() sits on the hot receive path. Rebuilding one contiguous
|
||||
// buffer per feed (Buffer.concat) re-copies every already-buffered byte for
|
||||
// each incoming chunk — O(n²) per large frame. A chunk list assembles each
|
||||
// frame exactly once instead.
|
||||
private chunks: Buffer[] = []
|
||||
private bufferedLength = 0
|
||||
private onFrame: (frame: DecodedFrame) => void
|
||||
private onError: ((err: Error) => void) | null
|
||||
|
||||
@@ -119,23 +135,32 @@ export class FrameDecoder {
|
||||
}
|
||||
|
||||
feed(chunk: Buffer | Uint8Array): void {
|
||||
this.buffer = Buffer.concat([this.buffer, chunk])
|
||||
const buf = Buffer.isBuffer(chunk)
|
||||
? chunk
|
||||
: Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength)
|
||||
if (buf.length > 0) {
|
||||
this.chunks.push(buf)
|
||||
this.bufferedLength += buf.length
|
||||
}
|
||||
|
||||
while (this.buffer.length >= HEADER_LENGTH) {
|
||||
const length = this.buffer.readUInt32BE(9)
|
||||
while (this.bufferedLength >= HEADER_LENGTH) {
|
||||
const header = this.peekBytes(HEADER_LENGTH)
|
||||
const length = header.readUInt32BE(9)
|
||||
const totalLength = HEADER_LENGTH + length
|
||||
|
||||
if (this.bufferedLength < totalLength) {
|
||||
// Not fully received yet (also holds oversized frames until they can
|
||||
// be skipped whole, keeping the decoder synchronized).
|
||||
break
|
||||
}
|
||||
|
||||
if (length > MAX_MESSAGE_SIZE) {
|
||||
// Why: Throwing here would leave the buffer in a partially consumed
|
||||
// state — subsequent feed() calls would try to parse the leftover
|
||||
// payload bytes as a new header, corrupting every future frame.
|
||||
// Instead we skip the entire oversized frame so the decoder stays
|
||||
// synchronized with the stream.
|
||||
if (this.buffer.length < totalLength) {
|
||||
// Haven't received the full oversized payload yet; wait for more data.
|
||||
break
|
||||
}
|
||||
this.buffer = this.buffer.subarray(totalLength)
|
||||
this.discardBytes(totalLength)
|
||||
const err = new Error(`Frame payload too large: ${length} bytes — discarded`)
|
||||
if (this.onError) {
|
||||
this.onError(err)
|
||||
@@ -145,23 +170,20 @@ export class FrameDecoder {
|
||||
continue
|
||||
}
|
||||
|
||||
if (this.buffer.length < totalLength) {
|
||||
break
|
||||
}
|
||||
|
||||
const framed = this.takeBytes(totalLength)
|
||||
const frame: DecodedFrame = {
|
||||
type: this.buffer[0],
|
||||
id: this.buffer.readUInt32BE(1),
|
||||
ack: this.buffer.readUInt32BE(5),
|
||||
payload: this.buffer.subarray(HEADER_LENGTH, totalLength)
|
||||
type: framed[0],
|
||||
id: framed.readUInt32BE(1),
|
||||
ack: framed.readUInt32BE(5),
|
||||
payload: framed.subarray(HEADER_LENGTH, totalLength)
|
||||
}
|
||||
this.buffer = this.buffer.subarray(totalLength)
|
||||
this.onFrame(frame)
|
||||
}
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.buffer = Buffer.alloc(0)
|
||||
this.chunks = []
|
||||
this.bufferedLength = 0
|
||||
}
|
||||
|
||||
// Why: at the handshake → dispatcher transition, the next consumer must
|
||||
@@ -169,10 +191,74 @@ export class FrameDecoder {
|
||||
// frame. This returns and clears the decoder's internal residue so the
|
||||
// caller can hand it to the dispatcher (or stdout pipe) without loss.
|
||||
drain(): Buffer {
|
||||
const out = this.buffer
|
||||
this.buffer = Buffer.alloc(0)
|
||||
const out =
|
||||
this.chunks.length === 1 ? this.chunks[0] : Buffer.concat(this.chunks, this.bufferedLength)
|
||||
this.reset()
|
||||
return out
|
||||
}
|
||||
|
||||
/** View of the first `count` buffered bytes without consuming them. */
|
||||
private peekBytes(count: number): Buffer {
|
||||
const first = this.chunks[0]
|
||||
if (first.length >= count) {
|
||||
return first
|
||||
}
|
||||
const out = Buffer.allocUnsafe(count)
|
||||
let copied = 0
|
||||
for (const part of this.chunks) {
|
||||
copied += part.copy(out, copied, 0, Math.min(part.length, count - copied))
|
||||
if (copied >= count) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Consume and return the first `count` buffered bytes (single copy). */
|
||||
private takeBytes(count: number): Buffer {
|
||||
const first = this.chunks[0]
|
||||
if (first.length === count) {
|
||||
this.chunks.shift()
|
||||
this.bufferedLength -= count
|
||||
return first
|
||||
}
|
||||
if (first.length > count) {
|
||||
this.chunks[0] = first.subarray(count)
|
||||
this.bufferedLength -= count
|
||||
return first.subarray(0, count)
|
||||
}
|
||||
const out = Buffer.allocUnsafe(count)
|
||||
let copied = 0
|
||||
while (copied < count) {
|
||||
const part = this.chunks[0]
|
||||
const take = Math.min(part.length, count - copied)
|
||||
part.copy(out, copied, 0, take)
|
||||
copied += take
|
||||
if (take === part.length) {
|
||||
this.chunks.shift()
|
||||
} else {
|
||||
this.chunks[0] = part.subarray(take)
|
||||
}
|
||||
}
|
||||
this.bufferedLength -= count
|
||||
return out
|
||||
}
|
||||
|
||||
/** Consume the first `count` buffered bytes without assembling them. */
|
||||
private discardBytes(count: number): void {
|
||||
let remaining = count
|
||||
while (remaining > 0) {
|
||||
const part = this.chunks[0]
|
||||
if (part.length <= remaining) {
|
||||
this.chunks.shift()
|
||||
remaining -= part.length
|
||||
} else {
|
||||
this.chunks[0] = part.subarray(remaining)
|
||||
remaining = 0
|
||||
}
|
||||
}
|
||||
this.bufferedLength -= count
|
||||
}
|
||||
}
|
||||
|
||||
export function parseJsonRpcMessage(payload: Buffer): JsonRpcMessage {
|
||||
|
||||
@@ -14,7 +14,10 @@ import {
|
||||
const { mockPtySpawn, mockPtyInstance } = vi.hoisted(() => ({
|
||||
mockPtySpawn: vi.fn(),
|
||||
mockPtyInstance: {
|
||||
pid: 12345,
|
||||
// Why: attach now proves the backing pid is alive before replaying, so the
|
||||
// default managed PTY must report a live pid. Reuse the test runner's own
|
||||
// pid — always alive — so unrelated attach tests are not seen as dead.
|
||||
pid: process.pid,
|
||||
onData: vi.fn(),
|
||||
onExit: vi.fn(),
|
||||
write: vi.fn(),
|
||||
@@ -609,6 +612,71 @@ describe('PtyHandler', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('reports not-found and reaps a reattach whose backing shell is dead', async () => {
|
||||
// Why: a lingering managed entry whose child died without an onExit would
|
||||
// otherwise attach-succeed with an empty replay and strand the pane on a
|
||||
// black shell. A provably-dead pid must surface as not-found so the SSH
|
||||
// provider maps it to SSH_SESSION_EXPIRED and the pane respawns fresh.
|
||||
let onExitCb: ((evt: { exitCode: number }) => void) | undefined
|
||||
mockPtySpawn.mockReturnValue({
|
||||
...mockPtyInstance,
|
||||
onData: vi.fn(),
|
||||
onExit: vi.fn((cb: (evt: { exitCode: number }) => void) => {
|
||||
onExitCb = cb
|
||||
})
|
||||
})
|
||||
const exits: { id: string; paneKey?: string }[] = []
|
||||
handler.setExitListener((evt) => exits.push(evt))
|
||||
|
||||
await dispatcher.callRequest('pty.spawn', { env: { ORCA_PANE_KEY: 'tab-dead:0' } })
|
||||
expect(handler.activePtyCount).toBe(1)
|
||||
expect(onExitCb).toBeDefined()
|
||||
|
||||
const aliveSpy = vi.spyOn(ptyShellUtils, 'isProcessAlive').mockReturnValue(false)
|
||||
try {
|
||||
await expect(
|
||||
dispatcher.callRequest('pty.attach', { id: 'pty-1', suppressReplayNotification: true })
|
||||
).rejects.toThrow('PTY "pty-1" not found')
|
||||
} finally {
|
||||
aliveSpy.mockRestore()
|
||||
}
|
||||
|
||||
// The stale entry is reaped: cache-eviction observers fire and the map slot
|
||||
// is freed so a later attach also cleanly reports not-found.
|
||||
expect(exits).toEqual([{ id: 'pty-1', paneKey: 'tab-dead:0' }])
|
||||
expect(handler.activePtyCount).toBe(0)
|
||||
await expect(
|
||||
dispatcher.callRequest('pty.attach', { id: 'pty-1', suppressReplayNotification: true })
|
||||
).rejects.toThrow('PTY "pty-1" not found')
|
||||
})
|
||||
|
||||
it('replays for a reattach whose backing shell is still alive', async () => {
|
||||
// Guard the other direction: a live pid must never be reaped, so a quiet
|
||||
// but live session still returns its buffered replay on reattach.
|
||||
let dataCallback: ((data: string) => void) | undefined
|
||||
mockPtySpawn.mockReturnValue({
|
||||
...mockPtyInstance,
|
||||
onData: vi.fn((cb: (data: string) => void) => {
|
||||
dataCallback = cb
|
||||
}),
|
||||
onExit: vi.fn()
|
||||
})
|
||||
await dispatcher.callRequest('pty.spawn', {})
|
||||
dataCallback?.('prompt$ ')
|
||||
|
||||
const aliveSpy = vi.spyOn(ptyShellUtils, 'isProcessAlive').mockReturnValue(true)
|
||||
try {
|
||||
const result = await dispatcher.callRequest('pty.attach', {
|
||||
id: 'pty-1',
|
||||
suppressReplayNotification: true
|
||||
})
|
||||
expect(result).toEqual({ replay: 'prompt$ ' })
|
||||
} finally {
|
||||
aliveSpy.mockRestore()
|
||||
}
|
||||
expect(handler.activePtyCount).toBe(1)
|
||||
})
|
||||
|
||||
it('terminates spawned PTY when request becomes stale before response', async () => {
|
||||
const killSpy = vi.fn()
|
||||
const term = { ...mockPtyInstance, kill: killSpy, onData: vi.fn(), onExit: vi.fn() }
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
resolveProcessCwd,
|
||||
processHasChildren,
|
||||
getForegroundProcessName,
|
||||
isProcessAlive,
|
||||
listShellProfiles
|
||||
} from './pty-shell-utils'
|
||||
import { getRelayShellLaunchConfig } from './pty-shell-launch'
|
||||
@@ -692,6 +693,22 @@ export class PtyHandler {
|
||||
throw new Error(`PTY "${id}" not found`)
|
||||
}
|
||||
|
||||
// Why: a reattach can arrive for a relay PTY whose backing shell already
|
||||
// died without node-pty delivering onExit (e.g. the child was reaped out of
|
||||
// band while the SSH channel was down). The map entry lingers, so attach
|
||||
// would otherwise "succeed" with an empty replay and strand the reattached
|
||||
// pane on a black, unresponsive shell. Prove liveness here; if the pid is
|
||||
// provably gone, reap the stale entry and report not-found so the caller
|
||||
// drops the dead lease and spawns fresh — the same recovery path an expired
|
||||
// grace window already takes.
|
||||
if (managed.pty.pid && !isProcessAlive(managed.pty.pid)) {
|
||||
this.notifyExitListener(managed)
|
||||
disposeManagedPty(managed)
|
||||
this.ptys.delete(id)
|
||||
this.clearPtyFlowState(id)
|
||||
throw new Error(`PTY "${id}" not found`)
|
||||
}
|
||||
|
||||
// Why: PTY ids are a per-relay-process counter (pty-1, pty-2, …). When the
|
||||
// relay changes generation — an app update deploys a new content-hashed
|
||||
// relay dir, or a grace-expired relay restarts — the counter resets, so an
|
||||
|
||||
@@ -11,6 +11,7 @@ vi.mock('child_process', () => ({
|
||||
import { resetProcessTableSnapshotForTests } from '../shared/process-table-snapshot'
|
||||
import {
|
||||
getForegroundProcessName,
|
||||
isProcessAlive,
|
||||
resolveDefaultCwd,
|
||||
resolveWindowsDefaultShell
|
||||
} from './pty-shell-utils'
|
||||
@@ -51,6 +52,40 @@ beforeEach(() => {
|
||||
resetProcessTableSnapshotForTests()
|
||||
})
|
||||
|
||||
describe('isProcessAlive', () => {
|
||||
it('reports the test runner process as alive', () => {
|
||||
expect(isProcessAlive(process.pid)).toBe(true)
|
||||
})
|
||||
|
||||
it('reports a process as dead ONLY on ESRCH', () => {
|
||||
// Why: attach() reaps a lingering managed PTY based on this, so a false
|
||||
// positive would kill a live remote shell. Only "no such process" counts.
|
||||
const spy = vi.spyOn(process, 'kill').mockImplementation(() => {
|
||||
const err = new Error('no such process') as NodeJS.ErrnoException
|
||||
err.code = 'ESRCH'
|
||||
throw err
|
||||
})
|
||||
try {
|
||||
expect(isProcessAlive(2147483646)).toBe(false)
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('treats an unsignalable process (EPERM) as alive', () => {
|
||||
const spy = vi.spyOn(process, 'kill').mockImplementation(() => {
|
||||
const err = new Error('operation not permitted') as NodeJS.ErrnoException
|
||||
err.code = 'EPERM'
|
||||
throw err
|
||||
})
|
||||
try {
|
||||
expect(isProcessAlive(1)).toBe(true)
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveWindowsDefaultShell', () => {
|
||||
it('uses an existing SHELL override when one is provided', () => {
|
||||
expect(
|
||||
|
||||
@@ -147,6 +147,19 @@ export async function processHasChildren(pid: number): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
// Why: signal 0 probes existence without delivering a signal. Only ESRCH ("no
|
||||
// such process") proves the pid is gone; EPERM means it exists but is
|
||||
// unsignalable, so treat every non-ESRCH outcome as alive. Kept conservative so
|
||||
// a liveness check can only ever declare a *provably* dead process dead.
|
||||
export function isProcessAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
return true
|
||||
} catch (err) {
|
||||
return (err as NodeJS.ErrnoException).code !== 'ESRCH'
|
||||
}
|
||||
}
|
||||
|
||||
function collectDescendants(
|
||||
rows: ProcessTableRow[],
|
||||
rootPid: number
|
||||
|
||||
+64
-8
@@ -373,15 +373,44 @@ async function main(): Promise<void> {
|
||||
// write to a dead pipe, silently failing or throwing EPIPE. When a
|
||||
// socket client reconnects, setWrite swaps the callback to the socket.
|
||||
let stdoutAlive = true
|
||||
const dispatcher = new RelayDispatcher((data) => {
|
||||
if (stdoutAlive) {
|
||||
// Why: one-shot waiters parked by the dispatcher's bulk lane when stdout
|
||||
// reports saturation (write() === false). Flushed on 'drain' and on every
|
||||
// stdout-death path so a stalled file-stream pump never outlives the pipe
|
||||
// it was waiting on.
|
||||
const stdoutDrainWaiters = new Set<() => void>()
|
||||
const flushStdoutDrainWaiters = (): void => {
|
||||
for (const cb of Array.from(stdoutDrainWaiters)) {
|
||||
stdoutDrainWaiters.delete(cb)
|
||||
cb()
|
||||
}
|
||||
}
|
||||
process.stdout.on('drain', flushStdoutDrainWaiters)
|
||||
const dispatcher = new RelayDispatcher(
|
||||
(data) => {
|
||||
if (!stdoutAlive) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
process.stdout.write(data)
|
||||
// Why: surface Node's backpressure signal to the dispatcher so bulk
|
||||
// frames (fs.streamChunk) wait for drain instead of queueing megabytes
|
||||
// ahead of interactive pty.data frames on the SSH channel.
|
||||
return process.stdout.write(data)
|
||||
} catch {
|
||||
stdoutAlive = false
|
||||
flushStdoutDrainWaiters()
|
||||
return undefined
|
||||
}
|
||||
},
|
||||
{
|
||||
waitWriteDrain: (cb) => {
|
||||
if (!stdoutAlive) {
|
||||
cb()
|
||||
return
|
||||
}
|
||||
stdoutDrainWaiters.add(cb)
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const context = new RelayContext()
|
||||
|
||||
@@ -680,11 +709,35 @@ async function main(): Promise<void> {
|
||||
)
|
||||
cancelGrace('socket client accepted')
|
||||
|
||||
const clientId = dispatcher.attachClient((data) => {
|
||||
if (!sock.destroyed) {
|
||||
sock.write(data)
|
||||
// Why: same backpressure surface as the stdout sink — bulk frames wait
|
||||
// for the socket to drain so they cannot bury interactive PTY frames.
|
||||
const sockDrainWaiters = new Set<() => void>()
|
||||
const flushSockDrainWaiters = (): void => {
|
||||
for (const cb of Array.from(sockDrainWaiters)) {
|
||||
sockDrainWaiters.delete(cb)
|
||||
cb()
|
||||
}
|
||||
})
|
||||
}
|
||||
sock.on('drain', flushSockDrainWaiters)
|
||||
sock.on('close', flushSockDrainWaiters)
|
||||
sock.on('error', flushSockDrainWaiters)
|
||||
const clientId = dispatcher.attachClient(
|
||||
(data) => {
|
||||
if (!sock.destroyed) {
|
||||
return sock.write(data)
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
{
|
||||
waitWriteDrain: (cb) => {
|
||||
if (sock.destroyed) {
|
||||
cb()
|
||||
return
|
||||
}
|
||||
sockDrainWaiters.add(cb)
|
||||
}
|
||||
}
|
||||
)
|
||||
socketClients.set(sock, clientId)
|
||||
|
||||
// Why: bytes that arrived in the same TCP send as the handshake frame
|
||||
@@ -889,6 +942,7 @@ async function main(): Promise<void> {
|
||||
// before the grace period starts.
|
||||
process.stdout.on('error', () => {
|
||||
stdoutAlive = false
|
||||
flushStdoutDrainWaiters()
|
||||
dispatcher.invalidateClient()
|
||||
})
|
||||
|
||||
@@ -934,6 +988,7 @@ async function main(): Promise<void> {
|
||||
// dead so the primary client write callback becomes a no-op while
|
||||
// socket clients, if any, keep their own live transports.
|
||||
stdoutAlive = false
|
||||
flushStdoutDrainWaiters()
|
||||
dispatcher.invalidateClient()
|
||||
if (socketClients.size === 0) {
|
||||
startGrace('stdin ended')
|
||||
@@ -942,6 +997,7 @@ async function main(): Promise<void> {
|
||||
|
||||
process.stdin.on('error', () => {
|
||||
stdoutAlive = false
|
||||
flushStdoutDrainWaiters()
|
||||
dispatcher.invalidateClient()
|
||||
if (socketClients.size === 0) {
|
||||
startGrace('stdin error')
|
||||
|
||||
@@ -101,6 +101,8 @@
|
||||
--color-status-success: var(--status-success);
|
||||
--color-status-success-background: var(--status-success-background);
|
||||
--color-status-success-border: var(--status-success-border);
|
||||
--color-tab-group-split-divider: var(--tab-group-split-divider);
|
||||
--color-tab-group-split-divider-strong: var(--tab-group-split-divider-strong);
|
||||
--color-git-graph-ref: var(--git-graph-ref);
|
||||
--color-git-graph-remote-ref: var(--git-graph-remote-ref);
|
||||
--color-git-graph-base-ref: var(--git-graph-base-ref);
|
||||
@@ -202,6 +204,11 @@
|
||||
--git-graph-lane-4: #40b0a6;
|
||||
--git-graph-lane-5: #b66dff;
|
||||
--annotation-highlight: #f59e0b;
|
||||
/* Why: tab-group splits use a dedicated divider — `--border` is too faint on
|
||||
card/editor surfaces when panes are side-by-side or stacked. Default must
|
||||
meet >=3:1 against `--card`, not only the hover/drag strong state. */
|
||||
--tab-group-split-divider: #868690;
|
||||
--tab-group-split-divider-strong: #71717a;
|
||||
}
|
||||
|
||||
/* ── Dark Mode ───────────────────────────────────────── */
|
||||
@@ -285,6 +292,9 @@
|
||||
--git-graph-lane-4: #40b0a6;
|
||||
--git-graph-lane-5: #b66dff;
|
||||
--annotation-highlight: #fbbf24;
|
||||
/* Brighter than terminal defaults so the always-visible line clears 3:1 on `--card`. */
|
||||
--tab-group-split-divider: #71717a;
|
||||
--tab-group-split-divider-strong: #a1a1aa;
|
||||
}
|
||||
|
||||
.linear-priority-bars {
|
||||
@@ -618,6 +628,52 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Tab-group split resize handle — wide hit area, visible center line. */
|
||||
.tab-group-split-resize-handle {
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.tab-group-split-resize-handle.is-vertical {
|
||||
width: 6px;
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
.tab-group-split-resize-handle.is-horizontal {
|
||||
height: 6px;
|
||||
cursor: row-resize;
|
||||
}
|
||||
|
||||
.tab-group-split-resize-handle::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
border-radius: 1px;
|
||||
background: var(--tab-group-split-divider);
|
||||
transition: background 100ms ease;
|
||||
}
|
||||
|
||||
.tab-group-split-resize-handle.is-vertical::after {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
width: 3px;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.tab-group-split-resize-handle.is-horizontal::after {
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
height: 3px;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.tab-group-split-resize-handle:hover::after,
|
||||
.tab-group-split-resize-handle.is-dragging::after {
|
||||
background: var(--tab-group-split-divider-strong);
|
||||
}
|
||||
|
||||
/* ── Titlebar ────────────────────────────────────────── */
|
||||
|
||||
/* Why: Radix portals (dropdowns, popovers, dialogs, sheets) render at the
|
||||
@@ -1904,13 +1960,14 @@
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Why: Monaco owns Peek References scrollables and auto-hides faded needed
|
||||
vertical thumbs while the widget stays open; revalidate these classes on upgrade. */
|
||||
/* Why: Monaco's faded scrollbar uses the class `invisible`, which collides with
|
||||
Tailwind's `.invisible` utility (visibility: hidden) — so also restore visibility. */
|
||||
.monaco-editor
|
||||
.reference-zone-widget
|
||||
.monaco-scrollable-element
|
||||
> .scrollbar.vertical.invisible.fade {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useEffect, useId, useMemo, useRef, useState } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useAppStore } from '../store'
|
||||
import { selectCodexRestartInputs } from './codex-restart-chip-inputs'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { shouldFocusMobileDriverAction } from './terminal-pane/mobile-driver-overlay-focus'
|
||||
import {
|
||||
@@ -73,8 +75,14 @@ export default function CodexRestartChip({
|
||||
worktreeId: string
|
||||
}): React.JSX.Element | null {
|
||||
const tabs = useAppStore((s) => s.tabsByWorktree[worktreeId] ?? EMPTY_TABS)
|
||||
const ptyIdsByTabId = useAppStore((s) => s.ptyIdsByTabId)
|
||||
const codexRestartNoticeByPtyId = useAppStore((s) => s.codexRestartNoticeByPtyId)
|
||||
// Why: both of these maps churn on unrelated pty lifecycle events (ptyIdsByTabId
|
||||
// on attach/detach; codexRestartNoticeByPtyId is re-spread even when empty on
|
||||
// pty teardown), so subscribe to them only while a restart notice actually
|
||||
// exists. Otherwise this per-worktree chip re-rendered on every pty event to
|
||||
// compute "no notice → render nothing". See codex-restart-chip-inputs.
|
||||
const { ptyIdsByTabId, codexRestartNoticeByPtyId } = useAppStore(
|
||||
useShallow(selectCodexRestartInputs)
|
||||
)
|
||||
const staleWorktreePtyIds = useMemo(
|
||||
() =>
|
||||
collectStalePtyIdsForTabs({
|
||||
|
||||
@@ -3848,7 +3848,21 @@ function PRActionsPanel({
|
||||
const actionItem = { ...item, state: localState }
|
||||
const mergePresentation = presentGitHubPRMergeState(actionItem)
|
||||
const mergeMethods = resolveGitHubPRMergeMethods(actionItem.mergeMethodSettings)
|
||||
const sourceSettings = getTaskSourceRuntimeSettings(sourceContext)
|
||||
const repoOwnerSettings = useAppStore(
|
||||
useShallow((s) => getSettingsForRepoRuntimeOwner(s, repoId ?? item.repoId ?? null))
|
||||
)
|
||||
const sourceSettings = useMemo(() => {
|
||||
if (sourceContext?.provider !== 'github') {
|
||||
return repoOwnerSettings
|
||||
}
|
||||
const sourceRuntimeSettings = getTaskSourceRuntimeSettings(sourceContext)
|
||||
return sourceRuntimeSettings.activeRuntimeEnvironmentId
|
||||
? ({
|
||||
...repoOwnerSettings,
|
||||
...sourceRuntimeSettings
|
||||
} as typeof repoOwnerSettings)
|
||||
: repoOwnerSettings
|
||||
}, [repoOwnerSettings, sourceContext])
|
||||
const mergeTarget = getActiveRuntimeTarget(sourceSettings)
|
||||
const canMutateWithRepoContext =
|
||||
!!repoPath || !!projectOrigin || mergeTarget.kind === 'environment'
|
||||
|
||||
@@ -57,7 +57,7 @@ import {
|
||||
import TabGroupSplitLayout from './tab-group/TabGroupSplitLayout'
|
||||
import AiVaultSessionDropLayer from './tab-group/AiVaultSessionDropLayer'
|
||||
import { shouldAutoCreateInitialTerminal } from './terminal/initial-terminal'
|
||||
import { shouldRepairActiveTerminalTab } from './terminal/active-terminal-repair'
|
||||
import { resolveRepairedActiveTerminalTabId } from './terminal/active-terminal-repair'
|
||||
import { scheduleBackgroundTerminalWorktreeMeasure } from './terminal/background-terminal-worktree-visibility'
|
||||
import {
|
||||
getEffectiveLayoutForWorktree as getEffectiveLayout,
|
||||
@@ -252,6 +252,7 @@ function Terminal(): React.JSX.Element | null {
|
||||
const pendingStartupByTabId = useAppStore((s) => s.pendingStartupByTabId)
|
||||
const terminalParkingEnabled = useAppStore((s) => s.settings?.terminalHiddenViewParking !== false)
|
||||
const activeTabId = useAppStore((s) => s.activeTabId)
|
||||
const activeTabIdByWorktree = useAppStore((s) => s.activeTabIdByWorktree)
|
||||
const createTab = useAppStore((s) => s.createTab)
|
||||
const closeTab = useAppStore((s) => s.closeTab)
|
||||
const setActiveTab = useAppStore((s) => s.setActiveTab)
|
||||
@@ -720,19 +721,38 @@ function Terminal(): React.JSX.Element | null {
|
||||
}, [queueEditorCloseRequests])
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldRepairActiveTerminalTab({ activeTabType, activeTabId, tabs })) {
|
||||
const rememberedTabId = renderedActiveWorktreeId
|
||||
? (activeTabIdByWorktree[renderedActiveWorktreeId] ?? null)
|
||||
: null
|
||||
// Why: prefer the worktree's remembered active tab over the first tab so a
|
||||
// repair firing on a transient worktree-switch render restores the tab the
|
||||
// user left on instead of permanently resetting the selection to Terminal 1.
|
||||
const repairedTabId = resolveRepairedActiveTerminalTabId({
|
||||
activeTabType,
|
||||
activeTabId,
|
||||
rememberedTabId,
|
||||
tabs
|
||||
})
|
||||
if (!repairedTabId) {
|
||||
return
|
||||
}
|
||||
// Why: mutating Zustand during render trips React's "Cannot update a
|
||||
// component while rendering a different component" warning. Keep the repair
|
||||
// terminal-only so inactive CLI-created tabs cannot steal editor/browser focus.
|
||||
setActiveTab(tabs[0].id)
|
||||
setActiveTab(repairedTabId)
|
||||
// Why: `tabs` is intentionally the dependency here because the repair must
|
||||
// react to tab-order/content changes, not just scalar IDs. The list comes
|
||||
// from Zustand selectors and is small in practice, so this explicit repair
|
||||
// effect is preferred over duplicating reconciliation state.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeTabId, activeTabType, setActiveTab, tabs])
|
||||
}, [
|
||||
activeTabId,
|
||||
activeTabType,
|
||||
setActiveTab,
|
||||
tabs,
|
||||
activeTabIdByWorktree,
|
||||
renderedActiveWorktreeId
|
||||
])
|
||||
|
||||
// Track which worktrees have been activated during this app session.
|
||||
// Only mount TerminalPanes for visited worktrees to prevent mass PTY
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { shallow } from 'zustand/shallow'
|
||||
import {
|
||||
EMPTY_CODEX_RESTART_INPUTS,
|
||||
selectCodexRestartInputs,
|
||||
type CodexRestartInputsState
|
||||
} from './codex-restart-chip-inputs'
|
||||
|
||||
describe('selectCodexRestartInputs', () => {
|
||||
it('returns the frozen empty bundle while no restart notice exists', () => {
|
||||
const state: CodexRestartInputsState = {
|
||||
ptyIdsByTabId: { 'tab-1': ['pty-1'] },
|
||||
codexRestartNoticeByPtyId: {}
|
||||
}
|
||||
expect(selectCodexRestartInputs(state)).toBe(EMPTY_CODEX_RESTART_INPUTS)
|
||||
|
||||
// Churning EITHER map while no notice exists must NOT change the selected
|
||||
// reference, so a useShallow subscription skips the re-render. This covers
|
||||
// the pty-teardown path that re-spreads codexRestartNoticeByPtyId even empty.
|
||||
const churnedPty: CodexRestartInputsState = {
|
||||
...state,
|
||||
ptyIdsByTabId: { 'tab-1': ['pty-2'], 'tab-2': ['pty-3'] }
|
||||
}
|
||||
const churnedNotice: CodexRestartInputsState = {
|
||||
ptyIdsByTabId: state.ptyIdsByTabId,
|
||||
codexRestartNoticeByPtyId: {} // fresh empty object, same as a teardown re-spread
|
||||
}
|
||||
expect(selectCodexRestartInputs(churnedPty)).toBe(EMPTY_CODEX_RESTART_INPUTS)
|
||||
expect(selectCodexRestartInputs(churnedNotice)).toBe(EMPTY_CODEX_RESTART_INPUTS)
|
||||
expect(shallow(selectCodexRestartInputs(state), selectCodexRestartInputs(churnedNotice))).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('exposes both live maps the instant a restart notice exists', () => {
|
||||
const ptyIdsByTabId = { 'tab-1': ['pty-1'] }
|
||||
const codexRestartNoticeByPtyId = { 'pty-1': {} as never }
|
||||
const state: CodexRestartInputsState = { ptyIdsByTabId, codexRestartNoticeByPtyId }
|
||||
const selected = selectCodexRestartInputs(state)
|
||||
// Live references pass straight through so the stale-pty memo + notice lookup derive fully.
|
||||
expect(selected.ptyIdsByTabId).toBe(ptyIdsByTabId)
|
||||
expect(selected.codexRestartNoticeByPtyId).toBe(codexRestartNoticeByPtyId)
|
||||
expect(selected).not.toBe(EMPTY_CODEX_RESTART_INPUTS)
|
||||
})
|
||||
|
||||
it('shallow-changes only when a live map reference changes while a notice exists', () => {
|
||||
const ptyIdsByTabId = { 'tab-1': ['pty-1'] }
|
||||
const s1: CodexRestartInputsState = {
|
||||
ptyIdsByTabId,
|
||||
codexRestartNoticeByPtyId: { 'pty-1': {} as never }
|
||||
}
|
||||
const r1 = selectCodexRestartInputs(s1)
|
||||
expect(shallow(r1, selectCodexRestartInputs(s1))).toBe(true)
|
||||
|
||||
const s2: CodexRestartInputsState = { ...s1, ptyIdsByTabId: { 'tab-1': ['pty-9'] } }
|
||||
expect(shallow(r1, selectCodexRestartInputs(s2))).toBe(false)
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user