From 1b331f282cd4da880b888f242e3545fffbba4cb5 Mon Sep 17 00:00:00 2001 From: Guillermo Avelar <116450483+ghee-yeh@users.noreply.github.com> Date: Thu, 16 Jul 2026 23:10:15 -0500 Subject: [PATCH 01/17] feat(editor): bindable keyboard shortcut to add a markdown review note (Mod+Alt+N) (#8250) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(editor): bindable shortcut to add a markdown review note Adds editor.addReviewNote (default Mod+Alt+N) to the shared keybinding registry and wires it into all three markdown surfaces: the rich editor key handler invokes the annotation popover opener, the Monaco editor installs a keydown listener that opens the composer for the tracked selection target, and the preview maps the DOM selection to its annotation block. openAnnotationPopover now prefers the live selection target over synced state so the shortcut works even before the sync render lands. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XYtipbTz8N4woN1sxTK1ia * fix(editor): cover list items and Monaco path for add-review-note shortcut Tag the preview's list-item annotation blocks with data-annotation-block-key so the shortcut resolves selections inside li blocks (review feedback), and extend the e2e spec to drive the Monaco source-editor wiring. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XYtipbTz8N4woN1sxTK1ia * docs(e2e): explain store-driven view-mode switch in add-review-note spec Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XYtipbTz8N4woN1sxTK1ia * refactor(editor): extract add-review-note + selection-flush modules to satisfy max-lines after rebase * refactor(editor): spread key-handler params and extract TOC hook to satisfy max-lines * test(editor): move add-review-note installer test into its own describe * fix(editor): pass add-review-note chord through when Monaco cannot act; cover preview surface e2e * fix(editor): unify add-review-note chord consumption — consume only when a composer opens * fix(editor): gate list-item annotation block key on composer availability * fix(editor): require live selection for keyboard add-review-note * chore: retrigger CI against current main (merge ref built during transient main breakage at 6e91ca6c0) --------- Co-authored-by: Claude Fable 5 Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> --- .../src/components/editor/MarkdownPreview.tsx | 35 +++- .../src/components/editor/MonacoEditor.tsx | 23 ++- .../components/editor/RichMarkdownEditor.tsx | 40 +--- .../editor/editor-shortcuts.test.ts | 69 +++++++ .../src/components/editor/editor-shortcuts.ts | 21 ++ ...rkdown-preview-annotation-shortcut.test.ts | 95 +++++++++ .../markdown-preview-annotation-shortcut.ts | 38 ++++ .../rich-markdown-annotation-shortcut.ts | 21 ++ .../rich-markdown-editor-config.test.ts | 1 + .../editor/rich-markdown-editor-config.ts | 47 +---- .../editor/rich-markdown-key-handler.test.ts | 38 ++++ .../editor/rich-markdown-key-handler.ts | 47 +---- .../editor/rich-markdown-selection-flush.ts | 42 ++++ .../rich-markdown-tab-key-handler.test.ts | 1 + .../use-rich-markdown-table-of-contents.ts | 44 +++++ .../editor/useRichMarkdownReviewController.ts | 78 ++++---- src/shared/keybindings.test.ts | 10 + src/shared/keybindings.ts | 9 + .../markdown-add-review-note-shortcut.spec.ts | 184 ++++++++++++++++++ 19 files changed, 688 insertions(+), 155 deletions(-) create mode 100644 src/renderer/src/components/editor/markdown-preview-annotation-shortcut.test.ts create mode 100644 src/renderer/src/components/editor/markdown-preview-annotation-shortcut.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-annotation-shortcut.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-selection-flush.ts create mode 100644 src/renderer/src/components/editor/use-rich-markdown-table-of-contents.ts create mode 100644 tests/e2e/markdown-add-review-note-shortcut.spec.ts diff --git a/src/renderer/src/components/editor/MarkdownPreview.tsx b/src/renderer/src/components/editor/MarkdownPreview.tsx index 3b4c53c3fbc..6e7a80e67b1 100644 --- a/src/renderer/src/components/editor/MarkdownPreview.tsx +++ b/src/renderer/src/components/editor/MarkdownPreview.tsx @@ -68,6 +68,10 @@ import { isMarkdownPreviewFindShortcut, setActiveMarkdownPreviewSearchMatch } from './markdown-preview-search' +import { + getMarkdownAnnotationBlockKeyForSelection, + isMarkdownPreviewAddReviewNoteShortcut +} from './markdown-preview-annotation-shortcut' import { usePreserveSectionDuringExternalEdit } from './usePreserveSectionDuringExternalEdit' import { openHttpLink, type HttpLinkSourceOwner } from '@/lib/http-link-routing' import { getShortcutPlatform } from '@/lib/shortcut-platform' @@ -916,6 +920,20 @@ export default function MarkdownPreview({ return } + if ( + isMarkdownPreviewAddReviewNoteShortcut(event, getShortcutPlatform(), keybindings) && + targetInsidePreview && + markdownAnnotationsEnabled + ) { + const blockKey = getMarkdownAnnotationBlockKeyForSelection(root, window.getSelection()) + if (blockKey) { + event.preventDefault() + event.stopPropagation() + setActiveAnnotationBlockKey(blockKey) + } + return + } + if (!isSearchOpen) { return } @@ -930,7 +948,7 @@ export default function MarkdownPreview({ window.addEventListener('keydown', handleKeyDown, { capture: true }) return () => window.removeEventListener('keydown', handleKeyDown, { capture: true }) - }, [closeSearch, isSearchOpen, keybindings, openSearch]) + }, [closeSearch, isSearchOpen, keybindings, markdownAnnotationsEnabled, openSearch]) const handleCopyMarkdownReviewNotes = useCallback(async (): Promise => { if (markdownReviewNotes.length === 0) { @@ -1248,6 +1266,7 @@ export default function MarkdownPreview({ className={`markdown-annotation-block ${hasReviewNotes ? 'has-review-notes' : ''}`.trim()} data-source-line={range.startLine} data-source-end-line={range.endLine} + data-annotation-block-key={blockKey} onClick={(event) => handleAnnotatedMarkdownBlockClick(range, event)} > {rendered} @@ -1668,6 +1687,11 @@ export default function MarkdownPreview({ } const blockKey = `li:${range.startLine}-${range.endLine}` const hasReviewNotes = getMarkdownCommentsForRange(range).length > 0 + const controls = renderAnnotationControls( + range, + blockKey, + getMarkdownPreviewAnnotationQuote(children) + ) return (
  • handleAnnotatedMarkdownBlockClick(range, event)} > {children} - {renderAnnotationControls( - range, - blockKey, - getMarkdownPreviewAnnotationQuote(children) - )} + {controls}
  • ) diff --git a/src/renderer/src/components/editor/MonacoEditor.tsx b/src/renderer/src/components/editor/MonacoEditor.tsx index daf288d8ce9..a1945794b8b 100644 --- a/src/renderer/src/components/editor/MonacoEditor.tsx +++ b/src/renderer/src/components/editor/MonacoEditor.tsx @@ -48,7 +48,11 @@ import { getDiffCommentPopoverTop } from '../diff-comments/diff-comment-popover-position' import { isLinuxUserAgent } from '../terminal-pane/pane-helpers' -import { installEditorSaveShortcut, installMonacoEditorFindShortcut } from './editor-shortcuts' +import { + installEditorAddReviewNoteShortcut, + installEditorSaveShortcut, + installMonacoEditorFindShortcut +} from './editor-shortcuts' import { Plus } from 'lucide-react' import { getMonacoMarkdownSelectionAnnotationTarget, @@ -198,6 +202,10 @@ export default function MonacoEditor({ const [commentPopover, setCommentPopover] = useState(null) const [selectionAnnotationTarget, setSelectionAnnotationTarget] = useState(null) + // Why: the Monaco mount closure installs its keydown listeners once, so the + // add-review-note shortcut reads the live selection target through a ref. + const selectionAnnotationTargetRef = useRef(null) + selectionAnnotationTargetRef.current = selectionAnnotationTarget const isDark = settings?.theme === 'dark' || (settings?.theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches) @@ -413,6 +421,18 @@ export default function MonacoEditor({ propsRef.current.onSave(value) }) const cleanupFindShortcut = installMonacoEditorFindShortcut(editorInstance) + // Opens the same composer as the selection "+" button; the target ref + // mirrors the last-rendered selection target and is null unless markdown + // annotations are enabled and text is selected. + const cleanupAddReviewNoteShortcut = installEditorAddReviewNoteShortcut(editorDomNode, () => { + const target = selectionAnnotationTargetRef.current + if (!target) { + return false + } + setCommentPopover(target) + setSelectionAnnotationTarget(null) + return true + }) const searchInFilesAction = editorInstance.addAction({ id: 'orca.searchInFiles', label: translate('auto.components.editor.MonacoEditor.fd68ae03b3', 'Search in Files'), @@ -512,6 +532,7 @@ export default function MonacoEditor({ gutterMouseDownSub.dispose() cleanupSaveShortcut() cleanupFindShortcut() + cleanupAddReviewNoteShortcut() editorDomNode.removeEventListener('paste', onLargeTextPaste, { capture: true }) searchInFilesAction.dispose() autoHeightSub?.dispose() diff --git a/src/renderer/src/components/editor/RichMarkdownEditor.tsx b/src/renderer/src/components/editor/RichMarkdownEditor.tsx index fdbaee0dfcb..2f499ba6b65 100644 --- a/src/renderer/src/components/editor/RichMarkdownEditor.tsx +++ b/src/renderer/src/components/editor/RichMarkdownEditor.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import React, { useCallback, useEffect, useRef, useState } from 'react' import { useEditorState, type Editor } from '@tiptap/react' import type { DiffComment, MarkdownDocument } from '../../../../shared/types' import { useAppStore } from '@/store' @@ -10,9 +10,7 @@ import { useLinkBubble } from './useLinkBubble' import { useEditorScrollRestore } from './useEditorScrollRestore' import { useModifierHeldClass } from './useModifierHeldClass' import { registerPendingEditorFlush } from './editor-pending-flush' -import type { MarkdownTocItem } from './markdown-table-of-contents' -import { findRichMarkdownTocHeadingTarget } from './rich-markdown-toc-heading-target' -import { selectMarkdownTableOfContents } from './markdown-toc-visibility-gate' +import { useRichMarkdownTableOfContents } from './use-rich-markdown-table-of-contents' import { RichMarkdownEditorSurface } from './RichMarkdownEditorSurface' import { useRichMarkdownEditorInstance } from './useRichMarkdownEditorInstance' import { useRichMarkdownMenuController } from './useRichMarkdownMenuController' @@ -56,10 +54,6 @@ type RichMarkdownEditorProps = { headerSlot?: React.ReactNode } -function flattenMarkdownTocItems(items: MarkdownTocItem[]): MarkdownTocItem[] { - return items.flatMap((item) => [item, ...flattenMarkdownTocItems(item.children)]) -} - export default function RichMarkdownEditor({ fileId, content, @@ -112,6 +106,7 @@ export default function RichMarkdownEditor({ const onOpenDocLinkRef = useRef(onOpenDocLink) const handleLocalImagePickRef = useRef<() => void>(() => {}) const openSearchRef = useRef<() => void>(() => {}) + const openAnnotationPopoverRef = useRef<(requireLiveSelection?: boolean) => boolean>(() => false) // Why: ProseMirror keeps the initial handleKeyDown closure, so `editor` stays // stuck at the first-render null value unless we read the live instance here. const editorRef = useRef(null) @@ -145,17 +140,10 @@ export default function RichMarkdownEditor({ worktreeId, worktreeRoot }) - // Why: building the table of contents runs a full-document remark parse on - // every content change. The result is only used while the panel is open - // (closed by default), so gate the parse on visibility; including - // showTableOfContents in deps rebuilds the outline the moment it opens. - const tableOfContentsItems = useMemo( - () => selectMarkdownTableOfContents(showTableOfContents, content), - [content, showTableOfContents] - ) - const flatTableOfContentsItems = useMemo( - () => flattenMarkdownTocItems(tableOfContentsItems), - [tableOfContentsItems] + const { tableOfContentsItems, navigateToTableOfContentsItem } = useRichMarkdownTableOfContents( + showTableOfContents, + content, + scrollContainerRef ) // Why: assigning callback refs during render keeps them current before any @@ -166,6 +154,7 @@ export default function RichMarkdownEditor({ onSaveRef.current = onSave onOpenDocLinkRef.current = onOpenDocLink isEditingLinkRef.current = isEditingLink + openAnnotationPopoverRef.current = review.openAnnotationPopover const reconcileRoundTripRef = useRichMarkdownReconcileRoundTrip({ htmlSuperscriptLinkContext, filePath, @@ -258,6 +247,7 @@ export default function RichMarkdownEditor({ markdownSourceLineOffsetRef: review.markdownSourceLineOffsetRef, flushPendingSerialization, openSearchRef, + openAnnotationPopoverRef, syncAnnotationTarget: review.syncAnnotationTarget, clearAnnotationTarget: review.clearAnnotationTarget, scrollRichMarkdownReviewNoteCardIntoView: review.scrollRichMarkdownReviewNoteCardIntoView, @@ -360,18 +350,6 @@ export default function RichMarkdownEditor({ }) openSearchRef.current = openSearch - const navigateToTableOfContentsItem = useCallback( - (id: string): void => { - const container = scrollContainerRef.current - if (!container) { - return - } - const heading = findRichMarkdownTocHeadingTarget(container, flatTableOfContentsItems, id) - heading?.scrollIntoView({ block: 'center' }) - }, - [flatTableOfContentsItems] - ) - return ( ({ })) import { + installEditorAddReviewNoteShortcut, installEditorFindShortcut, installMonacoDiffChangeNavigationShortcut, installMonacoEditorFindShortcut @@ -201,6 +202,74 @@ describe('installEditorFindShortcut', () => { }) }) +describe('installEditorAddReviewNoteShortcut', () => { + it('invokes add-review-note on its default binding and honors overrides', () => { + const container = document.createElement('div') + const input = document.createElement('textarea') + const onAddReviewNote = vi.fn(() => true) + container.appendChild(input) + document.body.appendChild(container) + const dispose = installEditorAddReviewNoteShortcut(container, onAddReviewNote) + + const defaultEvent = dispatchKeyDown(input, { + key: 'n', + code: 'KeyN', + metaKey: true, + altKey: true + }) + const repeatEvent = dispatchKeyDown(input, { + key: 'n', + code: 'KeyN', + metaKey: true, + altKey: true, + repeat: true + }) + const unrelatedEvent = dispatchKeyDown(input, { key: 'n', code: 'KeyN', metaKey: true }) + + expect(defaultEvent.defaultPrevented).toBe(true) + expect(repeatEvent.defaultPrevented).toBe(false) + expect(unrelatedEvent.defaultPrevented).toBe(false) + expect(onAddReviewNote).toHaveBeenCalledTimes(1) + + shortcutState.keybindings = { 'editor.addReviewNote': ['Mod+Shift+A'] } + const overriddenEvent = dispatchKeyDown(input, { + key: 'a', + code: 'KeyA', + metaKey: true, + shiftKey: true + }) + expect(overriddenEvent.defaultPrevented).toBe(true) + expect(onAddReviewNote).toHaveBeenCalledTimes(2) + + dispose() + dispatchKeyDown(input, { key: 'a', code: 'KeyA', metaKey: true, shiftKey: true }) + expect(onAddReviewNote).toHaveBeenCalledTimes(2) + }) + + it('leaves the chord unconsumed when the handler reports it did not act', () => { + const container = document.createElement('div') + const input = document.createElement('textarea') + const onDownstreamKeyDown = vi.fn() + const onAddReviewNote = vi.fn(() => false) + container.appendChild(input) + document.body.appendChild(container) + input.addEventListener('keydown', onDownstreamKeyDown) + const dispose = installEditorAddReviewNoteShortcut(container, onAddReviewNote) + + const event = dispatchKeyDown(input, { + key: 'n', + code: 'KeyN', + metaKey: true, + altKey: true + }) + + expect(onAddReviewNote).toHaveBeenCalledTimes(1) + expect(event.defaultPrevented).toBe(false) + expect(onDownstreamKeyDown).toHaveBeenCalledTimes(1) + dispose() + }) +}) + describe('installMonacoDiffChangeNavigationShortcut', () => { function createDiffNavigationFixture(): { container: HTMLDivElement diff --git a/src/renderer/src/components/editor/editor-shortcuts.ts b/src/renderer/src/components/editor/editor-shortcuts.ts index 825caf6f1ee..5038498a561 100644 --- a/src/renderer/src/components/editor/editor-shortcuts.ts +++ b/src/renderer/src/components/editor/editor-shortcuts.ts @@ -79,6 +79,27 @@ export function installMonacoDiffChangeNavigationShortcut( return () => target.removeEventListener('keydown', handleKeyDown, true) } +export function installEditorAddReviewNoteShortcut( + target: HTMLElement, + onAddReviewNote: () => boolean +): () => void { + const handleKeyDown = (event: KeyboardEvent): void => { + if (event.repeat || !editorShortcutMatches('editor.addReviewNote', event)) { + return + } + // Why: only consume the chord when a composer actually opens; on files + // where review notes can never apply the key must stay available to + // whatever else the user bound it to. + if (onAddReviewNote()) { + event.preventDefault() + event.stopPropagation() + } + } + + target.addEventListener('keydown', handleKeyDown, true) + return () => target.removeEventListener('keydown', handleKeyDown, true) +} + type MonacoFindShortcutEditor = { getAction: (id: string) => { run: () => void | Promise } | null getContainerDomNode: () => HTMLElement diff --git a/src/renderer/src/components/editor/markdown-preview-annotation-shortcut.test.ts b/src/renderer/src/components/editor/markdown-preview-annotation-shortcut.test.ts new file mode 100644 index 00000000000..ed16221dfbb --- /dev/null +++ b/src/renderer/src/components/editor/markdown-preview-annotation-shortcut.test.ts @@ -0,0 +1,95 @@ +// @vitest-environment happy-dom + +import { afterEach, describe, expect, it } from 'vitest' +import { + getMarkdownAnnotationBlockKeyForSelection, + isMarkdownPreviewAddReviewNoteShortcut +} from './markdown-preview-annotation-shortcut' + +function createPreviewFixture(): { + root: HTMLDivElement + block: HTMLDivElement + paragraph: HTMLParagraphElement +} { + const root = document.createElement('div') + const block = document.createElement('div') + block.className = 'markdown-annotation-block' + block.setAttribute('data-annotation-block-key', 'p:3-5') + const paragraph = document.createElement('p') + paragraph.textContent = 'Some rendered markdown text' + block.appendChild(paragraph) + root.appendChild(block) + document.body.appendChild(root) + return { root, block, paragraph } +} + +function selectTextIn(node: Node): Selection { + const selection = window.getSelection() + if (!selection) { + throw new Error('Selection API unavailable in test environment') + } + const range = document.createRange() + range.selectNodeContents(node) + selection.removeAllRanges() + selection.addRange(range) + return selection +} + +afterEach(() => { + window.getSelection()?.removeAllRanges() + document.body.replaceChildren() +}) + +describe('getMarkdownAnnotationBlockKeyForSelection', () => { + it('returns the block key for a selection inside an annotation block', () => { + const { root, paragraph } = createPreviewFixture() + const selection = selectTextIn(paragraph) + + expect(getMarkdownAnnotationBlockKeyForSelection(root, selection)).toBe('p:3-5') + }) + + it('returns null for a collapsed selection', () => { + const { root, paragraph } = createPreviewFixture() + const selection = selectTextIn(paragraph) + selection.collapseToStart() + + expect(getMarkdownAnnotationBlockKeyForSelection(root, selection)).toBeNull() + }) + + it('returns null when the selection is outside the preview root', () => { + const { root } = createPreviewFixture() + const outside = document.createElement('p') + outside.textContent = 'other text' + document.body.appendChild(outside) + const selection = selectTextIn(outside) + + expect(getMarkdownAnnotationBlockKeyForSelection(root, selection)).toBeNull() + }) + + it('returns null without a selection', () => { + const { root } = createPreviewFixture() + + expect(getMarkdownAnnotationBlockKeyForSelection(root, null)).toBeNull() + }) +}) + +describe('isMarkdownPreviewAddReviewNoteShortcut', () => { + it('matches the default binding and respects overrides', () => { + const defaultEvent = { + key: 'n', + code: 'KeyN', + metaKey: true, + ctrlKey: false, + altKey: true, + shiftKey: false + } + + expect(isMarkdownPreviewAddReviewNoteShortcut(defaultEvent, 'darwin')).toBe(true) + expect(isMarkdownPreviewAddReviewNoteShortcut(defaultEvent, 'linux')).toBe(false) + expect( + isMarkdownPreviewAddReviewNoteShortcut(defaultEvent, 'darwin', { + 'editor.addReviewNote': ['Mod+Shift+A'] + }) + ).toBe(false) + }) +}) diff --git a/src/renderer/src/components/editor/markdown-preview-annotation-shortcut.ts b/src/renderer/src/components/editor/markdown-preview-annotation-shortcut.ts new file mode 100644 index 00000000000..b64dc4c67f5 --- /dev/null +++ b/src/renderer/src/components/editor/markdown-preview-annotation-shortcut.ts @@ -0,0 +1,38 @@ +import { keybindingMatchesAction, type KeybindingOverrides } from '../../../../shared/keybindings' + +export function isMarkdownPreviewAddReviewNoteShortcut( + event: Pick, + platform: NodeJS.Platform, + keybindings?: KeybindingOverrides +): boolean { + return keybindingMatchesAction('editor.addReviewNote', event, platform, keybindings) +} + +function closestAnnotationBlockKey(node: Node | null, root: HTMLElement): string | null { + const element = node instanceof Element ? node : (node?.parentElement ?? null) + const block = element?.closest('[data-annotation-block-key]') ?? null + if (!block || !root.contains(block)) { + return null + } + return block.getAttribute('data-annotation-block-key') +} + +/** + * Maps the current DOM text selection to the annotation block that should host + * the review-note composer. Returns null when the selection is collapsed or + * falls outside an annotatable block of this preview root. + */ +export function getMarkdownAnnotationBlockKeyForSelection( + root: HTMLElement, + selection: Selection | null +): string | null { + if (!selection || selection.isCollapsed || selection.rangeCount === 0) { + return null + } + // Why: a selection spanning multiple blocks anchors the composer on the + // block where the selection started, falling back to where it ended. + return ( + closestAnnotationBlockKey(selection.anchorNode, root) ?? + closestAnnotationBlockKey(selection.focusNode, root) + ) +} diff --git a/src/renderer/src/components/editor/rich-markdown-annotation-shortcut.ts b/src/renderer/src/components/editor/rich-markdown-annotation-shortcut.ts new file mode 100644 index 00000000000..318154d412e --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-annotation-shortcut.ts @@ -0,0 +1,21 @@ +import type { KeyHandlerContext } from './rich-markdown-key-handler' +import { editorShortcutMatches } from './editor-shortcuts' + +/** + * Mod+Alt+N: open the review-note composer for the current selection. + */ +export function handleRichMarkdownAddReviewNoteShortcut( + ctx: KeyHandlerContext, + event: KeyboardEvent +): boolean { + if (!editorShortcutMatches('editor.addReviewNote', event)) { + return false + } + // Why: require the live selection so a collapsed selection cannot reopen a + // stale target; consume the chord only when a composer actually opens. + if (!ctx.openAnnotationPopoverRef.current(true)) { + return false + } + event.preventDefault() + return true +} diff --git a/src/renderer/src/components/editor/rich-markdown-editor-config.test.ts b/src/renderer/src/components/editor/rich-markdown-editor-config.test.ts index 8573ddea973..dd58ff40838 100644 --- a/src/renderer/src/components/editor/rich-markdown-editor-config.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-editor-config.test.ts @@ -73,6 +73,7 @@ function createConfigParams(overrides: Partial = {}): Editor markdownSourceLineOffsetRef: ref(0), flushPendingSerialization: vi.fn(), openSearchRef: ref(vi.fn()), + openAnnotationPopoverRef: ref(vi.fn()), syncAnnotationTarget: vi.fn(), clearAnnotationTarget: vi.fn(), scrollRichMarkdownReviewNoteCardIntoView: vi.fn(), diff --git a/src/renderer/src/components/editor/rich-markdown-editor-config.ts b/src/renderer/src/components/editor/rich-markdown-editor-config.ts index ec94eda94d4..5877d9eb31e 100644 --- a/src/renderer/src/components/editor/rich-markdown-editor-config.ts +++ b/src/renderer/src/components/editor/rich-markdown-editor-config.ts @@ -77,6 +77,7 @@ export type EditorConfigParams = { markdownSourceLineOffsetRef: MutableRefObject flushPendingSerialization: () => void openSearchRef: MutableRefObject<() => void> + openAnnotationPopoverRef: MutableRefObject<(requireLiveSelection?: boolean) => boolean> syncAnnotationTarget: (editor: Editor) => void clearAnnotationTarget: () => void scrollRichMarkdownReviewNoteCardIntoView: (commentId: string) => void @@ -109,17 +110,7 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE reconcileRoundTripRef, onContentChangeRef, onDirtyStateHintRef, - onSaveRef, onOpenDocLinkRef, - isEditingLinkRef, - slashMenuRef, - filteredSlashCommandsRef, - selectedCommandIndexRef, - docLinkMenuRef, - filteredDocLinkRowsRef, - selectedDocLinkIndexRef, - handleLocalImagePickRef, - handleEmojiPickRef, typedEmptyOrderedListMarkerRef, cancelAutoFocusRef, serializeTimerRef, @@ -127,15 +118,11 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE isApplyingProgrammaticUpdateRef, markdownCommentsRef, markdownSourceLineOffsetRef, - flushPendingSerialization, - openSearchRef, syncAnnotationTarget, clearAnnotationTarget, scrollRichMarkdownReviewNoteCardIntoView, setIsEditingLink, setLinkBubble, - setSelectedCommandIndex, - setSelectedDocLinkIndex, setSlashMenu, setDocLinkMenu } = params @@ -172,36 +159,12 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE typedEmptyOrderedListMarkerRef.current = /^\d+\.$/.test(beforeCursor) return false }, + // Why: KeyHandlerContext is a typed subset of EditorConfigParams, so the + // spread stays type-checked while new context fields avoid re-listing + // every ref here. handleKeyDown: createRichMarkdownKeyHandler({ - isMac, - editorRef, - rootRef, - lastCommittedMarkdownRef, - originalSourceRef, - baseCanonicalRef, - reconcileRoundTripRef, - onContentChangeRef, - onSaveRef, - isEditingLinkRef, - slashMenuRef, - filteredSlashCommandsRef, - selectedCommandIndexRef, - docLinkMenuRef, - filteredDocLinkRowsRef, - selectedDocLinkIndexRef, - handleLocalImagePickRef, - handleEmojiPickRef, - typedEmptyOrderedListMarkerRef, - flushPendingSerialization, - openSearchRef, + ...params, linkBubbleOwnerId: codec.transport.key, - htmlSuperscriptLinkContext, - setIsEditingLink, - setLinkBubble, - setSelectedCommandIndex, - setSelectedDocLinkIndex, - setSlashMenu, - setDocLinkMenu, openSelectedHtmlSuperscriptLink: () => openSelectedHtmlSuperscriptLink({ activateMarkdownLink, diff --git a/src/renderer/src/components/editor/rich-markdown-key-handler.test.ts b/src/renderer/src/components/editor/rich-markdown-key-handler.test.ts index 02d53e1b626..ebd71f84f31 100644 --- a/src/renderer/src/components/editor/rich-markdown-key-handler.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-key-handler.test.ts @@ -4,6 +4,12 @@ import StarterKit from '@tiptap/starter-kit' import { createIsolatedMarkdownExtensionForTests } from './isolated-markdown-extension-for-tests' import { createRichMarkdownKeyHandler, type KeyHandlerContext } from './rich-markdown-key-handler' +// Why: keybinding matching resolves the platform from navigator.userAgent, +// which is environment-dependent under vitest; pin it for determinism. +vi.mock('@/lib/shortcut-platform', () => ({ + getShortcutPlatform: () => 'darwin' as NodeJS.Platform +})) + const extensions = [StarterKit, createIsolatedMarkdownExtensionForTests()] function createEditor(content: object): Editor { @@ -82,6 +88,7 @@ function createContext(editor: Editor, typedMarker: boolean): KeyHandlerContext subscribe: () => () => {}, update: () => {} }, + openAnnotationPopoverRef: { current: vi.fn(() => true) }, setIsEditingLink: vi.fn(), setLinkBubble: vi.fn(), setSelectedCommandIndex: vi.fn(), @@ -105,6 +112,37 @@ function emptyTopLevelOrderedList(): object { } describe('rich markdown key handler', () => { + it('opens the review-note composer on the add-review-note shortcut', () => { + const editor = createEditor(emptyTopLevelOrderedList()) + + try { + const ctx = createContext(editor, false) + const event = keyEvent('n', { metaKey: true, altKey: true, code: 'KeyN' }) + + expect(createRichMarkdownKeyHandler(ctx)(null, event)).toBe(true) + expect(event.preventDefault).toHaveBeenCalled() + expect(ctx.openAnnotationPopoverRef.current).toHaveBeenCalledWith(true) + } finally { + editor.destroy() + } + }) + + it('leaves the add-review-note chord unconsumed when no composer opens', () => { + const editor = createEditor(emptyTopLevelOrderedList()) + + try { + const ctx = createContext(editor, false) + ctx.openAnnotationPopoverRef.current = vi.fn(() => false) + const event = keyEvent('n', { metaKey: true, altKey: true, code: 'KeyN' }) + + expect(createRichMarkdownKeyHandler(ctx)(null, event)).toBe(false) + expect(event.preventDefault).not.toHaveBeenCalled() + expect(ctx.openAnnotationPopoverRef.current).toHaveBeenCalledTimes(1) + } finally { + editor.destroy() + } + }) + it('preserves a typed empty ordered-list shortcut on Enter', () => { const editor = createEditor(emptyTopLevelOrderedList()) diff --git a/src/renderer/src/components/editor/rich-markdown-key-handler.ts b/src/renderer/src/components/editor/rich-markdown-key-handler.ts index d8995f3c913..b0a223887b5 100644 --- a/src/renderer/src/components/editor/rich-markdown-key-handler.ts +++ b/src/renderer/src/components/editor/rich-markdown-key-handler.ts @@ -3,6 +3,7 @@ import type { Editor } from '@tiptap/react' import { getShortcutPlatform } from '@/lib/shortcut-platform' import { useAppStore } from '@/store' import { isMarkdownPreviewFindShortcut } from './markdown-preview-search' +import { handleRichMarkdownAddReviewNoteShortcut } from './rich-markdown-annotation-shortcut' import type { LinkBubbleState } from './RichMarkdownLinkBubble' import { commitRow, type DocLinkMenuRow, type DocLinkMenuState } from './rich-markdown-commands' import { @@ -21,6 +22,7 @@ import { handleRichMarkdownCitationKey } from './rich-markdown-citation-keyboard import type { RichMarkdownHtmlSuperscriptLinkContext } from './rich-markdown-html-superscript-link-context' import { handleRichMarkdownLinkShortcut } from './rich-markdown-link-shortcut' import { handleRichMarkdownSaveShortcut } from './rich-markdown-save-shortcut' +import { flushPendingProseMirrorSelection } from './rich-markdown-selection-flush' export type KeyHandlerContext = { isMac: boolean @@ -44,6 +46,7 @@ export type KeyHandlerContext = { typedEmptyOrderedListMarkerRef: MutableRefObject flushPendingSerialization: () => void openSearchRef: MutableRefObject<() => void> + openAnnotationPopoverRef: MutableRefObject<(requireLiveSelection?: boolean) => boolean> setIsEditingLink: (editing: boolean) => void setLinkBubble: (bubble: LinkBubbleState | null) => void setSelectedCommandIndex: Dispatch> @@ -59,47 +62,6 @@ function isComposingMarkdownInput(event: KeyboardEvent, editor: Editor | null): return event.isComposing || editor?.view.composing === true } -type NativeSelectionSnapshot = { - anchorNode: Node | null - anchorOffset: number - focusNode: Node | null - focusOffset: number -} - -type ProseMirrorDomObserver = { - currentSelection?: { - set?: (selection: NativeSelectionSnapshot) => void - } - flush?: () => void -} - -type ProseMirrorViewWithDomObserver = Editor['view'] & { - domObserver?: ProseMirrorDomObserver -} - -function flushPendingProseMirrorSelection(editor: Editor): void { - let observer: ProseMirrorDomObserver | undefined - try { - observer = (editor.view as ProseMirrorViewWithDomObserver).domObserver - } catch { - return - } - - if (typeof observer?.flush !== 'function') { - return - } - - // Why: immediate Tab after a mouse click can run before ProseMirror has - // copied the native selection into editor state, so list commands hit stale item state. - observer.currentSelection?.set?.({ - anchorNode: null, - anchorOffset: 0, - focusNode: null, - focusOffset: 0 - }) - observer.flush() -} - /** * Why: extracted from RichMarkdownEditor to stay under the file line-limit * while keeping the keyboard handler logic co-located and testable. @@ -133,6 +95,9 @@ export function createRichMarkdownKeyHandler( if (handleRichMarkdownSaveShortcut(ctx, event)) { return true } + if (handleRichMarkdownAddReviewNoteShortcut(ctx, event)) { + return true + } // Strikethrough: Cmd/Ctrl+Shift+X (standard shortcut used by Google // Docs, Notion, etc. — supplements Tiptap's built-in Mod+Shift+S). diff --git a/src/renderer/src/components/editor/rich-markdown-selection-flush.ts b/src/renderer/src/components/editor/rich-markdown-selection-flush.ts new file mode 100644 index 00000000000..a7dd336ed2e --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-selection-flush.ts @@ -0,0 +1,42 @@ +import type { Editor } from '@tiptap/react' + +type NativeSelectionSnapshot = { + anchorNode: Node | null + anchorOffset: number + focusNode: Node | null + focusOffset: number +} + +type ProseMirrorDomObserver = { + currentSelection?: { + set?: (selection: NativeSelectionSnapshot) => void + } + flush?: () => void +} + +type ProseMirrorViewWithDomObserver = Editor['view'] & { + domObserver?: ProseMirrorDomObserver +} + +export function flushPendingProseMirrorSelection(editor: Editor): void { + let observer: ProseMirrorDomObserver | undefined + try { + observer = (editor.view as ProseMirrorViewWithDomObserver).domObserver + } catch { + return + } + + if (typeof observer?.flush !== 'function') { + return + } + + // Why: immediate Tab after a mouse click can run before ProseMirror has + // copied the native selection into editor state, so list commands hit stale item state. + observer.currentSelection?.set?.({ + anchorNode: null, + anchorOffset: 0, + focusNode: null, + focusOffset: 0 + }) + observer.flush() +} diff --git a/src/renderer/src/components/editor/rich-markdown-tab-key-handler.test.ts b/src/renderer/src/components/editor/rich-markdown-tab-key-handler.test.ts index b9675d5e385..de6b9934d27 100644 --- a/src/renderer/src/components/editor/rich-markdown-tab-key-handler.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-tab-key-handler.test.ts @@ -89,6 +89,7 @@ function createContext(editor: Editor): KeyHandlerContext { subscribe: () => () => {}, update: () => {} }, + openAnnotationPopoverRef: { current: vi.fn() }, setIsEditingLink: vi.fn(), setLinkBubble: vi.fn(), setSelectedCommandIndex: vi.fn(), diff --git a/src/renderer/src/components/editor/use-rich-markdown-table-of-contents.ts b/src/renderer/src/components/editor/use-rich-markdown-table-of-contents.ts new file mode 100644 index 00000000000..de2040627cd --- /dev/null +++ b/src/renderer/src/components/editor/use-rich-markdown-table-of-contents.ts @@ -0,0 +1,44 @@ +import { useCallback, useMemo, type MutableRefObject } from 'react' +import type { MarkdownTocItem } from './markdown-table-of-contents' +import { findRichMarkdownTocHeadingTarget } from './rich-markdown-toc-heading-target' +import { selectMarkdownTableOfContents } from './markdown-toc-visibility-gate' + +function flattenMarkdownTocItems(items: MarkdownTocItem[]): MarkdownTocItem[] { + return items.flatMap((item) => [item, ...flattenMarkdownTocItems(item.children)]) +} + +export function useRichMarkdownTableOfContents( + showTableOfContents: boolean, + content: string, + scrollContainerRef: MutableRefObject +): { + tableOfContentsItems: MarkdownTocItem[] + navigateToTableOfContentsItem: (id: string) => void +} { + // Why: building the table of contents runs a full-document remark parse on + // every content change. The result is only used while the panel is open + // (closed by default), so gate the parse on visibility; including + // showTableOfContents in deps rebuilds the outline the moment it opens. + const tableOfContentsItems = useMemo( + () => selectMarkdownTableOfContents(showTableOfContents, content), + [content, showTableOfContents] + ) + const flatTableOfContentsItems = useMemo( + () => flattenMarkdownTocItems(tableOfContentsItems), + [tableOfContentsItems] + ) + + const navigateToTableOfContentsItem = useCallback( + (id: string): void => { + const container = scrollContainerRef.current + if (!container) { + return + } + const heading = findRichMarkdownTocHeadingTarget(container, flatTableOfContentsItems, id) + heading?.scrollIntoView({ block: 'center' }) + }, + [flatTableOfContentsItems, scrollContainerRef] + ) + + return { tableOfContentsItems, navigateToTableOfContentsItem } +} diff --git a/src/renderer/src/components/editor/useRichMarkdownReviewController.ts b/src/renderer/src/components/editor/useRichMarkdownReviewController.ts index 43bade891b8..82b408006b1 100644 --- a/src/renderer/src/components/editor/useRichMarkdownReviewController.ts +++ b/src/renderer/src/components/editor/useRichMarkdownReviewController.ts @@ -201,41 +201,51 @@ export function useRichMarkdownReviewController({ ] ) - const openAnnotationPopover = useCallback((): void => { - if (!annotationTarget || !canAnnotateRichMarkdown) { - return - } - const editor = editorRef.current - const root = rootRef.current - const liveTarget = editor && root ? getRichMarkdownAnnotationTarget(editor, root) : null - const target = editor - ? clampRichMarkdownAnnotationTarget(editor, liveTarget ?? annotationTarget) - : annotationTarget - if ( - !target || - hasRichMarkdownCommentForRange(markdownComments, target, markdownSourceLineOffset) - ) { + // Why: reports whether a composer actually opened so keyboard callers can + // leave the chord unconsumed when this no-ops (mouse callers ignore it). + const openAnnotationPopover = useCallback( + (requireLiveSelection = false): boolean => { + if (!canAnnotateRichMarkdown) { + return false + } + const editor = editorRef.current + const root = rootRef.current + // Why: keyboard callers require the live selection to avoid stale-target + // races; the mouse button may use the target from the render that exposed it. + const liveTarget = editor && root ? getRichMarkdownAnnotationTarget(editor, root) : null + const baseTarget = liveTarget ?? (requireLiveSelection ? null : annotationTarget) + if (!baseTarget) { + return false + } + const target = editor ? clampRichMarkdownAnnotationTarget(editor, baseTarget) : baseTarget + if ( + !target || + hasRichMarkdownCommentForRange(markdownComments, target, markdownSourceLineOffset) + ) { + setAnnotationTarget(null) + return false + } + editor?.view.dispatch( + editor.state.tr.setMeta(richMarkdownAnnotationHighlightPluginKey, { + activeRange: { from: target.from, to: target.to } + }) + ) + // Why: opening a draft should reserve the notes rail immediately; saved notes stay visible. + setReviewRailOpen(true) + setAnnotationPopover(target) setAnnotationTarget(null) - return - } - editor?.view.dispatch( - editor.state.tr.setMeta(richMarkdownAnnotationHighlightPluginKey, { - activeRange: { from: target.from, to: target.to } - }) - ) - // Why: opening a draft should reserve the notes rail immediately; saved notes stay visible. - setReviewRailOpen(true) - setAnnotationPopover(target) - setAnnotationTarget(null) - }, [ - annotationTarget, - canAnnotateRichMarkdown, - editorRef, - markdownComments, - markdownSourceLineOffset, - rootRef, - setReviewRailOpen - ]) + return true + }, + [ + annotationTarget, + canAnnotateRichMarkdown, + editorRef, + markdownComments, + markdownSourceLineOffset, + rootRef, + setReviewRailOpen + ] + ) useEffect(() => { if (canAnnotateRichMarkdown) { diff --git a/src/shared/keybindings.test.ts b/src/shared/keybindings.test.ts index 4bf8344f499..db6ef52f138 100644 --- a/src/shared/keybindings.test.ts +++ b/src/shared/keybindings.test.ts @@ -197,6 +197,16 @@ describe('keybindings', () => { expect(formatKeybindingList(['Mod+Shift+O'], 'darwin')).toBe('⌘⇧O') }) + it('defines a default shortcut for adding an editor review note', () => { + expect(getEffectiveKeybindingsForAction('editor.addReviewNote', 'darwin')).toEqual([ + 'Mod+Alt+N' + ]) + expect(getEffectiveKeybindingsForAction('editor.addReviewNote', 'linux')).toEqual(['Mod+Alt+N']) + expect(getEffectiveKeybindingsForAction('editor.addReviewNote', 'win32')).toEqual(['Mod+Alt+N']) + expect(formatKeybindingList(['Mod+Alt+N'], 'darwin')).toBe('⌘⌥N') + expect(formatKeybindingList(['Mod+Alt+N'], 'linux')).toBe('Ctrl+Alt+N') + }) + it('defines platform-native replace-in-editor shortcuts', () => { expect(getEffectiveKeybindingsForAction('editor.replace', 'darwin')).toEqual(['Mod+Alt+F']) expect(getEffectiveKeybindingsForAction('editor.replace', 'linux')).toEqual(['Mod+H']) diff --git a/src/shared/keybindings.ts b/src/shared/keybindings.ts index f3c04d2ac9e..097cb661eed 100644 --- a/src/shared/keybindings.ts +++ b/src/shared/keybindings.ts @@ -92,6 +92,7 @@ export type KeybindingActionId = | 'editor.copyContext' | 'editor.previousChange' | 'editor.nextChange' + | 'editor.addReviewNote' | 'fileExplorer.undo' | 'fileExplorer.redo' | 'fileExplorer.copyPath' @@ -852,6 +853,14 @@ export const KEYBINDING_DEFINITIONS: readonly KeybindingDefinition[] = [ defaultBindings: platformBindings(['F7']), allowBareKeybindings: true }, + { + id: 'editor.addReviewNote', + title: 'Add Review Note', + group: 'Editors', + scope: 'editor', + searchKeywords: ['shortcut', 'editor', 'markdown', 'note', 'comment', 'annotation', 'review'], + defaultBindings: platformBindings(['Mod+Alt+N']) + }, { id: 'fileExplorer.undo', title: 'Undo file operation', diff --git a/tests/e2e/markdown-add-review-note-shortcut.spec.ts b/tests/e2e/markdown-add-review-note-shortcut.spec.ts new file mode 100644 index 00000000000..3fb16f5c073 --- /dev/null +++ b/tests/e2e/markdown-add-review-note-shortcut.spec.ts @@ -0,0 +1,184 @@ +import { test, expect } from './helpers/orca-app' +import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { + cleanupMarkdownFixture, + createMarkdownFixture, + getActiveWorktreeContext, + openMarkdownFixture, + waitForRichMarkdownEditor +} from './helpers/markdown-ordered-list-exit' + +test.describe('Markdown add-review-note shortcut', () => { + test.beforeEach(async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + }) + + test('opens the review-note composer for the current selection in the rich editor', async ({ + orcaPage + }, testInfo) => { + const context = await getActiveWorktreeContext(orcaPage) + let filePath: string | null = null + + try { + filePath = await createMarkdownFixture( + context, + 'add-review-note', + testInfo.workerIndex, + 'A paragraph to annotate with a review note.\n' + ) + await openMarkdownFixture(orcaPage, context, filePath) + const editor = await waitForRichMarkdownEditor(orcaPage) + await editor.click() + await orcaPage.keyboard.press('ControlOrMeta+A') + + await orcaPage.keyboard.press('ControlOrMeta+Alt+N') + + await expect(orcaPage.getByPlaceholder('Add note for the AI')).toBeVisible({ + timeout: 5_000 + }) + } finally { + await cleanupMarkdownFixture(filePath) + } + }) + + test('opens the composer for the current selection in the Monaco source editor', async ({ + orcaPage + }, testInfo) => { + const context = await getActiveWorktreeContext(orcaPage) + let filePath: string | null = null + + try { + filePath = await createMarkdownFixture( + context, + 'add-review-note-source', + testInfo.workerIndex, + 'A paragraph to annotate from the source editor.\n' + ) + await openMarkdownFixture(orcaPage, context, filePath) + await waitForRichMarkdownEditor(orcaPage) + await orcaPage.evaluate(() => { + // Why: switch to source mode through the store — the toolbar toggle is + // an icon menu that is brittle to locate; the store action is what it + // dispatches anyway, and the shortcut under test is mode-independent. + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + const state = store.getState() + if (!state.activeFileId) { + throw new Error('No active editor file') + } + state.setMarkdownViewMode(state.activeFileId, 'source') + }) + const monaco = orcaPage.locator('.monaco-editor').first() + await expect(monaco).toBeVisible({ timeout: 25_000 }) + await monaco.click() + await orcaPage.keyboard.press('ControlOrMeta+A') + + await orcaPage.keyboard.press('ControlOrMeta+Alt+N') + + await expect(orcaPage.getByPlaceholder('Add note for the AI')).toBeVisible({ + timeout: 5_000 + }) + } finally { + await cleanupMarkdownFixture(filePath) + } + }) + + test('opens the inline composer for the selected block in the markdown preview', async ({ + orcaPage + }, testInfo) => { + const context = await getActiveWorktreeContext(orcaPage) + let filePath: string | null = null + + try { + filePath = await createMarkdownFixture( + context, + 'add-review-note-preview', + testInfo.workerIndex, + 'A paragraph to annotate from the preview.\n' + ) + await openMarkdownFixture(orcaPage, context, filePath) + await waitForRichMarkdownEditor(orcaPage) + await orcaPage.evaluate(() => { + // Why: open the real markdown-preview tab through the store — preview + // is a separate file mode, not a view mode of the edit tab, and the + // toolbar entry point is an icon menu that is brittle to locate. + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + const state = store.getState() + const file = state.openFiles.find((f) => f.id === state.activeFileId) + if (!file) { + throw new Error('No active editor file') + } + state.openMarkdownPreview( + { + filePath: file.filePath, + relativePath: file.relativePath, + worktreeId: file.worktreeId, + runtimeEnvironmentId: file.runtimeEnvironmentId, + language: 'markdown' + }, + { sourceFileId: file.id } + ) + }) + await expect(orcaPage.locator('[data-annotation-block-key]').first()).toBeVisible({ + timeout: 25_000 + }) + + await orcaPage.evaluate(() => { + // Why: mirror a reader selecting rendered text — focus lands on the + // preview's tabIndex=0 root and the DOM selection covers the block. + const block = document.querySelector('[data-annotation-block-key]') + if (!block) { + throw new Error('No annotation block found in preview') + } + const focusable = block.closest('[tabindex]') + if (!focusable) { + throw new Error('No focusable preview root above the annotation block') + } + focusable.focus() + const paragraph = block.querySelector('p') ?? block + const selection = window.getSelection() + const range = document.createRange() + range.selectNodeContents(paragraph) + selection?.removeAllRanges() + selection?.addRange(range) + }) + + await orcaPage.keyboard.press('ControlOrMeta+Alt+N') + + await expect(orcaPage.getByPlaceholder('Add note for the AI')).toBeVisible({ + timeout: 5_000 + }) + } finally { + await cleanupMarkdownFixture(filePath) + } + }) + + test('does not open the composer without a text selection', async ({ orcaPage }, testInfo) => { + const context = await getActiveWorktreeContext(orcaPage) + let filePath: string | null = null + + try { + filePath = await createMarkdownFixture( + context, + 'add-review-note-no-selection', + testInfo.workerIndex, + 'A paragraph without any selection.\n' + ) + await openMarkdownFixture(orcaPage, context, filePath) + const editor = await waitForRichMarkdownEditor(orcaPage) + await editor.click() + + await orcaPage.keyboard.press('ControlOrMeta+Alt+N') + + await expect(orcaPage.getByPlaceholder('Add note for the AI')).toHaveCount(0) + } finally { + await cleanupMarkdownFixture(filePath) + } + }) +}) From 1284a00e93fa1a6fff38e4103dfe59a3fbcc2f1d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 16 Jul 2026 21:29:17 -0700 Subject: [PATCH 02/17] fix(native-chat): don't force a default model/effort on agent spawn (#9134) #9085 made resolveNativeChatSessionOptionDefaults fall back to the catalog default model (sonnet) and effort (high) whenever the user had not explicitly picked one, injecting `--model sonnet --effort high` (and codex `-m`/`-c` equivalents) onto every agent spawn: composer, worktree activation, source-control agents, onboarding, work-item launch, floating terminal, and new-tab launch. That silently overrode each user's own CLI-configured default model. Return undefined (spawn the agent exactly as its own CLI would) unless the user has explicitly selected a model, and carry only explicitly stored option values. An explicit model selection still applies that model and its catalog option defaults via resolveAgentSessionOptionLaunch. --- .../onboarding-folder-agent-startup.test.ts | 10 ++-- .../src/lib/launch-agent-in-new-tab.test.ts | 25 ++++----- .../src/lib/launch-work-item-direct.test.ts | 6 +- .../worktree-activation-created-agent.test.ts | 5 +- .../repos-onboarding-folder-startup.test.ts | 5 +- .../agent-session-option-catalog.test.ts | 55 ++++++++++++++++--- .../native-chat-session-option-defaults.ts | 24 ++------ 7 files changed, 72 insertions(+), 58 deletions(-) diff --git a/src/renderer/src/components/onboarding/onboarding-folder-agent-startup.test.ts b/src/renderer/src/components/onboarding/onboarding-folder-agent-startup.test.ts index 1e57102600f..18068817358 100644 --- a/src/renderer/src/components/onboarding/onboarding-folder-agent-startup.test.ts +++ b/src/renderer/src/components/onboarding/onboarding-folder-agent-startup.test.ts @@ -14,8 +14,7 @@ describe('buildOnboardingFolderAgentStartup', () => { }) expect(startup).toEqual({ - command: - "codex '-m' 'gpt-5.6-sol' '-c' 'model_reasoning_effort=medium' '--dangerously-bypass-approvals-and-sandbox'", + command: "codex '--dangerously-bypass-approvals-and-sandbox'", env: {}, launchAgent: 'codex', launchConfig: { @@ -23,7 +22,7 @@ describe('buildOnboardingFolderAgentStartup', () => { agentArgs: '--dangerously-bypass-approvals-and-sandbox', agentEnv: {} }, - sessionOptions: { model: 'gpt-5.6-sol', effort: 'medium' }, + sessionOptions: undefined, telemetry: { agent_kind: 'codex', launch_source: 'onboarding', @@ -99,8 +98,7 @@ describe('buildOnboardingFolderAgentStartup', () => { false ) ).toEqual({ - command: - "echo onboarding-folder-agent '-m' 'gpt-5.6-sol' '-c' 'model_reasoning_effort=medium' '--dangerously-bypass-approvals-and-sandbox'", + command: "echo onboarding-folder-agent '--dangerously-bypass-approvals-and-sandbox'", env: {}, launchAgent: 'codex', launchConfig: { @@ -108,7 +106,7 @@ describe('buildOnboardingFolderAgentStartup', () => { agentArgs: '--dangerously-bypass-approvals-and-sandbox', agentEnv: {} }, - sessionOptions: { model: 'gpt-5.6-sol', effort: 'medium' }, + sessionOptions: undefined, telemetry: { agent_kind: 'codex', launch_source: 'onboarding', diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts index b3c5029a317..c43f98ef969 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts @@ -340,8 +340,7 @@ describe('launchAgentInNewTab', () => { environmentId: 'web-runtime', targetGroupId: 'group-1', activate: true, - command: - "codex '-m' 'gpt-5.6-sol' '-c' 'model_reasoning_effort=medium' '--model' 'gpt-5' '--reasoning-effort' 'high' 'fix the spinner'", + command: "codex '--model' 'gpt-5' '--reasoning-effort' 'high' 'fix the spinner'", env: { CODEX_PROFILE: 'captured' }, startupCommandDelivery: 'shell-ready', launchConfig: { @@ -486,8 +485,7 @@ describe('launchAgentInNewTab', () => { expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( 'tab-1', expect.objectContaining({ - command: - "claude '--model' 'sonnet' '--effort' 'high' '--dangerously-skip-permissions' --prefill 'review Bob''s change'" + command: "claude '--dangerously-skip-permissions' --prefill 'review Bob''s change'" }) ) }) @@ -505,7 +503,7 @@ describe('launchAgentInNewTab', () => { expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( 'tab-1', expect.objectContaining({ - command: 'claude "--model" "sonnet" "--effort" "high" "--dangerously-skip-permissions"' + command: 'claude "--dangerously-skip-permissions"' }) ) }) @@ -523,7 +521,7 @@ describe('launchAgentInNewTab', () => { expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( 'tab-1', expect.objectContaining({ - command: "claude '--model' 'sonnet' '--effort' 'high' '--dangerously-skip-permissions'" + command: "claude '--dangerously-skip-permissions'" }) ) }) @@ -543,8 +541,7 @@ describe('launchAgentInNewTab', () => { expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( 'tab-1', expect.objectContaining({ - command: - 'codex "-m" "gpt-5.6-sol" "-c" "model_reasoning_effort=medium" "--model" "gpt-5" "fix the spinner"' + command: 'codex "--model" "gpt-5" "fix the spinner"' }) ) }) @@ -564,8 +561,7 @@ describe('launchAgentInNewTab', () => { expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( 'tab-1', expect.objectContaining({ - command: - "claude '--model' 'sonnet' '--effort' 'high' '--dangerously-skip-permissions' --prefill 'review Bob'\\''s change'" + command: "claude '--dangerously-skip-permissions' --prefill 'review Bob'\\''s change'" }) ) }) @@ -583,7 +579,7 @@ describe('launchAgentInNewTab', () => { expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( 'tab-1', expect.objectContaining({ - command: "claude '--model' 'sonnet' '--effort' 'high' '--dangerously-skip-permissions'" + command: "claude '--dangerously-skip-permissions'" }) ) }) @@ -620,8 +616,7 @@ describe('launchAgentInNewTab', () => { expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( 'tab-1', expect.objectContaining({ - command: - "claude '--model' 'sonnet' '--effort' 'high' '--dangerously-skip-permissions' --prefill 'review Bob'\\''s change'" + command: "claude '--dangerously-skip-permissions' --prefill 'review Bob'\\''s change'" }) ) }) @@ -642,7 +637,7 @@ describe('launchAgentInNewTab', () => { expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( 'tab-1', expect.objectContaining({ - command: "claude '--model' 'sonnet' '--effort' 'high' '--dangerously-skip-permissions'" + command: "claude '--dangerously-skip-permissions'" }) ) expect(mockPasteDraftWhenAgentReady).toHaveBeenCalledWith( @@ -848,7 +843,7 @@ describe('launchAgentInNewTab', () => { expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( 'tab-1', expect.objectContaining({ - command: "codex '-m' 'gpt-5.6-sol' '-c' 'model_reasoning_effort=medium' '--model' 'gpt-5.5'" + command: "codex '--model' 'gpt-5.5'" }) ) }) diff --git a/src/renderer/src/lib/launch-work-item-direct.test.ts b/src/renderer/src/lib/launch-work-item-direct.test.ts index 9eedb05be75..c76c91bc030 100644 --- a/src/renderer/src/lib/launch-work-item-direct.test.ts +++ b/src/renderer/src/lib/launch-work-item-direct.test.ts @@ -402,7 +402,7 @@ describe('launchWorkItemDirect', () => { cmdOverrides: {}, agentArgs: '--dangerously-skip-permissions', agentEnv: {}, - sessionOptions: { model: 'sonnet', effort: 'high' }, + sessionOptions: undefined, platform: 'win32', isRemote: false }) @@ -526,7 +526,7 @@ describe('launchWorkItemDirect', () => { cmdOverrides: {}, agentArgs: '--yolo', agentEnv: {}, - sessionOptions: { model: 'auto' }, + sessionOptions: undefined, platform: 'linux', isRemote: true }) @@ -536,7 +536,7 @@ describe('launchWorkItemDirect', () => { cmdOverrides: {}, agentArgs: '--yolo', agentEnv: {}, - sessionOptions: { model: 'auto' }, + sessionOptions: undefined, platform: 'linux', isRemote: true, allowEmptyPromptLaunch: true diff --git a/src/renderer/src/lib/worktree-activation-created-agent.test.ts b/src/renderer/src/lib/worktree-activation-created-agent.test.ts index 89fcb83c92d..dfa23851a6f 100644 --- a/src/renderer/src/lib/worktree-activation-created-agent.test.ts +++ b/src/renderer/src/lib/worktree-activation-created-agent.test.ts @@ -96,8 +96,7 @@ describe('activateAndRevealWorktree created agent reopen', () => { expect(result).toEqual({ primaryTabId: reopenedTab?.id }) expect(reopenedTab).toBeDefined() expect(state.pendingStartupByTabId[reopenedTab!.id]).toEqual({ - command: - "codex '-m' 'gpt-5.6-sol' '-c' 'model_reasoning_effort=medium' '--dangerously-bypass-approvals-and-sandbox'", + command: "codex '--dangerously-bypass-approvals-and-sandbox'", env: {}, launchAgent: 'codex', launchConfig: { @@ -106,7 +105,7 @@ describe('activateAndRevealWorktree created agent reopen', () => { agentEnv: {} }, launchToken: expect.any(String), - sessionOptions: { model: 'gpt-5.6-sol', effort: 'medium' }, + sessionOptions: undefined, telemetry: { agent_kind: 'codex', launch_source: 'sidebar', diff --git a/src/renderer/src/store/slices/repos-onboarding-folder-startup.test.ts b/src/renderer/src/store/slices/repos-onboarding-folder-startup.test.ts index b5c44f9ca40..18f6cfb1f5e 100644 --- a/src/renderer/src/store/slices/repos-onboarding-folder-startup.test.ts +++ b/src/renderer/src/store/slices/repos-onboarding-folder-startup.test.ts @@ -58,8 +58,7 @@ describe('repo slice skipped-onboarding folder startup', () => { { sidebarRevealBehavior: 'auto', startup: { - command: - "codex '-m' 'gpt-5.6-sol' '-c' 'model_reasoning_effort=medium' '--dangerously-bypass-approvals-and-sandbox'", + command: "codex '--dangerously-bypass-approvals-and-sandbox'", env: {}, launchAgent: 'codex', launchConfig: { @@ -67,7 +66,7 @@ describe('repo slice skipped-onboarding folder startup', () => { agentArgs: '--dangerously-bypass-approvals-and-sandbox', agentEnv: {} }, - sessionOptions: { model: 'gpt-5.6-sol', effort: 'medium' }, + sessionOptions: undefined, telemetry: { agent_kind: 'codex', launch_source: 'onboarding', diff --git a/src/shared/agent-session-option-catalog.test.ts b/src/shared/agent-session-option-catalog.test.ts index c4e7eca1404..ffabb087e61 100644 --- a/src/shared/agent-session-option-catalog.test.ts +++ b/src/shared/agent-session-option-catalog.test.ts @@ -72,7 +72,7 @@ describe('agent session option catalog', () => { }) }) - it('restores defaults per model without leaking values across models', () => { + it('resolves only stored values without leaking values across models', () => { let persisted = updateNativeChatSessionOptionDefaults({ persisted: undefined, agent: 'claude', @@ -96,17 +96,56 @@ describe('agent session option catalog', () => { }) expect(resolveNativeChatSessionOptionDefaults(persisted, 'claude')).toEqual({ - model: 'sonnet', - effort: 'high' + model: 'sonnet' }) expect(persisted.claude?.valuesByModel?.opus).toEqual({ effort: 'xhigh' }) }) - it('seeds a fresh launch from the catalog defaults', () => { - expect(resolveNativeChatSessionOptionDefaults(undefined, 'claude')).toEqual({ - model: 'sonnet', - effort: 'high' - }) + it('spawns vanilla when the user has not explicitly selected a model', () => { + // Regression (#9085): a fresh launch must not force the catalog default + // model/effort — the agent must spawn exactly as its own CLI would. + expect(resolveNativeChatSessionOptionDefaults(undefined, 'claude')).toBeUndefined() + expect(resolveNativeChatSessionOptionDefaults({}, 'claude')).toBeUndefined() expect(resolveNativeChatSessionOptionDefaults({}, 'future-agent')).toBeUndefined() }) + + it('resolves an explicitly selected model and only its stored options', () => { + let persisted = updateNativeChatSessionOptionDefaults({ + persisted: undefined, + agent: 'claude', + modelId: 'opus', + optionId: 'model', + value: 'opus' + }) + expect(resolveNativeChatSessionOptionDefaults(persisted, 'claude')).toEqual({ + model: 'opus' + }) + persisted = updateNativeChatSessionOptionDefaults({ + persisted, + agent: 'claude', + modelId: 'opus', + optionId: 'effort', + value: 'xhigh' + }) + expect(resolveNativeChatSessionOptionDefaults(persisted, 'claude')).toEqual({ + model: 'opus', + effort: 'xhigh' + }) + }) + + it('keeps catalog option defaults after the user explicitly selects a model', () => { + const persisted = updateNativeChatSessionOptionDefaults({ + persisted: undefined, + agent: 'claude', + modelId: 'sonnet', + optionId: 'model', + value: 'sonnet' + }) + const defaults = resolveNativeChatSessionOptionDefaults(persisted, 'claude') + + expect(resolveAgentSessionOptionLaunch('claude', defaults)).toEqual({ + args: ['--model', 'sonnet', '--effort', 'high'], + appliedValues: { model: 'sonnet', effort: 'high' } + }) + }) }) diff --git a/src/shared/native-chat-session-option-defaults.ts b/src/shared/native-chat-session-option-defaults.ts index 98bd0187113..5edc3c49193 100644 --- a/src/shared/native-chat-session-option-defaults.ts +++ b/src/shared/native-chat-session-option-defaults.ts @@ -1,10 +1,5 @@ import type { AgentType } from './agent-status-types' -import { - catalogDefaultModel, - findCatalogModel, - getAgentSessionOptionCatalog, - sessionOptionValueIsValid -} from './agent-session-option-catalog' +import { sessionOptionValueIsValid } from './agent-session-option-catalog' import type { PersistedNativeChatSessionOptions, SessionOptionValue @@ -14,16 +9,10 @@ export function resolveNativeChatSessionOptionDefaults( persisted: PersistedNativeChatSessionOptions | null | undefined, agent: AgentType ): Record | undefined { - const catalog = getAgentSessionOptionCatalog(agent) const entry = persisted?.[agent] - // Why: fresh launches must be authoritative too, so resolve the catalog's - // declared fallback before the launch command is assembled. - const modelId = - typeof entry?.model === 'string' && entry.model.trim() - ? entry.model - : catalog - ? catalogDefaultModel(catalog)?.id - : undefined + // Why: untouched settings must preserve the agent CLI's configured defaults; + // only a model explicitly selected by the user authorizes launch flags. + const modelId = typeof entry?.model === 'string' && entry.model.trim() ? entry.model : undefined if (!modelId) { return undefined } @@ -36,11 +25,6 @@ export function resolveNativeChatSessionOptionDefaults( } } } - - const model = catalog ? findCatalogModel(catalog, modelId) : undefined - for (const option of model?.options ?? []) { - values[option.id] ??= option.kind.defaultValue - } return values } From fa85536f3a952430375b8448880d32cd82397005 Mon Sep 17 00:00:00 2001 From: Glen Maddern Date: Fri, 17 Jul 2026 14:32:13 +1000 Subject: [PATCH 03/17] fix(ssh): repair unbuilt relay native deps (#8686) Co-authored-by: Orca Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> Co-authored-by: Jinwoo-H Co-authored-by: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> --- src/main/ssh/ssh-connection.test.ts | 151 +++- src/main/ssh/ssh-connection.ts | 255 ++++-- src/main/ssh/ssh-file-transfer-abort.test.ts | 55 ++ src/main/ssh/ssh-file-transfer-abort.ts | 76 ++ src/main/ssh/ssh-relay-build-toolchain.ts | 9 +- .../ssh-relay-cross-version-isolation.test.ts | 7 +- src/main/ssh/ssh-relay-deploy-helpers.test.ts | 147 +++- src/main/ssh/ssh-relay-deploy-helpers.ts | 158 +--- src/main/ssh/ssh-relay-deploy-timing.ts | 13 + src/main/ssh/ssh-relay-deploy.test.ts | 210 ++++- src/main/ssh/ssh-relay-deploy.ts | 818 ++++++++++++++---- src/main/ssh/ssh-relay-exec-command.ts | 156 ++++ src/main/ssh/ssh-relay-gc-claim.ts | 236 +++++ src/main/ssh/ssh-relay-gc-retry.test.ts | 275 ++++++ src/main/ssh/ssh-relay-gc-tombstone.ts | 33 + .../ssh/ssh-relay-install-lock-commands.ts | 176 ++++ src/main/ssh/ssh-relay-install-lock.ts | 162 ++++ .../ssh/ssh-relay-native-deps-install.test.ts | 399 ++++++++- src/main/ssh/ssh-relay-repair-lock.ts | 153 ++++ .../ssh/ssh-relay-versioned-install.test.ts | 632 +++++++++++++- src/main/ssh/ssh-relay-versioned-install.ts | 209 ++--- src/main/ssh/ssh-remote-commands.test.ts | 335 ++++++- src/main/ssh/ssh-remote-commands.ts | 54 +- src/main/ssh/ssh-remote-node-resolution.ts | 5 + src/main/ssh/ssh-remote-platform-detection.ts | 34 +- src/main/ssh/ssh-system-fallback.test.ts | 44 +- src/main/ssh/system-ssh-command.ts | 3 + .../ssh/system-ssh-operation-lifecycle.ts | 8 +- src/relay/pty-handler.test.ts | 23 + src/relay/pty-handler.ts | 111 ++- src/relay/subprocess-test-utils.ts | 2 +- src/relay/subprocess.test.ts | 83 +- 32 files changed, 4339 insertions(+), 693 deletions(-) create mode 100644 src/main/ssh/ssh-file-transfer-abort.test.ts create mode 100644 src/main/ssh/ssh-file-transfer-abort.ts create mode 100644 src/main/ssh/ssh-relay-deploy-timing.ts create mode 100644 src/main/ssh/ssh-relay-exec-command.ts create mode 100644 src/main/ssh/ssh-relay-gc-claim.ts create mode 100644 src/main/ssh/ssh-relay-gc-retry.test.ts create mode 100644 src/main/ssh/ssh-relay-gc-tombstone.ts create mode 100644 src/main/ssh/ssh-relay-install-lock-commands.ts create mode 100644 src/main/ssh/ssh-relay-install-lock.ts create mode 100644 src/main/ssh/ssh-relay-repair-lock.ts diff --git a/src/main/ssh/ssh-connection.test.ts b/src/main/ssh/ssh-connection.test.ts index e8c5d71234c..3274d3f5daa 100644 --- a/src/main/ssh/ssh-connection.test.ts +++ b/src/main/ssh/ssh-connection.test.ts @@ -23,6 +23,7 @@ type MockSshClient = { lastExecCommand?: string lastConnectConfig?: unknown exec: (cmd: string, cb: (err: Error | undefined, channel: unknown) => void) => void + sftp: (cb: (err: Error | undefined, channel: unknown) => void) => void } let clientInstances: MockSshClient[] = [] @@ -690,15 +691,15 @@ describe('SshConnection', () => { vi.useFakeTimers() try { - const outcomePromise = conn - .exec('printf ready') - .then(() => 'opened') - .catch((error: Error) => error.message) + const outcomePromise = conn.exec('printf ready').catch((error: Error) => error) await vi.advanceTimersByTimeAsync(30_000) const outcome = await Promise.race([outcomePromise, Promise.resolve('pending')]) - expect(outcome).toBe('SSH exec channel timed out') + expect(outcome).toMatchObject({ + message: 'SSH exec channel timed out', + sshChannelCloseConfirmed: false + }) } finally { vi.useRealTimers() } @@ -784,15 +785,54 @@ describe('SshConnection', () => { try { const outcomePromise = conn .exec('printf ready', { signal: controller.signal }) - .then(() => 'opened') - .catch((error: Error) => error.name) + .catch((error: Error) => error) controller.abort() // Why: a hung socket must not pin the aborted caller for the full 30s // connect timeout — the abort settles at the 5s grace bound instead. await vi.advanceTimersByTimeAsync(5_000) - await expect(outcomePromise).resolves.toBe('AbortError') + await expect(outcomePromise).resolves.toMatchObject({ + name: 'AbortError', + sshChannelCloseConfirmed: false + }) + } finally { + vi.useRealTimers() + } + }) + + it('drains an exec channel that opens after the abort grace has settled', async () => { + const conn = new SshConnection(createTarget(), createCallbacks()) + await conn.connect() + execBehavior = 'pending' + const controller = new AbortController() + const lateChannel = Object.assign(new EventEmitter(), { + close: vi.fn(), + resume: vi.fn(), + stderr: { resume: vi.fn() } + }) + + vi.useFakeTimers() + try { + const outcomePromise = conn + .exec('printf ready', { signal: controller.signal }) + .catch((error: Error) => error) + + controller.abort() + await vi.advanceTimersByTimeAsync(5_000) + const outcome = await outcomePromise + expect(outcome).toMatchObject({ + name: 'AbortError', + sshChannelCloseConfirmed: false + }) + + pendingExecCallback?.(undefined, lateChannel) + + expect(lateChannel.resume).toHaveBeenCalledTimes(1) + expect(lateChannel.stderr.resume).toHaveBeenCalledTimes(1) + expect(lateChannel.close).toHaveBeenCalledTimes(1) + lateChannel.emit('close') + expect(outcome).toMatchObject({ sshChannelCloseConfirmed: true }) } finally { vi.useRealTimers() } @@ -837,7 +877,7 @@ describe('SshConnection', () => { const lateChannel = Object.assign(new EventEmitter(), { close: vi.fn(), resume: vi.fn(), - stderr: { resume: vi.fn() } + stderr: Object.assign(new EventEmitter(), { resume: vi.fn() }) }) const outcomePromise = conn @@ -855,11 +895,41 @@ describe('SshConnection', () => { expect(early).toBe('pending') expect(lateChannel.close).toHaveBeenCalledTimes(1) expect(lateChannel.resume).toHaveBeenCalled() + expect(() => lateChannel.emit('error', new Error('late channel teardown'))).not.toThrow() + expect(() => lateChannel.stderr.emit('error', new Error('late stderr teardown'))).not.toThrow() lateChannel.emit('close') await expect(outcomePromise).resolves.toBe('AbortError') }) + it('removes the late-channel close listener when abort grace expires', async () => { + const conn = new SshConnection(createTarget(), createCallbacks()) + await conn.connect() + vi.useFakeTimers() + try { + sftpBehavior = 'pending' + const controller = new AbortController() + const lateSftp = Object.assign(new EventEmitter(), { end: vi.fn() }) + + const outcomePromise = conn + .sftp(controller.signal) + .then(() => 'opened') + .catch((error: Error) => error.name) + await vi.advanceTimersByTimeAsync(0) + controller.abort() + pendingSftpCallback?.(undefined, lateSftp) + expect(lateSftp.listenerCount('close')).toBe(1) + expect(() => lateSftp.emit('error', new Error('late SFTP teardown'))).not.toThrow() + + await vi.advanceTimersByTimeAsync(5_000) + + await expect(outcomePromise).resolves.toBe('AbortError') + expect(lateSftp.listenerCount('close')).toBe(0) + } finally { + vi.useRealTimers() + } + }) + it('times out when ssh2 never opens an SFTP channel', async () => { const conn = new SshConnection(createTarget(), createCallbacks()) await conn.connect() @@ -867,15 +937,13 @@ describe('SshConnection', () => { vi.useFakeTimers() try { - const outcomePromise = conn - .sftp() - .then(() => 'opened') - .catch((error: Error) => error.message) + const outcomePromise = conn.sftp().catch((error: Error) => error) await vi.advanceTimersByTimeAsync(30_000) const outcome = await Promise.race([outcomePromise, Promise.resolve('pending')]) - expect(outcome).toBe('SSH SFTP channel timed out') + expect(outcome).toMatchObject({ message: 'SSH SFTP channel timed out' }) + expect(outcome).not.toHaveProperty('sshChannelCloseConfirmed') } finally { vi.useRealTimers() } @@ -1368,6 +1436,61 @@ describe('SshConnection', () => { ) }) + it('composes a caller abort into system SSH relay uploads', async () => { + vi.mocked(resolveWithSshG).mockResolvedValueOnce(createResolvedConfig()) + const conn = new SshConnection(createTarget({ configHost: 'fdpass-host' }), createCallbacks()) + const controller = new AbortController() + let transferSignal: AbortSignal | undefined + vi.mocked(uploadDirectoryViaSystemSsh).mockImplementationOnce( + (_target, _localDir, _remoteDir, options) => { + transferSignal = options?.signal + return new Promise((_resolve, reject) => { + transferSignal?.addEventListener('abort', () => reject(transferSignal?.reason), { + once: true + }) + }) + } + ) + + await conn.connect() + const upload = conn.uploadDirectory('/tmp/local-relay', '/remote/relay', { + signal: controller.signal + }) + await vi.waitFor(() => expect(transferSignal).toBeDefined()) + controller.abort() + + await expect(upload).rejects.toMatchObject({ name: 'AbortError' }) + expect(transferSignal?.aborted).toBe(true) + }) + + it('keeps connection disconnect cancellation linked to caller-scoped relay writes', async () => { + vi.mocked(resolveWithSshG).mockResolvedValueOnce(createResolvedConfig()) + const conn = new SshConnection(createTarget({ configHost: 'fdpass-host' }), createCallbacks()) + const controller = new AbortController() + let transferSignal: AbortSignal | undefined + vi.mocked(writeFileViaSystemSsh).mockImplementationOnce( + (_target, _remotePath, _contents, options) => { + transferSignal = options?.signal + return new Promise((_resolve, reject) => { + transferSignal?.addEventListener('abort', () => reject(transferSignal?.reason), { + once: true + }) + }) + } + ) + + await conn.connect() + const write = conn.writeFile('/remote/relay/.version', '0.1.0', { + signal: controller.signal + }) + await vi.waitFor(() => expect(transferSignal).toBeDefined()) + await conn.disconnect() + + await expect(write).rejects.toMatchObject({ name: 'AbortError' }) + expect(controller.signal.aborted).toBe(false) + expect(transferSignal?.aborted).toBe(true) + }) + it('keeps an upload session cancelled after the connection disconnects', async () => { const conn = new SshConnection( createTarget({ proxyCommand: 'ssh -W %h:%p bastion.example.com' }), diff --git a/src/main/ssh/ssh-connection.ts b/src/main/ssh/ssh-connection.ts index 2856d33b2f0..b8bd0cbd57f 100644 --- a/src/main/ssh/ssh-connection.ts +++ b/src/main/ssh/ssh-connection.ts @@ -41,6 +41,10 @@ import { import type { RemoteHostPlatform } from './ssh-remote-platform' import type { FileUploadSession } from '../providers/types' import { isSshSessionLimitError } from './ssh-session-limit-error' +import { + createLinkedSshFileTransferSignal, + raceSftpFileTransferWithAbort +} from './ssh-file-transfer-abort' export type { SshConnectionCallbacks } from './ssh-connection-utils' type SshRemoteFileOptions = { @@ -157,13 +161,14 @@ export class SshConnection { 'SSH exec channel timed out', (callback) => client.exec(remoteCommand, callback), (channel) => channel.close(), - options?.signal + options?.signal, + true ), options?.signal ) } - async sftp(): Promise { + async sftp(signal?: AbortSignal): Promise { if (this.useSystemSshTransport) { throw new Error('SFTP is not available when using system SSH transport') } @@ -171,12 +176,15 @@ export class SshConnection { throw new Error('Not connected') } const client = this.client - return this.openSessionChannelWithRetry(() => - this.waitForSshCallback( - 'SSH SFTP channel timed out', - (callback) => client.sftp(callback), - (sftp) => sftp.end() - ) + return this.openSessionChannelWithRetry( + () => + this.waitForSshCallback( + 'SSH SFTP channel timed out', + (callback) => client.sftp(callback), + (sftp) => sftp.end(), + signal + ), + signal ) } @@ -219,10 +227,20 @@ export class SshConnection { timeoutMessage: string, register: (callback: (error: Error | undefined, value: T) => void) => void, cleanupLateValue?: (value: T) => void, - signal?: AbortSignal + signal?: AbortSignal, + trackRemoteCommandTermination = false ): Promise { return new Promise((resolve, reject) => { + type ChannelOpenTerminationError = Error & { sshChannelCloseConfirmed: boolean } let settled = false + let unconfirmedOpenError: ChannelOpenTerminationError | null = null + const markOpenUnconfirmed = (error: Error): Error => { + if (!trackRemoteCommandTermination) { + return error + } + unconfirmedOpenError = Object.assign(error, { sshChannelCloseConfirmed: false }) + return unconfirmedOpenError + } // Why: rejecting the instant the signal aborts lets the caller proceed // while the in-flight channel open completes in the background and holds // a server-side session slot (MaxSessions). Mark the abort and settle @@ -241,16 +259,41 @@ export class SshConnection { abortDeadlineTimer = setTimeout(() => { settled = true cleanup() - reject(createSshOperationAbortError()) + reject(markOpenUnconfirmed(createSshOperationAbortError())) }, ABORTED_CHANNEL_CLOSE_GRACE_MS) } const timer = setTimeout(() => { settled = true cleanup() - reject(abortRequested ? createSshOperationAbortError() : new Error(timeoutMessage)) + reject( + markOpenUnconfirmed( + abortRequested ? createSshOperationAbortError() : new Error(timeoutMessage) + ) + ) }, CONNECT_TIMEOUT_MS) + const discardLateValue = (value: T, onClose?: () => void): void => { + const emitter = value as Partial & { + resume?: () => void + stderr?: Partial & { resume?: () => void } + } + const swallowLateError = (): void => {} + emitter.on?.('error', swallowLateError) + emitter.stderr?.on?.('error', swallowLateError) + if (onClose) { + emitter.once?.('close', onClose) + } + // Why: ssh2 can withhold CHANNEL_CLOSE while discarded exec streams + // remain unread, and teardown errors have no other owner. + emitter.resume?.() + emitter.stderr?.resume?.() + try { + cleanupLateValue?.(value) + } catch { + /* best effort */ + } + } const rejectAfterClose = (value: T): void => { - const abortError = createSshOperationAbortError() + const abortError = markOpenUnconfirmed(createSshOperationAbortError()) const emitter = value as Partial & { resume?: () => void stderr?: { resume?: () => void } @@ -262,23 +305,24 @@ export class SshConnection { } finished = true clearTimeout(closeGraceTimer) + emitter.removeListener?.('close', confirmAndDone) reject(abortError) } + const confirmAndDone = (): void => { + if (unconfirmedOpenError === abortError) { + unconfirmedOpenError.sshChannelCloseConfirmed = true + } + done() + } // Why: bounded — a remote that never confirms the close must not hang // the aborted operation forever. const closeGraceTimer = setTimeout(done, ABORTED_CHANNEL_CLOSE_GRACE_MS) if (typeof emitter.once === 'function') { - emitter.once('close', done) + emitter.once('close', confirmAndDone) } // Why: ssh2 withholds the 'close' event until the channel's streams // are drained; nobody else will ever read this discarded channel. - emitter.resume?.() - emitter.stderr?.resume?.() - try { - cleanupLateValue?.(value) - } catch { - /* best effort */ - } + discardLateValue(value) if (typeof emitter.once !== 'function') { done() } @@ -289,11 +333,11 @@ export class SshConnection { // rejected. Close that late resource so the remote channel is not // left open with no owner. if (!error && value !== undefined) { - try { - cleanupLateValue?.(value) - } catch { - /* best effort */ - } + discardLateValue(value, () => { + if (unconfirmedOpenError) { + unconfirmedOpenError.sshChannelCloseConfirmed = true + } + }) } return } @@ -334,23 +378,51 @@ export class SshConnection { async uploadDirectory( localDir: string, remoteDir: string, - options?: SshRemoteFileOptions + options?: SshRemoteFileOptions & { signal?: AbortSignal } ): Promise { - if (!this.useSystemSshTransport) { - const sftp = await this.sftp() - try { - const { uploadDirectory } = await import('./ssh-relay-deploy-helpers') - await uploadDirectory(sftp, localDir, remoteDir) - } finally { - sftp.end() + // Why: relay deployment timeout and connection teardown are independent; + // either owner must stop a transfer that can otherwise outlive its lock. + const linkedSignal = createLinkedSshFileTransferSignal( + [this.systemOperationAbortController.signal, options?.signal].filter( + (signal): signal is AbortSignal => signal !== undefined + ) + ) + try { + if (!this.useSystemSshTransport) { + const sftp = await this.sftp(linkedSignal.signal) + const swallowLateSftpError = (): void => {} + let sftpEndRequested = false + const endSftp = (): void => { + if (!sftpEndRequested) { + sftpEndRequested = true + sftp.end() + } + } + sftp.on('error', swallowLateSftpError) + sftp.once('close', () => sftp.removeListener('error', swallowLateSftpError)) + try { + const { uploadDirectory } = await import('./ssh-relay-deploy-helpers') + await raceSftpFileTransferWithAbort( + uploadDirectory(sftp, localDir, remoteDir), + linkedSignal.signal, + (onClose) => { + sftp.once('close', onClose) + endSftp() + } + ) + } finally { + endSftp() + } + return } - return + await uploadDirectoryViaSystemSsh(this.target, localDir, remoteDir, { + signal: linkedSignal.signal, + hostPlatform: options?.hostPlatform, + ...this.getSystemSshBuildArgsOptions() + }) + } finally { + linkedSignal.dispose() } - await uploadDirectoryViaSystemSsh(this.target, localDir, remoteDir, { - signal: this.systemOperationAbortController.signal, - hostPlatform: options?.hostPlatform, - ...this.getSystemSshBuildArgsOptions() - }) } async downloadFile( @@ -404,55 +476,74 @@ export class SshConnection { async writeFile( remotePath: string, contents: string, - options?: SshRemoteFileOptions + options?: SshRemoteFileOptions & { signal?: AbortSignal } ): Promise { - if (!this.useSystemSshTransport) { - const sftp = await this.sftp() - const swallowLateSftpError = (): void => {} - sftp.on('error', swallowLateSftpError) - try { - await new Promise((resolve, reject) => { - const ws = sftp.createWriteStream(remotePath) - let settled = false - const cleanup = (): void => { - ws.removeListener('close', onClose) - ws.removeListener('error', onError) + // Keep package/version writes under the same dual cancellation contract as uploads. + const linkedSignal = createLinkedSshFileTransferSignal( + [this.systemOperationAbortController.signal, options?.signal].filter( + (signal): signal is AbortSignal => signal !== undefined + ) + ) + try { + if (!this.useSystemSshTransport) { + const sftp = await this.sftp(linkedSignal.signal) + const swallowLateSftpError = (): void => {} + let sftpEndRequested = false + const endSftp = (): void => { + if (!sftpEndRequested) { + sftpEndRequested = true + sftp.end() } - const onClose = (): void => { - if (settled) { - return + } + sftp.on('error', swallowLateSftpError) + sftp.once('close', () => sftp.removeListener('error', swallowLateSftpError)) + try { + const write = new Promise((resolve, reject) => { + const ws = sftp.createWriteStream(remotePath) + let settled = false + const cleanup = (): void => { + sftp.removeListener('error', onError) + ws.removeListener('close', onClose) + ws.removeListener('error', onError) } - settled = true - cleanup() - resolve() - } - const onError = (err: Error): void => { - sftp.removeListener('error', onError) - if (settled) { - return + const onClose = (): void => { + if (settled) { + return + } + settled = true + cleanup() + resolve() } - settled = true - cleanup() - reject(err) - } - sftp.prependOnceListener('error', onError) - ws.once('close', onClose) - ws.once('error', onError) - ws.end(contents) - }) - } finally { - sftp.end() - setImmediate(() => { - sftp.removeListener('error', swallowLateSftpError) - }) + const onError = (err: Error): void => { + if (settled) { + return + } + settled = true + cleanup() + reject(err) + } + sftp.prependOnceListener('error', onError) + ws.once('close', onClose) + ws.once('error', onError) + ws.end(contents) + }) + await raceSftpFileTransferWithAbort(write, linkedSignal.signal, (onClose) => { + sftp.once('close', onClose) + endSftp() + }) + } finally { + endSftp() + } + return } - return + await writeFileViaSystemSsh(this.target, remotePath, contents, { + signal: linkedSignal.signal, + hostPlatform: options?.hostPlatform, + ...this.getSystemSshBuildArgsOptions() + }) + } finally { + linkedSignal.dispose() } - await writeFileViaSystemSsh(this.target, remotePath, contents, { - signal: this.systemOperationAbortController.signal, - hostPlatform: options?.hostPlatform, - ...this.getSystemSshBuildArgsOptions() - }) } async writeBuffer( diff --git a/src/main/ssh/ssh-file-transfer-abort.test.ts b/src/main/ssh/ssh-file-transfer-abort.test.ts new file mode 100644 index 00000000000..49abe0a1839 --- /dev/null +++ b/src/main/ssh/ssh-file-transfer-abort.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from 'vitest' +import { raceSftpFileTransferWithAbort } from './ssh-file-transfer-abort' + +describe('raceSftpFileTransferWithAbort', () => { + it('waits for confirmed SFTP close before rejecting an abort', async () => { + const controller = new AbortController() + let confirmClose: () => void = () => {} + const promise = raceSftpFileTransferWithAbort( + new Promise(() => {}), + controller.signal, + (onClose) => { + confirmClose = onClose + } + ) + + controller.abort() + const pending = await Promise.race([ + promise.then( + () => 'settled', + () => 'settled' + ), + Promise.resolve('pending') + ]) + expect(pending).toBe('pending') + + confirmClose() + await expect(promise).rejects.toMatchObject({ + name: 'AbortError', + sshChannelCloseConfirmed: true + }) + }) + + it('marks teardown unconfirmed when SFTP never closes', async () => { + vi.useFakeTimers() + try { + const controller = new AbortController() + const promise = raceSftpFileTransferWithAbort( + new Promise(() => {}), + controller.signal, + () => {} + ) + const outcome = promise.catch((error: Error) => error) + + controller.abort() + await vi.advanceTimersByTimeAsync(5_000) + + await expect(outcome).resolves.toMatchObject({ + name: 'AbortError', + sshChannelCloseConfirmed: false + }) + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/main/ssh/ssh-file-transfer-abort.ts b/src/main/ssh/ssh-file-transfer-abort.ts new file mode 100644 index 00000000000..a7b6cce2e49 --- /dev/null +++ b/src/main/ssh/ssh-file-transfer-abort.ts @@ -0,0 +1,76 @@ +import { createSshOperationAbortError } from './ssh-connection-utils' + +export function createLinkedSshFileTransferSignal(signals: readonly AbortSignal[]): { + signal: AbortSignal + dispose: () => void +} { + const controller = new AbortController() + const listeners = signals.map((signal) => { + const listener = (): void => controller.abort(signal.reason) + if (signal.aborted) { + listener() + } else { + signal.addEventListener('abort', listener, { once: true }) + } + return { signal, listener } + }) + return { + signal: controller.signal, + dispose: () => { + for (const { signal, listener } of listeners) { + signal.removeEventListener('abort', listener) + } + } + } +} + +export function raceSftpFileTransferWithAbort( + operation: Promise, + signal: AbortSignal, + closeSftp: (onClose: () => void) => void +): Promise { + return new Promise((resolve, reject) => { + let settled = false + let abortError: (Error & { sshChannelCloseConfirmed: boolean }) | null = null + let closeGraceTimer: ReturnType | null = null + const settle = (fn: typeof resolve | typeof reject, value: T | Error): void => { + if (settled) { + return + } + settled = true + if (closeGraceTimer) { + clearTimeout(closeGraceTimer) + } + signal.removeEventListener('abort', onAbort) + fn(value as never) + } + const onAbort = (): void => { + // Why: rejecting the caller is insufficient; ending SFTP stops the + // abandoned transfer from mutating a successor relay install. + abortError = Object.assign(createSshOperationAbortError(), { + sshChannelCloseConfirmed: false + }) + closeGraceTimer = setTimeout(() => settle(reject, abortError!), 5_000) + closeSftp(() => { + abortError!.sshChannelCloseConfirmed = true + settle(reject, abortError!) + }) + } + signal.addEventListener('abort', onAbort, { once: true }) + void operation.then( + (value) => { + if (!abortError) { + settle(resolve, value) + } + }, + (error: unknown) => { + if (!abortError) { + settle(reject, error instanceof Error ? error : new Error(String(error))) + } + } + ) + if (signal.aborted) { + onAbort() + } + }) +} diff --git a/src/main/ssh/ssh-relay-build-toolchain.ts b/src/main/ssh/ssh-relay-build-toolchain.ts index cdbdb676441..208deceb6e0 100644 --- a/src/main/ssh/ssh-relay-build-toolchain.ts +++ b/src/main/ssh/ssh-relay-build-toolchain.ts @@ -141,15 +141,20 @@ export function formatMissingToolchainError( // callers fall back to the original install error in those cases. export async function probeBuildToolchain( conn: SshConnection, - hostPlatform: RemoteHostPlatform + hostPlatform: RemoteHostPlatform, + signal?: AbortSignal ): Promise { if (isWindowsRemoteHost(hostPlatform)) { return null } try { - const output = await execCommand(conn, buildToolchainProbeCommand(), { wrapCommand: true }) + const output = await execCommand(conn, buildToolchainProbeCommand(), { + wrapCommand: true, + signal + }) return parseBuildToolchainProbe(output) } catch { + signal?.throwIfAborted() return null } } diff --git a/src/main/ssh/ssh-relay-cross-version-isolation.test.ts b/src/main/ssh/ssh-relay-cross-version-isolation.test.ts index a29801c3632..14f063e3bf1 100644 --- a/src/main/ssh/ssh-relay-cross-version-isolation.test.ts +++ b/src/main/ssh/ssh-relay-cross-version-isolation.test.ts @@ -33,6 +33,9 @@ vi.mock('./ssh-relay-deploy-helpers', () => ({ onData: vi.fn(), onClose: vi.fn() }), + isUnconfirmedSshCommandTermination: (error: unknown) => + error instanceof Error && + (error as Error & { sshChannelCloseConfirmed?: boolean }).sshChannelCloseConfirmed === false, execCommand: vi.fn() })) @@ -101,8 +104,10 @@ describe('cross-version isolation', () => { '__ORCA_REMOTE_PLATFORM__ Linux x86_64', // tagged POSIX platform probe '/home/u', // echo $HOME 'MISSING', // isRelayAlreadyInstalled (v2 dir doesn't exist) + 'OPEN', // no sibling GC claim '', // mkdir -p remoteRelayDir (v2) 'OK', // mkdir lock OK + 'OPEN', // GC did not claim while the install lock was acquired 'MISSING', // re-probe after lock → still missing → proceed with install '', // mkdir remoteDir (uploadRelay) '', // chmod +x node @@ -111,9 +116,9 @@ describe('cross-version isolation', () => { 'ORCA-NPTY-PROBE-OK\n', // node -e require() load-test (post-install verify) '', // rm -f probe-stderr (best-effort cleanup after probe resolved) '', // touch .install-complete (finalizeInstall) - '', // rm -rf .install-lock 'DEAD', // launch socket probe 'READY', // socket poll + '', // release .install-lock after relay liveness is observable // GC scan begins here 'relay-0.1.0+v1hash\nrelay-0.1.0+v2hash\n', // ls listing 'OPEN', // v1 lock probe (siblings only — current dir is v2) diff --git a/src/main/ssh/ssh-relay-deploy-helpers.test.ts b/src/main/ssh/ssh-relay-deploy-helpers.test.ts index cb5477091d6..4df9c8ad28a 100644 --- a/src/main/ssh/ssh-relay-deploy-helpers.test.ts +++ b/src/main/ssh/ssh-relay-deploy-helpers.test.ts @@ -11,9 +11,10 @@ import { function createMockChannel(): ClientChannel { return Object.assign(new EventEmitter(), { - stderr: new EventEmitter(), + stderr: Object.assign(new EventEmitter(), { resume: vi.fn() }), stdin: { write: vi.fn() }, - close: vi.fn() + close: vi.fn(), + resume: vi.fn() }) as unknown as ClientChannel } @@ -29,6 +30,44 @@ async function execCommandRejection(promise: Promise): Promise { } describe('waitForSentinel', () => { + it('closes and rejects with AbortError while clearing startup resources on abort', async () => { + vi.useFakeTimers() + try { + const channel = createMockChannel() + const controller = new AbortController() + const removeListener = vi.spyOn(controller.signal, 'removeEventListener') + const transportPromise = waitForSentinel(channel, controller.signal) + + expect(vi.getTimerCount()).toBe(1) + controller.abort() + + await expect(transportPromise).rejects.toMatchObject({ name: 'AbortError' }) + expect(channel.close).toHaveBeenCalledTimes(1) + expect(removeListener).toHaveBeenCalledWith('abort', expect.any(Function)) + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + + it('removes the abort listener and timers when startup fails before the sentinel', async () => { + vi.useFakeTimers() + try { + const channel = createMockChannel() + const controller = new AbortController() + const removeListener = vi.spyOn(controller.signal, 'removeEventListener') + const transportPromise = waitForSentinel(channel, controller.signal) + + channel.emit('error', new Error('remote host rebooted')) + + await expect(transportPromise).rejects.toThrow('remote host rebooted') + expect(removeListener).toHaveBeenCalledWith('abort', expect.any(Function)) + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + it('buffers post-sentinel chunks until the transport subscribes', async () => { const channel = createMockChannel() const transportPromise = waitForSentinel(channel) @@ -168,6 +207,32 @@ describe('waitForSentinel', () => { }) describe('execCommand', () => { + it('waits for channel close before rejecting a timed-out remote command', async () => { + vi.useFakeTimers() + try { + const channel = createMockChannel() + const conn = { exec: vi.fn().mockResolvedValue(channel) } + const commandPromise = execCommand(conn as never, 'npm rebuild', { timeoutMs: 1_000 }) + + await vi.advanceTimersByTimeAsync(1_000) + expect(channel.close).toHaveBeenCalledTimes(1) + expect( + await Promise.race([ + commandPromise.then( + () => 'settled', + () => 'settled' + ), + Promise.resolve('pending') + ]) + ).toBe('pending') + + channel.emit('close', 0) + await expect(commandPromise).rejects.toThrow('timed out after 1s') + } finally { + vi.useRealTimers() + } + }) + it('rejects on command channel errors instead of emitting uncaught errors', async () => { const channel = createMockChannel() const conn = { @@ -177,6 +242,8 @@ describe('execCommand', () => { await Promise.resolve() expect(() => channel.emit('error', new Error('remote host rebooted'))).not.toThrow() + expect(channel.close).toHaveBeenCalledOnce() + channel.emit('close', 1) await expect(commandPromise).rejects.toThrow('remote host rebooted') expect(channel.listenerCount('error')).toBe(0) expect(channel.listenerCount('data')).toBe(0) @@ -224,6 +291,22 @@ describe('execCommand', () => { expect(error.message).toContain('gnu++20') }) + it('bounds command output while preserving the actionable tail', async () => { + const channel = createMockChannel() + const conn = { exec: vi.fn().mockResolvedValue(channel) } + const commandPromise = execCommand(conn as never, 'npm rebuild') + + await Promise.resolve() + channel.emit('data', Buffer.from(`old-prefix-${'x'.repeat(1024 * 1024)}`)) + channel.emit('data', Buffer.from('gyp ERR! tail diagnosis')) + channel.emit('close', 1) + + const error = await execCommandRejection(commandPromise) + expect(error.message).not.toContain('old-prefix') + expect(error.message).toContain('gyp ERR! tail diagnosis') + expect(error.message.length).toBeLessThan(1024 * 1024 + 200) + }) + it('keeps the merged error message greppable by the build-toolchain probe', async () => { const channel = createMockChannel() const conn = { @@ -260,14 +343,26 @@ describe('execCommand', () => { await Promise.resolve() const rejection = expect(commandPromise).rejects.toThrow('timed out') await vi.advanceTimersByTimeAsync(30_000) + expect(channel.close).toHaveBeenCalledOnce() + await vi.advanceTimersByTimeAsync(5_000) await rejection expect(channel.close).toHaveBeenCalledOnce() - expect(channel.listenerCount('error')).toBe(0) + expect(channel.resume).toHaveBeenCalledOnce() + expect(channel.stderr.resume).toHaveBeenCalledOnce() + expect(() => channel.emit('error', new Error('late channel error'))).not.toThrow() + expect(() => channel.stderr.emit('error', new Error('late stderr error'))).not.toThrow() + channel.emit('data', Buffer.from('late stdout')) + channel.stderr.emit('data', Buffer.from('late stderr')) + expect(channel.listenerCount('error')).toBe(1) expect(channel.listenerCount('data')).toBe(0) + expect(channel.listenerCount('close')).toBe(1) + expect(channel.stderr.listenerCount('error')).toBe(1) + expect(channel.stderr.listenerCount('data')).toBe(0) + channel.emit('close', 0) + expect(channel.listenerCount('error')).toBe(0) expect(channel.listenerCount('close')).toBe(0) expect(channel.stderr.listenerCount('error')).toBe(0) - expect(channel.stderr.listenerCount('data')).toBe(0) } finally { vi.useRealTimers() } @@ -309,6 +404,48 @@ describe('execCommand', () => { expect(channel.stderr.listenerCount('data')).toBe(0) }) + it('keeps system-SSH command termination unconfirmed after the local child closes', async () => { + const channel = createMockChannel() + const controller = new AbortController() + const conn = { + exec: vi.fn().mockResolvedValue(channel), + usesSystemSshTransport: vi.fn().mockReturnValueOnce(true).mockReturnValue(false) + } + const commandPromise = execCommand(conn as never, 'npm install', { + signal: controller.signal + }) + + await Promise.resolve() + controller.abort() + channel.emit('close', 0) + + await expect(commandPromise).rejects.toMatchObject({ + name: 'AbortError', + sshChannelCloseConfirmed: false + }) + }) + + it('tags an externally closed system-SSH command as unconfirmed', async () => { + const channel = createMockChannel() as ClientChannel & { _closeRequested?: boolean } + channel.close = vi.fn(() => { + channel._closeRequested = true + }) + const conn = { + exec: vi.fn().mockResolvedValue(channel), + usesSystemSshTransport: vi.fn().mockReturnValue(true) + } + const commandPromise = execCommand(conn as never, 'npm install') + + await Promise.resolve() + channel.close() + channel.emit('close', 1) + + await expect(commandPromise).rejects.toMatchObject({ + name: 'AbortError', + sshChannelCloseConfirmed: false + }) + }) + it('handles aborts that happen while the SSH exec channel is still opening', async () => { const channel = createMockChannel() const controller = new AbortController() @@ -357,6 +494,8 @@ describe('execCommand', () => { expect(conn.exec).toHaveBeenCalledWith('npm install', { wrapCommand: false }) const rejection = expect(commandPromise).rejects.toThrow('timed out after 240s') await vi.advanceTimersByTimeAsync(240_000) + expect(channel.close).toHaveBeenCalledOnce() + await vi.advanceTimersByTimeAsync(5_000) await rejection expect(channel.close).toHaveBeenCalledOnce() diff --git a/src/main/ssh/ssh-relay-deploy-helpers.ts b/src/main/ssh/ssh-relay-deploy-helpers.ts index 1b1cdd27074..80d2b78ced2 100644 --- a/src/main/ssh/ssh-relay-deploy-helpers.ts +++ b/src/main/ssh/ssh-relay-deploy-helpers.ts @@ -1,18 +1,21 @@ import type { ClientChannel } from 'ssh2' -import type { SshConnection } from './ssh-connection' -import { createSshOperationAbortError, type SshExecOptions } from './ssh-connection-utils' +import { createSshOperationAbortError } from './ssh-connection-utils' import { RELAY_SENTINEL, RELAY_SENTINEL_TIMEOUT_MS } from './relay-protocol' import type { MultiplexerTransport } from './ssh-channel-multiplexer' import { buildRelayVersionMismatchError } from './ssh-relay-handshake-mismatch' export { uploadFile, uploadDirectory, mkdirSftp } from './sftp-upload' +export { execCommand, isUnconfirmedSshCommandTermination } from './ssh-relay-exec-command' // ── Sentinel detection ──────────────────────────────────────────────── const MAX_RELAY_STARTUP_BUFFER_BYTES = 64 * 1024 const RELAY_SENTINEL_BUFFER = Buffer.from(RELAY_SENTINEL, 'utf-8') -export function waitForSentinel(channel: ClientChannel): Promise { +export function waitForSentinel( + channel: ClientChannel, + signal?: AbortSignal +): Promise { return new Promise((resolve, reject) => { let sentinelReceived = false let settled = false @@ -39,17 +42,14 @@ export function waitForSentinel(channel: ClientChannel): Promise { timeoutFired = true - channel.close() timeoutGraceTimer = setTimeout(() => { - if (!settled) { - settled = true - reject( - new Error( - `Relay failed to start within ${RELAY_SENTINEL_TIMEOUT_MS / 1000}s.${stderrOutput ? ` stderr: ${stderrOutput.trim()}` : ''}` - ) + rejectStartup( + new Error( + `Relay failed to start within ${RELAY_SENTINEL_TIMEOUT_MS / 1000}s.${stderrOutput ? ` stderr: ${stderrOutput.trim()}` : ''}` ) - } + ) }, TIMEOUT_GRACE_MS) + channel.close() }, RELAY_SENTINEL_TIMEOUT_MS) const cancelTimers = (): void => { @@ -59,6 +59,31 @@ export function waitForSentinel(channel: ClientChannel): Promise { + cancelTimers() + signal?.removeEventListener('abort', onAbort) + } + const rejectStartup = (err: Error): void => { + if (settled) { + return + } + settled = true + cleanupStartup() + reject(err) + } + const onAbort = (): void => { + if (settled) { + return + } + settled = true + cleanupStartup() + channel.close() + reject(createSshOperationAbortError()) + } + signal?.addEventListener('abort', onAbort, { once: true }) + if (signal?.aborted) { + onAbort() + } channel.on('exit', (code: number | null) => { if (typeof code === 'number') { @@ -87,12 +112,8 @@ export function waitForSentinel(channel: ClientChannel): Promise { - cancelTimers() if (!sentinelReceived) { - if (!settled) { - settled = true - reject(err) - } + rejectStartup(err) return } notifyClosed() @@ -113,9 +134,7 @@ export function waitForSentinel(channel: ClientChannel): Promise { if (!sentinelReceived) { - cancelTimers() if (!settled) { - settled = true // Why: a wire-handshake mismatch on the daemon side closes the // socket; --connect prints the mismatch detail to stderr and exits // with code 42 BEFORE writing the sentinel. Translate that into a @@ -126,13 +145,13 @@ export function waitForSentinel(channel: ClientChannel): Promise 0) { pendingAfterSentinel = afterSentinel } - settled = true - const transport: MultiplexerTransport = { write: (buf: Buffer) => channel.stdin.write(buf), onData: (cb) => { @@ -233,97 +251,3 @@ export function waitForSentinel(channel: ClientChannel): Promise { - const { timeoutMs = EXEC_TIMEOUT_MS, ...execOptions } = options ?? {} - const signal = options?.signal - if (signal?.aborted) { - throw createSshOperationAbortError() - } - const channel = await conn.exec(command, execOptions) - return new Promise((resolve, reject) => { - let stdout = '' - let stderr = '' - let settled = false - - const cleanup = (): void => { - clearTimeout(timeout) - signal?.removeEventListener('abort', onAbort) - channel.off('error', fail) - channel.stderr.off('error', fail) - channel.off('data', onStdoutData) - channel.stderr.off('data', onStderrData) - channel.off('close', onClose) - } - const settle = (fn: typeof resolve | typeof reject, val: string | Error): void => { - if (settled) { - return - } - settled = true - cleanup() - fn(val as never) - } - const fail = (err: Error): void => { - settle(reject, abortRequested ? createSshOperationAbortError() : err) - } - // Why: sshd counts the session against MaxSessions until CHANNEL_CLOSE - // completes. Settling on abort before the channel actually closes lets the - // concurrent-bootstrap sequential fallback reissue an exec while the slot - // is still held, so it gets refused again. Close and settle from onClose. - let abortRequested = false - const onAbort = (): void => { - abortRequested = true - channel.close() - } - const onStdoutData = (data: Buffer): void => { - stdout += data.toString('utf-8') - } - const onStderrData = (data: Buffer): void => { - stderr += data.toString('utf-8') - } - const onClose = (code: number): void => { - if (abortRequested) { - settle(reject, createSshOperationAbortError()) - } else if (code !== 0) { - // Why: on the system-ssh transport channel.stderr carries local OpenSSH - // client noise; preferring it masks the real failure in stdout (2>&1). - const output = [stderr.trim(), stdout.trim()].filter(Boolean).join('\n') - settle(reject, new Error(`Command "${command}" failed (exit ${code}): ${output}`)) - } else { - settle(resolve, stdout) - } - } - const timeout = setTimeout(() => { - channel.close() - settle( - reject, - abortRequested - ? createSshOperationAbortError() - : new Error(`Command "${command}" timed out after ${timeoutMs / 1000}s`) - ) - }, timeoutMs) - - // Why: remote reboot tears down exec channels with stream errors. Without - // scoped listeners, Node treats those as uncaught exceptions. - signal?.addEventListener('abort', onAbort, { once: true }) - channel.on('error', fail) - channel.stderr.on('error', fail) - channel.on('data', onStdoutData) - channel.stderr.on('data', onStderrData) - channel.on('close', onClose) - if (signal?.aborted) { - onAbort() - } - }) -} diff --git a/src/main/ssh/ssh-relay-deploy-timing.ts b/src/main/ssh/ssh-relay-deploy-timing.ts new file mode 100644 index 00000000000..5dc24f937f8 --- /dev/null +++ b/src/main/ssh/ssh-relay-deploy-timing.ts @@ -0,0 +1,13 @@ +// npm install on a cold Windows cache plus antivirus scanning can exceed the +// default 30s exec timeout. +export const NATIVE_DEPS_COMMAND_TIMEOUT_MS = 240_000 + +// Why: a missing binding can require both install and rebuild while the same +// install lock is held. Concurrent first installs must wait for that valid +// holder instead of failing halfway through its bounded work. +const NATIVE_DEPS_REPAIR_BUDGET_MS = 2 * NATIVE_DEPS_COMMAND_TIMEOUT_MS + +// Why: native repair also runs separately bounded chmod/probe/diagnostic/ +// finalize commands around install + rebuild. Keep the outer bound above that +// valid worst path while remaining below the 20-minute stale-lock threshold. +export const RELAY_DEPLOY_TIMEOUT_MS = NATIVE_DEPS_REPAIR_BUDGET_MS + 7 * 60_000 diff --git a/src/main/ssh/ssh-relay-deploy.test.ts b/src/main/ssh/ssh-relay-deploy.test.ts index 33467819035..28fe2a3e49c 100644 --- a/src/main/ssh/ssh-relay-deploy.test.ts +++ b/src/main/ssh/ssh-relay-deploy.test.ts @@ -42,6 +42,9 @@ vi.mock('./ssh-relay-deploy-helpers', () => ({ onData: vi.fn(), onClose: vi.fn() }), + isUnconfirmedSshCommandTermination: (error: unknown) => + error instanceof Error && + (error as Error & { sshChannelCloseConfirmed?: boolean }).sshChannelCloseConfirmed === false, execCommand: vi.fn().mockResolvedValue('__ORCA_REMOTE_PLATFORM__ Linux x86_64') })) @@ -49,27 +52,38 @@ vi.mock('./ssh-remote-node-resolution', () => ({ resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node') })) -// Why: the versioned-install module shells out to the remote for install -// state, lock acquisition, and GC. Tests stub these to no-ops so the deploy -// happy-path is exercised without a real SSH connection. +// Why: the versioned-install modules shell out for install state, locking, +// and GC. Stub them so deploy tests need no real SSH connection. vi.mock('./ssh-relay-versioned-install', () => ({ readLocalFullVersion: vi.fn().mockReturnValue('0.1.0+abcdef012345'), computeRemoteRelayDir: (home: string, v: string) => `${home}/.orca-remote/relay-${v}`, isRelayAlreadyInstalled: vi.fn().mockResolvedValue(true), - acquireInstallLock: vi.fn().mockResolvedValue(undefined), finalizeInstall: vi.fn().mockResolvedValue(undefined), abandonInstall: vi.fn().mockResolvedValue(undefined), gcOldRelayVersions: vi.fn().mockResolvedValue(undefined) })) +vi.mock('./ssh-relay-install-lock', () => ({ + acquireInstallLock: vi.fn().mockResolvedValue(undefined) +})) + +vi.mock('./ssh-relay-repair-lock', () => ({ + tryAcquireRelayRepairLock: vi.fn().mockResolvedValue('acquired') +})) + vi.mock('./ssh-connection-utils', () => ({ - shellEscape: (s: string) => `'${s}'` + shellEscape: (s: string) => `'${s}'`, + createSshOperationAbortError: () => + Object.assign(new Error('SSH operation was cancelled'), { + name: 'AbortError' + }) })) import { deployAndLaunchRelay } from './ssh-relay-deploy' import { execCommand, waitForSentinel } from './ssh-relay-deploy-helpers' import { resolveRemoteNodePath } from './ssh-remote-node-resolution' import { isRelayAlreadyInstalled } from './ssh-relay-versioned-install' +import { acquireInstallLock } from './ssh-relay-install-lock' import type { SshConnection } from './ssh-connection' import type * as SshRemoteNodeResolution from './ssh-remote-node-resolution' import { @@ -133,7 +147,8 @@ describe('deployAndLaunchRelay', () => { expect(mockExecCommand).toHaveBeenCalledWith( conn, - "printf '\\n%s ' '__ORCA_REMOTE_PLATFORM__'; uname -sm" + "printf '\\n%s ' '__ORCA_REMOTE_PLATFORM__'; uname -sm", + expect.objectContaining({ signal: expect.any(AbortSignal) }) ) }) @@ -153,6 +168,26 @@ describe('deployAndLaunchRelay', () => { expect(progress).toContain('Starting relay...') }) + it('does not launch fresh after unconfirmed stale-socket cleanup', async () => { + const conn = makeMockConnection() + const unconfirmedCleanup = Object.assign(new Error('socket cleanup still running'), { + sshChannelCloseConfirmed: false + }) + vi.mocked(waitForSentinel).mockRejectedValueOnce(new Error('stale relay reconnect failed')) + vi.mocked(execCommand) + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') + .mockResolvedValueOnce('/home/user') + .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') + .mockResolvedValueOnce('ALIVE') + .mockRejectedValueOnce(unconfirmedCleanup) + + await expect(deployAndLaunchRelay(conn)).rejects.toBe(unconfirmedCleanup) + + const commands = vi.mocked(conn.exec).mock.calls.map(([command]) => command) + expect(commands).toHaveLength(1) + expect(commands.some((command) => command.includes('--detached'))).toBe(false) + }) + it('resolves the remote node path once per deploy', async () => { const conn = makeMockConnection() const mockExecCommand = vi.mocked(execCommand) @@ -202,7 +237,7 @@ describe('deployAndLaunchRelay', () => { assertionError = err } finally { // Drain the rest of the happy path so a failed assertion does not leave - // the deploy promise pending until its 300s timeout. + // the deploy promise pending until the overall deploy timeout. mockExecCommand.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe mockExecCommand.mockResolvedValueOnce('DEAD') // socket probe mockExecCommand.mockResolvedValueOnce('READY') // socket poll @@ -287,7 +322,10 @@ describe('deployAndLaunchRelay', () => { await deployAndLaunchRelay(conn) - expect(isRelayAlreadyInstalled).toHaveBeenCalledTimes(2) + expect(isRelayAlreadyInstalled).toHaveBeenCalledTimes(3) + expect(vi.mocked(isRelayAlreadyInstalled).mock.calls[2]?.[3]).toMatchObject({ + rethrowSessionLimitErrors: true + }) expect(resolveRemoteNodePath).toHaveBeenCalledTimes(2) }) @@ -314,11 +352,17 @@ describe('deployAndLaunchRelay', () => { await deployAndLaunchRelay(conn) - expect(isRelayAlreadyInstalled).toHaveBeenCalledTimes(2) + expect(isRelayAlreadyInstalled).toHaveBeenCalledTimes(3) expect(vi.mocked(isRelayAlreadyInstalled).mock.calls[0]?.[3]).toMatchObject({ rethrowSessionLimitErrors: true }) - expect(vi.mocked(isRelayAlreadyInstalled).mock.calls[1]?.[3]).toBeUndefined() + expect(vi.mocked(isRelayAlreadyInstalled).mock.calls[1]?.[3]).toMatchObject({ + rethrowSessionLimitErrors: undefined, + signal: expect.any(AbortSignal) + }) + expect(vi.mocked(isRelayAlreadyInstalled).mock.calls[2]?.[3]).toMatchObject({ + rethrowSessionLimitErrors: true + }) expect(resolveRemoteNodePath).toHaveBeenCalledTimes(2) }) @@ -499,7 +543,10 @@ describe('deployAndLaunchRelay', () => { expect(sawLegacyDir).toBe(false) }) - it('has a 300-second overall timeout', async () => { + it('bounds the overall deploy so install + rebuild both fit under the timeout', async () => { + // Why: the outer bound must exceed the worst-case sequential native-deps + // work — a first install (240s) AND a follow-up rebuild (240s) — so a + // legitimate install-then-rebuild is not falsely timed out mid-repair. const conn = makeMockConnection() const mockExecCommand = vi.mocked(execCommand) @@ -511,15 +558,140 @@ describe('deployAndLaunchRelay', () => { // Catch the rejection immediately to avoid unhandled rejection warning const promise = deployAndLaunchRelay(conn).catch((err: Error) => err) + // Not timed out yet at the old 300s bound (install + rebuild need more). await vi.advanceTimersByTimeAsync(301_000) + expect(await Promise.race([promise, Promise.resolve('pending')])).toBe('pending') + + await vi.advanceTimersByTimeAsync(600_000) const result = await promise expect(result).toBeInstanceOf(Error) - expect((result as Error).message).toBe('Relay deployment timed out after 300s') + expect((result as Error).message).toBe('Relay deployment timed out after 900s') vi.useRealTimers() }) + it('aborts a contended install-lock wait at the overall deploy timeout', async () => { + vi.useFakeTimers() + try { + const conn = makeMockConnection() + vi.mocked(execCommand) + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') + .mockResolvedValueOnce('/home/user') + vi.mocked(isRelayAlreadyInstalled).mockResolvedValueOnce(false) + let lockSignal: AbortSignal | undefined + vi.mocked(acquireInstallLock).mockImplementationOnce((_conn, _dir, _host, options) => { + lockSignal = options?.signal + return new Promise((_resolve, reject) => { + lockSignal?.addEventListener('abort', () => reject(lockSignal?.reason), { once: true }) + }) + }) + + const promise = deployAndLaunchRelay(conn).catch((err: Error) => err) + await vi.advanceTimersByTimeAsync(0) + expect(acquireInstallLock).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(900_000) + + const result = await promise + expect(result).toBeInstanceOf(Error) + expect((result as Error).message).toBe('Relay deployment timed out after 900s') + expect(lockSignal?.aborted).toBe(true) + } finally { + vi.useRealTimers() + } + }) + + it('aborts an in-progress relay upload at the overall deploy timeout', async () => { + vi.useFakeTimers() + try { + const conn = makeMockConnection() + vi.mocked(execCommand) + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') + .mockResolvedValueOnce('/home/user') + .mockResolvedValueOnce('') // mkdir remote relay dir + vi.mocked(isRelayAlreadyInstalled).mockResolvedValueOnce(false).mockResolvedValueOnce(false) + let uploadSignal: AbortSignal | undefined + conn.uploadDirectory = vi.fn((_localDir, _remoteDir, options) => { + uploadSignal = options?.signal + return new Promise((_resolve, reject) => { + uploadSignal?.addEventListener('abort', () => reject(uploadSignal?.reason), { + once: true + }) + }) + }) + + const promise = deployAndLaunchRelay(conn).catch((err: Error) => err) + await vi.advanceTimersByTimeAsync(0) + expect(conn.uploadDirectory).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(900_000) + + const result = await promise + expect(result).toBeInstanceOf(Error) + expect((result as Error).message).toBe('Relay deployment timed out after 900s') + expect(uploadSignal?.aborted).toBe(true) + } finally { + vi.useRealTimers() + } + }) + + it('aborts a launch started near the deploy deadline and closes its channel once', async () => { + vi.useFakeTimers() + try { + const launchChannel = { + on: vi.fn(), + stderr: { on: vi.fn() }, + stdin: {}, + stdout: { on: vi.fn() }, + close: vi.fn() + } + const conn = makeMockConnection() + vi.mocked(conn.exec).mockResolvedValue(launchChannel as never) + const mockExecCommand = vi.mocked(execCommand) + mockExecCommand + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') + .mockResolvedValueOnce('/home/user') + .mockImplementationOnce( + () => + new Promise((resolve) => + setTimeout(() => resolve('ORCA-NATIVE-DEPS-OK'), 899_900) + ) + ) + .mockResolvedValueOnce('DEAD') + .mockImplementationOnce((_conn, _command, options) => { + return new Promise((_resolve, reject) => { + options?.signal?.addEventListener( + 'abort', + () => { + const error = new Error('SSH operation was cancelled') + error.name = 'AbortError' + reject(error) + }, + { once: true } + ) + }) + }) + + const promise = deployAndLaunchRelay(conn).catch((err: Error) => err) + await vi.advanceTimersByTimeAsync(899_900) + expect(conn.exec).toHaveBeenCalledTimes(1) + expect(launchChannel.close).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(100) + + const result = await promise + expect(result).toBeInstanceOf(Error) + expect((result as Error).message).toBe('Relay deployment timed out after 900s') + expect(launchChannel.close).toHaveBeenCalledTimes(1) + expect(mockExecCommand).toHaveBeenCalledTimes(5) + await vi.advanceTimersByTimeAsync(10_000) + expect(mockExecCommand).toHaveBeenCalledTimes(5) + } finally { + vi.useRealTimers() + } + }) + it('uses distinct target-specific relay socket paths', async () => { const connA = makeMockConnection() const connB = makeMockConnection() @@ -596,6 +768,20 @@ describe('deployAndLaunchRelay', () => { expect(launchScript).not.toContain('\\\\.\\pipe\\agent-hooks') const waitScript = decodedScripts.find((script) => script.includes('deadline=Date.now()')) ?? '' expect(waitScript).toContain('setTimeout(attempt,intervalMs)') + const windowsLaunchCalls = mockExecCommand.mock.calls.filter(([, command]) => { + const script = decodePowerShellCommand(command) + return ( + script?.includes('.windows-active-pipe') || + script?.includes('Invoke-CimMethod') || + script?.includes('deadline=Date.now()') + ) + }) + expect(windowsLaunchCalls.length).toBeGreaterThan(0) + expect( + windowsLaunchCalls.every(([, , options]) => options?.signal instanceof AbortSignal) + ).toBe(true) + expect(vi.mocked(conn.exec).mock.calls[0]?.[1]?.signal).toBeInstanceOf(AbortSignal) + expect(vi.mocked(waitForSentinel).mock.calls[0]?.[1]).toBeInstanceOf(AbortSignal) }) it('relaunches Windows remotes on a fallback pipe when reconnecting the occupied pipe fails', async () => { diff --git a/src/main/ssh/ssh-relay-deploy.ts b/src/main/ssh/ssh-relay-deploy.ts index 26501a8371b..56779b87f0f 100644 --- a/src/main/ssh/ssh-relay-deploy.ts +++ b/src/main/ssh/ssh-relay-deploy.ts @@ -8,18 +8,30 @@ import { app } from 'electron' import type { SshConnection } from './ssh-connection' import type { RelayPlatform } from './relay-protocol' import type { MultiplexerTransport } from './ssh-channel-multiplexer' -import { uploadDirectory, waitForSentinel, execCommand } from './ssh-relay-deploy-helpers' +import { + uploadDirectory, + waitForSentinel, + execCommand, + isUnconfirmedSshCommandTermination +} from './ssh-relay-deploy-helpers' import { resolveRemoteNodePath } from './ssh-remote-node-resolution' import { readLocalFullVersion, computeRemoteRelayDir, isRelayAlreadyInstalled, - acquireInstallLock, finalizeInstall, abandonInstall, gcOldRelayVersions } from './ssh-relay-versioned-install' -import { shellEscape } from './ssh-connection-utils' +import { acquireInstallLock } from './ssh-relay-install-lock' +import { tryAcquireRelayRepairLock } from './ssh-relay-repair-lock' +import { + releaseRelayGcClaimWithRetry, + tryAcquireRelayGcClaim, + waitForRelayGcClaimRelease +} from './ssh-relay-gc-claim' +import { NATIVE_DEPS_COMMAND_TIMEOUT_MS, RELAY_DEPLOY_TIMEOUT_MS } from './ssh-relay-deploy-timing' +import { createSshOperationAbortError, shellEscape } from './ssh-connection-utils' import { probeBuildToolchain, formatMissingToolchainError, @@ -66,16 +78,14 @@ export type RelayDeployResult = { sockPath?: string } -// Why: individual exec commands have 30s timeouts, but the full deploy -// pipeline (detect platform → check existing → upload → npm install → -// launch) has no overall bound. A hanging `npm install` or slow SFTP -// upload could block the connection indefinitely. First-time installs need -// room for the longer native dependency install bound below. -const RELAY_DEPLOY_TIMEOUT_MS = 300_000 - -// npm install on a cold Windows cache plus antivirus scanning can exceed the -// default 30s exec timeout. -const NATIVE_DEPS_INSTALL_TIMEOUT_MS = 240_000 +class RelayDirectoryGcConflictError extends Error { + constructor( + readonly remoteRelayDir: string, + readonly hostPlatform: RemoteHostPlatform + ) { + super(`Relay directory GC is in progress at ${remoteRelayDir}`) + } +} function execHostCommand( conn: SshConnection, @@ -108,15 +118,25 @@ export async function deployAndLaunchRelay( relayInstanceId?: string ): Promise { let timeoutHandle: ReturnType + // Why: Promise.race does not cancel its loser. Stop a contended install-lock + // waiter so it cannot acquire and mutate the relay after this call times out. + const deployAbortController = new AbortController() const timeoutPromise = new Promise((_resolve, reject) => { timeoutHandle = setTimeout(() => { + deployAbortController.abort() reject(new Error(`Relay deployment timed out after ${RELAY_DEPLOY_TIMEOUT_MS / 1000}s`)) }, RELAY_DEPLOY_TIMEOUT_MS) }) try { return await Promise.race([ - deployAndLaunchRelayInner(conn, onProgress, graceTimeSeconds, relayInstanceId), + deployAndLaunchRelayInner( + conn, + onProgress, + graceTimeSeconds, + relayInstanceId, + deployAbortController.signal + ), timeoutPromise ]) } finally { @@ -179,22 +199,29 @@ type RelayBootstrapState = { async function resolveRelayBootstrapStateSequentially( conn: SshConnection, hostPlatform: RemoteHostPlatform, - fullVersion: string + fullVersion: string, + signal?: AbortSignal ): Promise { - const installState = await resolveRemoteInstallState(conn, hostPlatform, fullVersion) - const nodePath = await resolveRemoteNodePath(conn, hostPlatform) + const installState = await resolveRemoteInstallState(conn, hostPlatform, fullVersion, { signal }) + const nodePath = await resolveRemoteNodePath(conn, hostPlatform, { signal }) return { ...installState, nodePath } } async function resolveRelayBootstrapState( conn: SshConnection, hostPlatform: RemoteHostPlatform, - fullVersion: string + fullVersion: string, + signal?: AbortSignal ): Promise { if (!conn.canRunConcurrentExecCommands()) { - return resolveRelayBootstrapStateSequentially(conn, hostPlatform, fullVersion) + return resolveRelayBootstrapStateSequentially(conn, hostPlatform, fullVersion, signal) } const abortController = new AbortController() + const abortForDeploy = (): void => abortController.abort() + signal?.addEventListener('abort', abortForDeploy, { once: true }) + if (signal?.aborted) { + abortForDeploy() + } const installStatePromise = resolveRemoteInstallState(conn, hostPlatform, fullVersion, { rethrowSessionLimitErrors: true, signal: abortController.signal @@ -205,10 +232,12 @@ async function resolveRelayBootstrapState( }) try { const [installState, nodePath] = await Promise.all([installStatePromise, nodePathPromise]) + signal?.throwIfAborted() return { ...installState, nodePath } } catch (err) { abortController.abort() const settled = await Promise.allSettled([installStatePromise, nodePathPromise]) + signal?.throwIfAborted() if (!isSshSessionLimitError(err)) { throw err } @@ -224,7 +253,9 @@ async function resolveRelayBootstrapState( console.warn( '[ssh-relay] Concurrent bootstrap probes hit the remote SSH session limit; retrying sequentially.' ) - return resolveRelayBootstrapStateSequentially(conn, hostPlatform, fullVersion) + return resolveRelayBootstrapStateSequentially(conn, hostPlatform, fullVersion, signal) + } finally { + signal?.removeEventListener('abort', abortForDeploy) } } @@ -241,11 +272,40 @@ async function deployAndLaunchRelayInner( conn: SshConnection, onProgress?: (status: string) => void, graceTimeSeconds?: number, - relayInstanceId?: string + relayInstanceId?: string, + deploySignal?: AbortSignal +): Promise { + while (true) { + deploySignal?.throwIfAborted() + try { + return await deployAndLaunchRelayAttempt( + conn, + onProgress, + graceTimeSeconds, + relayInstanceId, + deploySignal + ) + } catch (err) { + if (!(err instanceof RelayDirectoryGcConflictError)) { + throw err + } + // Why: GC atomically moves the old install aside. Wait for its stable + // sibling claim to clear, then recompute install state from scratch. + await waitForRelayGcClaimRelease(conn, err.remoteRelayDir, err.hostPlatform, deploySignal) + } + } +} + +async function deployAndLaunchRelayAttempt( + conn: SshConnection, + onProgress?: (status: string) => void, + graceTimeSeconds?: number, + relayInstanceId?: string, + deploySignal?: AbortSignal ): Promise { onProgress?.('Detecting remote platform...') console.log('[ssh-relay] Detecting remote platform...') - const hostPlatform = await detectRemoteHostPlatform(conn) + const hostPlatform = await detectRemoteHostPlatform(conn, { signal: deploySignal }) if (!hostPlatform) { throw new Error( 'Unsupported remote platform. Orca relay supports: linux-x64, linux-arm64, darwin-x64, darwin-arm64, win32-x64, win32-arm64.' @@ -275,57 +335,104 @@ async function deployAndLaunchRelayInner( // fail-fast; remotes that reject overlapping session channels retry the old // sequential order so restrictive SSH servers keep working. const { remoteHome, remoteRelayDir, alreadyInstalled, nodePath } = - await resolveRelayBootstrapState(conn, hostPlatform, fullVersion) + await resolveRelayBootstrapState(conn, hostPlatform, fullVersion, deploySignal) console.log(`[ssh-relay] Remote dir: ${remoteRelayDir}`) console.log(`[ssh-relay] Already installed at ${fullVersion}: ${alreadyInstalled}`) + let ownsInstallLock = false + let launchGcClaimToken: string | undefined if (alreadyInstalled) { - await repairInstalledNativeDeps(conn, remoteRelayDir, platform, hostPlatform, nodePath) + const launchFence = await repairInstalledNativeDeps( + conn, + remoteRelayDir, + platform, + hostPlatform, + nodePath, + deploySignal + ) + ownsInstallLock = launchFence.ownsInstallLock + launchGcClaimToken = launchFence.gcClaimToken + deploySignal?.throwIfAborted() } else { // Why: serialize concurrent first-installs of the same version against - // each other via an atomic mkdir lock. The losing caller polls and either + // each other via a host-native exclusive lock. The losing caller polls and either // re-checks `alreadyInstalled` (now true) or steals a stale lock. - await acquireInstallLock(conn, remoteRelayDir, hostPlatform) + await acquireInstallLock(conn, remoteRelayDir, hostPlatform, { signal: deploySignal }) + ownsInstallLock = true try { // Re-probe after acquiring the lock — a sibling installer may have // finished while we were waiting. - if (!(await isRelayAlreadyInstalled(conn, remoteRelayDir, hostPlatform))) { + if ( + !(await isRelayAlreadyInstalled(conn, remoteRelayDir, hostPlatform, { + signal: deploySignal + })) + ) { onProgress?.('Uploading relay...') console.log('[ssh-relay] Uploading relay...') - await uploadRelay(conn, platform, remoteRelayDir, fullVersion, hostPlatform) + await uploadRelay(conn, platform, remoteRelayDir, fullVersion, hostPlatform, deploySignal) console.log('[ssh-relay] Upload complete') onProgress?.('Installing native dependencies...') console.log('[ssh-relay] Installing native dependencies...') - await installNativeDeps(conn, remoteRelayDir, platform, hostPlatform, nodePath) + await installNativeDeps( + conn, + remoteRelayDir, + platform, + hostPlatform, + nodePath, + deploySignal + ) console.log('[ssh-relay] Native deps installed') - // Why: write `.install-complete` BEFORE releasing the lock so a - // sibling never observes the dir as "complete but locked", which - // would lead GC to skip a recoverable dir indefinitely. - await finalizeInstall(conn, remoteRelayDir, hostPlatform) - } else { - await abandonInstall(conn, remoteRelayDir, hostPlatform) + // Why: mark complete but retain the lock until launch makes daemon + // liveness observable to cross-version GC. + await finalizeInstall(conn, remoteRelayDir, hostPlatform, { + signal: deploySignal, + releaseLock: false + }) } } catch (err) { // Why: leave a partial install dir in place (no `.install-complete`) // so the next deploy detects the partial and re-runs upload + install. - // Just release the lock so a concurrent caller can retry. - await abandonInstall(conn, remoteRelayDir, hostPlatform) + // Keep the lock if remote command termination was not confirmed; stale + // recovery is safer than overlapping a still-running npm process. + if (!isUnconfirmedSshCommandTermination(err)) { + await abandonInstall(conn, remoteRelayDir, hostPlatform) + ownsInstallLock = false + } throw err } } - onProgress?.('Starting relay...') - console.log('[ssh-relay] Launching relay...') - const launched = await launchRelay( - conn, - remoteRelayDir, - hostPlatform, - nodePath, - graceTimeSeconds, - relayInstanceId - ) + let launched: Awaited> + let launchLivenessObserved = false + try { + deploySignal?.throwIfAborted() + onProgress?.('Starting relay...') + console.log('[ssh-relay] Launching relay...') + launched = await launchRelay( + conn, + remoteRelayDir, + hostPlatform, + nodePath, + graceTimeSeconds, + relayInstanceId, + deploySignal + ) + launchLivenessObserved = true + } finally { + // Why: older clients understand only the install lock. If launch never + // becomes live, retain it for stale recovery so their GC cannot race a + // concurrent caller that was waiting behind this owner. + if (ownsInstallLock && launchLivenessObserved) { + await abandonInstall(conn, remoteRelayDir, hostPlatform) + } + // The detached start may outlive a timed-out SSH command. Preserve either + // fence on failed launch until stale recovery can prove the handoff ended. + if (launchGcClaimToken && launchLivenessObserved) { + await releaseRelayGcClaimWithRetry(conn, remoteRelayDir, launchGcClaimToken, hostPlatform) + } + } console.log('[ssh-relay] Relay started successfully') // Why: best-effort cleanup of unreferenced sibling version dirs. Errors @@ -352,7 +459,8 @@ async function uploadRelay( platform: RelayPlatform, remoteDir: string, fullVersion: string, - hostPlatform: RemoteHostPlatform + hostPlatform: RemoteHostPlatform, + signal?: AbortSignal ): Promise { const localRelayDir = getLocalRelayPath(platform) if (!localRelayDir || !existsSync(localRelayDir)) { @@ -363,16 +471,19 @@ async function uploadRelay( } // Create remote directory - await execHostCommand(conn, hostPlatform, makeRemoteDirectoryCommand(hostPlatform, remoteDir)) + await execHostCommand(conn, hostPlatform, makeRemoteDirectoryCommand(hostPlatform, remoteDir), { + signal + }) - await uploadDirectoryForConnection(conn, localRelayDir, remoteDir, hostPlatform) + await uploadDirectoryForConnection(conn, localRelayDir, remoteDir, hostPlatform, signal) // Make the node binary executable if (!isWindowsRemoteHost(hostPlatform)) { await execHostCommand( conn, hostPlatform, - makeRemoteExecutableCommand(hostPlatform, joinRemotePath(hostPlatform, remoteDir, 'node')) + makeRemoteExecutableCommand(hostPlatform, joinRemotePath(hostPlatform, remoteDir, 'node')), + { signal } ) } @@ -383,7 +494,8 @@ async function uploadRelay( conn, hostPlatform, joinRemotePath(hostPlatform, remoteDir, '.version'), - fullVersion + fullVersion, + signal ) } @@ -391,10 +503,11 @@ async function uploadDirectoryForConnection( conn: SshConnection, localRelayDir: string, remoteDir: string, - hostPlatform: RemoteHostPlatform + hostPlatform: RemoteHostPlatform, + signal?: AbortSignal ): Promise { if (typeof conn.uploadDirectory === 'function') { - await conn.uploadDirectory(localRelayDir, remoteDir, { hostPlatform }) + await conn.uploadDirectory(localRelayDir, remoteDir, { hostPlatform, signal }) return } @@ -410,10 +523,11 @@ async function writeRemoteFile( conn: SshConnection, hostPlatform: RemoteHostPlatform, remotePath: string, - contents: string + contents: string, + signal?: AbortSignal ): Promise { if (typeof conn.writeFile === 'function') { - await conn.writeFile(remotePath, contents, { hostPlatform }) + await conn.writeFile(remotePath, contents, { hostPlatform, signal }) return } @@ -438,31 +552,64 @@ const RELAY_NATIVE_DEPS = { '@parcel/watcher': '2.5.6' } as const -async function hasRequiredNativeDeps( +type RelayNativeDepName = keyof typeof RELAY_NATIVE_DEPS +const RELAY_NATIVE_DEP_NAMES = Object.keys(RELAY_NATIVE_DEPS) as RelayNativeDepName[] +const NATIVE_DEPS_MISSING_PREFIX = 'ORCA-NATIVE-DEPS-MISSING:' + +// Why: npm 12 blocks dependency lifecycle scripts unless each exact package +// version is approved, even when ignore-scripts is explicitly disabled. +const RELAY_NATIVE_DEP_SCRIPT_ALLOWLIST = Object.fromEntries( + Object.entries(RELAY_NATIVE_DEPS).map(([name, version]) => [`${name}@${version}`, true]) +) + +function nativeDepsProbeJs(successToken: string): string { + // Why: node-pty's Windows wrapper defers loading conpty.node until first + // spawn, so require("node-pty") alone cannot prove the binding is healthy. + const loadNodePty = + 'require("node-pty"); require("node-pty/lib/utils").loadNativeModule(process.platform==="win32"&&Number(require("os").release().split(".")[2])>=18309?"conpty":"pty")' + return `(()=>{const missing=[];try{${loadNodePty}}catch{missing.push("node-pty")}try{require("@parcel/watcher")}catch{missing.push("@parcel/watcher")}if(missing.length){console.log("${NATIVE_DEPS_MISSING_PREFIX}"+missing.join(","));process.exitCode=1}else{console.log(${JSON.stringify(successToken)})}})()` +} + +function missingNativeDepsFromProbe(output: string): RelayNativeDepName[] { + const marker = output + .split(/\r?\n/) + .find((line) => line.trim().startsWith(NATIVE_DEPS_MISSING_PREFIX)) + if (!marker) { + return [...RELAY_NATIVE_DEP_NAMES] + } + const reported = marker.trim().slice(NATIVE_DEPS_MISSING_PREFIX.length).split(',') + return RELAY_NATIVE_DEP_NAMES.filter((name) => reported.includes(name)) +} + +async function probeRequiredNativeDeps( conn: SshConnection, remoteDir: string, hostPlatform: RemoteHostPlatform, - nodePath: string -): Promise { + nodePath: string, + signal?: AbortSignal +): Promise<{ available: boolean; missing: RelayNativeDepName[] }> { const escapedNode = shellEscape(nodePath) + const probeJs = nativeDepsProbeJs('ORCA-NATIVE-DEPS-OK') try { const command = isWindowsRemoteHost(hostPlatform) ? commandWithNodePath( hostPlatform, nodePath, remoteDir, - `try { & ${powerShellLiteral(nodePath)} -e ${powerShellNativeArg('require.resolve("node-pty"); require.resolve("@parcel/watcher"); console.log("ORCA-NATIVE-DEPS-OK")')} } catch { 'MISSING' }` + `try { & ${powerShellLiteral(nodePath)} -e ${powerShellNativeArg(probeJs)} } catch { 'MISSING' }` ) : commandWithNodePath( hostPlatform, nodePath, remoteDir, - `(${escapedNode} -e 'require.resolve("node-pty"); require.resolve("@parcel/watcher"); console.log("ORCA-NATIVE-DEPS-OK")' 2>/dev/null || echo MISSING)` + `(${escapedNode} -e ${shellEscape(probeJs)} 2>/dev/null || echo MISSING)` ) - const probe = await execHostCommand(conn, hostPlatform, command) - return probe.includes('ORCA-NATIVE-DEPS-OK') + const probe = await execHostCommand(conn, hostPlatform, command, { signal }) + const available = probe.includes('ORCA-NATIVE-DEPS-OK') + return { available, missing: available ? [] : missingNativeDepsFromProbe(probe) } } catch { - return false + signal?.throwIfAborted() + return { available: false, missing: [...RELAY_NATIVE_DEP_NAMES] } } } @@ -471,25 +618,116 @@ async function repairInstalledNativeDeps( remoteDir: string, platform: RelayPlatform, hostPlatform: RemoteHostPlatform, - nodePath: string -): Promise { - if (await hasRequiredNativeDeps(conn, remoteDir, hostPlatform, nodePath)) { - return + nodePath: string, + signal?: AbortSignal +): Promise<{ ownsInstallLock: boolean; gcClaimToken?: string }> { + const initialProbe = await probeRequiredNativeDeps( + conn, + remoteDir, + hostPlatform, + nodePath, + signal + ) + const lockResult = await tryAcquireRelayRepairLock(conn, remoteDir, hostPlatform, { signal }) + if (lockResult === 'gc') { + throw new RelayDirectoryGcConflictError(remoteDir, hostPlatform) + } + if (lockResult === 'acquired') { + let stillInstalled: boolean + try { + stillInstalled = await isRelayAlreadyInstalled(conn, remoteDir, hostPlatform, { + rethrowSessionLimitErrors: true, + signal + }) + } catch (err) { + await abandonInstall(conn, remoteDir, hostPlatform) + throw err + } + if (!stillInstalled) { + // Why: GC may finish its rename before our lock attempt recreates the + // original path. Never trust probes made before this locked recheck. + await abandonInstall(conn, remoteDir, hostPlatform) + throw new RelayDirectoryGcConflictError(remoteDir, hostPlatform) + } + } + const gcClaimToken = + lockResult === 'busy' || lockResult === 'error' + ? await acquireRelayLaunchGcFence(conn, remoteDir, hostPlatform, signal) + : undefined + if (initialProbe.available) { + // Why: even a healthy reconnect must stay fenced until launch liveness is + // observable; otherwise cross-version GC can rename after this probe. + return { ownsInstallLock: lockResult === 'acquired', gcClaimToken } } + // Why: an already-installed relay can launch in degraded mode (fs/git/preflight + // still work; native-backed ops fail), so native-deps repair is best-effort: + // lock contention and repair failures must not abort the connection. Pre-repair + // this path used require.resolve (which passed for a present-but-unloadable + // binding), so a fatal repair here would be a straight regression. console.warn(`[ssh-relay] Repairing missing native deps at ${remoteDir}`) - await acquireInstallLock(conn, remoteDir, hostPlatform) + if (lockResult === 'busy' || lockResult === 'error') { + console.warn( + `[ssh-relay] Native-deps repair lock is ${lockResult} at ${remoteDir}; launching degraded` + ) + return { ownsInstallLock: false, gcClaimToken } + } try { // Why: older complete relay dirs were created before @parcel/watcher was // installed. Re-probe under the lock so only one reconnect mutates the dir. - if (!(await hasRequiredNativeDeps(conn, remoteDir, hostPlatform, nodePath))) { - await installNativeDeps(conn, remoteDir, platform, hostPlatform, nodePath) - await finalizeInstall(conn, remoteDir, hostPlatform) - } else { - await abandonInstall(conn, remoteDir, hostPlatform) + const probe = await probeRequiredNativeDeps(conn, remoteDir, hostPlatform, nodePath, signal) + if (!probe.available) { + await installNativeDeps( + conn, + remoteDir, + platform, + hostPlatform, + nodePath, + signal, + probe.missing + ) + await finalizeInstall(conn, remoteDir, hostPlatform, { signal, releaseLock: false }) } + return { ownsInstallLock: true } } catch (err) { - await abandonInstall(conn, remoteDir, hostPlatform) + const terminationUnconfirmed = isUnconfirmedSshCommandTermination(err) + // Why: keep a confirmed-failure lock through degraded launch so GC cannot + // move the relay before liveness is visible. Unconfirmed remote mutation + // keeps its stale-recoverable lock beyond this connection. + console.warn( + `[ssh-relay] Native deps repair failed at ${remoteDir}; launching degraded: ${ + err instanceof Error ? err.message : String(err) + }` + ) + return { ownsInstallLock: !terminationUnconfirmed } + } +} + +async function acquireRelayLaunchGcFence( + conn: SshConnection, + remoteDir: string, + hostPlatform: RemoteHostPlatform, + signal?: AbortSignal +): Promise { + const token = await tryAcquireRelayGcClaim(conn, remoteDir, hostPlatform, signal) + if (!token) { + signal?.throwIfAborted() + throw new RelayDirectoryGcConflictError(remoteDir, hostPlatform) + } + try { + signal?.throwIfAborted() + const stillInstalled = await isRelayAlreadyInstalled(conn, remoteDir, hostPlatform, { + rethrowSessionLimitErrors: true, + signal + }) + if (!stillInstalled) { + throw new RelayDirectoryGcConflictError(remoteDir, hostPlatform) + } + // Why: a caller that cannot own the install lock still needs its own + // durable fence; never borrow another connection's lock through launch. + return token + } catch (err) { + await releaseRelayGcClaimWithRetry(conn, remoteDir, token, hostPlatform) throw err } } @@ -507,14 +745,14 @@ async function installNativeDeps( remoteDir: string, platform: RelayPlatform, hostPlatform: RemoteHostPlatform, - nodePath: string + nodePath: string, + signal?: AbortSignal, + resetDeps: RelayNativeDepName[] = [] ): Promise { - // Why: node's bin directory must be in PATH for npm's child processes. + // Why: commandWithNodePath puts node's bin directory in PATH for npm's child processes. // npm install runs node-pty's prebuild script (`node scripts/prebuild.js`) // which spawns `node` as a child — if node isn't in PATH, that child // fails with exit 127 even though we invoked npm via its full path. - const escapedNode = shellEscape(nodePath) - // npm init -y rejects '+' in derived package names (content-hashed dir // names like relay-0.1.0+abc123). Bypass it with a fixed minimal // package.json. type:commonjs pins module resolution against Node default @@ -524,38 +762,51 @@ async function installNativeDeps( version: '1.0.0', private: true, type: 'commonjs', - dependencies: RELAY_NATIVE_DEPS + dependencies: RELAY_NATIVE_DEPS, + allowScripts: RELAY_NATIVE_DEP_SCRIPT_ALLOWLIST })}\n` await writeRemoteFile( conn, hostPlatform, joinRemotePath(hostPlatform, remoteDir, 'package.json'), - pkgJson + pkgJson, + signal ) try { const installArgs = Object.entries(RELAY_NATIVE_DEPS) .map(([dep, version]) => shellEscape(`${dep}@${version}`)) .join(' ') + // Why: npm reports a present package as up to date even when one packaged + // native file was deleted. Reset only dependencies the probe found broken. + const resetCommand = resetNativeDepsCommand(hostPlatform, resetDeps) + const resetPrefix = resetCommand ? `${resetCommand}; ` : '' const command = isWindowsRemoteHost(hostPlatform) ? commandWithNodePath( hostPlatform, nodePath, remoteDir, - `npm install --omit=dev --no-audit --no-fund ${Object.entries(RELAY_NATIVE_DEPS) + `${resetPrefix}npm install --ignore-scripts=false --omit=dev --no-audit --no-fund ${Object.entries( + RELAY_NATIVE_DEPS + ) .map(([dep, version]) => powerShellLiteral(`${dep}@${version}`)) - .join(' ')}` + .join(' ')}; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }` ) : commandWithNodePath( hostPlatform, nodePath, remoteDir, - `npm install --omit=dev --no-audit --no-fund ${installArgs} 2>&1` + `${resetPrefix}npm install --ignore-scripts=false --omit=dev --no-audit --no-fund ${installArgs} 2>&1` ) await execHostCommand(conn, hostPlatform, command, { - timeoutMs: NATIVE_DEPS_INSTALL_TIMEOUT_MS + timeoutMs: NATIVE_DEPS_COMMAND_TIMEOUT_MS, + signal }) } catch (err) { + if (isUnconfirmedSshCommandTermination(err)) { + throw err + } + signal?.throwIfAborted() // Don't write .install-complete on hard fail; reconnect retries on a // partial install. Greppable token so user bug reports paste something // searchable. @@ -568,7 +819,7 @@ async function installNativeDeps( // remote and replace node-gyp's opaque `not found: make` with an actionable // install hint instead of leaking the raw npm output to the user. if (platform.startsWith('linux') && shouldProbeBuildToolchainAfterNativeDepsFailure(msg)) { - const toolchain = await probeBuildToolchain(conn, hostPlatform) + const toolchain = await probeBuildToolchain(conn, hostPlatform, signal) if (toolchain?.toolchainMissing) { throw new Error(formatMissingToolchainError(toolchain, msg)) } @@ -576,54 +827,178 @@ async function installNativeDeps( throw err } - // SFTP doesn't preserve execute bits; node-pty's spawn-helper prebuild - // must be +x for posix_spawnp. - if (!isWindowsRemoteHost(hostPlatform)) { - await execHostCommand( - conn, - hostPlatform, - `find ${shellEscape(joinRemotePath(hostPlatform, remoteDir, 'node_modules/node-pty/prebuilds'))} -name spawn-helper -exec chmod +x {} + 2>/dev/null; true` - ) + await makeNodePtySpawnHelperExecutable(conn, remoteDir, hostPlatform, signal) + + let probe = await probeInstalledNativeDeps(conn, remoteDir, hostPlatform, nodePath, signal) + if (!probe.available) { + // Why: npm treats an already-present package as up to date, so enabling + // lifecycle scripts on install cannot repair a binding skipped earlier. + console.warn(`[ssh-relay] Rebuilding unloadable native deps at ${remoteDir}`) + let rebuilt = false + try { + await rebuildNativeDeps(conn, remoteDir, hostPlatform, nodePath, signal) + rebuilt = true + } catch (err) { + if (isUnconfirmedSshCommandTermination(err)) { + throw err + } + signal?.throwIfAborted() + console.warn( + `[ssh-relay][NATIVE-DEPS-REBUILD-FAIL] npm rebuild native deps failed at ${remoteDir} (${platform}): ${(err as Error).message}` + ) + } + signal?.throwIfAborted() + if (rebuilt) { + await makeNodePtySpawnHelperExecutable(conn, remoteDir, hostPlatform, signal) + probe = await probeInstalledNativeDeps(conn, remoteDir, hostPlatform, nodePath, signal) + } } - // node -e require() catches unloadable installs (wrong arch, missing - // prebuild, broken native binding) that test -d cannot. Stderr → file - // so .bashrc noise can't pollute the sentinel match; preserved for the - // [NPTY-MISSING] breadcrumb. MISSING is non-fatal by design — see - // docs/ssh-relay-versioned-install-dirs.md (relay still serves - // fs/git/preflight; only pty.spawn fails at runtime). - const PROBE_OK = 'ORCA-NPTY-PROBE-OK' - const stderrFile = joinRemotePath(hostPlatform, remoteDir, '.npty-probe.stderr') - const escapedStderr = shellEscape(stderrFile) - const probeCommand = isWindowsRemoteHost(hostPlatform) + // MISSING is non-fatal by design: the relay still serves fs/git/preflight; + // only native-backed operations fail on hosts that cannot build the addons. + if (!probe.available) { + console.warn( + `[ssh-relay][NPTY-MISSING] native deps installed but require() failed at ${remoteDir} (${platform}). stdout=${probe.output.trim().slice(-200)} stderr=${probe.stderr.trim().slice(-500)}` + ) + } +} + +function resetNativeDepsCommand( + hostPlatform: RemoteHostPlatform, + resetDeps: RelayNativeDepName[] +): string { + if (resetDeps.length === 0) { + return '' + } + const resetNodePty = resetDeps.includes('node-pty') + const resetWatcher = resetDeps.includes('@parcel/watcher') + if (isWindowsRemoteHost(hostPlatform)) { + const commands: string[] = [] + if (resetNodePty) { + commands.push( + `Remove-Item -LiteralPath ${powerShellLiteral('node_modules/node-pty')} -Recurse -Force -ErrorAction SilentlyContinue` + ) + } + if (resetWatcher) { + commands.push( + `Remove-Item -LiteralPath ${powerShellLiteral('node_modules/@parcel/watcher')} -Recurse -Force -ErrorAction SilentlyContinue`, + `$parcelScope = ${powerShellLiteral('node_modules/@parcel')}`, + `Get-ChildItem -LiteralPath $parcelScope -Force -ErrorAction SilentlyContinue | Where-Object { $_.Name.StartsWith('watcher-', [StringComparison]::Ordinal) } | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue` + ) + } + return commands.join('; ') + } + const commands: string[] = [] + if (resetNodePty) { + commands.push(`rm -rf ${shellEscape('node_modules/node-pty')}`) + } + if (resetWatcher) { + commands.push( + `rm -rf ${shellEscape('node_modules/@parcel/watcher')}`, + `find ${shellEscape('node_modules/@parcel')} -maxdepth 1 -name 'watcher-*' -exec rm -rf {} + 2>/dev/null || true` + ) + } + return commands.join('; ') +} + +async function rebuildNativeDeps( + conn: SshConnection, + remoteDir: string, + hostPlatform: RemoteHostPlatform, + nodePath: string, + signal?: AbortSignal +): Promise { + const depNames = Object.keys(RELAY_NATIVE_DEPS) + const command = isWindowsRemoteHost(hostPlatform) ? commandWithNodePath( hostPlatform, nodePath, remoteDir, - `try { & ${powerShellLiteral(nodePath)} -e ${powerShellNativeArg('require("node-pty"); console.log(process.argv[1])')} ${powerShellLiteral(PROBE_OK)}; if ($LASTEXITCODE -ne 0) { 'MISSING' } } catch { 'MISSING' }` + `npm rebuild --ignore-scripts=false ${depNames.map(powerShellLiteral).join(' ')}; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }` ) : commandWithNodePath( hostPlatform, nodePath, remoteDir, - `(${escapedNode} -e 'require("node-pty"); console.log(process.argv[1])' ${shellEscape(PROBE_OK)} 2>${escapedStderr} || echo MISSING)` + `npm rebuild --ignore-scripts=false ${depNames.map(shellEscape).join(' ')} 2>&1` ) - const probeOutput = await execHostCommand(conn, hostPlatform, probeCommand) - if (!probeOutput.includes(PROBE_OK)) { - const remoteStderr = isWindowsRemoteHost(hostPlatform) - ? '' - : await execHostCommand(conn, hostPlatform, `cat ${escapedStderr} 2>/dev/null; true`).catch( - () => '' - ) - console.warn( - `[ssh-relay][NPTY-MISSING] node-pty installed but require() failed at ${remoteDir} (${platform}). stdout=${probeOutput.trim().slice(-200)} stderr=${remoteStderr.trim().slice(-500)}` - ) + await execHostCommand(conn, hostPlatform, command, { + timeoutMs: NATIVE_DEPS_COMMAND_TIMEOUT_MS, + signal + }) +} + +async function makeNodePtySpawnHelperExecutable( + conn: SshConnection, + remoteDir: string, + hostPlatform: RemoteHostPlatform, + signal?: AbortSignal +): Promise { + if (isWindowsRemoteHost(hostPlatform)) { + return } + // SFTP doesn't preserve execute bits; node-pty's spawn-helper prebuild + // must be +x for posix_spawnp. await execHostCommand( conn, hostPlatform, - removeRemoteFileCommand(hostPlatform, stderrFile) - ).catch(() => {}) + `find ${shellEscape(joinRemotePath(hostPlatform, remoteDir, 'node_modules/node-pty/prebuilds'))} -name spawn-helper -exec chmod +x {} + 2>/dev/null; true`, + { signal } + ) +} + +async function probeInstalledNativeDeps( + conn: SshConnection, + remoteDir: string, + hostPlatform: RemoteHostPlatform, + nodePath: string, + signal?: AbortSignal +): Promise<{ + available: boolean + missing: RelayNativeDepName[] + output: string + stderr: string +}> { + // require() catches unloadable installs (wrong arch, missing prebuild, or a + // skipped lifecycle script) that require.resolve() and test -d both miss. + const PROBE_OK = 'ORCA-NPTY-PROBE-OK' + const stderrFile = joinRemotePath(hostPlatform, remoteDir, '.npty-probe.stderr') + const escapedStderr = shellEscape(stderrFile) + const probeJs = nativeDepsProbeJs(PROBE_OK) + const probeCommand = isWindowsRemoteHost(hostPlatform) + ? commandWithNodePath( + hostPlatform, + nodePath, + remoteDir, + `try { & ${powerShellLiteral(nodePath)} -e ${powerShellNativeArg(probeJs)} ${powerShellLiteral(PROBE_OK)}; if ($LASTEXITCODE -ne 0) { 'MISSING' } } catch { 'MISSING' }` + ) + : commandWithNodePath( + hostPlatform, + nodePath, + remoteDir, + `(${shellEscape(nodePath)} -e ${shellEscape(probeJs)} ${shellEscape(PROBE_OK)} 2>${escapedStderr} || echo MISSING)` + ) + const probeOutput = await execHostCommand(conn, hostPlatform, probeCommand, { signal }) + const remoteStderr = + probeOutput.includes(PROBE_OK) || isWindowsRemoteHost(hostPlatform) + ? '' + : await execHostCommand(conn, hostPlatform, `cat ${escapedStderr} 2>/dev/null; true`, { + signal + }).catch(() => '') + signal?.throwIfAborted() + if (!isWindowsRemoteHost(hostPlatform)) { + // The POSIX probe redirects stderr to this file; the Windows probe does not. + await execHostCommand(conn, hostPlatform, removeRemoteFileCommand(hostPlatform, stderrFile), { + signal + }).catch(() => {}) + signal?.throwIfAborted() + } + return { + available: probeOutput.includes(PROBE_OK), + missing: probeOutput.includes(PROBE_OK) ? [] : missingNativeDepsFromProbe(probeOutput), + output: probeOutput, + stderr: remoteStderr + } } function getLocalRelayPath(platform: RelayPlatform): string | null { @@ -663,7 +1038,8 @@ async function launchRelay( hostPlatform: RemoteHostPlatform, nodePath: string, graceTimeSeconds?: number, - relayInstanceId?: string + relayInstanceId?: string, + signal?: AbortSignal ): Promise<{ transport: MultiplexerTransport; nodePath: string; sockPath: string }> { // Why: Phase 1 of the plan requires Node.js on the remote. We use the // system `node` rather than bundling a node binary, keeping the relay @@ -695,21 +1071,27 @@ async function launchRelay( conn, hostPlatform, remoteDir, - activePipeMarkerPath + activePipeMarkerPath, + signal )) ?? { sockPath: sockFile, endpointDir } const fallbackEndpoint = buildWindowsRelayFallbackEndpoint(hostPlatform, remoteDir, sockName) - return launchWindowsRelay(conn, hostPlatform, { - remoteDir, - nodePath, - sockPath: activeEndpoint.sockPath, - endpointDir: activeEndpoint.endpointDir, - graceTime, - activePipeMarkerPath, - reconnectFallback: fallbackEndpoint - }) + return launchWindowsRelay( + conn, + hostPlatform, + { + remoteDir, + nodePath, + sockPath: activeEndpoint.sockPath, + endpointDir: activeEndpoint.endpointDir, + graceTime, + activePipeMarkerPath, + reconnectFallback: fallbackEndpoint + }, + signal + ) } // Why: after an app restart a relay may still be running in its grace @@ -719,29 +1101,43 @@ async function launchRelay( try { const probeOutput = await execCommand( conn, - `test -S ${shellEscape(sockFile)} && echo ALIVE || echo DEAD` + `test -S ${shellEscape(sockFile)} && echo ALIVE || echo DEAD`, + { signal } ) console.warn(`[ssh-relay] Socket probe result: "${probeOutput.trim()}"`) if (probeOutput.trim() === 'ALIVE') { console.log('[ssh-relay] Existing relay socket found, attempting reconnect...') try { const channel = await conn.exec( - `cd ${escapedDir} && ${escapedNode} relay.js --connect --sock-path ${shellEscape(sockFile)}` + `cd ${escapedDir} && ${escapedNode} relay.js --connect --sock-path ${shellEscape(sockFile)}`, + { signal } ) - const transport = await waitForSentinel(channel) + const transport = await waitForSentinel(channel, signal) console.log('[ssh-relay] Reconnected to existing relay via socket') return { transport, nodePath, sockPath: sockFile } } catch (err) { + signal?.throwIfAborted() console.warn( '[ssh-relay] Socket reconnect failed, launching fresh relay:', err instanceof Error ? err.message : String(err) ) // Why: stale socket from a crashed relay — remove it so the // fresh launch can bind a new socket at the same path. - await execCommand(conn, `rm -f ${shellEscape(sockFile)}`).catch(() => {}) + await execCommand(conn, `rm -f ${shellEscape(sockFile)}`, { signal }).catch( + (cleanupErr) => { + if (isUnconfirmedSshCommandTermination(cleanupErr)) { + throw cleanupErr + } + } + ) + signal?.throwIfAborted() } } - } catch { + } catch (err) { + if (isUnconfirmedSshCommandTermination(err)) { + throw err + } + signal?.throwIfAborted() // Probe failed — fall through to fresh launch } @@ -762,7 +1158,7 @@ async function launchRelay( // subsequent logging (it wraps process.stderr/stdout), so the current log // stays at relay.log for the `tail relay.log` diagnostics workflow. const launchCmd = `cd ${escapedDir} && nohup ${escapedNode} relay.js --detached --grace-time ${graceTime} --sock-path ${shellEscape(sockFile)} --log-file ${shellEscape(logFile)} > ${shellEscape(logFile)} 2>&1 {}) launchChannel.on('error', () => {}) launchChannel.stderr.on('data', () => {}) @@ -784,36 +1180,39 @@ async function launchRelay( const POLL_TIMEOUT_MS = 10_000 const pollStart = Date.now() let socketReady = false - while (Date.now() - pollStart < POLL_TIMEOUT_MS) { - try { - // Why: node is guaranteed to exist on the remote (we just deployed - // the relay with it). Using it to probe the socket is more portable - // than python3/socat/perl which may not be installed. The socket - // path is passed as argv[1] to avoid shell quoting issues with -e. - const result = await execCommand( - conn, - `${escapedNode} -e 'var s=require("net").connect(process.argv[1]);s.on("connect",function(){s.destroy();process.stdout.write("READY")});s.on("error",function(){process.stdout.write("WAITING")})' ${shellEscape(sockFile)} 2>/dev/null || (test -S ${shellEscape(sockFile)} && echo READY || echo WAITING)` - ) - if (result.trim() === 'READY') { - socketReady = true - break + try { + while (Date.now() - pollStart < POLL_TIMEOUT_MS) { + try { + // Why: node is guaranteed to exist on the remote (we just deployed + // the relay with it). Using it to probe the socket is more portable + // than python3/socat/perl which may not be installed. The socket + // path is passed as argv[1] to avoid shell quoting issues with -e. + const result = await execCommand( + conn, + `${escapedNode} -e 'var s=require("net").connect(process.argv[1]);s.on("connect",function(){s.destroy();process.stdout.write("READY")});s.on("error",function(){process.stdout.write("WAITING")})' ${shellEscape(sockFile)} 2>/dev/null || (test -S ${shellEscape(sockFile)} && echo READY || echo WAITING)`, + { signal } + ) + if (result.trim() === 'READY') { + socketReady = true + break + } + } catch { + signal?.throwIfAborted() + /* exec failed, retry */ } - } catch { - /* exec failed, retry */ + await waitForRelayPoll(POLL_INTERVAL_MS, signal) } - await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)) + } finally { + launchChannel.close() } - // Why: close the fire-and-forget launch channel now that the relay's - // socket is either ready or the poll timed out. Leaving it open leaks - // an SSH channel per relay restart. - launchChannel.close() - if (!socketReady) { const logOutput = await execCommand( conn, - `tail -20 ${shellEscape(logFile)} 2>/dev/null || echo "(no log)"` + `tail -20 ${shellEscape(logFile)} 2>/dev/null || echo "(no log)"`, + { signal } ).catch(() => '(could not read log)') + signal?.throwIfAborted() throw new Error(`Relay failed to start within ${POLL_TIMEOUT_MS / 1000}s. Log:\n${logOutput}`) } @@ -822,9 +1221,28 @@ async function launchRelay( // stdin/stdout to the relay's Unix socket — same path used for reconnect // after app restart. const channel = await conn.exec( - `cd ${escapedDir} && ${escapedNode} relay.js --connect --sock-path ${shellEscape(sockFile)}` + `cd ${escapedDir} && ${escapedNode} relay.js --connect --sock-path ${shellEscape(sockFile)}`, + { signal } ) - return { transport: await waitForSentinel(channel), nodePath, sockPath: sockFile } + return { transport: await waitForSentinel(channel, signal), nodePath, sockPath: sockFile } +} + +function waitForRelayPoll(delayMs: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const onAbort = (): void => { + clearTimeout(timeout) + signal?.removeEventListener('abort', onAbort) + reject(createSshOperationAbortError()) + } + const timeout = setTimeout(() => { + signal?.removeEventListener('abort', onAbort) + resolve() + }, delayMs) + signal?.addEventListener('abort', onAbort, { once: true }) + if (signal?.aborted) { + onAbort() + } + }) } function buildWindowsRelayFallbackEndpoint( @@ -844,15 +1262,20 @@ async function readWindowsActiveRelayEndpoint( conn: SshConnection, hostPlatform: RemoteHostPlatform, remoteDir: string, - markerPath: string + markerPath: string, + signal?: AbortSignal ): Promise { const output = await execHostCommand( conn, hostPlatform, powerShellCommand( `if (Test-Path -LiteralPath ${powerShellLiteral(markerPath)} -PathType Leaf) { Get-Content -LiteralPath ${powerShellLiteral(markerPath)} -Raw -ErrorAction SilentlyContinue }` - ) - ).catch(() => '') + ), + { signal } + ).catch(() => { + signal?.throwIfAborted() + return '' + }) const sockPath = output.trim() if (!isWindowsRelayPipePath(sockPath)) { return null @@ -867,15 +1290,18 @@ async function rememberWindowsActiveRelayEndpoint( conn: SshConnection, hostPlatform: RemoteHostPlatform, markerPath: string, - sockPath: string + sockPath: string, + signal?: AbortSignal ): Promise { await execHostCommand( conn, hostPlatform, powerShellCommand( `Set-Content -LiteralPath ${powerShellLiteral(markerPath)} -Value ${powerShellLiteral(sockPath)} -NoNewline` - ) + ), + { signal } ).catch((err) => { + signal?.throwIfAborted() // Why: fallback pipe names are deterministic, so losing this marker does // not force the next deploy to orphan an undiscoverable relay. console.warn( @@ -901,17 +1327,19 @@ type WindowsRelayLaunchOptions = { async function launchWindowsRelay( conn: SshConnection, hostPlatform: RemoteHostPlatform, - opts: WindowsRelayLaunchOptions + opts: WindowsRelayLaunchOptions, + signal?: AbortSignal ): Promise<{ transport: MultiplexerTransport; nodePath: string; sockPath: string }> { let launchOpts = opts - if ((await probeWindowsRelayPipe(conn, hostPlatform, opts)) === 'READY') { + if ((await probeWindowsRelayPipe(conn, hostPlatform, opts, signal)) === 'READY') { try { - const transport = await connectWindowsRelay(conn, hostPlatform, opts) + const transport = await connectWindowsRelay(conn, hostPlatform, opts, signal) await rememberWindowsActiveRelayEndpoint( conn, hostPlatform, opts.activePipeMarkerPath, - opts.sockPath + opts.sockPath, + signal ) return { transport, @@ -919,6 +1347,7 @@ async function launchWindowsRelay( sockPath: opts.sockPath } } catch (err) { + signal?.throwIfAborted() console.warn( '[ssh-relay] Windows named pipe reconnect failed, launching fresh relay:', err instanceof Error ? err.message : String(err) @@ -936,15 +1365,16 @@ async function launchWindowsRelay( if ( launchOpts !== opts && - (await probeWindowsRelayPipe(conn, hostPlatform, launchOpts)) === 'READY' + (await probeWindowsRelayPipe(conn, hostPlatform, launchOpts, signal)) === 'READY' ) { try { - const transport = await connectWindowsRelay(conn, hostPlatform, launchOpts) + const transport = await connectWindowsRelay(conn, hostPlatform, launchOpts, signal) await rememberWindowsActiveRelayEndpoint( conn, hostPlatform, launchOpts.activePipeMarkerPath, - launchOpts.sockPath + launchOpts.sockPath, + signal ) return { transport, @@ -952,6 +1382,7 @@ async function launchWindowsRelay( sockPath: launchOpts.sockPath } } catch (err) { + signal?.throwIfAborted() console.warn( '[ssh-relay] Windows fallback pipe reconnect failed, relaunching relay:', err instanceof Error ? err.message : String(err) @@ -973,20 +1404,29 @@ async function launchWindowsRelay( launchOpts.graceTime, logFile, errFile - ) + ), + { signal } ) const POLL_INTERVAL_MS = 200 const POLL_TIMEOUT_MS = 10_000 if ( - await waitForWindowsRelayPipe(conn, hostPlatform, launchOpts, POLL_TIMEOUT_MS, POLL_INTERVAL_MS) + await waitForWindowsRelayPipe( + conn, + hostPlatform, + launchOpts, + POLL_TIMEOUT_MS, + POLL_INTERVAL_MS, + signal + ) ) { - const transport = await connectWindowsRelay(conn, hostPlatform, launchOpts) + const transport = await connectWindowsRelay(conn, hostPlatform, launchOpts, signal) await rememberWindowsActiveRelayEndpoint( conn, hostPlatform, launchOpts.activePipeMarkerPath, - launchOpts.sockPath + launchOpts.sockPath, + signal ) return { transport, @@ -998,8 +1438,12 @@ async function launchWindowsRelay( const logOutput = await execHostCommand( conn, hostPlatform, - windowsRelayTailLogCommand(logFile, errFile) - ).catch(() => '(could not read log)') + windowsRelayTailLogCommand(logFile, errFile), + { signal } + ).catch(() => { + signal?.throwIfAborted() + return '(could not read log)' + }) throw new Error(`Relay failed to start within ${POLL_TIMEOUT_MS / 1000}s. Log:\n${logOutput}`) } @@ -1010,13 +1454,14 @@ async function connectWindowsRelay( remoteDir: string nodePath: string sockPath: string - } + }, + signal?: AbortSignal ): Promise { const channel = await conn.exec( windowsRelayConnectCommand(hostPlatform, opts.nodePath, opts.remoteDir, opts.sockPath), - { wrapCommand: false } + { wrapCommand: false, signal } ) - return waitForSentinel(channel) + return waitForSentinel(channel, signal) } function windowsRelayConnectCommand( @@ -1083,12 +1528,14 @@ async function probeWindowsRelayPipe( remoteDir: string nodePath: string sockPath: string - } + }, + signal?: AbortSignal ): Promise<'READY' | 'WAITING'> { const result = await execHostCommand( conn, hostPlatform, - windowsRelayProbeCommand(hostPlatform, opts.nodePath, opts.remoteDir, opts.sockPath) + windowsRelayProbeCommand(hostPlatform, opts.nodePath, opts.remoteDir, opts.sockPath), + { signal } ) return result.trim() === 'READY' ? 'READY' : 'WAITING' } @@ -1102,7 +1549,8 @@ async function waitForWindowsRelayPipe( sockPath: string }, timeoutMs: number, - intervalMs: number + intervalMs: number, + signal?: AbortSignal ): Promise { try { const result = await execHostCommand( @@ -1111,10 +1559,12 @@ async function waitForWindowsRelayPipe( windowsRelayWaitCommand(hostPlatform, opts.nodePath, opts.remoteDir, opts.sockPath, { timeoutMs, intervalMs - }) + }), + { signal } ) return result.trim() === 'READY' } catch { + signal?.throwIfAborted() return false } } diff --git a/src/main/ssh/ssh-relay-exec-command.ts b/src/main/ssh/ssh-relay-exec-command.ts new file mode 100644 index 00000000000..e4bfd8bc042 --- /dev/null +++ b/src/main/ssh/ssh-relay-exec-command.ts @@ -0,0 +1,156 @@ +import type { SshConnection } from './ssh-connection' +import { createSshOperationAbortError, type SshExecOptions } from './ssh-connection-utils' +import type { SystemSshCommandChannel } from './system-ssh-command' + +const EXEC_TIMEOUT_MS = 30_000 +const COMMAND_CLOSE_GRACE_MS = 5_000 +const MAX_EXEC_OUTPUT_CHARS = 1024 * 1024 + +type ExecCommandOptions = SshExecOptions & { + timeoutMs?: number +} + +type SshCommandTerminationError = Error & { + sshChannelCloseConfirmed: boolean +} + +export function isUnconfirmedSshCommandTermination( + error: unknown +): error is SshCommandTerminationError { + return ( + error instanceof Error && + (error as Partial).sshChannelCloseConfirmed === false + ) +} + +export async function execCommand( + conn: SshConnection, + command: string, + options?: ExecCommandOptions +): Promise { + const { timeoutMs = EXEC_TIMEOUT_MS, ...execOptions } = options ?? {} + const signal = options?.signal + if (signal?.aborted) { + throw createSshOperationAbortError() + } + // Why: reconnect/disconnect can flip the connection back to ssh2 before a + // killed local OpenSSH child emits close; the channel's transport is immutable. + const openedWithSystemSsh = conn.usesSystemSshTransport?.() === true + const channel = await conn.exec(command, execOptions) + return new Promise((resolve, reject) => { + let stdout = '' + let stderr = '' + let settled = false + let terminationError: SshCommandTerminationError | null = null + let closeGraceTimer: ReturnType | null = null + + const cleanup = (): void => { + clearTimeout(timeout) + if (closeGraceTimer) { + clearTimeout(closeGraceTimer) + } + signal?.removeEventListener('abort', onAbort) + channel.off('error', fail) + channel.stderr.off('error', fail) + channel.off('data', onStdoutData) + channel.stderr.off('data', onStderrData) + channel.off('close', onClose) + } + const settle = (fn: typeof resolve | typeof reject, val: string | Error): void => { + if (settled) { + return + } + settled = true + cleanup() + fn(val as never) + } + const guardUnconfirmedTeardown = (): void => { + const swallowLateError = (): void => {} + const cleanupGuards = (): void => { + channel.off('error', swallowLateError) + channel.stderr.off('error', swallowLateError) + channel.off('close', cleanupGuards) + } + channel.on('error', swallowLateError) + channel.stderr.on('error', swallowLateError) + channel.once('close', cleanupGuards) + // Why: an unconfirmed close can arrive after the caller's bounded wait; + // keep draining discarded streams so ssh2 can finish CHANNEL_CLOSE. + channel.resume() + channel.stderr.resume() + } + // Why: sshd counts the session against MaxSessions until CHANNEL_CLOSE + // completes. Settling on abort before the channel actually closes lets the + // concurrent-bootstrap sequential fallback reissue an exec while the slot + // is still held, so it gets refused again. Close and settle from onClose. + const requestTermination = (error: Error): void => { + if (terminationError) { + return + } + terminationError = Object.assign(error, { sshChannelCloseConfirmed: false }) + clearTimeout(timeout) + // Why: callers must not release an install lock while its remote npm + // process can still mutate node_modules. Prefer confirmed channel close, + // but bound a broken transport's teardown wait. + closeGraceTimer = setTimeout(() => { + guardUnconfirmedTeardown() + settle(reject, error) + }, COMMAND_CLOSE_GRACE_MS) + channel.close() + } + const fail = (err: Error): void => requestTermination(err) + const onAbort = (): void => requestTermination(createSshOperationAbortError()) + const onStdoutData = (data: Buffer): void => { + stdout = appendExecOutputTail(stdout, data.toString('utf-8')) + } + const onStderrData = (data: Buffer): void => { + stderr = appendExecOutputTail(stderr, data.toString('utf-8')) + } + const onClose = (code: number): void => { + if ( + !terminationError && + openedWithSystemSsh && + (channel as SystemSshCommandChannel)._closeRequested + ) { + terminationError = Object.assign(createSshOperationAbortError(), { + sshChannelCloseConfirmed: false + }) + } + if (terminationError) { + // Why: a system-SSH channel closes when the local OpenSSH child exits; + // that does not prove the remote command stopped, especially with a ControlMaster. + if (!openedWithSystemSsh) { + terminationError.sshChannelCloseConfirmed = true + } + settle(reject, terminationError) + } else if (code !== 0) { + // Why: on the system-ssh transport channel.stderr carries local OpenSSH + // client noise; preferring it masks the real failure in stdout (2>&1). + const output = [stderr.trim(), stdout.trim()].filter(Boolean).join('\n') + settle(reject, new Error(`Command "${command}" failed (exit ${code}): ${output}`)) + } else { + settle(resolve, stdout) + } + } + const timeout = setTimeout(() => { + requestTermination(new Error(`Command "${command}" timed out after ${timeoutMs / 1000}s`)) + }, timeoutMs) + + // Why: remote reboot tears down exec channels with stream errors. Without + // scoped listeners, Node treats those as uncaught exceptions. + signal?.addEventListener('abort', onAbort, { once: true }) + channel.on('error', fail) + channel.stderr.on('error', fail) + channel.on('data', onStdoutData) + channel.stderr.on('data', onStderrData) + channel.on('close', onClose) + if (signal?.aborted) { + onAbort() + } + }) +} + +function appendExecOutputTail(existing: string, chunk: string): string { + const combined = existing + chunk + return combined.length > MAX_EXEC_OUTPUT_CHARS ? combined.slice(-MAX_EXEC_OUTPUT_CHARS) : combined +} diff --git a/src/main/ssh/ssh-relay-gc-claim.ts b/src/main/ssh/ssh-relay-gc-claim.ts new file mode 100644 index 00000000000..d0c8cc1221f --- /dev/null +++ b/src/main/ssh/ssh-relay-gc-claim.ts @@ -0,0 +1,236 @@ +import { randomUUID } from 'node:crypto' +import type { SshConnection } from './ssh-connection' +import { shellEscape } from './ssh-connection-utils' +import { execCommand } from './ssh-relay-deploy-helpers' +import { + probeInstallLockExistsCommand, + tryCreateInstallLockCommand, + tryStealInstallLockCommand +} from './ssh-relay-install-lock-commands' +import { removeRemoteTreeCommand } from './ssh-remote-commands' +import { powerShellCommand, powerShellLiteral } from './ssh-remote-powershell' +import { + getRemoteHostPlatform, + isWindowsRemoteHost, + joinRemotePath, + type RemoteHostPlatform +} from './ssh-remote-platform' + +const DEFAULT_REMOTE_HOST = getRemoteHostPlatform('linux-x64') +const RELAY_GC_CLAIM_SUFFIX = '.gc-claim' +const RELAY_GC_OWNER_NAME = '.gc-owner' +// Why: the claim guards only a bounded sibling rename or launch handoff, not +// npm or deletion. Ten minutes bounds crashes while exceeding either sequence. +const RELAY_GC_CLAIM_STALE_SECONDS = 10 * 60 + +function execHostCommand( + conn: SshConnection, + host: RemoteHostPlatform, + command: string, + signal?: AbortSignal +): Promise { + return execCommand(conn, command, { + wrapCommand: !isWindowsRemoteHost(host), + signal + }) +} + +export function relayGcClaimPath(remoteRelayDir: string): string { + return `${remoteRelayDir}${RELAY_GC_CLAIM_SUFFIX}` +} + +export async function isRelayGcClaimed( + conn: SshConnection, + remoteRelayDir: string, + host: RemoteHostPlatform = DEFAULT_REMOTE_HOST, + signal?: AbortSignal +): Promise { + const claimPath = relayGcClaimPath(remoteRelayDir) + const output = await execHostCommand( + conn, + host, + probeInstallLockExistsCommand(host, claimPath), + signal + ) + const markers = new Set( + output + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line === 'LOCKED' || line === 'OPEN') + ) + if (markers.size !== 1) { + throw new Error(`Inconclusive relay GC claim probe at ${claimPath}`) + } + return markers.has('LOCKED') +} + +export async function tryAcquireRelayGcClaim( + conn: SshConnection, + remoteRelayDir: string, + host: RemoteHostPlatform = DEFAULT_REMOTE_HOST, + signal?: AbortSignal +): Promise { + const claimPath = relayGcClaimPath(remoteRelayDir) + try { + const created = await execHostCommand( + conn, + host, + tryCreateInstallLockCommand(host, claimPath), + signal + ) + if (created.trim().endsWith('OK')) { + return writeRelayGcClaimOwner(conn, remoteRelayDir, host, signal) + } + const stolen = await execHostCommand( + conn, + host, + tryStealInstallLockCommand(host, claimPath, RELAY_GC_CLAIM_STALE_SECONDS), + signal + ) + if (!stolen.trim().endsWith('OK')) { + return null + } + return writeRelayGcClaimOwner(conn, remoteRelayDir, host, signal) + } catch { + signal?.throwIfAborted() + return null + } +} + +async function writeRelayGcClaimOwner( + conn: SshConnection, + remoteRelayDir: string, + host: RemoteHostPlatform, + signal?: AbortSignal +): Promise { + const token = `${process.pid}-${Date.now()}-${randomUUID()}` + const claimPath = relayGcClaimPath(remoteRelayDir) + const ownerPath = joinRemotePath(host, claimPath, RELAY_GC_OWNER_NAME) + const command = isWindowsRemoteHost(host) + ? powerShellCommand( + `Set-Content -LiteralPath ${powerShellLiteral(ownerPath)} -Value ${powerShellLiteral(token)} -NoNewline -ErrorAction Stop` + ) + : `printf %s ${shellEscape(token)} > ${shellEscape(ownerPath)}` + try { + await execHostCommand(conn, host, command, signal) + return token + } catch { + // Why: the write may have succeeded remotely before SSH lost its reply. + // A conditional release removes only the claim generation with our token. + await releaseRelayGcClaim(conn, remoteRelayDir, token, host) + return null + } +} + +export async function isRelayGcClaimOwned( + conn: SshConnection, + remoteRelayDir: string, + token: string, + host: RemoteHostPlatform = DEFAULT_REMOTE_HOST +): Promise { + const ownerPath = joinRemotePath(host, relayGcClaimPath(remoteRelayDir), RELAY_GC_OWNER_NAME) + const command = isWindowsRemoteHost(host) + ? powerShellCommand( + `if ((Get-Content -LiteralPath ${powerShellLiteral(ownerPath)} -Raw -ErrorAction SilentlyContinue) -ceq ${powerShellLiteral(token)}) { 'OWNED' } else { 'LOST' }` + ) + : `test "$(cat ${shellEscape(ownerPath)} 2>/dev/null)" = ${shellEscape(token)} && echo OWNED || echo LOST` + const output = await execHostCommand(conn, host, command).catch(() => 'LOST') + return output.trim() === 'OWNED' +} + +export type RelayGcClaimReleaseResult = 'released' | 'lost' | 'unknown' + +export async function releaseRelayGcClaim( + conn: SshConnection, + remoteRelayDir: string, + token: string, + host: RemoteHostPlatform = DEFAULT_REMOTE_HOST +): Promise { + const claimPath = relayGcClaimPath(remoteRelayDir) + const ownerPath = joinRemotePath(host, claimPath, RELAY_GC_OWNER_NAME) + const command = isWindowsRemoteHost(host) + ? powerShellCommand( + `$claim = ${powerShellLiteral(claimPath)}; ` + + `if (-not (Test-Path -LiteralPath $claim)) { 'RELEASED' } ` + + `elseif ((Get-Content -LiteralPath ${powerShellLiteral(ownerPath)} -Raw -ErrorAction SilentlyContinue) -cne ${powerShellLiteral(token)}) { 'LOST' } ` + + 'else { try { Remove-Item -LiteralPath $claim -Recurse -Force -ErrorAction Stop } catch {}; ' + + "if (Test-Path -LiteralPath $claim) { 'UNKNOWN' } else { 'RELEASED' } }" + ) + : [ + `if ! test -e ${shellEscape(claimPath)}; then echo RELEASED;`, + `elif test "$(cat ${shellEscape(ownerPath)} 2>/dev/null)" != ${shellEscape(token)}; then echo LOST;`, + `else ${removeRemoteTreeCommand(host, claimPath)} 2>/dev/null;`, + `if test -e ${shellEscape(claimPath)}; then echo UNKNOWN; else echo RELEASED; fi; fi` + ].join(' ') + const output = await execHostCommand(conn, host, command).catch(() => 'UNKNOWN') + switch (output.trim()) { + case 'RELEASED': + return 'released' + case 'LOST': + return 'lost' + default: + return 'unknown' + } +} + +export async function releaseRelayGcClaimWithRetry( + conn: SshConnection, + remoteRelayDir: string, + token: string, + host: RemoteHostPlatform = DEFAULT_REMOTE_HOST +): Promise { + let result: RelayGcClaimReleaseResult = 'unknown' + for (let attempt = 0; attempt < 3 && result === 'unknown'; attempt++) { + result = await releaseRelayGcClaim(conn, remoteRelayDir, token, host) + } + return result +} + +export async function waitForRelayGcClaimRelease( + conn: SshConnection, + remoteRelayDir: string, + host: RemoteHostPlatform = DEFAULT_REMOTE_HOST, + signal?: AbortSignal +): Promise { + while (true) { + const claimed = await isRelayGcClaimed(conn, remoteRelayDir, host, signal).catch(() => true) + signal?.throwIfAborted() + if (!claimed) { + return + } + const claimPath = relayGcClaimPath(remoteRelayDir) + const recovered = await execHostCommand( + conn, + host, + tryStealInstallLockCommand(host, claimPath, RELAY_GC_CLAIM_STALE_SECONDS), + signal + ).catch(() => 'BUSY') + signal?.throwIfAborted() + if (recovered.trim().endsWith('OK')) { + const token = await writeRelayGcClaimOwner(conn, remoteRelayDir, host, signal) + if (token) { + const release = await releaseRelayGcClaim(conn, remoteRelayDir, token, host) + if (release === 'released') { + return + } + } + // A lost or uncertain release can mean a successor owns the stable path. + // Probe again instead of treating recovery as complete. + continue + } + await new Promise((resolve, reject) => { + const onAbort = (): void => { + clearTimeout(timer) + reject(signal?.reason) + } + const timer = setTimeout(() => { + signal?.removeEventListener('abort', onAbort) + resolve() + }, 1_000) + signal?.addEventListener('abort', onAbort, { once: true }) + if (signal?.aborted) { + onAbort() + } + }) + } +} diff --git a/src/main/ssh/ssh-relay-gc-retry.test.ts b/src/main/ssh/ssh-relay-gc-retry.test.ts new file mode 100644 index 00000000000..fee4946d356 --- /dev/null +++ b/src/main/ssh/ssh-relay-gc-retry.test.ts @@ -0,0 +1,275 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ app: { getAppPath: () => '/mock/app' } })) +vi.mock('fs', () => ({ + existsSync: vi.fn().mockReturnValue(true), + readFileSync: vi.fn().mockReturnValue('0.1.0+gc-retry') +})) +vi.mock('./relay-protocol', () => ({ + RELAY_VERSION: '0.1.0', + RELAY_REMOTE_DIR: '.orca-remote', + parseUnameToRelayPlatform: vi.fn().mockReturnValue('linux-x64'), + RELAY_SENTINEL: 'ORCA-RELAY v0.1.0 READY\n', + RELAY_SENTINEL_TIMEOUT_MS: 10_000 +})) +vi.mock('./ssh-relay-deploy-helpers', () => ({ + uploadDirectory: vi.fn(), + waitForSentinel: vi.fn().mockResolvedValue({ + write: vi.fn(), + onData: vi.fn(), + onClose: vi.fn() + }), + isUnconfirmedSshCommandTermination: () => false, + execCommand: vi.fn() +})) +vi.mock('./ssh-remote-node-resolution', () => ({ + resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node') +})) +vi.mock('./ssh-relay-versioned-install', () => ({ + readLocalFullVersion: vi.fn().mockReturnValue('0.1.0+gc-retry'), + computeRemoteRelayDir: (home: string, version: string) => `${home}/.orca-remote/relay-${version}`, + isRelayAlreadyInstalled: vi.fn().mockResolvedValue(true), + finalizeInstall: vi.fn(), + abandonInstall: vi.fn(), + gcOldRelayVersions: vi.fn().mockResolvedValue(undefined) +})) +vi.mock('./ssh-relay-install-lock', () => ({ acquireInstallLock: vi.fn() })) +vi.mock('./ssh-relay-repair-lock', () => ({ + tryAcquireRelayRepairLock: vi.fn().mockResolvedValue('acquired') +})) +vi.mock('./ssh-relay-gc-claim', () => ({ + releaseRelayGcClaimWithRetry: vi.fn().mockResolvedValue('released'), + tryAcquireRelayGcClaim: vi.fn().mockResolvedValue('launch-token'), + waitForRelayGcClaimRelease: vi.fn().mockResolvedValue(undefined) +})) +vi.mock('./ssh-connection-utils', () => ({ + shellEscape: (value: string) => `'${value}'`, + createSshOperationAbortError: () => Object.assign(new Error('cancelled'), { name: 'AbortError' }) +})) + +import { deployAndLaunchRelay } from './ssh-relay-deploy' +import { execCommand, waitForSentinel } from './ssh-relay-deploy-helpers' +import { + releaseRelayGcClaimWithRetry, + tryAcquireRelayGcClaim, + waitForRelayGcClaimRelease +} from './ssh-relay-gc-claim' +import { tryAcquireRelayRepairLock } from './ssh-relay-repair-lock' +import { abandonInstall, isRelayAlreadyInstalled } from './ssh-relay-versioned-install' +import type { SshConnection } from './ssh-connection' + +function makeConnection(): SshConnection { + const channel = { + on: vi.fn(), + stderr: { on: vi.fn() }, + close: vi.fn() + } + return { + canRunConcurrentExecCommands: vi.fn().mockReturnValue(true), + exec: vi.fn().mockResolvedValue(channel) + } as unknown as SshConnection +} + +describe('relay GC deploy retry', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('recomputes install state when GC wins before a healthy relay launch', async () => { + const conn = makeConnection() + vi.mocked(tryAcquireRelayRepairLock).mockResolvedValueOnce('gc') + vi.mocked(execCommand).mockImplementation(async (_conn, command) => { + if (command.includes('__ORCA_REMOTE_PLATFORM__')) { + return '__ORCA_REMOTE_PLATFORM__ Linux x86_64' + } + if (command === 'echo $HOME') { + return '/home/user' + } + if (command.includes('node-pty')) { + return 'ORCA-NATIVE-DEPS-OK' + } + if (command.includes('var s=require("net").connect')) { + return 'READY' + } + if (command.includes('test -S')) { + return 'DEAD' + } + return '' + }) + + await deployAndLaunchRelay(conn) + + expect(waitForRelayGcClaimRelease).toHaveBeenCalledTimes(1) + expect(isRelayAlreadyInstalled).toHaveBeenCalledTimes(3) + expect(conn.exec).toHaveBeenCalledTimes(2) + }) + + it('recomputes state when GC finishes between the install probe and lock acquisition', async () => { + const conn = makeConnection() + vi.mocked(isRelayAlreadyInstalled) + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false) + .mockResolvedValue(true) + vi.mocked(execCommand).mockImplementation(async (_conn, command) => { + if (command.includes('__ORCA_REMOTE_PLATFORM__')) { + return '__ORCA_REMOTE_PLATFORM__ Linux x86_64' + } + if (command === 'echo $HOME') { + return '/home/user' + } + if (command.includes('node-pty')) { + return 'ORCA-NATIVE-DEPS-OK' + } + if (command.includes('var s=require("net").connect')) { + return 'READY' + } + if (command.includes('test -S')) { + return 'DEAD' + } + return '' + }) + + await deployAndLaunchRelay(conn) + + expect(abandonInstall).toHaveBeenCalledTimes(2) + expect(waitForRelayGcClaimRelease).toHaveBeenCalledTimes(1) + expect(isRelayAlreadyInstalled).toHaveBeenCalledTimes(4) + expect(tryAcquireRelayRepairLock).toHaveBeenCalledTimes(2) + }) + + it('owns a GC claim through launch when the install-lock state is indeterminate', async () => { + const conn = makeConnection() + vi.mocked(tryAcquireRelayRepairLock).mockResolvedValueOnce('error') + let resolveReady: ((value: Awaited>) => void) | undefined + vi.mocked(waitForSentinel).mockImplementationOnce( + async () => + new Promise>>((resolve) => { + resolveReady = resolve + }) + ) + vi.mocked(execCommand).mockImplementation(async (_conn, command) => { + if (command.includes('__ORCA_REMOTE_PLATFORM__')) { + return '__ORCA_REMOTE_PLATFORM__ Linux x86_64' + } + if (command === 'echo $HOME') { + return '/home/user' + } + if (command.includes('node-pty')) { + return 'ORCA-NATIVE-DEPS-OK' + } + if (command.includes('var s=require("net").connect')) { + return 'READY' + } + if (command.includes('test -S')) { + return 'DEAD' + } + return '' + }) + + const deploy = deployAndLaunchRelay(conn) + await vi.waitFor(() => expect(resolveReady).toBeDefined()) + expect(releaseRelayGcClaimWithRetry).not.toHaveBeenCalled() + resolveReady?.({ + write: vi.fn(), + onData: vi.fn(), + onClose: vi.fn() + }) + await deploy + + expect(waitForRelayGcClaimRelease).not.toHaveBeenCalled() + expect(tryAcquireRelayRepairLock).toHaveBeenCalledTimes(1) + expect(tryAcquireRelayGcClaim).toHaveBeenCalledTimes(1) + expect(releaseRelayGcClaimWithRetry).toHaveBeenCalledTimes(1) + expect(conn.exec).toHaveBeenCalledTimes(2) + }) + + it('retains an owned install lock when launch never becomes live', async () => { + const conn = makeConnection() + vi.mocked(waitForSentinel).mockRejectedValueOnce(new Error('launch failed')) + vi.mocked(execCommand).mockImplementation(async (_conn, command) => { + if (command.includes('__ORCA_REMOTE_PLATFORM__')) { + return '__ORCA_REMOTE_PLATFORM__ Linux x86_64' + } + if (command === 'echo $HOME') { + return '/home/user' + } + if (command.includes('node-pty')) { + return 'ORCA-NATIVE-DEPS-OK' + } + if (command.includes('var s=require("net").connect')) { + return 'READY' + } + if (command.includes('test -S')) { + return 'DEAD' + } + return '' + }) + + await expect(deployAndLaunchRelay(conn)).rejects.toThrow('launch failed') + + expect(abandonInstall).not.toHaveBeenCalled() + expect(releaseRelayGcClaimWithRetry).not.toHaveBeenCalled() + }) + + it('retains an owned launch claim when launch never becomes live', async () => { + const conn = makeConnection() + vi.mocked(tryAcquireRelayRepairLock).mockResolvedValueOnce('busy') + vi.mocked(waitForSentinel).mockRejectedValueOnce(new Error('launch failed')) + vi.mocked(execCommand).mockImplementation(async (_conn, command) => { + if (command.includes('__ORCA_REMOTE_PLATFORM__')) { + return '__ORCA_REMOTE_PLATFORM__ Linux x86_64' + } + if (command === 'echo $HOME') { + return '/home/user' + } + if (command.includes('node-pty')) { + return 'ORCA-NATIVE-DEPS-OK' + } + if (command.includes('var s=require("net").connect')) { + return 'READY' + } + if (command.includes('test -S')) { + return 'DEAD' + } + return '' + }) + + await expect(deployAndLaunchRelay(conn)).rejects.toThrow('launch failed') + + expect(abandonInstall).not.toHaveBeenCalled() + expect(releaseRelayGcClaimWithRetry).not.toHaveBeenCalled() + }) + + it('keeps retrying repeated launch-claim contention within the deploy bound', async () => { + const conn = makeConnection() + vi.mocked(tryAcquireRelayRepairLock).mockResolvedValue('busy') + vi.mocked(tryAcquireRelayGcClaim) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null) + .mockResolvedValue('launch-token') + vi.mocked(execCommand).mockImplementation(async (_conn, command) => { + if (command.includes('__ORCA_REMOTE_PLATFORM__')) { + return '__ORCA_REMOTE_PLATFORM__ Linux x86_64' + } + if (command === 'echo $HOME') { + return '/home/user' + } + if (command.includes('node-pty')) { + return 'ORCA-NATIVE-DEPS-OK' + } + if (command.includes('var s=require("net").connect')) { + return 'READY' + } + if (command.includes('test -S')) { + return 'DEAD' + } + return '' + }) + + await deployAndLaunchRelay(conn) + + expect(waitForRelayGcClaimRelease).toHaveBeenCalledTimes(2) + expect(tryAcquireRelayGcClaim).toHaveBeenCalledTimes(3) + expect(releaseRelayGcClaimWithRetry).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/ssh/ssh-relay-gc-tombstone.ts b/src/main/ssh/ssh-relay-gc-tombstone.ts new file mode 100644 index 00000000000..95684d4d20e --- /dev/null +++ b/src/main/ssh/ssh-relay-gc-tombstone.ts @@ -0,0 +1,33 @@ +import type { SshConnection } from './ssh-connection' +import { execCommand } from './ssh-relay-deploy-helpers' +import { removeRemoteTreeCommand } from './ssh-remote-commands' +import { + getRemoteHostPlatform, + isWindowsRemoteHost, + joinRemotePath, + type RemoteHostPlatform +} from './ssh-remote-platform' + +const DEFAULT_REMOTE_HOST = getRemoteHostPlatform('linux-x64') +const RELAY_GC_TOMBSTONE_REGEX = + /^relay-(?:v?\d+\.\d+\.\d+(?:\+[0-9a-f]+)?)\.gc-tombstone\.\d+\.\d+$/ + +export async function cleanupRelayGcTombstones( + conn: SshConnection, + baseDir: string, + entries: string[], + host: RemoteHostPlatform = DEFAULT_REMOTE_HOST +): Promise { + // Why: a confirmed rename isolates these paths from any recreated install. + // Strictly named leftovers are safe to retry after an interrupted GC pass. + for (const name of entries.filter((entry) => RELAY_GC_TOMBSTONE_REGEX.test(entry))) { + const tombstone = joinRemotePath(host, baseDir, name) + await execCommand(conn, removeRemoteTreeCommand(host, tombstone), { + wrapCommand: !isWindowsRemoteHost(host) + }).catch((err) => { + console.warn( + `[ssh-relay] GC failed to remove tombstone ${tombstone}: ${err instanceof Error ? err.message : String(err)}` + ) + }) + } +} diff --git a/src/main/ssh/ssh-relay-install-lock-commands.ts b/src/main/ssh/ssh-relay-install-lock-commands.ts new file mode 100644 index 00000000000..d6eab561a2d --- /dev/null +++ b/src/main/ssh/ssh-relay-install-lock-commands.ts @@ -0,0 +1,176 @@ +import { shellEscape } from './ssh-connection-utils' +import { powerShellCommand, powerShellLiteral } from './ssh-remote-powershell' +import { isWindowsRemoteHost, type RemoteHostPlatform } from './ssh-remote-platform' + +export function acquireInstallLockParentCommand( + host: RemoteHostPlatform, + remoteRelayDir: string +): string { + if (!isWindowsRemoteHost(host)) { + return `mkdir -p ${shellEscape(remoteRelayDir)}` + } + return powerShellCommand( + `$null = New-Item -ItemType Directory -Force -Path ${powerShellLiteral(remoteRelayDir)}` + ) +} + +export function tryCreateInstallLockCommand(host: RemoteHostPlatform, lockDir: string): string { + if (!isWindowsRemoteHost(host)) { + return `mkdir ${shellEscape(lockDir)} 2>&1 && echo OK || echo BUSY` + } + // Why: old Orca clients recognize only a directory at `.install-lock`, while + // concurrent New-Item calls can both report success in PowerShell 5.1. Keep + // that directory marker and arbitrate ownership with an atomic child file. + return powerShellCommand( + [ + `$lock = ${powerShellLiteral(lockDir)}`, + '$stream = $null', + 'try {', + "if (Test-Path -LiteralPath $lock) { 'BUSY' } else {", + '$null = New-Item -ItemType Directory -Path $lock -ErrorAction Stop', + "$owner = Join-Path $lock '.owner'", + '$stream = [System.IO.File]::Open($owner, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None)', + "'OK'", + '}', + `} catch { 'BUSY' } finally { if ($null -ne $stream) { $stream.Dispose() } }` + ].join('; ') + ) +} + +export function probeInstallLockExistsCommand(host: RemoteHostPlatform, lockPath: string): string { + if (!isWindowsRemoteHost(host)) { + return `test -e ${shellEscape(lockPath)} && echo LOCKED || echo OPEN` + } + // Why: one prerelease briefly wrote file locks; accept both shapes so those + // hosts remain recoverable after upgrading to directory-plus-owner locks. + return powerShellCommand( + `if (Test-Path -LiteralPath ${powerShellLiteral(lockPath)}) { 'LOCKED' } else { 'OPEN' }` + ) +} + +export function lockAgeSecondsCommand(host: RemoteHostPlatform, lockDir: string): string { + if (!isWindowsRemoteHost(host)) { + return `${posixLockAgeSecondsAssignment(lockDir)} && echo "$age" || echo` + } + return powerShellCommand( + [ + `$item = Get-Item -LiteralPath ${powerShellLiteral(lockDir)} -ErrorAction Stop`, + '$mtime = ([DateTimeOffset]$item.LastWriteTimeUtc).ToUnixTimeSeconds()', + '$now = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()', + 'Write-Output ($now - $mtime)' + ].join('; ') + ) +} + +export function tryStealInstallLockCommand( + host: RemoteHostPlatform, + lockDir: string, + staleAfterSeconds: number +): string { + if (!isWindowsRemoteHost(host)) { + return posixStealInstallLockCommand(lockDir, staleAfterSeconds) + } + return windowsStealInstallLockCommand(lockDir, staleAfterSeconds) +} + +function posixStealInstallLockCommand(lockDir: string, staleAfterSeconds: number): string { + const escapedLockDir = shellEscape(lockDir) + const escapedStealLockPrefix = shellEscape(`${lockDir}.steal`) + return [ + `${posixLockIdentityAssignment(lockDir, 'lock_key')} && mtime=\${lock_key%%:*} && now=$(date +%s) && age=$((now - mtime)) || age=0;`, + `if [ "\${age:-0}" -le ${staleAfterSeconds} ] 2>/dev/null; then echo BUSY; else`, + `steal_root=${escapedStealLockPrefix};`, + 'steal_generation=0;', + 'steal="$steal_root.$steal_generation";', + 'owns_steal=0;', + 'lock_tombstone=;', + 'while [ "$owns_steal" != 1 ]; do', + 'if mkdir "$steal" 2>/dev/null; then owns_steal=1; break; fi;', + `steal_mtime=$(stat -c %Y "$steal" 2>/dev/null || stat -f %m "$steal" 2>/dev/null) && steal_now=$(date +%s) && steal_age=$((steal_now - steal_mtime)) || break;`, + 'if [ "${steal_age:-0}" -le 120 ] 2>/dev/null; then break; fi;', + 'steal_generation=$((steal_generation + 1));', + 'steal="$steal_root.$steal_generation";', + 'done;', + 'if [ "$owns_steal" = 1 ]; then', + `trap 'rm -rf "$steal_root".* 2>/dev/null || true; rm -rf "$lock_tombstone" 2>/dev/null || true' EXIT;`, + `${posixLockIdentityAssignment(lockDir, 'current_key')} && current_mtime=\${current_key%%:*} && current_now=$(date +%s) && current_age=$((current_now - current_mtime)) || current_age=0;`, + `if [ "$current_key" = "$lock_key" ] && [ "\${current_age:-0}" -gt ${staleAfterSeconds} ] 2>/dev/null; then`, + `lock_tombstone=${escapedLockDir}.tombstone.$$.$(date +%s);`, + `if [ ! -e "$lock_tombstone" ] && mv ${escapedLockDir} "$lock_tombstone" 2>/dev/null; then mkdir ${escapedLockDir} 2>&1 && echo OK || echo BUSY; else echo BUSY; fi;`, + 'else echo BUSY; fi;', + 'else echo BUSY; fi; fi' + ].join(' ') +} + +function windowsStealInstallLockCommand(lockDir: string, staleAfterSeconds: number): string { + return powerShellCommand( + [ + `$lock = ${powerShellLiteral(lockDir)}`, + 'try {', + '$item = Get-Item -LiteralPath $lock -ErrorAction Stop', + '$mtime = ([DateTimeOffset]$item.LastWriteTimeUtc).ToUnixTimeSeconds()', + '$lockIdentity = "${mtime}:$($item.CreationTimeUtc.Ticks)"', + '$now = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()', + `if (($now - $mtime) -le ${staleAfterSeconds}) { 'BUSY' } else {`, + '$stealRoot = "$lock.steal"', + '$stealGeneration = 0', + '$steal = "$stealRoot.$stealGeneration"', + '$ownsSteal = $false', + '$lockTombstone = $null', + 'try {', + 'while (-not $ownsSteal) {', + 'try {', + '$stealStream = [System.IO.File]::Open($steal, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None)', + '$stealStream.Dispose()', + '$ownsSteal = $true', + 'break', + '} catch {', + 'try {', + '$stealItem = Get-Item -LiteralPath $steal -ErrorAction Stop', + '$stealMtime = ([DateTimeOffset]$stealItem.LastWriteTimeUtc).ToUnixTimeSeconds()', + '$stealAge = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - $stealMtime', + 'if ($stealAge -le 120) { break }', + '$stealGeneration++', + '$steal = "$stealRoot.$stealGeneration"', + '} catch { break }', + '}', + '}', + "if (-not $ownsSteal) { 'BUSY' } else {", + '$current = Get-Item -LiteralPath $lock -ErrorAction Stop', + '$currentMtime = ([DateTimeOffset]$current.LastWriteTimeUtc).ToUnixTimeSeconds()', + '$currentIdentity = "${currentMtime}:$($current.CreationTimeUtc.Ticks)"', + '$currentNow = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()', + `if (($currentIdentity -eq $lockIdentity) -and (($currentNow - $currentMtime) -gt ${staleAfterSeconds})) {`, + '$lockTombstone = "$lock.tombstone.$PID.$([DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds())"', + 'Move-Item -LiteralPath $lock -Destination $lockTombstone -ErrorAction Stop', + '$successorStream = $null', + "try { $null = New-Item -ItemType Directory -Path $lock -ErrorAction Stop; $successorOwner = Join-Path $lock '.owner'; $successorStream = [System.IO.File]::Open($successorOwner, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None); 'OK' } catch { 'BUSY' } finally { if ($null -ne $successorStream) { $successorStream.Dispose() } }", + "} else { 'BUSY' }", + '}', + "} catch { 'BUSY' } finally {", + 'if ($ownsSteal) {', + '$stealParent = Split-Path -Parent $stealRoot', + '$stealLeaf = Split-Path -Leaf $stealRoot', + 'Get-ChildItem -LiteralPath $stealParent -Force -ErrorAction SilentlyContinue | Where-Object { $_.Name.StartsWith($stealLeaf + ".", [StringComparison]::Ordinal) } | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue', + '}', + 'if ($null -ne $lockTombstone) { Remove-Item -LiteralPath $lockTombstone -Recurse -Force -ErrorAction SilentlyContinue }', + '}', + '}', + "} catch { 'BUSY' }" + ].join('; ') + ) +} + +function posixLockAgeSecondsAssignment(lockDir: string): string { + return `${posixLockMtimeSecondsAssignment(lockDir, 'mtime')} && now=$(date +%s) && age=$((now - mtime))` +} + +function posixLockIdentityAssignment(lockDir: string, variableName: string): string { + const escapedLockDir = shellEscape(lockDir) + return `${variableName}=$(stat -c %Y:%i ${escapedLockDir} 2>/dev/null || stat -f %m:%i ${escapedLockDir} 2>/dev/null)` +} + +function posixLockMtimeSecondsAssignment(lockDir: string, variableName: string): string { + const escapedLockDir = shellEscape(lockDir) + return `${variableName}=$(stat -c %Y ${escapedLockDir} 2>/dev/null || stat -f %m ${escapedLockDir} 2>/dev/null)` +} diff --git a/src/main/ssh/ssh-relay-install-lock.ts b/src/main/ssh/ssh-relay-install-lock.ts new file mode 100644 index 00000000000..7cfd561b1ad --- /dev/null +++ b/src/main/ssh/ssh-relay-install-lock.ts @@ -0,0 +1,162 @@ +import type { SshConnection } from './ssh-connection' +import { execCommand } from './ssh-relay-deploy-helpers' +import { RELAY_DEPLOY_TIMEOUT_MS } from './ssh-relay-deploy-timing' +import { isRelayGcClaimed, waitForRelayGcClaimRelease } from './ssh-relay-gc-claim' +import { + acquireInstallLockParentCommand, + lockAgeSecondsCommand, + tryCreateInstallLockCommand, + tryStealInstallLockCommand +} from './ssh-relay-install-lock-commands' +import { + getRemoteHostPlatform, + joinRemotePath, + type RemoteHostPlatform +} from './ssh-remote-platform' +import { removeRemoteTreeCommand } from './ssh-remote-commands' + +export const RELAY_INSTALL_LOCK_NAME = '.install-lock' + +const INSTALL_LOCK_POLL_MS = 1_000 +// Why: a fresh lock can cross the stale threshold during our bounded wait. +// Recheck infrequently so it becomes recoverable without adding an exec per poll. +const INSTALL_LOCK_STALE_RECHECK_MS = 60_000 +// Why: the lock holder can legitimately use the full deploy bound for upload, +// native install/rebuild, probes, and finalization. A concurrent first install +// must not fail earlier; completed-relay repair uses a separate one-shot path. +const INSTALL_LOCK_TIMEOUT_MS = RELAY_DEPLOY_TIMEOUT_MS +// Why: native-deps repair can keep running after the deploy backstop wins its +// Promise.race, so stale takeover must leave room for that bounded work. +export const INSTALL_LOCK_STALE_MS = 20 * 60_000 +export const INSTALL_LOCK_STALE_SECONDS = INSTALL_LOCK_STALE_MS / 1000 +const DEFAULT_REMOTE_HOST = getRemoteHostPlatform('linux-x64') + +function execHostCommand( + conn: SshConnection, + host: RemoteHostPlatform, + command: string, + options?: { signal?: AbortSignal } +): Promise { + return execCommand(conn, command, { + wrapCommand: host.commandDialect !== 'powershell', + signal: options?.signal + }) +} + +export async function isRelayInstallLockStale( + conn: SshConnection, + lockDir: string, + host: RemoteHostPlatform = DEFAULT_REMOTE_HOST +): Promise { + try { + // Why: remote time avoids clock skew between Orca clients making a live + // repair lock look old enough for GC or another installer to recover. + const out = await execHostCommand(conn, host, lockAgeSecondsCommand(host, lockDir)) + const ageSec = Number.parseInt(out.trim(), 10) + return Number.isFinite(ageSec) && ageSec >= 0 && ageSec * 1000 > INSTALL_LOCK_STALE_MS + } catch { + return false + } +} + +/** + * Acquire the per-version install lock via a host-native exclusive create. + * Why: POSIX mkdir and a Windows directory plus atomic owner file each give + * one winner while keeping the marker visible to older Windows Orca clients. + */ +export async function acquireInstallLock( + conn: SshConnection, + remoteRelayDir: string, + host: RemoteHostPlatform = DEFAULT_REMOTE_HOST, + options?: { signal?: AbortSignal } +): Promise { + const lockDir = joinRemotePath(host, remoteRelayDir, RELAY_INSTALL_LOCK_NAME) + + const start = Date.now() + let lastStaleCheckAt = Number.NEGATIVE_INFINITY + while (true) { + // Why: a crashed GC can leave the stable sibling claim behind. The shared + // waiter recovers stale claims instead of polling that orphan forever. + await waitForRelayGcClaimRelease(conn, remoteRelayDir, host, options?.signal) + options?.signal?.throwIfAborted() + await execHostCommand(conn, host, acquireInstallLockParentCommand(host, remoteRelayDir), { + signal: options?.signal + }) + try { + const result = await execHostCommand(conn, host, tryCreateInstallLockCommand(host, lockDir), { + signal: options?.signal + }) + if (result.trim().endsWith('OK')) { + // Why: GC may claim the sibling path between our first probe and lock + // creation. Recheck while holding the in-tree lock; one side backs off. + const claimedAfterAcquire = await isRelayGcClaimed( + conn, + remoteRelayDir, + host, + options?.signal + ).catch(() => true) + if (!claimedAfterAcquire && !options?.signal?.aborted) { + return + } + await execHostCommand(conn, host, removeRemoteTreeCommand(host, lockDir)).catch(() => {}) + options?.signal?.throwIfAborted() + } + } catch { + options?.signal?.throwIfAborted() + // A failed mkdir is lock contention; keep the connection-specific error + // out of the user path until the bounded wait expires. + } + if (Date.now() - lastStaleCheckAt >= INSTALL_LOCK_STALE_RECHECK_MS) { + lastStaleCheckAt = Date.now() + // Why: recover an already-stale lock immediately, then keep checking in + // case a fresh holder crosses the stale threshold while we are waiting. + const steal = await execHostCommand( + conn, + host, + tryStealInstallLockCommand(host, lockDir, INSTALL_LOCK_STALE_SECONDS), + { signal: options?.signal } + ).catch(() => 'BUSY') + options?.signal?.throwIfAborted() + if (steal.trim().endsWith('OK')) { + console.warn(`[ssh-relay] Stealing stale install lock at ${lockDir}`) + const claimedAfterSteal = await isRelayGcClaimed( + conn, + remoteRelayDir, + host, + options?.signal + ).catch(() => true) + if (!claimedAfterSteal && !options?.signal?.aborted) { + return + } + await execHostCommand(conn, host, removeRemoteTreeCommand(host, lockDir)).catch(() => {}) + options?.signal?.throwIfAborted() + } + } + if (Date.now() - start >= INSTALL_LOCK_TIMEOUT_MS) { + throw new Error( + `Could not acquire relay install lock at ${lockDir} after ${ + INSTALL_LOCK_TIMEOUT_MS / 1000 + }s; another install is still in progress.` + ) + } + await waitForInstallLockPoll(options?.signal) + } +} + +function waitForInstallLockPoll(signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const handleAbort = (): void => { + clearTimeout(timeout) + signal?.removeEventListener('abort', handleAbort) + reject(signal?.reason) + } + const timeout = setTimeout(() => { + signal?.removeEventListener('abort', handleAbort) + resolve() + }, INSTALL_LOCK_POLL_MS) + signal?.addEventListener('abort', handleAbort, { once: true }) + if (signal?.aborted) { + handleAbort() + } + }) +} diff --git a/src/main/ssh/ssh-relay-native-deps-install.test.ts b/src/main/ssh/ssh-relay-native-deps-install.test.ts index 4d2f92eb947..f38a2b1065b 100644 --- a/src/main/ssh/ssh-relay-native-deps-install.test.ts +++ b/src/main/ssh/ssh-relay-native-deps-install.test.ts @@ -1,6 +1,6 @@ // Why: regression coverage for the install-probe contract. The original // "node-pty is not available" bug shipped because every layer that should -// have caught it (chained shell, swallowing catch, dir-only probe) was +// have caught it (chained shell, swallowing catch, resolve-only probe) was // silent. Tests below pin the parts that, individually, would have caught // it. @@ -30,6 +30,9 @@ vi.mock('./ssh-relay-deploy-helpers', () => ({ onData: vi.fn(), onClose: vi.fn() }), + isUnconfirmedSshCommandTermination: (error: unknown) => + error instanceof Error && + (error as Error & { sshChannelCloseConfirmed?: boolean }).sshChannelCloseConfirmed === false, execCommand: vi.fn() })) @@ -41,26 +44,41 @@ vi.mock('./ssh-relay-versioned-install', () => ({ readLocalFullVersion: vi.fn().mockReturnValue('0.1.0+testhash'), computeRemoteRelayDir: (home: string, v: string) => `${home}/.orca-remote/relay-${v}`, isRelayAlreadyInstalled: vi.fn().mockResolvedValue(false), - acquireInstallLock: vi.fn().mockResolvedValue(undefined), finalizeInstall: vi.fn().mockResolvedValue(undefined), abandonInstall: vi.fn().mockResolvedValue(undefined), gcOldRelayVersions: vi.fn().mockResolvedValue(undefined) })) +vi.mock('./ssh-relay-install-lock', () => ({ + acquireInstallLock: vi.fn().mockResolvedValue(undefined) +})) + +vi.mock('./ssh-relay-repair-lock', () => ({ + tryAcquireRelayRepairLock: vi.fn().mockResolvedValue('acquired') +})) + +vi.mock('./ssh-relay-gc-claim', () => ({ + releaseRelayGcClaimWithRetry: vi.fn().mockResolvedValue('released'), + tryAcquireRelayGcClaim: vi.fn().mockResolvedValue('launch-token'), + waitForRelayGcClaimRelease: vi.fn().mockResolvedValue(undefined) +})) + vi.mock('./ssh-connection-utils', () => ({ shellEscape: (s: string) => `'${s}'` })) import { deployAndLaunchRelay } from './ssh-relay-deploy' -import { execCommand } from './ssh-relay-deploy-helpers' +import { execCommand, uploadDirectory } from './ssh-relay-deploy-helpers' +import { RELAY_DEPLOY_TIMEOUT_MS } from './ssh-relay-deploy-timing' import { parseUnameToRelayPlatform } from './relay-protocol' import { resolveRemoteNodePath } from './ssh-remote-node-resolution' import { - acquireInstallLock, abandonInstall, finalizeInstall, isRelayAlreadyInstalled } from './ssh-relay-versioned-install' +import { acquireInstallLock } from './ssh-relay-install-lock' +import { tryAcquireRelayRepairLock } from './ssh-relay-repair-lock' import type { SshConnection } from './ssh-connection' type SftpWriteCapture = { @@ -129,6 +147,7 @@ function decodePowerShellCommand(command: string): string | null { // 7: probe (cd && node -e require) // [8: cat stderr — only when probe stdout is MISSING (graceful path)] // 8 or 9: rm probe-stderr (best-effort cleanup; runs whenever probe resolved) +// [next: npm rebuild → chmod → second probe when the first probe is MISSING] // next: socket DEAD next: socket READY // // When the probe rejects (SSH channel close or cd-failure when the install @@ -143,6 +162,9 @@ function makeExecResponses(opts: { // Override probe stdout for shell-noise pressure tests. If set, replaces // the load-test stdout entirely (useful for testing pollution prefixes). probeStdoutOverride?: string + // Result after the automatic rebuild. Defaults to missing so legacy tests + // continue to exercise the final degraded-mode warning. + repairProbe?: 'ok' | 'missing' // Raw stdout for the build-toolchain probe that runs in installNativeDeps' // catch when `npm install` rejects on Linux. Defaults to a fully-present // toolchain so the original npm error propagates unchanged. @@ -187,6 +209,16 @@ function makeExecResponses(opts: { slots.push('') // cat stderr (graceful failure path captures detail) } slots.push('') // rm -f stderr (best-effort cleanup) + if (!probeOk) { + slots.push('') // npm rebuild with lifecycle scripts explicitly enabled + slots.push('') // chmod prebuilds after rebuild + const repairProbe = opts.repairProbe === 'ok' ? 'ORCA-NPTY-PROBE-OK\n' : 'MISSING\n' + slots.push(repairProbe) + if (!repairProbe.includes('ORCA-NPTY-PROBE-OK')) { + slots.push('') // cat stderr after unsuccessful rebuild + } + slots.push('') // rm -f stderr after rebuild probe + } } slots.push('DEAD', 'READY') return slots @@ -207,6 +239,7 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { // a leaked response. clearAllMocks doesn't drop the queue (it only clears // .mock.calls), so we explicitly mockReset. vi.mocked(execCommand).mockReset() + vi.mocked(uploadDirectory).mockResolvedValue(undefined) sftpCapture.paths.length = 0 for (const k of Object.keys(sftpCapture.contents)) { delete sftpCapture.contents[k] @@ -256,12 +289,17 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { // break `require('node-pty')`. expect(parsed.type).toBe('commonjs') expect(parsed.dependencies).toEqual({ '@parcel/watcher': '2.5.6', 'node-pty': '1.1.0' }) + expect(parsed.allowScripts).toEqual({ + '@parcel/watcher@2.5.6': true, + 'node-pty@1.1.0': true + }) const execCalls = vi.mocked(execCommand).mock.calls.map(([, c]) => c) const npmInstallIdx = execCalls.findIndex( (c) => c.includes('npm install') && c.includes('node-pty') && c.includes('@parcel/watcher') ) expect(npmInstallIdx).toBeGreaterThanOrEqual(0) + expect(execCalls[npmInstallIdx]).toContain('--ignore-scripts=false') // Pin actual ordering: number of execCommand calls observed at the moment // ws.end() ran for package.json must be < the index of `npm install`. // Catches a future refactor that fires SFTP-write and npm install via @@ -376,7 +414,96 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { expect(warnMessages.some((m) => m.includes('[ssh-relay][NPTY-MISSING]'))).toBe(true) expect(vi.mocked(finalizeInstall)).toHaveBeenCalledTimes(1) - expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled() + expect(vi.mocked(abandonInstall)).toHaveBeenCalledTimes(1) + }) + + it('rebuilds unloadable native deps and recovers before first relay launch', async () => { + const conn = makeMockConnection(sftpCapture) + feed(makeExecResponses({ npmInstall: 'ok', probe: 'missing', repairProbe: 'ok' })) + + await deployAndLaunchRelay(conn) + + const execCalls = vi.mocked(execCommand).mock.calls.map(([, c]) => c) + const failedProbeIdx = execCalls.findIndex((c) => c.includes('require("node-pty")')) + const rebuildIdx = execCalls.findIndex((c) => c.includes('npm rebuild')) + const repairedProbeIdx = execCalls.findIndex( + (c, index) => index > rebuildIdx && c.includes('require("node-pty")') + ) + expect(rebuildIdx).toBeGreaterThan(failedProbeIdx) + expect(execCalls[rebuildIdx]).toContain('--ignore-scripts=false') + expect(repairedProbeIdx).toBeGreaterThan(rebuildIdx) + + const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) + expect(warnMessages.some((m) => m.includes('[ssh-relay][NPTY-MISSING]'))).toBe(false) + expect(vi.mocked(finalizeInstall)).toHaveBeenCalledTimes(1) + }) + + it('propagates an SSH-channel failure from the post-rebuild re-probe', async () => { + // Why: the rebuild itself degrades gracefully, but the verification probe + // after it must still surface transport death — conflating a dead channel + // with "native deps missing" would finalize a half-repaired install. + const conn = makeMockConnection(sftpCapture) + feed([ + '__ORCA_REMOTE_PLATFORM__ Linux x86_64', // uname + '/home/u', // $HOME + '', // mkdir remoteDir (uploadRelay) + '', // chmod +x node + '', // npm install native deps + '', // chmod prebuilds + 'MISSING\n', // first probe: require() fails + '', // cat probe stderr + '', // rm probe stderr + '', // npm rebuild native deps + '', // chmod prebuilds after rebuild + { reject: 'SSH channel closed during native deps re-probe' } // re-probe rejects + ]) + + await expect(deployAndLaunchRelay(conn)).rejects.toThrow(/SSH channel closed/) + + // Rebuild failure is swallowed; a re-probe transport failure must not be. + const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) + expect(warnMessages.some((m) => m.includes('[ssh-relay][NPTY-MISSING]'))).toBe(false) + expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() + expect(vi.mocked(abandonInstall)).toHaveBeenCalledTimes(1) + }) + + it('aborts an in-progress native install and releases its lock at deploy timeout', async () => { + vi.useFakeTimers() + try { + const conn = makeMockConnection(sftpCapture) + feed([ + '__ORCA_REMOTE_PLATFORM__ Linux x86_64', + '/home/u', + '', // mkdir remoteDir + '' // chmod +x node + ]) + let installSignal: AbortSignal | undefined + vi.mocked(execCommand).mockImplementationOnce((_conn, command, options) => { + expect(command).toContain('npm install') + installSignal = options?.signal + return new Promise((_resolve, reject) => { + installSignal?.addEventListener('abort', () => reject(installSignal?.reason), { + once: true + }) + }) + }) + + const promise = deployAndLaunchRelay(conn).catch((err: Error) => err) + await vi.waitFor(() => expect(installSignal).toBeDefined()) + + await vi.advanceTimersByTimeAsync(RELAY_DEPLOY_TIMEOUT_MS) + + const result = await promise + expect(result).toBeInstanceOf(Error) + expect((result as Error).message).toBe( + `Relay deployment timed out after ${RELAY_DEPLOY_TIMEOUT_MS / 1000}s` + ) + expect(installSignal?.aborted).toBe(true) + expect(vi.mocked(abandonInstall)).toHaveBeenCalledTimes(1) + expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } }) it('lets a probe SSH-channel failure bubble up rather than silently mapping to MISSING', async () => { @@ -479,9 +606,9 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { expect(chmodPrebuildsIdx).toBeGreaterThan(npmIdx) expect(probeIdx).toBeGreaterThan(chmodPrebuildsIdx) - // Happy path: finalize exactly once, abandon never. + // Hold the install lock through launch, then release it exactly once. expect(vi.mocked(finalizeInstall)).toHaveBeenCalledTimes(1) - expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled() + expect(vi.mocked(abandonInstall)).toHaveBeenCalledTimes(1) }) it('matches the sentinel even with bashrc/MOTD noise prefixed to probe stdout', async () => { @@ -522,7 +649,7 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) expect(warnMessages.some((m) => m.includes('[ssh-relay][NPTY-MISSING]'))).toBe(true) expect(vi.mocked(finalizeInstall)).toHaveBeenCalledTimes(1) - expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled() + expect(vi.mocked(abandonInstall)).toHaveBeenCalledTimes(1) }) it('keeps Windows node-pty probe failures non-fatal by checking LASTEXITCODE', async () => { @@ -535,7 +662,8 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { '', // mkdir remoteDir '', // npm install native deps 'MISSING\n', // native process exit normalized by PowerShell command - '', // remove probe stderr file + '', // npm rebuild native deps + 'MISSING\n', // rebuilt native process still cannot load '', // no persisted active pipe marker 'WAITING', '', // WMI relay launch @@ -554,11 +682,29 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { const probeScript = decodePowerShellCommand(probeCommand) ?? '' expect(probeScript).toContain('$LASTEXITCODE -ne 0') expect(probeScript).toContain("'MISSING'") + expect(probeScript).toContain('loadNativeModule') + + const npmScripts = vi + .mocked(execCommand) + .mock.calls.map(([, command]) => decodePowerShellCommand(command) ?? '') + .filter((script) => script.includes('npm install') || script.includes('npm rebuild')) + expect(npmScripts).toHaveLength(2) + expect(npmScripts.every((script) => script.includes('--ignore-scripts=false'))).toBe(true) + expect( + npmScripts.every((script) => + script.includes('if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }') + ) + ).toBe(true) + expect( + vi + .mocked(execCommand) + .mock.calls.some(([, command]) => command.includes('.npty-probe.stderr')) + ).toBe(false) const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) expect(warnMessages.some((m) => m.includes('[ssh-relay][NPTY-MISSING]'))).toBe(true) expect(vi.mocked(finalizeInstall)).toHaveBeenCalledTimes(1) - expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled() + expect(vi.mocked(abandonInstall)).toHaveBeenCalledTimes(1) }) it('includes the platform tuple in NPTY-MISSING and native install failure logs', async () => { @@ -607,8 +753,8 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { feed([ '__ORCA_REMOTE_PLATFORM__ Linux x86_64', '/home/u', - 'MISSING', // first native-deps probe before lock - 'MISSING', // re-probe after lock + 'ORCA-NATIVE-DEPS-MISSING:@parcel/watcher\nMISSING', // first probe before lock + 'ORCA-NATIVE-DEPS-MISSING:@parcel/watcher\nMISSING', // re-probe after lock '', // npm install native deps '', // chmod prebuilds 'ORCA-NPTY-PROBE-OK\n', @@ -619,7 +765,10 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { await deployAndLaunchRelay(conn) - expect(vi.mocked(acquireInstallLock)).toHaveBeenCalledTimes(1) + expect(vi.mocked(tryAcquireRelayRepairLock)).toHaveBeenCalledTimes(1) + expect(vi.mocked(tryAcquireRelayRepairLock).mock.calls[0]?.[3]?.signal).toBeInstanceOf( + AbortSignal + ) expect(vi.mocked(finalizeInstall)).toHaveBeenCalledTimes(1) const execCalls = vi.mocked(execCommand).mock.calls.map(([, c]) => c) expect( @@ -627,6 +776,228 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { (c) => c.includes('npm install') && c.includes('node-pty') && c.includes('@parcel/watcher') ) ).toBe(true) + const installCommand = execCalls.find((c) => c.includes('npm install')) ?? '' + expect(installCommand).toContain('node_modules/@parcel/watcher') + expect(installCommand).toContain("-name 'watcher-*'") + expect(installCommand).not.toContain("rm -rf 'node_modules/node-pty'") + }) + + it('launches an already-installed relay in degraded mode when repair throws', async () => { + // Why: a repair failure on a completed dir (e.g. offline/proxy-locked npm) + // must not block the connection — the relay still serves fs/git/preflight. + // Pre-fix this rethrew and aborted the whole deploy. The next reconnect + // retries, so a transient failure self-heals. + vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(true) + const conn = makeMockConnection(sftpCapture) + feed([ + '__ORCA_REMOTE_PLATFORM__ Linux x86_64', + '/home/u', + 'MISSING', // health probe: require() fails + 'MISSING', // re-probe after lock + { reject: 'npm ERR! network ETIMEDOUT' }, // npm install fails (offline) + 'DEAD', + 'READY' + ]) + + // Deploy must resolve (degraded), not reject. + await deployAndLaunchRelay(conn) + + expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() + expect(vi.mocked(abandonInstall)).toHaveBeenCalledTimes(1) + const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) + expect(warnMessages.some((m) => m.includes('launching degraded'))).toBe(true) + }) + + it('retains the repair lock when remote command termination is unconfirmed', async () => { + vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(true) + const conn = makeMockConnection(sftpCapture) + vi.mocked(execCommand) + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') + .mockResolvedValueOnce('/home/u') + .mockResolvedValueOnce('MISSING') + .mockResolvedValueOnce('MISSING') + .mockRejectedValueOnce( + Object.assign(new Error('npm termination was not confirmed'), { + sshChannelCloseConfirmed: false + }) + ) + .mockResolvedValueOnce('DEAD') + .mockResolvedValueOnce('READY') + + await deployAndLaunchRelay(conn) + + expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled() + const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) + expect(warnMessages.some((message) => message.includes('launching degraded'))).toBe(true) + }) + + it('retains the first-install lock when an aborted npm install has unconfirmed teardown', async () => { + vi.useFakeTimers() + try { + const conn = makeMockConnection(sftpCapture) + feed([ + '__ORCA_REMOTE_PLATFORM__ Linux x86_64', + '/home/u', + '', // mkdir remoteDir + '' // chmod +x node + ]) + let installSignal: AbortSignal | undefined + vi.mocked(execCommand).mockImplementationOnce((_conn, command, options) => { + expect(command).toContain('npm install') + installSignal = options?.signal + return new Promise((_resolve, reject) => { + installSignal?.addEventListener( + 'abort', + () => { + // Mirrors execCommand's bounded close grace when ssh2 never + // confirms that the remote npm process stopped. + setTimeout( + () => + reject( + Object.assign(new Error('npm teardown remained unconfirmed'), { + sshChannelCloseConfirmed: false + }) + ), + 5_000 + ) + }, + { once: true } + ) + }) + }) + + const deploy = deployAndLaunchRelay(conn).catch((err: Error) => err) + await vi.waitFor(() => expect(installSignal).toBeDefined()) + await vi.advanceTimersByTimeAsync(RELAY_DEPLOY_TIMEOUT_MS) + const result = await deploy + expect(result).toBeInstanceOf(Error) + expect((result as Error).message).toContain('Relay deployment timed out') + await vi.advanceTimersByTimeAsync(5_000) + + expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled() + expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it('does not finalize or release a first-install lock after unconfirmed rebuild teardown', async () => { + const conn = makeMockConnection(sftpCapture) + vi.mocked(execCommand) + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') + .mockResolvedValueOnce('/home/u') + .mockResolvedValueOnce('') + .mockResolvedValueOnce('') + .mockResolvedValueOnce('') + .mockResolvedValueOnce('') + .mockResolvedValueOnce('MISSING') + .mockResolvedValueOnce('rebuild diagnostics') + .mockResolvedValueOnce('') + .mockRejectedValueOnce( + Object.assign(new Error('rebuild termination was not confirmed'), { + sshChannelCloseConfirmed: false + }) + ) + + await expect(deployAndLaunchRelay(conn)).rejects.toThrow( + 'rebuild termination was not confirmed' + ) + expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() + expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled() + }) + + it('retains the first-install lock when an aborted rebuild has unconfirmed teardown', async () => { + vi.useFakeTimers() + try { + const conn = makeMockConnection(sftpCapture) + feed([ + '__ORCA_REMOTE_PLATFORM__ Linux x86_64', + '/home/u', + '', // mkdir remoteDir + '', // chmod +x node + '', // npm install + '', // chmod prebuilds + 'MISSING', + 'rebuild diagnostics', + '' // remove probe diagnostics + ]) + let rebuildSignal: AbortSignal | undefined + vi.mocked(execCommand).mockImplementationOnce((_conn, command, options) => { + expect(command).toContain('npm rebuild') + rebuildSignal = options?.signal + return new Promise((_resolve, reject) => { + rebuildSignal?.addEventListener( + 'abort', + () => { + setTimeout( + () => + reject( + Object.assign(new Error('rebuild teardown remained unconfirmed'), { + sshChannelCloseConfirmed: false + }) + ), + 5_000 + ) + }, + { once: true } + ) + }) + }) + + const deploy = deployAndLaunchRelay(conn).catch((err: Error) => err) + await vi.waitFor(() => expect(rebuildSignal).toBeDefined()) + await vi.advanceTimersByTimeAsync(RELAY_DEPLOY_TIMEOUT_MS) + const result = await deploy + expect(result).toBeInstanceOf(Error) + expect((result as Error).message).toContain('Relay deployment timed out') + await vi.advanceTimersByTimeAsync(5_000) + + expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled() + expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it.each(['busy', 'error'] as const)('launches degraded when lock is %s', async (lockResult) => { + // Why: lock contention/wedge must not block a completed relay from launching + // in degraded mode — repair is best-effort and we hold no lock to release. + vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(true) + vi.mocked(tryAcquireRelayRepairLock).mockResolvedValueOnce(lockResult) + const conn = makeMockConnection(sftpCapture) + feed(['__ORCA_REMOTE_PLATFORM__ Linux x86_64', '/home/u', 'MISSING', 'DEAD', 'READY']) + + await deployAndLaunchRelay(conn) + + expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() + expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled() + const execCalls = vi.mocked(execCommand).mock.calls.map(([, c]) => c) + expect(execCalls.some((c) => c.includes('npm install'))).toBe(false) + const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) + expect(warnMessages.some((m) => m.includes(`repair lock is ${lockResult}`))).toBe(true) + }) + + it('loads native bindings when checking whether a completed relay needs repair', async () => { + vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(true) + const conn = makeMockConnection(sftpCapture) + feed([ + '__ORCA_REMOTE_PLATFORM__ Linux x86_64', + '/home/u', + 'ORCA-NATIVE-DEPS-OK', + 'DEAD', + 'READY' + ]) + + await deployAndLaunchRelay(conn) + + const healthProbe = vi + .mocked(execCommand) + .mock.calls.map(([, c]) => c) + .find((c) => c.includes('ORCA-NATIVE-DEPS-OK')) + expect(healthProbe).toContain('require("node-pty")') + expect(healthProbe).toContain('loadNativeModule') + expect(healthProbe).toContain('require("@parcel/watcher")') + expect(healthProbe).not.toContain('require.resolve') }) it('does not mutate an existing relay dir when required native deps are present', async () => { @@ -643,7 +1014,9 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { await deployAndLaunchRelay(conn) expect(vi.mocked(acquireInstallLock)).not.toHaveBeenCalled() + expect(vi.mocked(tryAcquireRelayRepairLock)).toHaveBeenCalledTimes(1) expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() + expect(vi.mocked(abandonInstall)).toHaveBeenCalledTimes(1) const execCalls = vi.mocked(execCommand).mock.calls.map(([, c]) => c) expect(execCalls.some((c) => c.includes('npm install'))).toBe(false) }) diff --git a/src/main/ssh/ssh-relay-repair-lock.ts b/src/main/ssh/ssh-relay-repair-lock.ts new file mode 100644 index 00000000000..01d58e390e1 --- /dev/null +++ b/src/main/ssh/ssh-relay-repair-lock.ts @@ -0,0 +1,153 @@ +import type { SshConnection } from './ssh-connection' +import { execCommand } from './ssh-relay-deploy-helpers' +import { + acquireInstallLockParentCommand, + lockAgeSecondsCommand, + probeInstallLockExistsCommand, + tryCreateInstallLockCommand, + tryStealInstallLockCommand +} from './ssh-relay-install-lock-commands' +import { isRelayGcClaimed } from './ssh-relay-gc-claim' +import { + getRemoteHostPlatform, + isWindowsRemoteHost, + joinRemotePath, + type RemoteHostPlatform +} from './ssh-remote-platform' +import { INSTALL_LOCK_STALE_SECONDS, RELAY_INSTALL_LOCK_NAME } from './ssh-relay-install-lock' +import { removeRemoteTreeCommand } from './ssh-remote-commands' + +const DEFAULT_REMOTE_HOST = getRemoteHostPlatform('linux-x64') + +export type RelayRepairLockResult = 'acquired' | 'busy' | 'gc' | 'error' + +function execHostCommand( + conn: SshConnection, + host: RemoteHostPlatform, + command: string, + signal?: AbortSignal +): Promise { + return execCommand(conn, command, { wrapCommand: !isWindowsRemoteHost(host), signal }) +} + +/** + * Try once to acquire the install lock for best-effort repair work. + * + * Why: a completed relay can launch in degraded mode, so repair must not wait + * behind another installer. An already-stale lock is still recovered now. + */ +export async function tryAcquireRelayRepairLock( + conn: SshConnection, + remoteRelayDir: string, + host: RemoteHostPlatform = DEFAULT_REMOTE_HOST, + options?: { signal?: AbortSignal } +): Promise { + const lockDir = joinRemotePath(host, remoteRelayDir, RELAY_INSTALL_LOCK_NAME) + try { + const gcClaimedBeforeAcquire = await isRelayGcClaimed( + conn, + remoteRelayDir, + host, + options?.signal + ).catch(() => undefined) + options?.signal?.throwIfAborted() + if (gcClaimedBeforeAcquire === true) { + return 'gc' + } + if (gcClaimedBeforeAcquire !== false) { + return 'error' + } + await execHostCommand( + conn, + host, + acquireInstallLockParentCommand(host, remoteRelayDir), + options?.signal + ) + const firstAttempt = await execHostCommand( + conn, + host, + tryCreateInstallLockCommand(host, lockDir), + options?.signal + ) + if (firstAttempt.trim().endsWith('OK')) { + return finishRepairLockAcquire(conn, remoteRelayDir, lockDir, host, options?.signal) + } + const steal = await execHostCommand( + conn, + host, + tryStealInstallLockCommand(host, lockDir, INSTALL_LOCK_STALE_SECONDS), + options?.signal + ) + if (steal.trim().endsWith('OK')) { + console.warn(`[ssh-relay] Stealing stale install lock at ${lockDir}`) + return finishRepairLockAcquire(conn, remoteRelayDir, lockDir, host, options?.signal) + } + return classifyRepairLockContention(conn, remoteRelayDir, lockDir, host, options?.signal) + } catch { + options?.signal?.throwIfAborted() + return classifyRepairLockContention(conn, remoteRelayDir, lockDir, host, options?.signal) + } +} + +async function classifyRepairLockContention( + conn: SshConnection, + remoteRelayDir: string, + lockDir: string, + host: RemoteHostPlatform, + signal?: AbortSignal +): Promise { + const gcClaimed = await isRelayGcClaimed(conn, remoteRelayDir, host, signal).catch( + () => undefined + ) + signal?.throwIfAborted() + if (gcClaimed === true) { + return 'gc' + } + if (gcClaimed !== false) { + return 'error' + } + + const lockProbe = await execHostCommand( + conn, + host, + probeInstallLockExistsCommand(host, lockDir), + signal + ).catch(() => '') + signal?.throwIfAborted() + if (lockProbe.trim() !== 'LOCKED') { + return 'error' + } + const ageOutput = await execHostCommand( + conn, + host, + lockAgeSecondsCommand(host, lockDir), + signal + ).catch(() => '') + signal?.throwIfAborted() + const ageSeconds = Number.parseInt(ageOutput.trim(), 10) + // Why: GC may remove stale locks, so only a positively observed fresh lock + // proves that another launch/repair owner is fencing this directory. + return Number.isFinite(ageSeconds) && ageSeconds >= 0 && ageSeconds <= INSTALL_LOCK_STALE_SECONDS + ? 'busy' + : 'error' +} + +async function finishRepairLockAcquire( + conn: SshConnection, + remoteRelayDir: string, + lockDir: string, + host: RemoteHostPlatform, + signal?: AbortSignal +): Promise { + const gcClaimed = await isRelayGcClaimed(conn, remoteRelayDir, host, signal).catch( + () => undefined + ) + if (gcClaimed === false && !signal?.aborted) { + return 'acquired' + } + // Why: GC may win its stable sibling claim while this command creates the + // in-tree lock. Back out before npm can mutate a directory being renamed. + await execHostCommand(conn, host, removeRemoteTreeCommand(host, lockDir)).catch(() => {}) + signal?.throwIfAborted() + return gcClaimed ? 'gc' : 'error' +} diff --git a/src/main/ssh/ssh-relay-versioned-install.test.ts b/src/main/ssh/ssh-relay-versioned-install.test.ts index 244ae80a48c..9bd3f73c8df 100644 --- a/src/main/ssh/ssh-relay-versioned-install.test.ts +++ b/src/main/ssh/ssh-relay-versioned-install.test.ts @@ -18,11 +18,19 @@ import { readLocalFullVersion, computeRemoteRelayDir, isRelayAlreadyInstalled, - acquireInstallLock, finalizeInstall, abandonInstall, gcOldRelayVersions } from './ssh-relay-versioned-install' +import { acquireInstallLock } from './ssh-relay-install-lock' +import { tryAcquireRelayRepairLock } from './ssh-relay-repair-lock' +import { + isRelayGcClaimed, + relayGcClaimPath, + releaseRelayGcClaim, + tryAcquireRelayGcClaim, + waitForRelayGcClaimRelease +} from './ssh-relay-gc-claim' import { execCommand } from './ssh-relay-deploy-helpers' import { getRemoteHostPlatform } from './ssh-remote-platform' import type { SshConnection } from './ssh-connection' @@ -88,6 +96,18 @@ describe('isRelayAlreadyInstalled', () => { expect(await isRelayAlreadyInstalled(conn, '/r')).toBe(false) }) + it('does not convert an aborted install probe into a missing install', async () => { + const controller = new AbortController() + mockExec.mockImplementationOnce(async () => { + controller.abort() + throw Object.assign(new Error('cancelled'), { name: 'AbortError' }) + }) + + await expect( + isRelayAlreadyInstalled(conn, '/r', undefined, { signal: controller.signal }) + ).rejects.toMatchObject({ name: 'AbortError' }) + }) + it('keeps default probe failures as not installed for SSH session-limit-shaped errors', async () => { const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), { reason: 4 @@ -124,26 +144,130 @@ describe('acquireInstallLock', () => { }) it('returns when mkdir reports OK', async () => { - // 1st call: mkdir -p remoteRelayDir - // 2nd call: mkdir lockDir → OK - mockExec.mockResolvedValueOnce('').mockResolvedValueOnce('OK') + mockExec + .mockResolvedValueOnce('OPEN') + .mockResolvedValueOnce('') + .mockResolvedValueOnce('OK') + .mockResolvedValueOnce('OPEN') await acquireInstallLock(conn, '/r') - expect(mockExec).toHaveBeenCalledTimes(2) + expect(mockExec).toHaveBeenCalledTimes(4) + }) + + it('recovers a stale sibling GC claim before first-install lock acquisition', async () => { + mockExec + .mockResolvedValueOnce('LOCKED') + .mockResolvedValueOnce('OK') + .mockResolvedValueOnce('') + .mockResolvedValueOnce('RELEASED') + .mockResolvedValueOnce('') + .mockResolvedValueOnce('OK') + .mockResolvedValueOnce('OPEN') + + await expect(acquireInstallLock(conn, '/r')).resolves.toBeUndefined() + + const commands = mockExec.mock.calls.map(([, command]) => command) + expect(commands[1]).toContain('lock_tombstone') + expect(commands[4]).toBe("mkdir -p '/r'") + }) + + it('returns immediately without deleting a live repair lock', async () => { + // Why: repair can run npm install plus rebuild under the same lock; a + // second reconnect must launch degraded rather than corrupt node_modules. + mockExec + .mockResolvedValueOnce('OPEN') + .mockResolvedValueOnce('') + .mockResolvedValueOnce('BUSY') + .mockResolvedValueOnce('BUSY') + .mockResolvedValueOnce('OPEN') + .mockResolvedValueOnce('LOCKED') + .mockResolvedValueOnce('0') + + await expect(tryAcquireRelayRepairLock(conn, '/r')).resolves.toBe('busy') + + const commands = mockExec.mock.calls.map(([, command]) => command) + expect(commands.some((command) => command.includes('lock_tombstone'))).toBe(true) + expect(commands.filter((command) => command.startsWith('rm -rf'))).toHaveLength(0) + }) + + it('does not report stale or indeterminate contention as a launch fence', async () => { + mockExec + .mockResolvedValueOnce('OPEN') + .mockResolvedValueOnce('') + .mockResolvedValueOnce('BUSY') + .mockResolvedValueOnce('BUSY') + .mockResolvedValueOnce('OPEN') + .mockResolvedValueOnce('LOCKED') + .mockResolvedValueOnce(`${21 * 60}`) + + await expect(tryAcquireRelayRepairLock(conn, '/r')).resolves.toBe('error') + + mockExec.mockReset().mockRejectedValueOnce(new Error('claim probe failed')) + await expect(tryAcquireRelayRepairLock(conn, '/r')).resolves.toBe('error') + }) + + it('recovers a stale best-effort repair lock without polling', async () => { + mockExec + .mockResolvedValueOnce('OPEN') + .mockResolvedValueOnce('') + .mockResolvedValueOnce('BUSY') + .mockResolvedValueOnce('OK') + .mockResolvedValueOnce('OPEN') + + await expect(tryAcquireRelayRepairLock(conn, '/r')).resolves.toBe('acquired') + + const commands = mockExec.mock.calls.map(([, command]) => command) + expect(commands.some((command) => command.includes('lock_tombstone'))).toBe(true) + expect(commands.filter((command) => command.includes('lock_tombstone'))).toHaveLength(1) + }) + + it('backs out when GC claims the sibling path during repair lock acquisition', async () => { + mockExec + .mockResolvedValueOnce('OPEN') + .mockResolvedValueOnce('') + .mockResolvedValueOnce('OK') + .mockResolvedValueOnce('LOCKED') + .mockResolvedValueOnce('') + + await expect(tryAcquireRelayRepairLock(conn, '/r')).resolves.toBe('gc') + + const lastCommand = mockExec.mock.calls.at(-1)?.[1] ?? '' + expect(lastCommand).toBe("rm -rf '/r/.install-lock'") + }) + + it('propagates cancellation through best-effort repair lock commands', async () => { + const abortController = new AbortController() + const abortError = Object.assign(new Error('cancelled'), { name: 'AbortError' }) + mockExec.mockRejectedValueOnce(abortError) + + const promise = tryAcquireRelayRepairLock(conn, '/r', undefined, { + signal: abortController.signal + }) + abortController.abort() + + await expect(promise).rejects.toMatchObject({ name: 'AbortError' }) + expect(mockExec.mock.calls[0]?.[2]?.signal).toBe(abortController.signal) }) it('polls until the lock becomes available (concurrent installer wins, then we acquire)', async () => { vi.useFakeTimers() try { - // Sequence: - // 1. mkdir -p (parent dir prep) - // 2. mkdir lockDir → BUSY (someone else holds it) - // 3. mkdir lockDir → BUSY again - // 4. mkdir lockDir → OK (concurrent installer released) - mockExec - .mockResolvedValueOnce('') - .mockResolvedValueOnce('BUSY') - .mockResolvedValueOnce('BUSY') - .mockResolvedValueOnce('OK') + let createAttempts = 0 + mockExec.mockImplementation(async (_conn: unknown, command: string) => { + if (command.includes('.gc-claim')) { + return 'OPEN' + } + if (command.startsWith('mkdir -p')) { + return '' + } + if (command.includes('lock_tombstone')) { + return 'BUSY' + } + if (command.includes('.install-lock')) { + createAttempts++ + return createAttempts >= 3 ? 'OK' : 'BUSY' + } + return '' + }) const promise = acquireInstallLock(conn, '/r') // Drive the polling loop: each iteration awaits a 1s timer. @@ -159,36 +283,48 @@ describe('acquireInstallLock', () => { } }) - it('steals a stale lock and retries with a reset timeout window', async () => { + it('immediately tries to steal an already-stale lock', async () => { + mockExec + .mockResolvedValueOnce('OPEN') + .mockResolvedValueOnce('') + .mockResolvedValueOnce('BUSY') + .mockResolvedValueOnce('OK') + .mockResolvedValueOnce('OPEN') + + await expect(acquireInstallLock(conn, '/r')).resolves.toBeUndefined() + + const cmds = mockExec.mock.calls.map(([, c]) => c) + expect(cmds).toHaveLength(5) + expect(cmds[3]).toContain('lock_tombstone') + }) + + it('retries stale takeover when a fresh lock ages out during the wait', async () => { vi.useFakeTimers({ now: 1_700_000_000_000 }) try { - let mkdirCalls = 0 + const recoverableAt = Date.now() + 60_000 mockExec.mockImplementation(async (_conn: unknown, cmd: string) => { + if (cmd.includes('.gc-claim')) { + return 'OPEN' + } if (cmd.startsWith('mkdir -p')) { return '' } + if (cmd.includes('lock_tombstone')) { + return Date.now() >= recoverableAt ? 'OK' : 'BUSY' + } if (cmd.includes('mkdir') && cmd.includes('.install-lock')) { - mkdirCalls++ - return mkdirCalls > 200 ? 'OK' : 'BUSY' - } - if (cmd.includes('stat')) { - return `${Math.floor((Date.now() - 10 * 60 * 1000) / 1000)}\n` - } - if (cmd.startsWith('rm -rf')) { - mkdirCalls = 1000 - return '' + return 'BUSY' } return '' }) const promise = acquireInstallLock(conn, '/r') - // Drive through the full timeout (120s) so the stale-recovery branch - // fires, then drive a few more seconds for the post-recovery retry. - await vi.advanceTimersByTimeAsync(125_000) - await promise + await vi.advanceTimersByTimeAsync(61_000) - const cmds = mockExec.mock.calls.map(([, c]) => c) - expect(cmds.some((c) => c.includes('rm -rf') && c.includes('.install-lock'))).toBe(true) + await expect(promise).resolves.toBeUndefined() + expect(mockExec.mock.calls.filter(([, cmd]) => cmd.includes('lock_tombstone'))).toHaveLength( + 2 + ) } finally { vi.useRealTimers() } @@ -198,26 +334,86 @@ describe('acquireInstallLock', () => { vi.useFakeTimers({ now: 1_700_000_000_000 }) try { mockExec.mockImplementation(async (_conn: unknown, cmd: string) => { + if (cmd.includes('.gc-claim')) { + return 'OPEN' + } if (cmd.startsWith('mkdir -p')) { return '' } + if (cmd.includes('lock_tombstone')) { + return 'BUSY' + } if (cmd.includes('mkdir') && cmd.includes('.install-lock')) { return 'BUSY' } if (cmd.includes('stat')) { - return `${Math.floor(Date.now() / 1000)}\n` + return '0\n' } return '' }) - const rejection = expect(acquireInstallLock(conn, '/r')).rejects.toThrow(/not yet stale/i) - await vi.advanceTimersByTimeAsync(125_000) + const rejection = expect(acquireInstallLock(conn, '/r')).rejects.toThrow( + /another install is still in progress/i + ) + await vi.advanceTimersByTimeAsync(905_000) await rejection } finally { vi.useRealTimers() } }) + it('stops polling when the caller aborts the lock wait', async () => { + vi.useFakeTimers() + try { + const abortController = new AbortController() + mockExec.mockImplementation(async (_conn: unknown, cmd: string) => + cmd.includes('.gc-claim') ? 'OPEN' : 'BUSY' + ) + + const promise = acquireInstallLock(conn, '/r', undefined, { + signal: abortController.signal + }) + await vi.advanceTimersByTimeAsync(1_000) + abortController.abort() + await expect(promise).rejects.toMatchObject({ name: 'AbortError' }) + + const callCountAfterAbort = mockExec.mock.calls.length + await vi.advanceTimersByTimeAsync(10_000) + expect(mockExec).toHaveBeenCalledTimes(callCountAfterAbort) + } finally { + vi.useRealTimers() + } + }) + + it('keeps waiting while a slow first installer is within the deploy bound', async () => { + vi.useFakeTimers({ now: 1_700_000_000_000 }) + try { + const availableAt = Date.now() + 500_000 + mockExec.mockImplementation(async (_conn: unknown, cmd: string) => { + if (cmd.includes('.gc-claim')) { + return 'OPEN' + } + if (cmd.startsWith('mkdir -p')) { + return '' + } + if (cmd.includes('mkdir') && cmd.includes('.install-lock')) { + return Date.now() >= availableAt ? 'OK' : 'BUSY' + } + return '' + }) + + const promise = acquireInstallLock(conn, '/r') + await vi.advanceTimersByTimeAsync(501_000) + + await expect(promise).resolves.toBeUndefined() + expect( + mockExec.mock.calls.filter(([, cmd]) => cmd.includes('lock_tombstone')).length + ).toBeGreaterThan(1) + } finally { + vi.useRealTimers() + } + }) + it('finalizeInstall writes .install-complete then removes the lock', async () => { mockExec.mockResolvedValueOnce('').mockResolvedValueOnce('') await finalizeInstall(conn, '/r') @@ -238,26 +434,220 @@ describe('acquireInstallLock', () => { }) }) +describe('relay GC claim', () => { + beforeEach(() => { + vi.clearAllMocks() + mockExec.mockReset() + }) + + it('parses explicit claim markers through remote startup noise', async () => { + mockExec.mockResolvedValueOnce('Welcome to host\nLOCKED\n') + await expect(isRelayGcClaimed(conn, '/relay/version')).resolves.toBe(true) + + mockExec.mockResolvedValueOnce('Last login: today\nOPEN\n') + await expect(isRelayGcClaimed(conn, '/relay/version')).resolves.toBe(false) + }) + + it('rejects missing or conflicting claim markers', async () => { + mockExec.mockResolvedValueOnce('Welcome to host\n') + await expect(isRelayGcClaimed(conn, '/relay/version')).rejects.toThrow('Inconclusive') + + mockExec.mockResolvedValueOnce('LOCKED\nOPEN\n') + await expect(isRelayGcClaimed(conn, '/relay/version')).rejects.toThrow('Inconclusive') + }) + + it('uses a stable sibling path outside the recursively deleted install', async () => { + mockExec.mockResolvedValueOnce('OK').mockResolvedValueOnce('') + + await expect(tryAcquireRelayGcClaim(conn, '/relay/version')).resolves.toEqual( + expect.any(String) + ) + + expect(relayGcClaimPath('/relay/version')).toBe('/relay/version.gc-claim') + expect(mockExec.mock.calls[0]?.[1]).toContain("mkdir '/relay/version.gc-claim'") + }) + + it('recovers and removes a stale sibling claim before retrying deploy', async () => { + mockExec + .mockResolvedValueOnce('LOCKED') + .mockResolvedValueOnce('OK') + .mockResolvedValueOnce('') + .mockResolvedValueOnce('RELEASED') + + await waitForRelayGcClaimRelease(conn, '/relay/version') + + const commands = mockExec.mock.calls.map(([, command]) => command) + expect(commands[1]).toContain('lock_tombstone') + expect(commands[3]).toContain("rm -rf '/relay/version.gc-claim'") + }) + + it('keeps waiting after losing a recovered claim until the claim is actually gone', async () => { + mockExec + .mockResolvedValueOnce('LOCKED') + .mockResolvedValueOnce('OK') + .mockResolvedValueOnce('') + .mockResolvedValueOnce('LOST') + .mockResolvedValueOnce('OPEN') + + await waitForRelayGcClaimRelease(conn, '/relay/version') + + expect(mockExec).toHaveBeenCalledTimes(5) + }) + + it('writes and conditionally releases the Windows sibling claim owner token', async () => { + const windows = getRemoteHostPlatform('win32-x64') + mockExec.mockResolvedValueOnce('OK').mockResolvedValueOnce('').mockResolvedValueOnce('RELEASED') + + const token = await tryAcquireRelayGcClaim(conn, 'C:/relay/version', windows) + expect(token).toEqual(expect.any(String)) + await expect(releaseRelayGcClaim(conn, 'C:/relay/version', token!, windows)).resolves.toBe( + 'released' + ) + + const ownerScript = decodePowerShellCommand(mockExec.mock.calls[1]?.[1] ?? '') + const releaseScript = decodePowerShellCommand(mockExec.mock.calls[2]?.[1] ?? '') + expect(ownerScript).toContain('Set-Content -LiteralPath') + expect(ownerScript).toContain('.gc-claim/.gc-owner') + expect(releaseScript).toContain('Get-Content -LiteralPath') + expect(releaseScript).toContain('-cne') + expect(releaseScript).toContain('Remove-Item -LiteralPath') + expect(releaseScript).toContain("'RELEASED'") + expect(releaseScript).toContain("'LOST'") + expect(releaseScript).toContain("'UNKNOWN'") + expect(releaseScript).not.toContain('}; elseif') + expect(releaseScript).not.toContain('{;') + }) + + it('conditionally releases a claim when the owner write reply is lost', async () => { + mockExec + .mockResolvedValueOnce('OK') + .mockRejectedValueOnce(new Error('owner write reply lost')) + .mockResolvedValueOnce('RELEASED') + + await expect(tryAcquireRelayGcClaim(conn, '/relay/version')).resolves.toBeNull() + + const ownerCommand = mockExec.mock.calls[1]?.[1] ?? '' + const releaseCommand = mockExec.mock.calls[2]?.[1] ?? '' + const token = ownerCommand.match(/printf %s '([^']+)'/)?.[1] + expect(token).toBeTruthy() + expect(releaseCommand).toContain(`!= '${token}'`) + }) +}) + describe('gcOldRelayVersions', () => { beforeEach(() => { vi.clearAllMocks() + mockExec.mockReset() }) it('removes a sibling that is complete, unlocked, and has no live socket', async () => { // ls listing mockExec.mockResolvedValueOnce('relay-0.1.0+aaa\nrelay-0.1.0+bbb\n') - // For sibling "aaa": LOCKED probe → OPEN, COMPLETE probe → COMPLETE, sock probe → empty (no ALIVE), then rm -rf + // For sibling "aaa": safety probes pass, then GC claims the install lock before removal. mockExec .mockResolvedValueOnce('OPEN') .mockResolvedValueOnce('COMPLETE') + .mockResolvedValueOnce('DEAD') + .mockResolvedValueOnce('OK') .mockResolvedValueOnce('') + .mockResolvedValueOnce('OPEN') + .mockResolvedValueOnce('COMPLETE') + .mockResolvedValueOnce('DEAD') + .mockResolvedValueOnce('OWNED') + .mockResolvedValueOnce('MOVED') + .mockResolvedValueOnce('RELEASED') .mockResolvedValueOnce('') await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb') const lastCmd = mockExec.mock.calls.at(-1)?.[1] ?? '' expect(lastCmd).toContain('rm -rf') - expect(lastCmd).toContain('relay-0.1.0+aaa') + expect(lastCmd).toContain('relay-0.1.0+aaa.gc-tombstone') + const commands = mockExec.mock.calls.map(([, command]) => command) + expect( + commands.some((command) => command === "rm -rf '/home/u/.orca-remote/relay-0.1.0+aaa'") + ).toBe(false) + }) + + it('cleans only strict POSIX orphan tombstones even with no relay candidates', async () => { + mockExec + .mockResolvedValueOnce( + [ + 'relay-0.1.0+abc.gc-tombstone.123.456', + 'relay-0.1.0+abc.gc-tombstone.bad.456', + 'xrelay-0.1.0+abc.gc-tombstone.123.456', + 'relay-0.1.0+abc.gc-tombstone.123.456.extra', + 'relay-0.1.0+abc.gc-tombstone.123.456/child', + 'logs' + ].join('\n') + ) + .mockResolvedValueOnce('') + + await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb') + + const removeCommands = mockExec.mock.calls + .map(([, command]) => command) + .filter((command) => command.startsWith('rm -rf')) + expect(removeCommands).toEqual([ + "rm -rf '/home/u/.orca-remote/relay-0.1.0+abc.gc-tombstone.123.456'" + ]) + }) + + it('cleans only strict Windows orphan tombstones even with no relay candidates', async () => { + const windows = getRemoteHostPlatform('win32-x64') + mockExec + .mockResolvedValueOnce( + 'relay-v0.1.0.gc-tombstone.123.456\nrelay-v0.1.0.gc-tombstone.latest.456\n' + ) + .mockResolvedValueOnce('') + + await gcOldRelayVersions(conn, 'C:/Users/u', 'C:/Users/u/.orca-remote/relay-0.1.0+bbb', windows) + + const removeScript = decodePowerShellCommand(mockExec.mock.calls[1]?.[1] ?? '') + expect(removeScript).toContain('relay-v0.1.0.gc-tombstone.123.456') + expect(removeScript).not.toContain('relay-v0.1.0.gc-tombstone.latest.456') + }) + + it('retries orphan tombstone cleanup on a later GC pass', async () => { + const tombstone = 'relay-0.1.0+abc.gc-tombstone.123.456' + mockExec + .mockResolvedValueOnce(tombstone) + .mockRejectedValueOnce(new Error('remove failed')) + .mockResolvedValueOnce(tombstone) + .mockResolvedValueOnce('') + + await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb') + await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb') + + const removeCommands = mockExec.mock.calls + .map(([, command]) => command) + .filter((command) => command.startsWith('rm -rf')) + expect(removeCommands).toHaveLength(2) + }) + + it('retries only an unknown claim release and stops after observing a lost generation', async () => { + mockExec + .mockResolvedValueOnce('relay-0.1.0+aaa\n') + .mockResolvedValueOnce('OPEN') + .mockResolvedValueOnce('COMPLETE') + .mockResolvedValueOnce('DEAD') + .mockResolvedValueOnce('OK') + .mockResolvedValueOnce('') + .mockResolvedValueOnce('OPEN') + .mockResolvedValueOnce('COMPLETE') + .mockResolvedValueOnce('DEAD') + .mockResolvedValueOnce('OWNED') + .mockResolvedValueOnce('MOVED') + .mockResolvedValueOnce('UNKNOWN') + .mockResolvedValueOnce('LOST') + .mockResolvedValueOnce('') + + await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb') + + const releaseCommands = mockExec.mock.calls + .map(([, command]) => command) + .filter((command) => command.includes('.gc-owner') && command.includes('echo RELEASED')) + expect(releaseCommands).toHaveLength(2) }) it('skips siblings that are missing .install-complete (mid-install or partial)', async () => { @@ -273,8 +663,8 @@ describe('gcOldRelayVersions', () => { it('skips siblings whose .install-lock is held and fresh', async () => { mockExec.mockResolvedValueOnce('relay-0.1.0+aaa\n') mockExec.mockResolvedValueOnce('LOCKED') - // isLockStale: mtime ~now → not stale. - mockExec.mockResolvedValueOnce(`${Math.floor(Date.now() / 1000)}\n`) + // isLockStale: age ~now → not stale. + mockExec.mockResolvedValueOnce('0\n') await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb') const cmds = mockExec.mock.calls.map(([, c]) => c) expect(cmds.some((c) => c.includes('rm -rf'))).toBe(false) @@ -283,12 +673,20 @@ describe('gcOldRelayVersions', () => { it('removes a sibling with a stale lock + .install-complete (rm-lock failed mid-finalize)', async () => { mockExec.mockResolvedValueOnce('relay-0.1.0+aaa\n') mockExec.mockResolvedValueOnce('LOCKED') - // isLockStale: mtime well in the past → stale. - const staleSec = Math.floor((Date.now() - 10 * 60 * 1000) / 1000) - mockExec.mockResolvedValueOnce(`${staleSec}\n`) + // isLockStale: age well above the stale window → stale. + mockExec.mockResolvedValueOnce(`${21 * 60}\n`) mockExec.mockResolvedValueOnce('COMPLETE') // .install-complete present - mockExec.mockResolvedValueOnce('') // socket probe → no ALIVE - mockExec.mockResolvedValueOnce('') // rm -rf + mockExec.mockResolvedValueOnce('DEAD') // socket probe + mockExec.mockResolvedValueOnce('OK') // stable sibling GC claim + mockExec.mockResolvedValueOnce('') // write claim ownership token + mockExec.mockResolvedValueOnce('LOCKED') + mockExec.mockResolvedValueOnce(`${21 * 60}\n`) + mockExec.mockResolvedValueOnce('COMPLETE') + mockExec.mockResolvedValueOnce('DEAD') + mockExec.mockResolvedValueOnce('OWNED') + mockExec.mockResolvedValueOnce('MOVED') + mockExec.mockResolvedValueOnce('RELEASED') // release sibling claim + mockExec.mockResolvedValueOnce('') // remove tombstone await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb') const lastCmd = mockExec.mock.calls.at(-1)?.[1] ?? '' expect(lastCmd).toContain('rm -rf') @@ -298,8 +696,15 @@ describe('gcOldRelayVersions', () => { it('GCs a legacy relay-v0.1.0 dir whose daemon is dead (no .install-complete required)', async () => { mockExec.mockResolvedValueOnce('relay-v0.1.0\n') mockExec.mockResolvedValueOnce('OPEN') // not locked - mockExec.mockResolvedValueOnce('') // socket probe → no ALIVE (no completeProbe — legacy) - mockExec.mockResolvedValueOnce('') // rm -rf + mockExec.mockResolvedValueOnce('DEAD') // socket probe (no completeProbe — legacy) + mockExec.mockResolvedValueOnce('OK') // GC claims candidate + mockExec.mockResolvedValueOnce('') + mockExec.mockResolvedValueOnce('OPEN') + mockExec.mockResolvedValueOnce('DEAD') + mockExec.mockResolvedValueOnce('OWNED') + mockExec.mockResolvedValueOnce('MOVED') + mockExec.mockResolvedValueOnce('RELEASED') + mockExec.mockResolvedValueOnce('') await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb') const cmds = mockExec.mock.calls.map(([, c]) => c) expect(cmds.some((c) => c.includes('rm -rf') && c.includes('relay-v0.1.0'))).toBe(true) @@ -327,6 +732,32 @@ describe('gcOldRelayVersions', () => { expect(cmds.some((c) => c.includes('rm -rf'))).toBe(false) }) + it('keeps a sibling when the install-lock probe fails or returns unexpected output', async () => { + mockExec + .mockResolvedValueOnce('relay-0.1.0+aaa\nrelay-0.1.0+ccc\n') + .mockRejectedValueOnce(new Error('lock probe failed')) + .mockResolvedValueOnce('INCONCLUSIVE') + + await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb') + + expect(mockExec).toHaveBeenCalledTimes(3) + }) + + it('keeps a sibling when the relay-liveness probe fails or returns unexpected output', async () => { + mockExec + .mockResolvedValueOnce('relay-0.1.0+aaa\nrelay-0.1.0+ccc\n') + .mockResolvedValueOnce('OPEN') + .mockResolvedValueOnce('COMPLETE') + .mockRejectedValueOnce(new Error('liveness probe failed')) + .mockResolvedValueOnce('OPEN') + .mockResolvedValueOnce('COMPLETE') + .mockResolvedValueOnce('INCONCLUSIVE') + + await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb') + + expect(mockExec).toHaveBeenCalledTimes(7) + }) + it('probes Windows GC liveness by connecting to named pipes, not process command lines', async () => { const windows = getRemoteHostPlatform('win32-x64') mockExec.mockResolvedValueOnce('relay-0.1.0+aaa\n') @@ -334,6 +765,14 @@ describe('gcOldRelayVersions', () => { .mockResolvedValueOnce('OPEN') .mockResolvedValueOnce('COMPLETE') .mockResolvedValueOnce('WAITING') + .mockResolvedValueOnce('OK') + .mockResolvedValueOnce('') + .mockResolvedValueOnce('OPEN') + .mockResolvedValueOnce('COMPLETE') + .mockResolvedValueOnce('WAITING') + .mockResolvedValueOnce('OWNED') + .mockResolvedValueOnce('MOVED') + .mockResolvedValueOnce('RELEASED') .mockResolvedValueOnce('') await gcOldRelayVersions( @@ -366,14 +805,113 @@ describe('gcOldRelayVersions', () => { mockExec .mockResolvedValueOnce('OPEN') .mockResolvedValueOnce('COMPLETE') + .mockResolvedValueOnce('DEAD') + .mockResolvedValueOnce('OK') .mockResolvedValueOnce('') + .mockResolvedValueOnce('OPEN') + .mockResolvedValueOnce('COMPLETE') + .mockResolvedValueOnce('DEAD') + .mockResolvedValueOnce('OWNED') + .mockResolvedValueOnce('MOVED') + .mockResolvedValueOnce('RELEASED') .mockResolvedValueOnce('') await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb') const cmds = mockExec.mock.calls.map(([, c]) => c) - const rmCmds = cmds.filter((c) => c.includes('rm -rf')) + const rmCmds = cmds.filter((c) => c.startsWith('rm') && c.includes('gc-tombstone')) expect(rmCmds).toHaveLength(1) expect(rmCmds[0]).toContain('relay-0.1.0+aaa') expect(rmCmds[0]).not.toContain('logs') expect(rmCmds[0]).not.toContain('backup') }) + + it('does not remove a candidate claimed by repair after the initial lock probe', async () => { + mockExec + .mockResolvedValueOnce('relay-0.1.0+aaa\n') + .mockResolvedValueOnce('OPEN') + .mockResolvedValueOnce('COMPLETE') + .mockResolvedValueOnce('DEAD') // socket probe + .mockResolvedValueOnce('OK') // GC sibling claim + .mockResolvedValueOnce('') // write claim ownership token + .mockResolvedValueOnce('LOCKED') // repair won before the safety recheck + .mockResolvedValueOnce('0') + .mockResolvedValueOnce('RELEASED') // release GC sibling claim + + await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb') + + const commands = mockExec.mock.calls.map(([, command]) => command) + expect( + commands.some( + (command) => + command.startsWith("rm -rf '/home/u/.orca-remote/relay-0.1.0+aaa'") && + !command.includes('.install-lock') + ) + ).toBe(false) + }) + + it('does not move a candidate after losing the sibling claim generation', async () => { + mockExec + .mockResolvedValueOnce('relay-0.1.0+aaa\n') + .mockResolvedValueOnce('OPEN') + .mockResolvedValueOnce('COMPLETE') + .mockResolvedValueOnce('DEAD') + .mockResolvedValueOnce('OK') + .mockResolvedValueOnce('') + .mockResolvedValueOnce('OPEN') + .mockResolvedValueOnce('COMPLETE') + .mockResolvedValueOnce('DEAD') + .mockResolvedValueOnce('LOST') + .mockResolvedValueOnce('LOST') + + await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb') + + const commands = mockExec.mock.calls.map(([, command]) => command) + expect(commands.some((command) => command.startsWith('mv '))).toBe(false) + }) + + it('releases the GC claim after a confirmed move failure', async () => { + mockExec + .mockResolvedValueOnce('relay-0.1.0+aaa\n') + .mockResolvedValueOnce('OPEN') + .mockResolvedValueOnce('COMPLETE') + .mockResolvedValueOnce('DEAD') + .mockResolvedValueOnce('OK') + .mockResolvedValueOnce('') + .mockResolvedValueOnce('OPEN') + .mockResolvedValueOnce('COMPLETE') + .mockResolvedValueOnce('DEAD') + .mockResolvedValueOnce('OWNED') + .mockRejectedValueOnce(new Error('move failed')) + .mockResolvedValueOnce('RELEASED') + + await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb') + + const lastCommand = mockExec.mock.calls.at(-1)?.[1] ?? '' + expect(lastCommand).toContain('rm -rf') + expect(lastCommand).toContain('relay-0.1.0+aaa.gc-claim') + }) + + it('keeps the GC claim when remote move termination is unconfirmed', async () => { + const unconfirmed = Object.assign(new Error('move timed out'), { + sshChannelCloseConfirmed: false + }) + mockExec + .mockResolvedValueOnce('relay-0.1.0+aaa\n') + .mockResolvedValueOnce('OPEN') + .mockResolvedValueOnce('COMPLETE') + .mockResolvedValueOnce('DEAD') + .mockResolvedValueOnce('OK') + .mockResolvedValueOnce('') + .mockResolvedValueOnce('OPEN') + .mockResolvedValueOnce('COMPLETE') + .mockResolvedValueOnce('DEAD') + .mockResolvedValueOnce('OWNED') + .mockRejectedValueOnce(unconfirmed) + + await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb') + + const releaseCommands = mockExec.mock.calls + .map(([, command]) => command) + .filter((command) => command.includes('relay-0.1.0+aaa.gc-claim') && command.startsWith('rm')) + expect(releaseCommands).toHaveLength(0) + }) }) diff --git a/src/main/ssh/ssh-relay-versioned-install.ts b/src/main/ssh/ssh-relay-versioned-install.ts index a669f0b7875..b211fec4dd0 100644 --- a/src/main/ssh/ssh-relay-versioned-install.ts +++ b/src/main/ssh/ssh-relay-versioned-install.ts @@ -15,16 +15,21 @@ import { existsSync, readFileSync } from 'node:fs' import type { SshConnection } from './ssh-connection' import { RELAY_REMOTE_DIR } from './relay-protocol' import { execCommand } from './ssh-relay-deploy-helpers' +import { probeInstallLockExistsCommand } from './ssh-relay-install-lock-commands' +import { isRelayInstallLockStale, RELAY_INSTALL_LOCK_NAME } from './ssh-relay-install-lock' +import { + isRelayGcClaimOwned, + releaseRelayGcClaimWithRetry, + tryAcquireRelayGcClaim +} from './ssh-relay-gc-claim' +import { cleanupRelayGcTombstones } from './ssh-relay-gc-tombstone' import { - acquireInstallLockParentCommand, listRelayBaseDirsCommand, - lockMtimeEpochCommand, - probeDirectoryExistsCommand, + moveRemoteTreeCommand, probeFileExistsCommand, probeRelayInstalledCommand, relayLivenessProbeCommand, removeRemoteTreeCommand, - tryCreateInstallLockCommand, writeRemoteEmptyFileCommand } from './ssh-remote-commands' import { @@ -36,6 +41,7 @@ import { type RemotePathFlavor } from './ssh-remote-platform' import { windowsRelayPipePathsForSocketName } from './ssh-relay-endpoints' +import { isUnconfirmedSshCommandTermination } from './ssh-relay-exec-command' import { isSshSessionLimitError } from './ssh-session-limit-error' // Why: the GC pass and the version-dir parser must agree on what counts as a @@ -51,18 +57,7 @@ const RELAY_VERSION_DIR_REGEX = /^relay-(v?\d+\.\d+\.\d+(\+[0-9a-f]+)?)$/ // living on remote disks forever. const LEGACY_RELAY_DIR_REGEX = /^relay-v\d+\.\d+\.\d+$/ -const INSTALL_LOCK_NAME = '.install-lock' const INSTALL_COMPLETE_NAME = '.install-complete' - -const INSTALL_LOCK_POLL_MS = 1_000 -const INSTALL_LOCK_TIMEOUT_MS = 120_000 -// Why: a stale lock dir from a crashed installer must be recoverable without -// user intervention. After the timeout we check the lock's mtime; if it's -// older than this window the previous installer is assumed dead and we steal -// the lock. 2 minutes is well above a normal `npm install node-pty` runtime -// (10–60s on slow hosts) so a slow concurrent installer is not falsely -// declared dead. -const INSTALL_LOCK_STALE_MS = 120_000 const DEFAULT_REMOTE_HOST = getRemoteHostPlatform('linux-x64') type RelayInstalledProbeOptions = { @@ -145,6 +140,7 @@ export async function isRelayAlreadyInstalled( ) return probe.trim() === 'OK' } catch (err) { + options?.signal?.throwIfAborted() if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) { throw err } @@ -153,101 +149,27 @@ export async function isRelayAlreadyInstalled( } /** - * Acquire the per-version install lock via atomic `mkdir`. Returns when the - * caller owns the lock; throws if the lock could not be acquired within - * INSTALL_LOCK_TIMEOUT_MS even after one stale-lock recovery attempt. - * - * Why mkdir: POSIX `mkdir` is atomic and fails with EEXIST if the dir already - * exists, giving us a free mutex. A second concurrent caller polls and - * eventually either acquires the lock or steals it after the stale window. - */ -export async function acquireInstallLock( - conn: SshConnection, - remoteRelayDir: string, - host: RemoteHostPlatform = DEFAULT_REMOTE_HOST -): Promise { - const lockDir = joinRemotePath(host, remoteRelayDir, INSTALL_LOCK_NAME) - // Why: the parent dir may not exist yet on a first install. mkdir -p is - // safe to run multiple times — it's a no-op if the dir already exists. - await execHostCommand(conn, host, acquireInstallLockParentCommand(host, remoteRelayDir)) - - let start = Date.now() - let recoveredOnce = false - while (true) { - try { - const result = await execHostCommand(conn, host, tryCreateInstallLockCommand(host, lockDir)) - if (result.trim().endsWith('OK')) { - return - } - } catch { - /* mkdir failed with non-zero — fall through to BUSY treatment */ - } - if (Date.now() - start >= INSTALL_LOCK_TIMEOUT_MS) { - if (recoveredOnce) { - throw new Error( - `Could not acquire relay install lock at ${lockDir} after ${ - INSTALL_LOCK_TIMEOUT_MS / 1000 - }s; another install is in progress or the lock is wedged.` - ) - } - // Stale-lock recovery: if the lock dir's mtime is older than the stale - // window, the previous installer crashed. Steal it and retry once, - // resetting the timeout window so a single post-recovery race doesn't - // immediately exhaust the budget. - const ageOk = await isLockStale(conn, lockDir, host) - if (ageOk) { - console.warn(`[ssh-relay] Stealing stale install lock at ${lockDir}`) - await execHostCommand(conn, host, removeRemoteTreeCommand(host, lockDir)).catch(() => {}) - recoveredOnce = true - start = Date.now() - continue - } - throw new Error( - `Could not acquire relay install lock at ${lockDir} after ${ - INSTALL_LOCK_TIMEOUT_MS / 1000 - }s and the lock is not yet stale.` - ) - } - await new Promise((r) => setTimeout(r, INSTALL_LOCK_POLL_MS)) - } -} - -async function isLockStale( - conn: SshConnection, - lockDir: string, - host: RemoteHostPlatform = DEFAULT_REMOTE_HOST -): Promise { - try { - // Why: `stat` flags differ between GNU coreutils (Linux) and BSD (macOS). - // We try GNU first, then BSD; both produce a Unix epoch in seconds on - // stdout. If both fail we conservatively treat the lock as not stale. - const out = await execHostCommand(conn, host, lockMtimeEpochCommand(host, lockDir)) - const mtimeSec = Number.parseInt(out.trim(), 10) - if (!Number.isFinite(mtimeSec)) { - return false - } - const ageMs = Date.now() - mtimeSec * 1000 - return ageMs > INSTALL_LOCK_STALE_MS - } catch { - return false - } -} - -/** - * Mark the install as complete and release the lock. Sentinel ordering is: - * write `.install-complete` FIRST, then remove `.install-lock`. This ensures - * a sibling dir is never observed by GC as "complete but locked", which - * would lead GC to skip a recoverable dir indefinitely. + * Mark the install as complete, then normally release the lock. Deploy keeps + * the lock through first launch so cross-version GC cannot move the directory + * between finalization and daemon liveness becoming observable. */ export async function finalizeInstall( conn: SshConnection, remoteRelayDir: string, - host: RemoteHostPlatform = DEFAULT_REMOTE_HOST + host: RemoteHostPlatform = DEFAULT_REMOTE_HOST, + options?: { signal?: AbortSignal; releaseLock?: boolean } ): Promise { const sentinel = joinRemotePath(host, remoteRelayDir, INSTALL_COMPLETE_NAME) - const lock = joinRemotePath(host, remoteRelayDir, INSTALL_LOCK_NAME) - await execHostCommand(conn, host, writeRemoteEmptyFileCommand(host, sentinel)) - await execHostCommand(conn, host, removeRemoteTreeCommand(host, lock)).catch(() => {}) + const lock = joinRemotePath(host, remoteRelayDir, RELAY_INSTALL_LOCK_NAME) + await execHostCommand(conn, host, writeRemoteEmptyFileCommand(host, sentinel), { + signal: options?.signal + }) + if (options?.releaseLock !== false) { + await execHostCommand(conn, host, removeRemoteTreeCommand(host, lock), { + signal: options?.signal + }).catch(() => {}) + } + options?.signal?.throwIfAborted() } /** @@ -260,7 +182,7 @@ export async function abandonInstall( remoteRelayDir: string, host: RemoteHostPlatform = DEFAULT_REMOTE_HOST ): Promise { - const lock = joinRemotePath(host, remoteRelayDir, INSTALL_LOCK_NAME) + const lock = joinRemotePath(host, remoteRelayDir, RELAY_INSTALL_LOCK_NAME) await execHostCommand(conn, host, removeRemoteTreeCommand(host, lock)).catch(() => {}) } @@ -295,10 +217,14 @@ export async function gcOldRelayVersions( } catch { return } - const candidates = listing + const entries = listing .split('\n') .map((s) => s.trim()) .filter(Boolean) + + await cleanupRelayGcTombstones(conn, baseDir, entries, host) + + const candidates = entries .filter((name) => RELAY_VERSION_DIR_REGEX.test(name)) .filter((name) => name !== currentDirName) @@ -316,7 +242,47 @@ export async function gcOldRelayVersions( kept.push(name) continue } - await execHostCommand(conn, host, removeRemoteTreeCommand(host, dir)) + // Why: the claim is a sibling, so it survives moving/deleting the + // candidate and lets installers back out before mutating the old path. + const gcClaimToken = await tryAcquireRelayGcClaim(conn, dir, host) + if (!gcClaimToken) { + kept.push(name) + continue + } + let preserveGcClaim = false + let gcClaimReleaseNeeded = true + try { + // Recheck under the stable claim. New installers probe the claim both + // before and after creating their in-tree lock, closing both orders. + if (!(await isCandidateSafeToRemove(conn, dir, name, host, options))) { + kept.push(name) + continue + } + if (!(await isRelayGcClaimOwned(conn, dir, gcClaimToken, host))) { + kept.push(name) + continue + } + const tombstone = `${dir}.gc-tombstone.${process.pid}.${Date.now()}` + const moved = await execHostCommand(conn, host, moveRemoteTreeCommand(host, dir, tombstone)) + if (moved.trim() !== 'MOVED') { + kept.push(name) + continue + } + // Once renamed, a fresh install at the original path is isolated from + // deletion of the tombstone, so the sibling claim can be released. + const release = await releaseRelayGcClaimWithRetry(conn, dir, gcClaimToken, host) + gcClaimReleaseNeeded = release === 'unknown' + await execHostCommand(conn, host, removeRemoteTreeCommand(host, tombstone)) + } catch (err) { + if (isUnconfirmedSshCommandTermination(err)) { + preserveGcClaim = true + } + throw err + } finally { + if (!preserveGcClaim && gcClaimReleaseNeeded) { + await releaseRelayGcClaimWithRetry(conn, dir, gcClaimToken, host) + } + } removed.push(name) } catch (err) { console.warn( @@ -346,23 +312,28 @@ async function isCandidateSafeToRemove( ): Promise { const isLegacy = LEGACY_RELAY_DIR_REGEX.test(name) - const lockDir = joinRemotePath(host, dir, INSTALL_LOCK_NAME) - const lockProbe = await execHostCommand( - conn, - host, - probeDirectoryExistsCommand(host, lockDir) - ).catch(() => 'OPEN') - const locked = lockProbe.trim() === 'LOCKED' + const lockDir = joinRemotePath(host, dir, RELAY_INSTALL_LOCK_NAME) + let lockProbe: string + try { + lockProbe = await execHostCommand(conn, host, probeInstallLockExistsCommand(host, lockDir)) + } catch { + return false + } + const lockState = lockProbe.trim() + if (lockState !== 'OPEN' && lockState !== 'LOCKED') { + return false + } + const locked = lockState === 'LOCKED' if (locked) { // Why: a locked dir is normally unsafe to remove — but a STALE lock - // (mtime older than INSTALL_LOCK_STALE_MS) means the previous installer + // (remote age older than INSTALL_LOCK_STALE_MS) means the previous installer // crashed and is never coming back. If the dir also has the // .install-complete sentinel (touch succeeded but the rm-lock at the // end of finalizeInstall failed), removing the dir is safe — no // installer is racing us, and the daemon (if any) keeps running off // its already-loaded code regardless of disk state. - if (!(await isLockStale(conn, lockDir, host))) { + if (!(await isRelayInstallLockStale(conn, lockDir, host))) { return false } process.stderr.write?.(`[ssh-relay] GC: lock at ${lockDir} is stale; treating as recoverable\n`) @@ -419,8 +390,10 @@ async function hasLiveRelaySocket( host, relayLivenessProbeCommand(host, dir, windowsOptions) ) - return out.includes('ALIVE') + const state = out.trim() + return state !== 'DEAD' && state !== 'WAITING' } catch { - return false + // Why: an inconclusive liveness probe must never authorize deletion. + return true } } diff --git a/src/main/ssh/ssh-remote-commands.test.ts b/src/main/ssh/ssh-remote-commands.test.ts index 952daf9edde..96cc8925515 100644 --- a/src/main/ssh/ssh-remote-commands.test.ts +++ b/src/main/ssh/ssh-remote-commands.test.ts @@ -1,24 +1,109 @@ +import { execFileSync, spawn, spawnSync } from 'node:child_process' +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + rmSync, + statSync, + utimesSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { describe, expect, it } from 'vitest' +import { + lockAgeSecondsCommand, + tryCreateInstallLockCommand, + tryStealInstallLockCommand +} from './ssh-relay-install-lock-commands' import { commandInRemoteDirectory, commandWithNodePath, listRelayBaseDirsCommand, makeRemoteDirectoryCommand, + moveRemoteTreeCommand, + probeDirectoryExistsCommand, probeRelayInstalledCommand, readRemoteHomeCommand, - relayLivenessProbeCommand, - tryCreateInstallLockCommand + relayLivenessProbeCommand } from './ssh-remote-commands' import { getRemoteHostPlatform } from './ssh-remote-platform' const posix = getRemoteHostPlatform('linux-x64') const windows = getRemoteHostPlatform('win32-x64') +const powerShellExecutable = ( + process.platform === 'win32' ? ['pwsh.exe', 'powershell.exe'] : ['pwsh'] +).find((candidate) => { + const result = spawnSync(candidate, ['-NoProfile', '-NonInteractive', '-Command', 'exit 0'], { + stdio: 'ignore' + }) + return result.status === 0 +}) +const powerShell51Executable = + process.platform === 'win32' && + spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', 'exit 0'], { + stdio: 'ignore' + }).status === 0 + ? 'powershell.exe' + : undefined function decodePowerShellCommand(command: string): string { const match = command.match(/-EncodedCommand\s+([A-Za-z0-9+/=]+)/) return match ? Buffer.from(match[1], 'base64').toString('utf16le') : '' } +function runShellCommand(command: string): Promise { + return new Promise((resolve, reject) => { + const child = spawn('/bin/sh', ['-c', command], { + stdio: ['ignore', 'pipe', 'pipe'] + }) + let stdout = '' + let stderr = '' + child.stdout.setEncoding('utf8') + child.stderr.setEncoding('utf8') + child.stdout.on('data', (chunk) => { + stdout += chunk + }) + child.stderr.on('data', (chunk) => { + stderr += chunk + }) + child.on('error', reject) + child.on('close', (code) => { + if (code === 0) { + resolve(stdout) + return + } + reject(new Error(`shell exited ${code}: ${stderr}`)) + }) + }) +} + +function runPowerShellCommand(executable: string, script: string): Promise { + return new Promise((resolve, reject) => { + const child = spawn(executable, ['-NoProfile', '-NonInteractive', '-Command', script], { + stdio: ['ignore', 'pipe', 'pipe'] + }) + let stdout = '' + let stderr = '' + child.stdout.setEncoding('utf8') + child.stderr.setEncoding('utf8') + child.stdout.on('data', (chunk) => { + stdout += chunk + }) + child.stderr.on('data', (chunk) => { + stderr += chunk + }) + child.on('error', reject) + child.on('close', (code) => { + if (code === 0) { + resolve(stdout) + return + } + reject(new Error(`PowerShell exited ${code}: ${stderr}`)) + }) + }) +} + describe('ssh remote command builders', () => { it('keeps POSIX deploy commands POSIX-native', () => { expect(readRemoteHomeCommand(posix)).toBe('echo $HOME') @@ -34,7 +119,7 @@ describe('ssh remote command builders', () => { expect(probeRelayInstalledCommand(windows, 'C:/Users/me/relay')).toContain('-EncodedCommand') }) - it('uses -Path for Windows New-Item commands', () => { + it('uses a legacy-visible Windows lock directory with an exclusive owner file', () => { const mkdirScript = decodePowerShellCommand( makeRemoteDirectoryCommand(windows, 'C:/Users/me/.orca-remote') ) @@ -43,9 +128,31 @@ describe('ssh remote command builders', () => { ) expect(mkdirScript).toContain('New-Item -ItemType Directory -Force -Path') - expect(lockScript).toContain('New-Item -ItemType Directory -Path') + expect(lockScript).toContain('[System.IO.FileMode]::CreateNew') + expect(lockScript).toContain('[System.IO.FileShare]::None') + expect(lockScript).toContain('New-Item -ItemType Directory -Path $lock') + expect(lockScript).toContain("Join-Path $lock '.owner'") expect(mkdirScript).not.toContain('New-Item -ItemType Directory -Force -LiteralPath') - expect(lockScript).not.toContain('New-Item -ItemType Directory -LiteralPath') + }) + + it('moves GC candidates to a sibling tombstone with host-native commands', () => { + expect(moveRemoteTreeCommand(posix, '/relay/old', '/relay/old.gc-tombstone')).toContain( + "mv '/relay/old' '/relay/old.gc-tombstone'" + ) + const windowsScript = decodePowerShellCommand( + moveRemoteTreeCommand(windows, 'C:/Users/me/relay/old', 'C:/Users/me/relay/old.gc-tombstone') + ) + expect(windowsScript).toContain('Move-Item -LiteralPath') + expect(windowsScript).toContain("-Destination 'C:/Users/me/relay/old.gc-tombstone'") + expect(windowsScript).toContain("'MOVED'") + }) + + it('emits an explicit POSIX liveness result so GC can fail closed', () => { + const command = relayLivenessProbeCommand(posix, '/home/u/.orca-remote/relay-0.1.0') + + expect(command).toContain('state=DEAD') + expect(command).toContain('[ -S "$f" ] && state=ALIVE') + expect(command).toContain('echo "$state"') }) it('uses named pipe try-connect liveness for Windows GC', () => { @@ -96,11 +203,227 @@ describe('ssh remote command builders', () => { tryCreateInstallLockCommand(windows, 'C:/Users/me/.orca-remote/relay/.install-lock') ) - expect(script).toContain('$ErrorActionPreference = "Stop"; try {') + expect(script).toContain('$stream = $null; try {') expect(script).toContain("} catch { 'BUSY' }") expect(script).not.toContain('}; catch') }) + it('computes install-lock age on the remote host clock', () => { + const posixCommand = lockAgeSecondsCommand(posix, '/home/me/.orca-remote/relay/.install-lock') + const windowsScript = decodePowerShellCommand( + lockAgeSecondsCommand(windows, 'C:/Users/me/.orca-remote/relay/.install-lock') + ) + + expect(posixCommand).toContain('date +%s') + expect(posixCommand).toContain('echo "$age"') + expect(windowsScript).toContain('[DateTimeOffset]::UtcNow.ToUnixTimeSeconds()') + expect(windowsScript).toContain('Write-Output ($now - $mtime)') + }) + + it('serializes stale recovery with unbounded numbered sibling claims', () => { + const posixCommand = tryStealInstallLockCommand( + posix, + '/home/me/.orca-remote/relay/.install-lock', + 20 * 60 + ) + const windowsScript = decodePowerShellCommand( + tryStealInstallLockCommand(windows, 'C:/Users/me/.orca-remote/relay/.install-lock', 20 * 60) + ) + + expect(posixCommand).toContain('.install-lock') + expect(posixCommand).toContain('.install-lock.steal') + expect(posixCommand).toContain('steal_generation') + expect(posixCommand).toContain('mtime=${lock_key%%:*}') + expect(posixCommand).toContain('-gt 1200') + expect(posixCommand).toContain('current_mtime') + expect(posixCommand).toContain('steal_generation + 1') + expect(posixCommand).not.toContain('.next.') + expect(posixCommand).toContain('trap') + expect(windowsScript).toContain('$lock.steal') + expect(windowsScript).toContain('$stealGeneration++') + expect(windowsScript).toContain('-gt 1200') + expect(windowsScript).toContain('$currentIdentity -eq $lockIdentity') + expect(windowsScript).toContain('[System.IO.FileMode]::CreateNew') + expect(windowsScript).not.toContain('.next.') + expect(windowsScript).toContain('finally') + }) + + it.runIf(powerShellExecutable)( + 'emits a parseable Windows stale-lock recovery command', + () => { + const script = decodePowerShellCommand( + tryStealInstallLockCommand( + windows, + 'C:/Users/orca-missing/.orca-remote/relay/.install-lock', + 20 * 60 + ) + ) + const result = spawnSync( + powerShellExecutable!, + ['-NoProfile', '-NonInteractive', '-Command', script], + { encoding: 'utf8' } + ) + + expect(result.status, result.stderr).toBe(0) + expect(result.stdout.trim()).toBe('BUSY') + }, + 15_000 + ) + + it.runIf(powerShell51Executable)( + 'lets only one Windows PowerShell 5.1 caller acquire an install lock', + async () => { + const root = mkdtempSync(join(tmpdir(), 'orca-install-lock-windows-race-')) + try { + const lockPath = join(root, '.install-lock') + const script = decodePowerShellCommand(tryCreateInstallLockCommand(windows, lockPath)) + const outputs = await Promise.all( + Array.from({ length: 16 }, () => runPowerShellCommand(powerShell51Executable!, script)) + ) + + expect(outputs.filter((output) => output.trim().endsWith('OK'))).toHaveLength(1) + expect(statSync(lockPath).isDirectory()).toBe(true) + expect(statSync(join(lockPath, '.owner')).isFile()).toBe(true) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }, + 30_000 + ) + + it.runIf(powerShell51Executable)( + 'recovers a stale Windows lock past abandoned steal generations', + async () => { + const root = mkdtempSync(join(tmpdir(), 'orca-install-lock-windows-stale-')) + try { + const lockPath = join(root, '.install-lock') + mkdirSync(lockPath) + const staleDate = new Date(Date.now() - 60 * 60_000) + utimesSync(lockPath, staleDate, staleDate) + for (let i = 0; i < 12; i++) { + const orphan = `${lockPath}.steal.${i}` + mkdirSync(orphan) + utimesSync(orphan, staleDate, staleDate) + } + const script = decodePowerShellCommand( + tryStealInstallLockCommand(windows, lockPath, 20 * 60) + ) + + const output = await runPowerShellCommand(powerShell51Executable!, script) + + expect(output.trim()).toBe('OK') + expect(statSync(lockPath).isDirectory()).toBe(true) + expect(statSync(join(lockPath, '.owner')).isFile()).toBe(true) + expect(readdirSync(root).filter((name) => name.includes('.steal.'))).toHaveLength(0) + expect(readdirSync(root).filter((name) => name.includes('.tombstone.'))).toHaveLength(0) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }, + 15_000 + ) + + it.runIf(powerShell51Executable)( + 'lets only one Windows PowerShell 5.1 caller replace a stale lock', + async () => { + const root = mkdtempSync(join(tmpdir(), 'orca-install-lock-windows-steal-race-')) + try { + const lockPath = join(root, '.install-lock') + mkdirSync(lockPath) + const staleDate = new Date(Date.now() - 60 * 60_000) + utimesSync(lockPath, staleDate, staleDate) + const script = decodePowerShellCommand( + tryStealInstallLockCommand(windows, lockPath, 20 * 60) + ) + + const outputs = await Promise.all( + Array.from({ length: 16 }, () => runPowerShellCommand(powerShell51Executable!, script)) + ) + + expect(outputs.filter((output) => output.trim().endsWith('OK'))).toHaveLength(1) + expect(statSync(lockPath).isDirectory()).toBe(true) + expect(statSync(join(lockPath, '.owner')).isFile()).toBe(true) + expect(readdirSync(root).filter((name) => name.includes('.tombstone.'))).toHaveLength(0) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }, + 30_000 + ) + + it.runIf(powerShellExecutable)( + 'keeps a new Windows lock visible to the previous directory-only GC probe', + async () => { + const root = mkdtempSync(join(tmpdir(), 'orca-install-lock-windows-compat-')) + try { + const lockPath = join(root, '.install-lock') + const acquire = decodePowerShellCommand(tryCreateInstallLockCommand(windows, lockPath)) + const legacyProbe = decodePowerShellCommand(probeDirectoryExistsCommand(windows, lockPath)) + + await expect(runPowerShellCommand(powerShellExecutable!, acquire)).resolves.toMatch(/OK/) + await expect(runPowerShellCommand(powerShellExecutable!, legacyProbe)).resolves.toMatch( + /LOCKED/ + ) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }, + 15_000 + ) + + it.runIf(process.platform !== 'win32')( + 'recovers and cleans more than eight orphaned numbered steal claims', + () => { + const root = mkdtempSync(join(tmpdir(), 'orca-install-lock-')) + try { + const lockDir = join(root, '.install-lock') + mkdirSync(lockDir) + const staleDate = new Date(Date.now() - 60 * 60_000) + utimesSync(lockDir, staleDate, staleDate) + const lockMtimeSeconds = Math.floor(statSync(lockDir).mtimeMs / 1000) + for (let i = 0; i < 12; i++) { + const orphan = `${lockDir}.steal.${i}` + mkdirSync(orphan) + utimesSync(orphan, staleDate, staleDate) + } + + const command = tryStealInstallLockCommand(posix, lockDir, 20 * 60) + const output = execFileSync('/bin/sh', ['-c', command], { encoding: 'utf8' }) + + expect(output.trim()).toBe('OK') + expect(existsSync(lockDir)).toBe(true) + expect(readdirSync(root).filter((name) => name.includes('.steal.'))).toHaveLength(0) + expect(Math.floor(statSync(lockDir).mtimeMs / 1000)).toBeGreaterThan(lockMtimeSeconds) + } finally { + rmSync(root, { recursive: true, force: true }) + } + } + ) + + it.runIf(process.platform !== 'win32')( + 'lets only one POSIX caller move and recreate a stale install lock', + async () => { + const root = mkdtempSync(join(tmpdir(), 'orca-install-lock-race-')) + try { + const lockDir = join(root, '.install-lock') + mkdirSync(lockDir) + const staleDate = new Date(Date.now() - 60 * 60_000) + utimesSync(lockDir, staleDate, staleDate) + const command = tryStealInstallLockCommand(posix, lockDir, 20 * 60) + const outputs = await Promise.all( + Array.from({ length: 64 }, () => runShellCommand(command)) + ) + const okCount = outputs.filter((output) => output.trim().endsWith('OK')).length + + expect(okCount).toBe(1) + expect(existsSync(lockDir)).toBe(true) + expect(readdirSync(root).some((name) => name.includes('.tombstone'))).toBe(false) + } finally { + rmSync(root, { recursive: true, force: true }) + } + } + ) + it('makes Windows remote directory changes fail before running scoped commands', () => { const scopedCommand = decodePowerShellCommand( commandInRemoteDirectory(windows, 'C:/Users/me/.orca-remote/relay-0.1.0', "'READY'") diff --git a/src/main/ssh/ssh-remote-commands.ts b/src/main/ssh/ssh-remote-commands.ts index 339f4aef594..b70761f8d54 100644 --- a/src/main/ssh/ssh-remote-commands.ts +++ b/src/main/ssh/ssh-remote-commands.ts @@ -45,6 +45,24 @@ export function removeRemoteTreeCommand(host: RemoteHostPlatform, remotePath: st ) } +export function moveRemoteTreeCommand( + host: RemoteHostPlatform, + sourcePath: string, + destinationPath: string +): string { + if (!isWindowsRemoteHost(host)) { + return `mv ${shellEscape(sourcePath)} ${shellEscape(destinationPath)} 2>&1 && echo MOVED || echo BUSY` + } + return powerShellCommand( + [ + 'try {', + `Move-Item -LiteralPath ${powerShellLiteral(sourcePath)} -Destination ${powerShellLiteral(destinationPath)} -ErrorAction Stop`, + "'MOVED'", + `} catch { 'BUSY' }` + ].join('; ') + ) +} + export function writeRemoteEmptyFileCommand(host: RemoteHostPlatform, remotePath: string): string { if (!isWindowsRemoteHost(host)) { return `touch ${shellEscape(remotePath)}` @@ -81,36 +99,6 @@ export function probeRelayInstalledCommand( ) } -export function acquireInstallLockParentCommand( - host: RemoteHostPlatform, - remoteRelayDir: string -): string { - return makeRemoteDirectoryCommand(host, remoteRelayDir) -} - -export function tryCreateInstallLockCommand(host: RemoteHostPlatform, lockDir: string): string { - if (!isWindowsRemoteHost(host)) { - return `mkdir ${shellEscape(lockDir)} 2>&1 && echo OK || echo BUSY` - } - // New-Item has no -LiteralPath parameter; using it breaks stock Windows PowerShell. - return powerShellCommand( - `$ErrorActionPreference = "Stop"; try { $null = New-Item -ItemType Directory -Path ${powerShellLiteral(lockDir)}; 'OK' } catch { 'BUSY' }` - ) -} - -export function lockMtimeEpochCommand(host: RemoteHostPlatform, lockDir: string): string { - if (!isWindowsRemoteHost(host)) { - return `stat -c %Y ${shellEscape(lockDir)} 2>/dev/null || stat -f %m ${shellEscape(lockDir)} 2>/dev/null || echo` - } - return powerShellCommand( - [ - `$item = Get-Item -LiteralPath ${powerShellLiteral(lockDir)} -ErrorAction Stop`, - '$dto = [DateTimeOffset]$item.LastWriteTimeUtc', - 'Write-Output $dto.ToUnixTimeSeconds()' - ].join('; ') - ) -} - export function listRelayBaseDirsCommand(host: RemoteHostPlatform, baseDir: string): string { if (!isWindowsRemoteHost(host)) { return `ls -1 ${shellEscape(baseDir)} 2>/dev/null || true` @@ -155,9 +143,9 @@ export function relayLivenessProbeCommand( ): string { if (!isWindowsRemoteHost(host)) { return ( - `for f in ${shellEscape(dir)}/relay-*.sock ${shellEscape(dir)}/relay.sock; do ` + - `[ -S "$f" ] && echo ALIVE && break; ` + - 'done; true' + `state=DEAD; for f in ${shellEscape(dir)}/relay-*.sock ${shellEscape(dir)}/relay.sock; do ` + + `[ -S "$f" ] && state=ALIVE && break; ` + + 'done; echo "$state"' ) } if (!windowsOptions) { diff --git a/src/main/ssh/ssh-remote-node-resolution.ts b/src/main/ssh/ssh-remote-node-resolution.ts index 01d6a0c7d48..a5041ed158d 100644 --- a/src/main/ssh/ssh-remote-node-resolution.ts +++ b/src/main/ssh/ssh-remote-node-resolution.ts @@ -122,6 +122,7 @@ true if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) { throw err } + throwIfAborted(options) // Fall through to login shell. } return null @@ -167,6 +168,7 @@ async function tryResolveViaLoginShell( if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) { throw err } + throwIfAborted(options) // Fall through. } return null @@ -191,6 +193,7 @@ async function nodeMeetsVersionRequirement( if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) { throw err } + throwIfAborted(options) // Binary missing or fails to run — not usable. return false } @@ -240,6 +243,7 @@ async function resolveRemoteWindowsNodePath( if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) { throw err } + throwIfAborted(options) // Fall through to the shared error below. } @@ -262,6 +266,7 @@ async function windowsNodeMeetsVersionRequirement( if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) { throw err } + throwIfAborted(options) return false } } diff --git a/src/main/ssh/ssh-remote-platform-detection.ts b/src/main/ssh/ssh-remote-platform-detection.ts index f72b5384335..44c31e617e4 100644 --- a/src/main/ssh/ssh-remote-platform-detection.ts +++ b/src/main/ssh/ssh-remote-platform-detection.ts @@ -11,30 +11,38 @@ import { powerShellCommand } from './ssh-remote-powershell' const PLATFORM_PROBE_MARKER = '__ORCA_REMOTE_PLATFORM__' export async function detectRemoteHostPlatform( - conn: SshConnection + conn: SshConnection, + options?: { signal?: AbortSignal } ): Promise { - const unamePlatform = await detectUnamePlatform(conn) + const unamePlatform = await detectUnamePlatform(conn, options?.signal) if (unamePlatform) { return getRemoteHostPlatform(unamePlatform) } - const windowsPlatform = await detectWindowsPlatform(conn) + const windowsPlatform = await detectWindowsPlatform(conn, options?.signal) return windowsPlatform ? getRemoteHostPlatform(windowsPlatform) : null } -async function detectUnamePlatform(conn: SshConnection): Promise { +async function detectUnamePlatform( + conn: SshConnection, + signal?: AbortSignal +): Promise { try { - const output = await execCommand( - conn, - // Why: Remote startup output may omit its trailing newline and must not absorb the marker. - `printf '\\n%s ' '${PLATFORM_PROBE_MARKER}'; uname -sm` - ) + // Why: Remote startup output may omit its trailing newline and must not absorb the marker. + const command = `printf '\\n%s ' '${PLATFORM_PROBE_MARKER}'; uname -sm` + const output = signal + ? await execCommand(conn, command, { signal }) + : await execCommand(conn, command) return parseRemotePlatformOutput(output) } catch { + signal?.throwIfAborted() return null } } -async function detectWindowsPlatform(conn: SshConnection): Promise { +async function detectWindowsPlatform( + conn: SshConnection, + signal?: AbortSignal +): Promise { try { const script = [ '$arch = $env:PROCESSOR_ARCHITECTURE', @@ -43,9 +51,13 @@ async function detectWindowsPlatform(conn: SshConnection): Promise arg.startsWith('ControlPath='))).toBe(true) + expect(args).toContain('ControlPersist=300') +} + type EventedProcess = EventEmitter & { stdin: EventEmitter & { write: ReturnType @@ -360,9 +370,7 @@ describe('spawnSystemSsh', () => { }) }) - expect(args).toContain('ControlMaster=auto') - expect(args.some((arg) => arg.startsWith('ControlPath='))).toBe(true) - expect(args).toContain('ControlPersist=300') + expectOrcaControlMasterArgs(args) expect(args).not.toContain('-S') }) @@ -374,9 +382,7 @@ describe('spawnSystemSsh', () => { }) }) - expect(args).toContain('ControlMaster=auto') - expect(args.some((arg) => arg.startsWith('ControlPath='))).toBe(true) - expect(args).toContain('ControlPersist=300') + expectOrcaControlMasterArgs(args) expect(args).not.toContain('-S') }) @@ -387,9 +393,7 @@ describe('spawnSystemSsh', () => { }) }) - expect(args).toContain('ControlMaster=auto') - expect(args.some((arg) => arg.startsWith('ControlPath='))).toBe(true) - expect(args).toContain('ControlPersist=300') + expectOrcaControlMasterArgs(args) expect(args).not.toContain('-S') }) @@ -414,9 +418,7 @@ describe('spawnSystemSsh', () => { resolvedConfig: createResolvedConfig() }) - expect(args).toContain('ControlMaster=auto') - expect(args.some((arg) => arg.startsWith('ControlPath='))).toBe(true) - expect(args).toContain('ControlPersist=300') + expectOrcaControlMasterArgs(args) expect(args).not.toContain('-S') }) @@ -432,10 +434,11 @@ describe('spawnSystemSsh', () => { it('adds keepalive options to Orca-owned ControlMaster connections', () => { const args = buildSshArgs(createTarget(), { resolvedConfig: createResolvedConfig() }) - expect(args).toContain('ControlMaster=auto') - expect(args).toContain('ControlPersist=300') - expect(args).toContain('ServerAliveInterval=15') - expect(args).toContain('ServerAliveCountMax=3') + expectOrcaControlMasterArgs(args) + if (process.platform !== 'win32') { + expect(args).toContain('ServerAliveInterval=15') + expect(args).toContain('ServerAliveCountMax=3') + } }) it('spawns a remote command through the system ssh target', () => { @@ -507,6 +510,15 @@ describe('spawnSystemSsh', () => { expect(mockProc.stdin.end).toHaveBeenCalledWith('contents') }) + it('marks a system command channel when local teardown is requested', () => { + const channel = spawnSystemSshCommand(createTarget(), 'npm install') + + channel.close() + + expect(channel._closeRequested).toBe(true) + expect(mockProc.kill).toHaveBeenCalledWith('SIGTERM') + }) + it('removes wrapped process listeners after command close', () => { const proc = createEventedProcess() spawnMock.mockReturnValue(proc) diff --git a/src/main/ssh/system-ssh-command.ts b/src/main/ssh/system-ssh-command.ts index 1f0ce5ed6df..eab96245ba5 100644 --- a/src/main/ssh/system-ssh-command.ts +++ b/src/main/ssh/system-ssh-command.ts @@ -17,6 +17,7 @@ export type SystemSshProcess = { export type SystemSshCommandChannel = ClientChannel & { _process?: ChildProcess + _closeRequested?: boolean } type SystemSshCommandOptions = SshExecOptions & SystemSshBuildArgsOptions @@ -103,12 +104,14 @@ function wrapCommandProcess(proc: ChildProcess): SystemSshCommandChannel { stdin: NodeJS.WritableStream stderr: NodeJS.ReadableStream _process?: ChildProcess + _closeRequested?: boolean close: () => void } mutableChannel.stdin = proc.stdin! mutableChannel.stderr = proc.stderr! mutableChannel._process = proc mutableChannel.close = () => { + mutableChannel._closeRequested = true try { proc.kill('SIGTERM') } catch { diff --git a/src/main/ssh/system-ssh-operation-lifecycle.ts b/src/main/ssh/system-ssh-operation-lifecycle.ts index 76a2ae3fb12..c9c4424325b 100644 --- a/src/main/ssh/system-ssh-operation-lifecycle.ts +++ b/src/main/ssh/system-ssh-operation-lifecycle.ts @@ -100,7 +100,13 @@ export async function awaitWithSystemSshAbort( // children to emit close after we've already signaled them. abortChildren() suppressLateOperationError = true - abortReject?.(createAbortError()) + abortReject?.( + Object.assign(createAbortError(), { + // The child was signaled, but this fast abort path intentionally does + // not wait for process exit; callers holding remote locks must retain them. + sshChannelCloseConfirmed: false + }) + ) } signal.addEventListener('abort', abort, { once: true }) if (signal.aborted) { diff --git a/src/relay/pty-handler.test.ts b/src/relay/pty-handler.test.ts index efab6bce51f..8de7ec8051b 100644 --- a/src/relay/pty-handler.test.ts +++ b/src/relay/pty-handler.test.ts @@ -181,6 +181,29 @@ describe('PtyHandler', () => { expect(handler.activePtyCount).toBe(1) }) + it('normalizes a missing native binding as degraded node-pty availability', async () => { + mockPtySpawn.mockImplementationOnce(() => { + throw new Error( + 'Failed to load native module: conpty.node, checked: build/Release, prebuilds/win32-x64' + ) + }) + + await expect(dispatcher.callRequest('pty.spawn', {})).rejects.toThrow( + 'node-pty is not available on this remote host' + ) + expect(handler.activePtyCount).toBe(0) + }) + + it('preserves unrelated node-pty spawn failures', async () => { + mockPtySpawn.mockImplementationOnce(() => { + throw new Error('File not found: missing-shell.exe') + }) + + await expect(dispatcher.callRequest('pty.spawn', {})).rejects.toThrow( + 'File not found: missing-shell.exe' + ) + }) + it('atomically caps concurrent PTY spawn admission', async () => { const results = await Promise.allSettled( Array.from({ length: MAX_RELAY_PTY_SESSIONS + 1 }, () => diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index c1de6809c15..fb0d38bf53c 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -1,6 +1,8 @@ /* oxlint-disable max-lines */ import type { IPty } from 'node-pty' import type * as NodePty from 'node-pty' +import { existsSync } from 'node:fs' +import { join } from 'node:path' import { resolveWindowsGitBashShellPath } from '../main/git-bash' import { WINDOWS_GIT_BASH_SHELL } from '../shared/windows-terminal-shell' import type { RelayDispatcher, RequestContext } from './dispatcher' @@ -38,21 +40,11 @@ import { import { isTuiAgent } from '../shared/tui-agent-config' import { forceKillPosixPtyProcessGroups } from '../main/pty/posix-pty-process-groups' -// Why: node-pty is a native addon that may not be installed on the remote. -// Dynamic import keeps the require() lazy so loadPty() returns null gracefully -// when the native module is unavailable. The static type import lets vitest -// intercept it in tests. -let ptyModule: typeof NodePty | null = null -async function loadPty(): Promise { - if (ptyModule) { - return ptyModule - } - try { - ptyModule = await import('node-pty') - return ptyModule - } catch { - return null - } +function isMissingNodePtyNativeBinding(error: unknown): boolean { + return ( + error instanceof Error && + /Failed to load native module: (?:conpty|pty)\.node(?:,|$)/.test(error.message) + ) } type ManagedPty = { @@ -296,6 +288,9 @@ export class PtyHandler { private pendingCreationDrainResolvers = new Set<() => void>() private worktreeRemovalCoordinator: RelayPtyWorktreeRemovalCoordinator | null = null private disposePromise: Promise | null = null + private ptyModule: typeof NodePty | null = null + private ptyModuleLoadPromise: Promise | null = null + private reloadPtyModuleFromDisk = false // Why: external observers need to drop per-pane state when a PTY exits. // Today the relay composes multiple consumers (hook-server cache eviction // and plugin-overlay dir cleanup) into a single callback at the call site @@ -316,6 +311,55 @@ export class PtyHandler { this.registerHandlers() } + private async loadPty(): Promise { + if (this.ptyModule) { + return this.ptyModule + } + if (this.ptyModuleLoadPromise) { + return this.ptyModuleLoadPromise + } + this.ptyModuleLoadPromise = this.loadPtyUncached() + try { + return await this.ptyModuleLoadPromise + } finally { + this.ptyModuleLoadPromise = null + } + } + + private async loadPtyUncached(): Promise { + if (!this.reloadPtyModuleFromDisk) { + try { + this.ptyModule = await import('node-pty') + return this.ptyModule + } catch { + this.reloadPtyModuleFromDisk = true + } + } + // Why: the relay is launched from its install dir today, but module + // resolution must remain tied to the deployed bundle rather than cwd. + const moduleEntry = join(__dirname, 'node_modules', 'node-pty', 'lib', 'index.js') + if (!existsSync(moduleEntry)) { + return null + } + try { + this.ptyModule = require(moduleEntry) as typeof NodePty + return this.ptyModule + } catch { + return null + } + } + + private invalidatePtyModuleAfterBindingFailure(): void { + this.ptyModule = null + this.reloadPtyModuleFromDisk = true + const moduleRoot = join(__dirname, 'node_modules', 'node-pty') + for (const cachedPath of Object.keys(require.cache)) { + if (isPathInsideOrEqual(moduleRoot, cachedPath)) { + delete require.cache[cachedPath] + } + } + } + setGraceTimeMs(graceTimeMs: number): void { this.graceTimeMs = Math.max(0, Math.floor(graceTimeMs)) } @@ -741,7 +785,7 @@ export class PtyHandler { params: Record, context?: RequestContext ): Promise<{ id: string }> { - const pty = await loadPty() + const pty = await this.loadPty() if (!pty) { throw new Error('node-pty is not available on this remote host') } @@ -814,17 +858,28 @@ export class PtyHandler { // includes Homebrew, nvm, and user-installed CLIs (claude, codex, gh). // When overlays are injected, the launch wrapper keeps those paths after // user startup files re-export their defaults. - const term = pty.spawn(shell, shellLaunch.args, { - // Why: node-pty overwrites env.TERM with `name`; keep caller-selected - // terminal identities instead of losing them at the final spawn boundary. - name: spawnEnv.TERM ?? 'xterm-256color', - cols, - rows, - cwd, - // Why: relay shells inherit process.env; never let an ambient Orca marker - // enable shell-ready behavior unless this spawn explicitly requested it. - env: { ...spawnEnv, ORCA_SHELL_READY_MARKER: '0', ...shellLaunch.env } - }) + let term: IPty + try { + term = pty.spawn(shell, shellLaunch.args, { + // Why: node-pty overwrites env.TERM with `name`; keep caller-selected + // terminal identities instead of losing them at the final spawn boundary. + name: spawnEnv.TERM ?? 'xterm-256color', + cols, + rows, + cwd, + // Why: relay shells inherit process.env; never let an ambient Orca marker + // enable shell-ready behavior unless this spawn explicitly requested it. + env: { ...spawnEnv, ORCA_SHELL_READY_MARKER: '0', ...shellLaunch.env } + }) + } catch (error) { + // Why: Windows node-pty loads conpty.node only on first spawn, after the + // wrapper import succeeded. Keep that late failure on the degraded path. + if (isMissingNodePtyNativeBinding(error)) { + this.invalidatePtyModuleAfterBindingFailure() + throw new Error('node-pty is not available on this remote host') + } + throw error + } // Why: capture the renderer-supplied paneKey on the managed entry so the // exit listener can evict per-pane caches without the relay needing a @@ -1215,7 +1270,7 @@ export class PtyHandler { } private async reviveEntry(entry: SerializedPtyEntry): Promise { - const ptyMod = await loadPty() + const ptyMod = await this.loadPty() if (!ptyMod) { return } diff --git a/src/relay/subprocess-test-utils.ts b/src/relay/subprocess-test-utils.ts index 69a6e607a06..b721f17968f 100644 --- a/src/relay/subprocess-test-utils.ts +++ b/src/relay/subprocess-test-utils.ts @@ -25,7 +25,7 @@ export type RelayProcess = { export function spawnRelay( entryPath: string, args: string[] = [], - options: Pick = {} + options: Pick = {} ): RelayProcess { const proc = spawn('node', [entryPath, ...args], { stdio: ['pipe', 'pipe', 'pipe'], diff --git a/src/relay/subprocess.test.ts b/src/relay/subprocess.test.ts index ee20461dd68..c772a8e514a 100644 --- a/src/relay/subprocess.test.ts +++ b/src/relay/subprocess.test.ts @@ -1,5 +1,13 @@ import { afterAll, beforeAll, describe, expect, it, afterEach } from 'vitest' -import { existsSync, mkdtempSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' +import { + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + unlinkSync, + writeFileSync +} from 'node:fs' import { rm } from 'node:fs/promises' import * as path from 'node:path' import { tmpdir } from 'node:os' @@ -46,9 +54,14 @@ afterAll(async () => { } }) -function spawn(args: string[] = [], env?: NodeJS.ProcessEnv): RelayProcess { +function spawnRelayEntry( + entryPath: string, + args: string[] = [], + env?: NodeJS.ProcessEnv +): RelayProcess { let relayArgs = args if (!args.includes('--sock-path')) { + // Why: Windows relays require a named pipe; filesystem socket paths fail with EACCES. const socketDir = mkdtempSync(path.join(tmpdir(), 'relay-sock-')) spawnedSocketDirs.push(socketDir) relayArgs = [ @@ -59,7 +72,11 @@ function spawn(args: string[] = [], env?: NodeJS.ProcessEnv): RelayProcess { path.join(socketDir, 'agent-hooks') ] } - return spawnRelay(relayEntry, relayArgs, env ? { env } : undefined) + return spawnRelay(entryPath, relayArgs, env ? { env } : undefined) +} + +function spawn(args: string[] = [], env?: NodeJS.ProcessEnv): RelayProcess { + return spawnRelayEntry(relayEntry, args, env) } function waitForChildExit( @@ -75,6 +92,22 @@ function waitForChildExit( }) } +function writeMockNodePty(root: string, source: string, withPackageEntry = false): void { + const nodePtyDir = path.join(root, 'node_modules', 'node-pty') + const libDir = path.join(nodePtyDir, 'lib') + mkdirSync(libDir, { recursive: true }) + if (withPackageEntry) { + writeFileSync(path.join(nodePtyDir, 'package.json'), '{"main":"lib/index.js"}\n') + } + writeFileSync(path.join(libDir, 'index.js'), source) +} + +const WORKING_NODE_PTY_MODULE = `module.exports = { spawn() { return { + pid: process.pid, + process: 'mock-shell', + onData() {}, onExit() {}, write() {}, resize() {}, kill() {}, clear() {} +} } }\n` + describe('Subprocess: Relay entry point', () => { let relay: RelayProcess | null = null let tmpDir: string @@ -103,6 +136,50 @@ describe('Subprocess: Relay entry point', () => { expect(readFileSync(relayEntry, 'utf8')).not.toContain('.toReversed(') }) + it('loads node-pty after an in-place dependency repair without restarting', async () => { + tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-native-repair-')) + const repairedRelayEntry = path.join(tmpDir, 'relay.js') + copyFileSync(relayEntry, repairedRelayEntry) + + relay = spawnRelayEntry(repairedRelayEntry) + await relay.sentinelReceived + + const failedId = relay.send('pty.spawn', { cols: 80, rows: 24 }) + const failed = await relay.waitForResponse(failedId) + expect(failed.error?.message).toContain('node-pty is not available') + + writeMockNodePty(tmpDir, WORKING_NODE_PTY_MODULE) + + const repairedId = relay.send('pty.spawn', { cols: 80, rows: 24 }) + const repaired = await relay.waitForResponse(repairedId) + expect(repaired.error).toBeUndefined() + expect(repaired.result).toMatchObject({ id: 'pty-1' }) + }, 10_000) + + it('reloads node-pty after a late native binding failure without restarting', async () => { + tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-native-late-repair-')) + const repairedRelayEntry = path.join(tmpDir, 'relay.js') + copyFileSync(relayEntry, repairedRelayEntry) + writeMockNodePty( + tmpDir, + `module.exports = { spawn() { throw new Error('Failed to load native module: conpty.node, checked: prebuilds/win32-x64') } }\n`, + true + ) + + relay = spawnRelayEntry(repairedRelayEntry) + await relay.sentinelReceived + + const failedId = relay.send('pty.spawn', { cols: 80, rows: 24 }) + const failed = await relay.waitForResponse(failedId) + expect(failed.error?.message).toContain('node-pty is not available') + + writeMockNodePty(tmpDir, WORKING_NODE_PTY_MODULE, true) + const repairedId = relay.send('pty.spawn', { cols: 80, rows: 24 }) + const repaired = await relay.waitForResponse(repairedId) + expect(repaired.error).toBeUndefined() + expect(repaired.result).toMatchObject({ id: 'pty-2' }) + }, 10_000) + it('responds to fs.stat over stdin/stdout', async () => { tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-sub-')) writeFileSync(path.join(tmpDir, 'test.txt'), 'hello') From 8ec050a875f9a482dfebc576d8c3c76c06ab059c Mon Sep 17 00:00:00 2001 From: fsdwen Date: Fri, 17 Jul 2026 12:39:13 +0800 Subject: [PATCH 04/17] =?UTF-8?q?fix(i18n):=20=E4=BF=AE=E5=A4=8D=20zh.json?= =?UTF-8?q?=20=E5=88=86=E9=A1=B5=E7=BF=BB=E8=AF=91=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E5=92=8C=209=20=E5=A4=84=E6=9C=AA=E7=BF=BB=E8=AF=91=E6=96=87?= =?UTF-8?q?=E6=A1=88=20(#8990)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 分页按钮文字修正:Next→下一页, Next page→前往下一页, Previous page→前往上一页 - 翻译 4 处 Jira 连接对话框未翻译描述文案 - 翻译 2 处 Jira 集成卡片描述(远程存储/本地存储) - 翻译 2 处文件删除确认混合场景文案 - 翻译 Grok 统一计费账户月度已用额度说明 Closes #8988 Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> --- src/renderer/src/i18n/locales/zh.json | 28 +++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index d6cd49a0234..52d6e397678 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -1606,12 +1606,12 @@ "cba2a2b7fb": "Linear 项目缺少工作区上下文。", "f4374519ae": "您的首选议题源(upstream)已不再为 {{value0}} 配置。将使用 origin。", "e9139db03f": "无法隐藏 {{value0}}。", - "b73717af92": "下一步", - "0c8df28045": "下一页", + "b73717af92": "下一页", + "0c8df28045": "前往下一页", "ae859c816b": "第{{value0}}页", "cd171f3391": "...", "297a805b64": "上一页", - "6cd6b3ae6a": "上一页", + "6cd6b3ae6a": "前往上一页", "e65757a338": "分页", "37d60046e3": "打开 GitHub 合并框", "1a9ea003dc": "无法禁用自动合并", @@ -8738,8 +8738,8 @@ "e1f5e6424c": "{{value0}} 个工作区{{value1}} 已连接", "disconnect_all": "断开连接", "account_scope_prefix": "账户范围", - "2d60ec7921": "Connect a Jira Cloud site with an API token, or a self-hosted Jira with a personal access token or username and password. Credentials are sent to the selected remote runtime and stored there with runtime-supported encryption.", - "977e360b71": "Connect a Jira Cloud site with an API token, or a self-hosted Jira with a personal access token or username and password. Credentials are stored locally and encrypted when local runtime storage supports it." + "2d60ec7921": "使用 API token 连接 Jira Cloud 站点,或使用个人访问令牌或用户名和密码连接自托管 Jira。凭据会发送到所选远程运行时并以运行时支持的加密方式存储。", + "977e360b71": "使用 API token 连接 Jira Cloud 站点,或使用个人访问令牌或用户名和密码连接自托管 Jira。凭据存储在本地,本地运行时存储支持时会进行加密。" } } } @@ -9099,9 +9099,9 @@ "b7e2d9f0a3": "与终端中 grok /usage 屏幕显示的每周额度百分比相同。", "c6d1a8f4e2": "{{when}} 重置", "e6dadc1e2b": "Monthly usage", - "75e396bf42": "Included monthly usage for Grok unified-billing accounts.", - "b36fa2c908": "Signed in. Orca reads the Grok CLI session stored on disk.", - "f08c41de73": "Session expired — run grok on the computer running Orca and wait for it to start. If prompted, complete sign-in, then click Refresh usage. No chat message is needed." + "75e396bf42": "Grok 统一计费账户的月度已用额度。", + "b36fa2c908": "已登录。Orca 读取存储在磁盘上的 Grok CLI 会话。", + "f08c41de73": "会话已过期 — 在运行 Orca 的计算机上运行 grok 并等待其启动。如果提示,请完成登录,然后点击刷新使用量。无需发送聊天消息。" }, "AppearanceWindowSidebarSection": { "usagePercentageDisplayUsed": "已用", @@ -10032,8 +10032,8 @@ "8b8ee9d22f": "无法确定哪个主机拥有此文件。请检查工作区连接后重试。", "af1270b90d": "永久删除 {{count}} 项?", "dd029aa5cd": "这会永久删除远程主机上所选的各项及任何目录内容。此操作无法撤销。", - "77fdc36183": "Delete {{count}} items?", - "fca915a67a": "Remote items are permanently deleted and cannot be undone. Local items move to the {{value0}}." + "77fdc36183": "删除 {{count}} 个项目?", + "fca915a67a": "远程项目将永久删除且无法撤销。本地项目将移至 {{value0}}。" }, "useFileExplorerHandlers": { "32cd9fd991": "无法打开符号链接目标" @@ -12764,7 +12764,7 @@ "e176f9d0c5": "Jira Cloud 站点 URL", "d785c42b8b": "使用 Jira Cloud 站点 URL、Atlassian 邮箱和 API token 来浏览议题。", "8388bdea2b": "连接 Jira 站点", - "2e2b69e48e": "Use a self-hosted Jira base URL and a personal access token to browse issues.", + "2e2b69e48e": "使用自托管 Jira 的基础 URL 和个人访问令牌来浏览议题。", "b67e919bd5": "Jira instance type", "17787d6e4b": "Atlassian Cloud", "bc7a831773": "Self-hosted", @@ -12772,15 +12772,15 @@ "cbc27fa599": "https://jira.example.com", "730d973bae": "Personal access token", "8b9c7b9e7b": "Jira personal access token", - "ccfb086d3e": "Create a personal access token in your Jira profile under Personal Access Tokens.", - "1d947a07ab": "Use a self-hosted Jira base URL, username, and password to browse issues.", + "ccfb086d3e": "在 Jira 个人资料中的 Personal Access Tokens 下创建一个个人访问令牌。", + "1d947a07ab": "使用自托管 Jira 的基础 URL、用户名和密码来浏览议题。", "f49708c369": "Jira authentication method", "84a810dd0e": "Username & password", "8d1223fa5c": "Username", "be9eba0a1b": "username", "70035652d7": "Password", "c50abbf340": "Jira account password", - "d8737db691": "Use your Jira Server or Data Center account username and password." + "d8737db691": "使用你的 Jira Server 或 Data Center 账户用户名和密码。" } } }, From 13c690b05aed1b4527ede3398d09ca7d697ebde7 Mon Sep 17 00:00:00 2001 From: Jinho Choi <83327025+nasagong@users.noreply.github.com> Date: Fri, 17 Jul 2026 13:59:44 +0900 Subject: [PATCH 05/17] fix(pet): match Codex's per-frame pacing and pointer interactions for imported pets (#8730) Imported .codex-pet bundles played every animation at a flat 8 fps (~9x too fast). Mirror Codex's exact per-frame duration tables, render uneven holds as step-end keyframes, upgrade legacy-persisted pets at render, and add the Codex mascot's pointer interactions (hover, grab-and-hold on frame 0, horizontal-drag running). Hardened over several adversarial review rounds: honor explicit fps, start each row/pet from frame 0, scope drag to its pointer, and guard untrusted persisted data. Fixes #8729 Co-authored-by: nasagong --- src/main/ipc/pet-bundle.test.ts | 35 ++++ src/main/ipc/pet-bundle.ts | 44 +++-- src/main/ipc/pet.test.ts | 63 +++++++ src/main/ipc/pet.ts | 9 +- .../pet/PetOverlay.frame-durations.test.tsx | 112 +++++++++++++ .../pet/PetOverlay.pet-switch.test.tsx | 107 ++++++++++++ .../PetOverlay.pointer-interaction.test.tsx | 156 ++++++++++++++++++ .../src/components/pet/PetOverlay.tsx | 110 ++++++------ .../components/pet/pet-agent-state.test.ts | 58 ++++++- .../src/components/pet/pet-agent-state.ts | 68 ++++++-- .../pet/sprite-animation-css.test.ts | 83 ++++++++++ .../components/pet/sprite-animation-css.ts | 99 +++++++++++ .../pet/usePetPointerInteraction.test.ts | 142 ++++++++++++++++ .../pet/usePetPointerInteraction.ts | 119 +++++++++++++ src/renderer/src/components/pet/usePetUrl.ts | 10 +- src/shared/codex-pet-sprite-defaults.test.ts | 111 +++++++++++++ src/shared/codex-pet-sprite-defaults.ts | 100 +++++++++++ src/shared/types.ts | 2 + 18 files changed, 1334 insertions(+), 94 deletions(-) create mode 100644 src/renderer/src/components/pet/PetOverlay.frame-durations.test.tsx create mode 100644 src/renderer/src/components/pet/PetOverlay.pet-switch.test.tsx create mode 100644 src/renderer/src/components/pet/PetOverlay.pointer-interaction.test.tsx create mode 100644 src/renderer/src/components/pet/sprite-animation-css.test.ts create mode 100644 src/renderer/src/components/pet/sprite-animation-css.ts create mode 100644 src/renderer/src/components/pet/usePetPointerInteraction.test.ts create mode 100644 src/renderer/src/components/pet/usePetPointerInteraction.ts create mode 100644 src/shared/codex-pet-sprite-defaults.test.ts create mode 100644 src/shared/codex-pet-sprite-defaults.ts diff --git a/src/main/ipc/pet-bundle.test.ts b/src/main/ipc/pet-bundle.test.ts index f8d4f880f4b..096dd37e853 100644 --- a/src/main/ipc/pet-bundle.test.ts +++ b/src/main/ipc/pet-bundle.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { applyCodexSpriteTimingDefaults } from '../../shared/codex-pet-sprite-defaults' import { CODEX_PET_ANIMATIONS, CODEX_PET_FRAME, @@ -63,6 +64,40 @@ describe('applyCodexPetDefaults', () => { expect(manifest.animations).toEqual(CODEX_PET_ANIMATIONS) }) + it('bakes uniform per-frame durations when a Codex-layout bundle pins an explicit fps', () => { + // The requested fps is baked as durations so it is honored rather than + // overridden by the timed table, and so the sprite never enters the + // no-durations legacy retiming path. + const manifest = applyCodexPetDefaults({ id: 'zippy', displayName: 'Zippy', fps: 4 }) + + expect(manifest.fps).toBe(4) + // 4 fps → 250ms per frame; idle keeps its six frames. + expect(manifest.animations?.idle).toEqual({ + row: 0, + frames: 6, + frameDurationsMs: [250, 250, 250, 250, 250, 250] + }) + }) + + it('keeps an explicit fps=8 bundle out of the legacy retiming path', () => { + // fps=8 matches the Codex default and the legacy geometry, but baking + // durations makes it a non-match so the uniform pacing is preserved. + const manifest = applyCodexPetDefaults({ id: 'octo', displayName: 'Octo', fps: 8 }) + const sprite = { + frameWidth: 192, + frameHeight: 208, + columns: 8, + rows: 9, + sheetWidth: 1536, + sheetHeight: 1872, + fps: 8, + defaultAnimation: 'idle', + animations: manifest.animations + } + expect(applyCodexSpriteTimingDefaults(sprite)).toBe(sprite) + expect(sprite.animations?.idle.frameDurationsMs).toEqual([125, 125, 125, 125, 125, 125]) + }) + it('does not override explicit Orca bundle sprite metadata', () => { const manifest = applyCodexPetDefaults({ spritesheetPath: 'custom.png', diff --git a/src/main/ipc/pet-bundle.ts b/src/main/ipc/pet-bundle.ts index 1d231ebaa21..1b701a7a105 100644 --- a/src/main/ipc/pet-bundle.ts +++ b/src/main/ipc/pet-bundle.ts @@ -1,4 +1,22 @@ import type { SpriteAnimation } from '../../shared/types' +import { + CODEX_PET_ANIMATIONS, + CODEX_PET_DEFAULT_ANIMATION, + CODEX_PET_DEFAULT_FPS, + CODEX_PET_FRAME, + CODEX_PET_SPRITESHEET_PATH, + codexAnimationsAtUniformFps +} from '../../shared/codex-pet-sprite-defaults' + +// Re-exported so main-side consumers keep their import path. The tables live +// in shared/ so the renderer can reuse the same fingerprint for upgrades. +export { + CODEX_PET_ANIMATIONS, + CODEX_PET_DEFAULT_ANIMATION, + CODEX_PET_DEFAULT_FPS, + CODEX_PET_FRAME, + CODEX_PET_SPRITESHEET_PATH +} export type PetManifestLike = { id?: string @@ -19,23 +37,6 @@ export type ResolvedPetManifest = T spritesheetPath: string } -export const CODEX_PET_SPRITESHEET_PATH = 'spritesheet.webp' -export const CODEX_PET_FRAME = { width: 192, height: 208 } as const -export const CODEX_PET_DEFAULT_ANIMATION = 'idle' -export const CODEX_PET_DEFAULT_FPS = 8 - -export const CODEX_PET_ANIMATIONS: Record = { - idle: { row: 0, frames: 6 }, - 'running-right': { row: 1, frames: 8 }, - 'running-left': { row: 2, frames: 8 }, - waving: { row: 3, frames: 4 }, - jumping: { row: 4, frames: 5 }, - failed: { row: 5, frames: 8 }, - waiting: { row: 6, frames: 6 }, - running: { row: 7, frames: 6 }, - review: { row: 8, frames: 6 } -} - function isCodexPetSpritePath(spritesheetPath: string | undefined): boolean { return spritesheetPath === undefined || /(^|[/\\])spritesheet\.webp$/i.test(spritesheetPath) } @@ -61,7 +62,14 @@ export function applyCodexPetDefaults( frame: manifest.frame ?? CODEX_PET_FRAME, fps: manifest.fps ?? CODEX_PET_DEFAULT_FPS, defaultAnimation: manifest.defaultAnimation ?? CODEX_PET_DEFAULT_ANIMATION, - animations: manifest.animations ?? CODEX_PET_ANIMATIONS + // Why: with no fps, bake Codex's intended uneven pacing; with an explicit + // fps, bake that as uniform durations so it is honored instead of being + // overridden by the timed table (and stays out of the legacy retiming path). + animations: + manifest.animations ?? + (manifest.fps === undefined + ? CODEX_PET_ANIMATIONS + : codexAnimationsAtUniformFps(manifest.fps)) } } diff --git a/src/main/ipc/pet.test.ts b/src/main/ipc/pet.test.ts index 59311df5613..5e857bb1244 100644 --- a/src/main/ipc/pet.test.ts +++ b/src/main/ipc/pet.test.ts @@ -109,4 +109,67 @@ describe('registerPetHandlers', () => { readFile(join(userDataDir, 'sidekicks', 'custom', result.id, 'spritesheet.png')) ).resolves.toEqual(sheetBytes) }) + + function webpVp8x(width: number, height: number): Buffer { + const u24 = (value: number): Buffer => + Buffer.from([value & 0xff, (value >> 8) & 0xff, (value >> 16) & 0xff]) + const payload = Buffer.concat([Buffer.from([0, 0, 0, 0]), u24(width - 1), u24(height - 1)]) + const size = Buffer.alloc(4) + size.writeUInt32LE(payload.byteLength, 0) + const riffSize = Buffer.alloc(4) + riffSize.writeUInt32LE(4 + 8 + payload.byteLength, 0) + return Buffer.concat([ + Buffer.from('RIFF'), + riffSize, + Buffer.from('WEBP'), + Buffer.from('VP8X'), + size, + payload + ]) + } + + async function writeSpriteBundle( + animations: Record + ): Promise { + const bundleDir = join(tempDir, 'durations.codex-pet') + await mkdir(bundleDir, { recursive: true }) + await writeFile( + join(bundleDir, 'pet.json'), + JSON.stringify({ + id: 'durations', + displayName: 'Durations', + spritesheetPath: 'sheet.webp', + frame: { width: 2, height: 2 }, + animations + }) + ) + await writeFile(join(bundleDir, 'sheet.webp'), webpVp8x(4, 2)) + return bundleDir + } + + it('imports a bundle whose animations declare per-frame durations', async () => { + const bundleDir = await writeSpriteBundle({ + idle: { row: 0, frames: 2, frameDurationsMs: [1680, 1920] } + }) + showOpenDialogMock.mockResolvedValue({ canceled: false, filePaths: [bundleDir] }) + + const result = (await getHandler('pet:importPetBundle')({ sender: {} })) as CustomPet + + expect(result.sprite?.animations?.idle).toEqual({ + row: 0, + frames: 2, + frameDurationsMs: [1680, 1920] + }) + }) + + it('rejects a bundle whose frame durations do not match the frame count', async () => { + const bundleDir = await writeSpriteBundle({ + idle: { row: 0, frames: 2, frameDurationsMs: [1680] } + }) + showOpenDialogMock.mockResolvedValue({ canceled: false, filePaths: [bundleDir] }) + + await expect(getHandler('pet:importPetBundle')({ sender: {} })).rejects.toThrow( + 'declares 1 frame durations but 2 frames' + ) + }) }) diff --git a/src/main/ipc/pet.ts b/src/main/ipc/pet.ts index 2a6c0e13c73..8e4b0d60ea0 100644 --- a/src/main/ipc/pet.ts +++ b/src/main/ipc/pet.ts @@ -112,7 +112,9 @@ const PetManifestSchema = z z.string().min(1).max(64), z.object({ row: z.number().int().min(0).max(256), - frames: z.number().int().positive().max(512) + frames: z.number().int().positive().max(512), + // Why: cap each hold at 60s so a bad manifest can't freeze the overlay. + frameDurationsMs: z.array(z.number().positive().max(60_000)).max(512).optional() }) ) .optional() @@ -394,6 +396,11 @@ export function registerPetHandlers(): void { `Animation "${name}" has ${anim.frames} frames but sheet only has ${columns} columns.` ) } + if (anim.frameDurationsMs && anim.frameDurationsMs.length !== anim.frames) { + throw new Error( + `Animation "${name}" declares ${anim.frameDurationsMs.length} frame durations but ${anim.frames} frames.` + ) + } } if (manifest.defaultAnimation && !manifest.animations[manifest.defaultAnimation]) { throw new Error(`defaultAnimation "${manifest.defaultAnimation}" not in animations.`) diff --git a/src/renderer/src/components/pet/PetOverlay.frame-durations.test.tsx b/src/renderer/src/components/pet/PetOverlay.frame-durations.test.tsx new file mode 100644 index 00000000000..1fb5819119a --- /dev/null +++ b/src/renderer/src/components/pet/PetOverlay.frame-durations.test.tsx @@ -0,0 +1,112 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const storeState = vi.hoisted(() => ({ + agentStatusByPaneKey: {}, + agentStatusEpoch: 0, + retainedAgentsByPaneKey: {}, + petSize: 180 +})) + +vi.mock('../../store', () => ({ + useAppStore: Object.assign( + (selector: (state: typeof storeState) => T): T => { + return selector(storeState) + }, + { + getState: () => storeState + } + ) +})) + +vi.mock('./usePetUrl', () => ({ + usePetUrl: () => ({ + url: 'blob:custom-pet', + ready: true, + sprite: { + frameWidth: 192, + frameHeight: 208, + columns: 8, + rows: 9, + sheetWidth: 1536, + sheetHeight: 1872, + fps: 8, + defaultAnimation: 'idle', + animations: { + // Codex ambient idle pacing: 6.6s cycle with long bookend holds. + idle: { row: 0, frames: 6, frameDurationsMs: [1680, 660, 660, 840, 840, 1920] } + } + }, + detected: null + }) +})) + +import { PetOverlay } from './PetOverlay' + +function renderPetOverlay(): { container: HTMLDivElement; root: Root } { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + act(() => { + root.render() + }) + return { container, root } +} + +function installLocalStorage(): void { + const values = new Map() + Object.defineProperty(window, 'localStorage', { + configurable: true, + value: { + clear: () => values.clear(), + getItem: (key: string) => values.get(key) ?? null, + removeItem: (key: string) => values.delete(key), + setItem: (key: string, value: string) => values.set(key, value) + } + }) +} + +describe('PetOverlay per-frame sprite durations', () => { + let root: Root | null = null + let container: HTMLDivElement | null = null + + beforeEach(() => { + installLocalStorage() + }) + + afterEach(() => { + if (root) { + act(() => root?.unmount()) + } + container?.remove() + root = null + container = null + }) + + it('emits one held keyframe stop per frame instead of uniform steps()', () => { + ;({ container, root } = renderPetOverlay()) + + const css = Array.from(container.querySelectorAll('style')) + .map((style) => style.textContent ?? '') + .join('\n') + + // Cumulative stops for [1680, 660, 660, 840, 840, 1920] over 6600ms. + expect(css).toContain('0% { background-position: 0px 0px; }') + expect(css).toContain('25.4545%') + expect(css).toContain('35.4545%') + expect(css).toContain('45.4545%') + expect(css).toContain('58.1818%') + expect(css).toContain('70.9091%') + expect(css).not.toContain('to { background-position:') + + const spriteDiv = Array.from(container.querySelectorAll('div')).find( + (div) => div.style.backgroundImage !== '' + ) + expect(spriteDiv?.style.animation).toContain('6.6s') + expect(spriteDiv?.style.animation).toContain('step-end') + expect(spriteDiv?.style.animation).toContain('infinite') + }) +}) diff --git a/src/renderer/src/components/pet/PetOverlay.pet-switch.test.tsx b/src/renderer/src/components/pet/PetOverlay.pet-switch.test.tsx new file mode 100644 index 00000000000..3467119ee18 --- /dev/null +++ b/src/renderer/src/components/pet/PetOverlay.pet-switch.test.tsx @@ -0,0 +1,107 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const storeState = vi.hoisted(() => ({ + agentStatusByPaneKey: {}, + agentStatusEpoch: 0, + retainedAgentsByPaneKey: {}, + petSize: 180 +})) + +// Why: a mutable pet so a re-render can simulate switching between two cached +// custom pets that both resolve the idle state to different rows. +const petUrlState = vi.hoisted(() => ({ + url: 'blob:pet-a', + idleRow: 0 +})) + +vi.mock('../../store', () => ({ + useAppStore: Object.assign( + (selector: (state: typeof storeState) => T): T => selector(storeState), + { getState: () => storeState } + ) +})) + +vi.mock('./usePetUrl', () => ({ + usePetUrl: () => ({ + url: petUrlState.url, + ready: true, + sprite: { + frameWidth: 192, + frameHeight: 208, + columns: 8, + rows: 9, + sheetWidth: 1536, + sheetHeight: 1872, + fps: 8, + defaultAnimation: 'idle', + animations: { idle: { row: petUrlState.idleRow, frames: 6 } } + }, + detected: null + }) +})) + +import { PetOverlay } from './PetOverlay' + +function installLocalStorage(): void { + const values = new Map() + Object.defineProperty(window, 'localStorage', { + configurable: true, + value: { + clear: () => values.clear(), + getItem: (key: string) => values.get(key) ?? null, + removeItem: (key: string) => values.delete(key), + setItem: (key: string, value: string) => values.set(key, value) + } + }) +} + +function spriteDiv(container: HTMLElement): HTMLDivElement | undefined { + return Array.from(container.querySelectorAll('div')).find( + (div) => div.style.backgroundImage !== '' + ) +} + +describe('PetOverlay pet switching', () => { + let root: Root | null = null + let container: HTMLDivElement | null = null + + beforeEach(() => { + installLocalStorage() + petUrlState.url = 'blob:pet-a' + petUrlState.idleRow = 0 + }) + + afterEach(() => { + if (root) { + act(() => root?.unmount()) + } + container?.remove() + root = null + container = null + }) + + it('remounts the sprite on a pet change so it cannot inherit the prior timeline', () => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + act(() => root?.render()) + const before = spriteDiv(container) + expect(before).toBeDefined() + + // Switch to a different cached pet whose idle maps to another row. + petUrlState.url = 'blob:pet-b' + petUrlState.idleRow = 2 + act(() => root?.render()) + + // key={url} makes React replace the element rather than update it in place; + // a fresh element starts its CSS animation at frame 0 instead of carrying + // the prior pet's currentTime. Without the key it would be the same node. + const after = spriteDiv(container) + expect(after).toBeDefined() + expect(after).not.toBe(before) + }) +}) diff --git a/src/renderer/src/components/pet/PetOverlay.pointer-interaction.test.tsx b/src/renderer/src/components/pet/PetOverlay.pointer-interaction.test.tsx new file mode 100644 index 00000000000..17fcdeb2cdc --- /dev/null +++ b/src/renderer/src/components/pet/PetOverlay.pointer-interaction.test.tsx @@ -0,0 +1,156 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const storeState = vi.hoisted(() => ({ + agentStatusByPaneKey: {}, + agentStatusEpoch: 0, + retainedAgentsByPaneKey: {}, + petSize: 180 +})) + +vi.mock('../../store', () => ({ + useAppStore: Object.assign( + (selector: (state: typeof storeState) => T): T => selector(storeState), + { getState: () => storeState } + ) +})) + +vi.mock('./usePetUrl', () => ({ + usePetUrl: () => ({ + url: 'blob:custom-pet', + ready: true, + sprite: { + frameWidth: 192, + frameHeight: 208, + columns: 8, + rows: 9, + sheetWidth: 1536, + sheetHeight: 1872, + fps: 8, + defaultAnimation: 'idle', + animations: { + idle: { row: 0, frames: 6, frameDurationsMs: [1680, 660, 660, 840, 840, 1920] }, + 'running-right': { row: 1, frames: 8 } + } + }, + detected: null + }) +})) + +import { PetOverlay } from './PetOverlay' + +function renderPetOverlay(): { container: HTMLDivElement; root: Root } { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + act(() => { + root.render() + }) + return { container, root } +} + +function installLocalStorage(): void { + const values = new Map() + Object.defineProperty(window, 'localStorage', { + configurable: true, + value: { + clear: () => values.clear(), + getItem: (key: string) => values.get(key) ?? null, + removeItem: (key: string) => values.delete(key), + setItem: (key: string, value: string) => values.set(key, value) + } + }) +} + +function spriteDiv(container: HTMLElement): HTMLDivElement { + const div = Array.from(container.querySelectorAll('div')).find( + (candidate) => candidate.style.backgroundImage !== '' + ) + if (!div) { + throw new Error('sprite div not found') + } + return div +} + +// The @keyframes name is `pet---`: the +// animation name keeps a switched-to row from reusing the prior timeline, and +// the generation suffix restarts a same-row grab from frame 0. +function animationName(container: HTMLElement): string { + return spriteDiv(container).style.animation.split(' ')[0] +} + +function firePointer(target: Element, type: string, clientX: number, clientY: number): void { + act(() => { + target.dispatchEvent( + new PointerEvent(type, { clientX, clientY, button: 0, pointerId: 1, bubbles: true }) + ) + }) +} + +describe('PetOverlay grab-and-hold pointer interaction', () => { + let root: Root | null = null + let container: HTMLDivElement | null = null + + beforeEach(() => { + installLocalStorage() + }) + + afterEach(() => { + if (root) { + act(() => root?.unmount()) + } + container?.remove() + root = null + container = null + }) + + it('freezes on a stationary grab, then animates once dragged past the deadzone', () => { + ;({ container, root } = renderPetOverlay()) + const wrapper = container.querySelector('.pointer-events-auto') + if (!wrapper) { + throw new Error('draggable wrapper not found') + } + + // Baseline: the live idle row animates (idle carries per-frame durations, so + // it renders as step-end rather than steps()). + expect(spriteDiv(container).style.animationPlayState).toBe('running') + expect(spriteDiv(container).style.animation).toContain('step-end') + expect(animationName(container)).toContain('idle') + const idleName = animationName(container) + + // Grab and hold still: mint a fresh restart (frame 0) and freeze there while + // staying on the live idle row. + firePointer(wrapper, 'pointerdown', 50, 50) + expect(spriteDiv(container).style.animationPlayState).toBe('paused') + expect(spriteDiv(container).style.animation).toContain('step-end') + expect(animationName(container)).not.toBe(idleName) + const heldName = animationName(container) + + // A sub-4px twitch stays frozen (deadzone). + firePointer(wrapper, 'pointermove', 52, 51) + expect(spriteDiv(container).style.animationPlayState).toBe('paused') + + // A large vertical-only move keeps the hold (no horizontal direction yet). + firePointer(wrapper, 'pointermove', 52, 80) + expect(spriteDiv(container).style.animationPlayState).toBe('paused') + + // Drag right past the 4px deadzone: switch to the running-right row (row 1, + // 8 frames, no durations → steps(8)) and resume animating. The keyframes + // name changes with the row, so it starts from frame 0 rather than reusing + // the idle timeline. + firePointer(wrapper, 'pointermove', 70, 80) + expect(spriteDiv(container).style.animationPlayState).toBe('running') + expect(spriteDiv(container).style.animation).toContain('steps(8)') + expect(animationName(container)).toContain('running-right') + expect(animationName(container)).not.toBe(heldName) + + // Release restores the live agent state (idle) and resumes animating. + firePointer(wrapper, 'pointerup', 70, 80) + expect(spriteDiv(container).style.animationPlayState).toBe('running') + expect(spriteDiv(container).style.animation).toContain('step-end') + expect(animationName(container)).toContain('idle') + }) +}) diff --git a/src/renderer/src/components/pet/PetOverlay.tsx b/src/renderer/src/components/pet/PetOverlay.tsx index 89fc81dc6da..a4b13c1d69b 100644 --- a/src/renderer/src/components/pet/PetOverlay.tsx +++ b/src/renderer/src/components/pet/PetOverlay.tsx @@ -5,11 +5,21 @@ import type { DetectedSpriteCacheEntry } from './pet-blob-cache' import type { CustomPet } from '../../../../shared/types' import { useAppStore } from '../../store' import { AGENT_STATUS_STALE_AFTER_MS } from '../../../../shared/agent-status-types' -import { selectPetAnimationName, type PetAnimationName } from './pet-agent-state' +import { + selectPetAnimationName, + type PetAnimationName, + type PetDragAnimation +} from './pet-agent-state' +import { usePetPointerInteraction } from './usePetPointerInteraction' +import { buildSpriteAnimationCss } from './sprite-animation-css' type Sprite = NonNullable -function usePetAnimationName(dragging: boolean): PetAnimationName { +function usePetAnimationName( + dragging: boolean, + dragAnimation: PetDragAnimation, + hovering: boolean +): PetAnimationName { const agentStatusByPaneKey = useAppStore((s) => s.agentStatusByPaneKey) const agentStatusEpoch = useAppStore((s) => s.agentStatusEpoch) const retainedAgentsByPaneKey = useAppStore((s) => s.retainedAgentsByPaneKey) @@ -22,6 +32,8 @@ function usePetAnimationName(dragging: boolean): PetAnimationName { entries: Object.values(agentStatusByPaneKey), retainedCount: Object.keys(retainedAgentsByPaneKey).length, dragging, + dragAnimation, + hovering, now: Date.now(), staleAfterMs: AGENT_STATUS_STALE_AFTER_MS }) @@ -37,15 +49,21 @@ function SpriteFrame({ sprite, animate, maxSize, - animationName + animationName, + restartKey }: { url: string sprite: Sprite animate: boolean maxSize: number animationName: PetAnimationName + // Why: folded into the keyframes name, so bumping it mints a fresh animation + // that restarts from frame 0 even when the state row is unchanged. + restartKey: number }): React.JSX.Element { - const animKeyframesId = useId().replace(/[^a-zA-Z0-9_-]/g, '') + // Why: name the @keyframes per animation (+restartKey for same-row grabs) so a + // switched-to row starts at frame 0 instead of inheriting the prior timeline. + const animKeyframesId = `${useId().replace(/[^a-zA-Z0-9_-]/g, '')}-${animationName}-${restartKey}` const anim = sprite.animations?.[animationName] || (sprite.defaultAnimation && sprite.animations?.[sprite.defaultAnimation]) || @@ -63,11 +81,15 @@ function SpriteFrame({ const bgH = sprite.sheetHeight * scale const startX = 0 const startY = -(row * sprite.frameHeight * scale) - const endX = -(frames * sprite.frameWidth * scale) - const duration = Math.max(0.1, frames / Math.max(0.1, sprite.fps)) - // Why: sprite keyframes are runtime CSS, not user-visible copy; translated - // CSS keywords make the browser discard the animation. - const keyframesCss = `@keyframes pet-${animKeyframesId} { from { background-position: ${startX}px ${startY}px; } to { background-position: ${endX}px ${startY}px; } }` + const { keyframesCss, animationCss } = buildSpriteAnimationCss({ + keyframesId: animKeyframesId, + frames, + fps: sprite.fps, + frameWidth: sprite.frameWidth, + scale, + rowOffsetY: startY, + frameDurationsMs: anim?.frameDurationsMs + }) return ( <> @@ -80,7 +102,7 @@ function SpriteFrame({ backgroundSize: `${bgW}px ${bgH}px`, backgroundPosition: `${startX}px ${startY}px`, imageRendering: 'pixelated', - animation: `pet-${animKeyframesId} ${duration}s steps(${frames}) infinite`, + animation: animationCss, animationPlayState: animate ? 'running' : 'paused' }} /> @@ -324,8 +346,10 @@ export function PetOverlay(): React.JSX.Element { }, [size] ) - const [dragging, setDragging] = useState(false) - const dragOffsetRef = useRef({ x: 0, y: 0 }) + const { dragging, dragAnimation, hovering, dragGeneration, handlers } = usePetPointerInteraction( + position, + (next) => setPosition(clampToViewport(next, size)) + ) useEffect(() => { const onResize = (): void => setPosition((prev) => clampToViewport(prev, size)) @@ -344,46 +368,12 @@ export function PetOverlay(): React.JSX.Element { } }, [dragging, position]) - const animate = documentVisible && !reducedMotion && !dragging - const animationName = usePetAnimationName(dragging) - - // Why: setPointerCapture routes subsequent pointer events to this element - // even when the cursor leaves the OS window, so dragging can't get stuck in - // the "true" state if the user releases outside the app. - const onPointerDown = (event: React.PointerEvent): void => { - if (event.button !== 0) { - return - } - dragOffsetRef.current = { - x: event.clientX - position.x, - y: event.clientY - position.y - } - event.currentTarget.setPointerCapture(event.pointerId) - setDragging(true) - event.preventDefault() - } - - const onPointerMove = (event: React.PointerEvent): void => { - if (!dragging) { - return - } - setPosition( - clampToViewport( - { - x: event.clientX - dragOffsetRef.current.x, - y: event.clientY - dragOffsetRef.current.y - }, - size - ) - ) - } - - const endDrag = (event: React.PointerEvent): void => { - if (event.currentTarget.hasPointerCapture(event.pointerId)) { - event.currentTarget.releasePointerCapture(event.pointerId) - } - setDragging(false) - } + const motionAllowed = documentVisible && !reducedMotion + // Why: a still/vertical grab freezes on frame 0 (Codex grab-and-hold); a + // horizontal drag keeps animating so the running rows show. Bob always pauses. + const spriteAnimate = motionAllowed && (!dragging || dragAnimation !== null) + const bobAnimate = motionAllowed && !dragging + const animationName = usePetAnimationName(dragging, dragAnimation, hovering) return ( // Why: the outer box and middle layer stay pointer-events-none so app chrome @@ -401,15 +391,12 @@ export function PetOverlay(): React.JSX.Element { >
    {sprite ? ( + // Why: remount per pet so switching cached sprites can't inherit the + // previous pet's animation timeline (same @keyframes name → carried + // currentTime); each pet starts clean. ) : detected ? ( - + ) : ( // Why: cap explicitly at the pet size — the w-fit/h-fit wrapper is // fit-content, so max-w/h-full has no fixed box to resolve against diff --git a/src/renderer/src/components/pet/pet-agent-state.test.ts b/src/renderer/src/components/pet/pet-agent-state.test.ts index 4c6d28b7135..09dc0813b5b 100644 --- a/src/renderer/src/components/pet/pet-agent-state.test.ts +++ b/src/renderer/src/components/pet/pet-agent-state.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import type { AgentStatusEntry, AgentStatusState } from '../../../../shared/agent-status-types' -import { selectPetAnimationName } from './pet-agent-state' +import { nextPetDragAnimation, selectPetAnimationName } from './pet-agent-state' const NOW = 1_000 const STALE_AFTER_MS = 500 @@ -28,6 +28,8 @@ function select( entries, retainedCount: 0, dragging: false, + dragAnimation: null, + hovering: false, now: NOW, staleAfterMs: STALE_AFTER_MS, ...options @@ -63,7 +65,57 @@ describe('selectPetAnimationName', () => { expect(select([entry('working'), entry('done', { interrupted: true })])).toBe('running') }) - it('uses jumping while the pet is being dragged', () => { - expect(select([entry('blocked')], { dragging: true })).toBe('jumping') + it('keeps the live agent state while grabbed and held still (no drag direction)', () => { + expect(select([entry('blocked')], { dragging: true })).toBe('waiting') + expect(select([entry('working')], { dragging: true })).toBe('running') + expect(select([], { dragging: true })).toBe('idle') + }) + + it('runs toward the drag direction while the pet is dragged horizontally', () => { + expect(select([entry('blocked')], { dragging: true, dragAnimation: 'running-right' })).toBe( + 'running-right' + ) + expect(select([], { dragging: true, dragAnimation: 'running-left' })).toBe('running-left') + }) + + it('uses jumping while hovered but not dragging', () => { + expect(select([], { hovering: true })).toBe('jumping') + expect(select([entry('working')], { hovering: true })).toBe('jumping') + }) + + it('prefers the live state over hover while grabbed (drag suppresses the hover jump)', () => { + expect(select([entry('working')], { dragging: true, hovering: true })).toBe('running') + }) +}) + +describe('nextPetDragAnimation', () => { + it('keeps the direction and baseline for sub-threshold horizontal travel', () => { + expect(nextPetDragAnimation(null, 3)).toEqual({ animation: null, accepted: false }) + expect(nextPetDragAnimation('running-right', -3)).toEqual({ + animation: 'running-right', + accepted: false + }) + }) + + it('picks the horizontal direction at the 4px threshold and advances the baseline', () => { + expect(nextPetDragAnimation(null, 4)).toEqual({ + animation: 'running-right', + accepted: true + }) + expect(nextPetDragAnimation(null, -4)).toEqual({ + animation: 'running-left', + accepted: true + }) + }) + + it('keeps the last direction without advancing when horizontal travel stays under 4px', () => { + // A vertical/near-vertical drag has ~0 horizontal delta: keep the direction + // but do not reset the baseline, so accumulated horizontal travel still adds + // up toward the threshold on a slow diagonal drag. + expect(nextPetDragAnimation('running-left', 0)).toEqual({ + animation: 'running-left', + accepted: false + }) + expect(nextPetDragAnimation(null, 0)).toEqual({ animation: null, accepted: false }) }) }) diff --git a/src/renderer/src/components/pet/pet-agent-state.ts b/src/renderer/src/components/pet/pet-agent-state.ts index f45d54bad35..26be938dd8d 100644 --- a/src/renderer/src/components/pet/pet-agent-state.ts +++ b/src/renderer/src/components/pet/pet-agent-state.ts @@ -1,27 +1,50 @@ import type { AgentStatusEntry } from '../../../../shared/agent-status-types' import { isExplicitAgentStatusFresh } from '@/lib/agent-status' -export type PetAnimationName = 'idle' | 'running' | 'waiting' | 'review' | 'jumping' +export type PetAnimationName = + | 'idle' + | 'running' + | 'waiting' + | 'review' + | 'jumping' + | 'running-right' + | 'running-left' + +export type PetDragAnimation = 'running-right' | 'running-left' | null + +// Why: direction tracks horizontal travel only. `accepted` (advance the +// baseline) fires solely on a >=4px horizontal move, so sub-threshold jitter +// and vertical drags keep the last direction without resetting the baseline — +// otherwise a slow diagonal drag would reset before ever crossing 4px. +export function nextPetDragAnimation( + current: PetDragAnimation, + deltaX: number +): { animation: PetDragAnimation; accepted: boolean } { + if (deltaX >= 4) { + return { animation: 'running-right', accepted: true } + } + if (deltaX <= -4) { + return { animation: 'running-left', accepted: true } + } + return { animation: current, accepted: false } +} export type PetAnimationInput = { entries: AgentStatusEntry[] retainedCount: number dragging: boolean + dragAnimation: PetDragAnimation + hovering: boolean now: number staleAfterMs: number } -export function selectPetAnimationName({ - entries, - retainedCount, - dragging, - now, - staleAfterMs -}: PetAnimationInput): PetAnimationName { - if (dragging) { - return 'jumping' - } - +function agentStateAnimation( + entries: AgentStatusEntry[], + retainedCount: number, + now: number, + staleAfterMs: number +): PetAnimationName { let hasWorking = false let hasDone = false @@ -47,3 +70,24 @@ export function selectPetAnimationName({ } return 'idle' } + +export function selectPetAnimationName({ + entries, + retainedCount, + dragging, + dragAnimation, + hovering, + now, + staleAfterMs +}: PetAnimationInput): PetAnimationName { + const base = agentStateAnimation(entries, retainedCount, now, staleAfterMs) + // Why: aligned with Codex. A horizontal drag runs toward the pointer, + // grab-and-hold keeps the live agent state, and only a plain hover jumps. + if (dragging) { + return dragAnimation ?? base + } + if (hovering) { + return 'jumping' + } + return base +} diff --git a/src/renderer/src/components/pet/sprite-animation-css.test.ts b/src/renderer/src/components/pet/sprite-animation-css.test.ts new file mode 100644 index 00000000000..43f72c4afd2 --- /dev/null +++ b/src/renderer/src/components/pet/sprite-animation-css.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest' +import { buildSpriteAnimationCss } from './sprite-animation-css' + +const BASE = { + keyframesId: 'kf', + frameWidth: 100, + scale: 1, + rowOffsetY: -200 +} + +describe('buildSpriteAnimationCss', () => { + it('emits one step-end stop per frame for uneven Codex-style pacing', () => { + const { keyframesCss, animationCss } = buildSpriteAnimationCss({ + ...BASE, + frames: 6, + fps: 8, + frameDurationsMs: [1680, 660, 660, 840, 840, 1920] + }) + + // Cumulative stops for [1680, 660, 660, 840, 840, 1920] over 6600ms. + expect(keyframesCss).toContain('0% { background-position: 0px -200px; }') + expect(keyframesCss).toContain('25.4545% { background-position: -100px -200px; }') + expect(keyframesCss).toContain('70.9091% { background-position: -500px -200px; }') + expect(keyframesCss).not.toContain(' to {') + expect(animationCss).toBe('pet-kf 6.6s step-end infinite') + }) + + it('falls back to uniform steps() when durations are absent, invalid, or corrupt', () => { + for (const frameDurationsMs of [ + undefined, + [100, 100], + [100, -1, 100], + [100, Number.NaN, 100], + // Untrusted persisted data: non-arrays with a matching `length` must not + // reach .every and throw. + { length: 3 } as unknown as number[], + 'aaa' as unknown as number[] + ]) { + const { keyframesCss, animationCss } = buildSpriteAnimationCss({ + ...BASE, + frames: 3, + fps: 6, + frameDurationsMs + }) + expect(keyframesCss).toBe( + '@keyframes pet-kf { from { background-position: 0px -200px; } to { background-position: -300px -200px; } }' + ) + expect(animationCss).toBe('pet-kf 0.5s steps(3) infinite') + } + }) + + it('degrades to uniform pacing rather than dropping a frame that rounds away', () => { + // A sub-precision frame among a ~minute total loses its own stop: a leading + // one collapses two stops to 0%, a trailing one rounds the final stop to + // 100% (no interval before the loop). Either way, degrade to steps() so + // every frame still renders instead of letting one vanish. + for (const frameDurationsMs of [ + [0.001, 60_000], + [60_000, 0.001] + ]) { + const { keyframesCss, animationCss } = buildSpriteAnimationCss({ + ...BASE, + frames: 2, + fps: 8, + frameDurationsMs + }) + expect(keyframesCss).not.toContain('step') + expect(keyframesCss).toContain(' to {') + expect(animationCss).toContain('steps(2)') + } + }) + + it('renders a genuinely short-but-representable frame as its own stop', () => { + const { keyframesCss } = buildSpriteAnimationCss({ + ...BASE, + frames: 2, + fps: 8, + frameDurationsMs: [10, 990] + }) + expect(keyframesCss).toContain('0% { background-position: 0px -200px; }') + expect(keyframesCss).toContain('1% { background-position: -100px -200px; }') + }) +}) diff --git a/src/renderer/src/components/pet/sprite-animation-css.ts b/src/renderer/src/components/pet/sprite-animation-css.ts new file mode 100644 index 00000000000..f04a8ecadf3 --- /dev/null +++ b/src/renderer/src/components/pet/sprite-animation-css.ts @@ -0,0 +1,99 @@ +// Sprite-sheet keyframe CSS for the pet overlay. Kept as a pure module so the +// pacing math is unit-testable without mounting the overlay or a DOM. + +export type SpriteAnimationCss = { + keyframesCss: string + animationCss: string +} + +export type SpriteAnimationCssInput = { + // Sanitized `@keyframes` identifier; folded with the restart key by the caller. + keyframesId: string + frames: number + fps: number + frameWidth: number + scale: number + // Vertical offset selecting the animation's row on the sheet. + rowOffsetY: number + // Per-frame holds in ms; uneven pacing renders when they pass validation. + frameDurationsMs: number[] | undefined +} + +// Why: sprite keyframes are runtime CSS, not user-visible copy; translated CSS +// keywords make the browser discard the animation, so keep them out of i18n. +export function buildSpriteAnimationCss({ + keyframesId, + frames, + fps, + frameWidth, + scale, + rowOffsetY, + frameDurationsMs +}: SpriteAnimationCssInput): SpriteAnimationCss { + const name = `pet-${keyframesId}` + const durations = validFrameDurations(frameDurationsMs, frames) + if (durations) { + const totalMs = durations.reduce((sum, ms) => sum + ms, 0) + // Why: Codex pets hold frames unevenly (idle rests ~1.9s on its last frame). + // steps() can't express that, so emit one step-end stop per frame. + const stops = stepEndStops(durations, totalMs, frameWidth, scale, rowOffsetY) + if (stops) { + return { + keyframesCss: `@keyframes ${name} { ${stops.join(' ')} }`, + animationCss: `${name} ${totalMs / 1000}s step-end infinite` + } + } + } + // Uniform sheet fps: one steps() run across the row. + const duration = Math.max(0.1, frames / Math.max(0.1, fps)) + const endX = -(frames * frameWidth * scale) + return { + keyframesCss: `@keyframes ${name} { from { background-position: 0px ${rowOffsetY}px; } to { background-position: ${endX}px ${rowOffsetY}px; } }`, + animationCss: `${name} ${duration}s steps(${frames}) infinite` + } +} + +function validFrameDurations( + frameDurationsMs: number[] | undefined, + frames: number +): number[] | null { + // Why: Array.isArray, not a truthiness check — persisted/RPC-synced sprites + // are untrusted, so a corrupt non-array value (e.g. { length: 6 }) must fail + // here rather than throw on .length/.every during render. + if ( + Array.isArray(frameDurationsMs) && + frameDurationsMs.length === frames && + frameDurationsMs.every((ms) => Number.isFinite(ms) && ms > 0) + ) { + return frameDurationsMs + } + return null +} + +// Cumulative step-end stops, one per frame. Returns null (→ uniform fallback) +// when a frame is too short to survive our 4-decimal precision: either two +// stops collapse to the same percentage, or the final stop rounds to 100% and +// so has no interval before the loop. Either way the frame would vanish, so we +// degrade to uniform pacing rather than silently drop it. +function stepEndStops( + durations: number[], + totalMs: number, + frameWidth: number, + scale: number, + rowOffsetY: number +): string[] | null { + const stops: string[] = [] + let elapsedMs = 0 + let previousPct = -1 + for (let index = 0; index < durations.length; index++) { + const pct = +((elapsedMs / totalMs) * 100).toFixed(4) + if (pct <= previousPct || pct >= 100) { + return null + } + previousPct = pct + const x = -(index * frameWidth * scale) + stops.push(`${pct}% { background-position: ${x}px ${rowOffsetY}px; }`) + elapsedMs += durations[index] + } + return stops +} diff --git a/src/renderer/src/components/pet/usePetPointerInteraction.test.ts b/src/renderer/src/components/pet/usePetPointerInteraction.test.ts new file mode 100644 index 00000000000..e234c32986d --- /dev/null +++ b/src/renderer/src/components/pet/usePetPointerInteraction.test.ts @@ -0,0 +1,142 @@ +// @vitest-environment happy-dom + +import { act, renderHook } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { usePetPointerInteraction } from './usePetPointerInteraction' + +type HandlerName = keyof ReturnType['handlers'] + +function fakeTarget(): { + setPointerCapture: (id: number) => void + hasPointerCapture: (id: number) => boolean + releasePointerCapture: (id: number) => void +} { + const captured = new Set() + return { + setPointerCapture: (id) => void captured.add(id), + hasPointerCapture: (id) => captured.has(id), + releasePointerCapture: (id) => void captured.delete(id) + } +} + +function setup() { + const moveTo = vi.fn() + const target = fakeTarget() + const { result } = renderHook(() => usePetPointerInteraction({ x: 0, y: 0 }, moveTo)) + + const event = (props: { + clientX?: number + clientY?: number + pointerId?: number + button?: number + }) => + ({ + button: 0, + pointerId: 1, + clientX: 0, + clientY: 0, + currentTarget: target, + preventDefault: () => {}, + ...props + }) as unknown as Parameters< + ReturnType['handlers']['onPointerDown'] + >[0] + + const fire = (name: HandlerName, props: Parameters[0] = {}): void => { + act(() => result.current.handlers[name](event(props))) + } + + return { result, moveTo, fire, event } +} + +describe('usePetPointerInteraction', () => { + it('starts a drag and bumps the restart generation on a primary grab', () => { + const { result, fire } = setup() + fire('onPointerDown', { clientX: 50, clientY: 50 }) + expect(result.current.dragging).toBe(true) + expect(result.current.dragAnimation).toBe(null) + expect(result.current.dragGeneration).toBe(1) + }) + + it('runs toward the horizontal drag direction past the 4px deadzone', () => { + const { result, fire, moveTo } = setup() + fire('onPointerDown', { clientX: 50, clientY: 50 }) + fire('onPointerMove', { clientX: 58, clientY: 50 }) + expect(result.current.dragAnimation).toBe('running-right') + expect(moveTo).toHaveBeenLastCalledWith({ x: 8, y: 0 }) + fire('onPointerMove', { clientX: 40, clientY: 50 }) + expect(result.current.dragAnimation).toBe('running-left') + }) + + it('ignores sub-deadzone jitter and keeps the last direction on vertical moves', () => { + const { result, fire } = setup() + fire('onPointerDown', { clientX: 50, clientY: 50 }) + fire('onPointerMove', { clientX: 52, clientY: 52 }) + expect(result.current.dragAnimation).toBe(null) + fire('onPointerMove', { clientX: 58, clientY: 52 }) + expect(result.current.dragAnimation).toBe('running-right') + fire('onPointerMove', { clientX: 58, clientY: 100 }) + expect(result.current.dragAnimation).toBe('running-right') + }) + + it('accumulates horizontal travel across a slow diagonal drag', () => { + // Regression: the baseline advances only on an accepted direction, so a + // sequence of sub-4px diagonal moves still crosses the threshold instead of + // resetting each time and never triggering. + const { result, fire } = setup() + fire('onPointerDown', { clientX: 50, clientY: 50 }) + fire('onPointerMove', { clientX: 53, clientY: 55 }) + expect(result.current.dragAnimation).toBe(null) + fire('onPointerMove', { clientX: 56, clientY: 60 }) + expect(result.current.dragAnimation).toBe('running-right') + }) + + it('keeps the latest horizontal direction when two moves land in one commit', () => { + // Regression: the direction lives in a ref, not the state closure, so a + // vertical move batched right after a horizontal one cannot resurrect the + // stale prior direction. + const { result } = renderHook(() => usePetPointerInteraction({ x: 0, y: 0 }, vi.fn())) + const target = fakeTarget() + const ev = (clientX: number, clientY: number) => + ({ + button: 0, + pointerId: 1, + clientX, + clientY, + currentTarget: target, + preventDefault: () => {} + }) as never + + act(() => result.current.handlers.onPointerDown(ev(50, 50))) + act(() => { + result.current.handlers.onPointerMove(ev(40, 50)) // left + result.current.handlers.onPointerMove(ev(40, 90)) // vertical from the new sample + }) + expect(result.current.dragAnimation).toBe('running-left') + }) + + it('scopes the drag to the owning pointer and ignores a second touch', () => { + const { result, fire, moveTo } = setup() + fire('onPointerDown', { clientX: 50, clientY: 50, pointerId: 1 }) + // A second pointer must not hijack the drag or mint a new restart. + fire('onPointerDown', { clientX: 50, clientY: 50, pointerId: 2 }) + expect(result.current.dragGeneration).toBe(1) + fire('onPointerMove', { clientX: 90, clientY: 50, pointerId: 2 }) + expect(moveTo).not.toHaveBeenCalled() + // Releasing the non-owning pointer leaves the drag alive. + fire('onPointerUp', { pointerId: 2 }) + expect(result.current.dragging).toBe(true) + // The owning pointer ends it. + fire('onPointerUp', { pointerId: 1 }) + expect(result.current.dragging).toBe(false) + expect(result.current.dragAnimation).toBe(null) + }) + + it('ends the drag when pointer capture is lost', () => { + const { result, fire } = setup() + fire('onPointerDown', { clientX: 50, clientY: 50 }) + fire('onLostPointerCapture', { pointerId: 1 }) + expect(result.current.dragging).toBe(false) + expect(result.current.dragAnimation).toBe(null) + }) +}) diff --git a/src/renderer/src/components/pet/usePetPointerInteraction.ts b/src/renderer/src/components/pet/usePetPointerInteraction.ts new file mode 100644 index 00000000000..9a3afd29d39 --- /dev/null +++ b/src/renderer/src/components/pet/usePetPointerInteraction.ts @@ -0,0 +1,119 @@ +import { useRef, useState, type PointerEvent as ReactPointerEvent } from 'react' +import { nextPetDragAnimation, type PetDragAnimation } from './pet-agent-state' + +type Point = { x: number; y: number } + +export type PetPointerInteraction = { + dragging: boolean + dragAnimation: PetDragAnimation + hovering: boolean + // Why: bumped on grab so the sprite restarts from frame 0, aligned with the + // Codex mascot. + dragGeneration: number + handlers: { + onPointerDown: (event: ReactPointerEvent) => void + onPointerMove: (event: ReactPointerEvent) => void + onPointerUp: (event: ReactPointerEvent) => void + onPointerCancel: (event: ReactPointerEvent) => void + onLostPointerCapture: (event: ReactPointerEvent) => void + onPointerEnter: () => void + onPointerLeave: () => void + } +} + +// Drag/hover state for the pet overlay. `position` is the overlay's current +// top-left corner and `moveTo` receives the unclamped position the drag wants. +export function usePetPointerInteraction( + position: Point, + moveTo: (next: Point) => void +): PetPointerInteraction { + const [dragging, setDragging] = useState(false) + const [dragAnimation, setDragAnimation] = useState(null) + const [hovering, setHovering] = useState(false) + const [dragGeneration, setDragGeneration] = useState(0) + const dragOffsetRef = useRef({ x: 0, y: 0 }) + // Why: horizontal baseline for the drag-direction hysteresis, advanced only on + // an accepted direction. Kept separate from dragOffsetRef (position math). + const dragBaselineXRef = useRef(0) + // Why: direction and the owning pointer are read+written inside pointer + // handlers, so keep them in refs immune to React's render batching. Reading + // the direction from state would let two coalesced moves in one commit + // resurrect a stale direction; `dragAnimation` state exists only to render. + const dragDirectionRef = useRef(null) + const activePointerRef = useRef(null) + + // Why: setPointerCapture routes subsequent pointer events to this element + // even when the cursor leaves the OS window, so dragging can't get stuck in + // the "true" state if the user releases outside the app. + const onPointerDown = (event: ReactPointerEvent): void => { + // Why: primary button only, and one pointer owns the drag — a second touch + // must not hijack the anchors mid-drag. + if (event.button !== 0 || activePointerRef.current !== null) { + return + } + activePointerRef.current = event.pointerId + dragOffsetRef.current = { + x: event.clientX - position.x, + y: event.clientY - position.y + } + dragBaselineXRef.current = event.clientX + dragDirectionRef.current = null + event.currentTarget.setPointerCapture(event.pointerId) + setDragging(true) + setDragAnimation(null) + setDragGeneration((generation) => generation + 1) + event.preventDefault() + } + + const onPointerMove = (event: ReactPointerEvent): void => { + if (event.pointerId !== activePointerRef.current) { + return + } + const next = nextPetDragAnimation( + dragDirectionRef.current, + event.clientX - dragBaselineXRef.current + ) + if (next.accepted) { + dragBaselineXRef.current = event.clientX + if (next.animation !== dragDirectionRef.current) { + dragDirectionRef.current = next.animation + setDragAnimation(next.animation) + } + } + moveTo({ + x: event.clientX - dragOffsetRef.current.x, + y: event.clientY - dragOffsetRef.current.y + }) + } + + const endDrag = (event: ReactPointerEvent): void => { + if (event.pointerId !== activePointerRef.current) { + return + } + activePointerRef.current = null + dragDirectionRef.current = null + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId) + } + setDragging(false) + setDragAnimation(null) + } + + return { + dragging, + dragAnimation, + hovering, + dragGeneration, + handlers: { + onPointerDown, + onPointerMove, + onPointerUp: endDrag, + onPointerCancel: endDrag, + // Why: capture can be revoked without a pointerup (e.g. the element loses + // it); treat that as the end of the drag so it can't wedge on. + onLostPointerCapture: endDrag, + onPointerEnter: () => setHovering(true), + onPointerLeave: () => setHovering(false) + } + } +} diff --git a/src/renderer/src/components/pet/usePetUrl.ts b/src/renderer/src/components/pet/usePetUrl.ts index 60c5dbef9d7..efc9fc05568 100644 --- a/src/renderer/src/components/pet/usePetUrl.ts +++ b/src/renderer/src/components/pet/usePetUrl.ts @@ -1,5 +1,6 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react' import type { CustomPet } from '../../../../shared/types' +import { applyCodexSpriteTimingDefaults } from '../../../../shared/codex-pet-sprite-defaults' import { useAppStore } from '../../store' import { BUNDLED_PET, findBundledPet, isBundledPetId } from './pet-models' import { @@ -114,7 +115,14 @@ export function usePetUrl(): ResolvedPet { customMeta.sprite.frameHeight > 0 && customMeta.sprite.fps > 0 ) { - return { url: customUrl, ready: true, sprite: customMeta.sprite, detected: null } + // Why: sprites persisted before per-frame durations existed pace ~9x too + // fast. Upgrade the legacy Codex fingerprint without forcing a re-import. + return { + url: customUrl, + ready: true, + sprite: applyCodexSpriteTimingDefaults(customMeta.sprite), + detected: null + } } const detected = detectedSpriteCache.get(customMeta.id) if (detected) { diff --git a/src/shared/codex-pet-sprite-defaults.test.ts b/src/shared/codex-pet-sprite-defaults.test.ts new file mode 100644 index 00000000000..efe6521acd4 --- /dev/null +++ b/src/shared/codex-pet-sprite-defaults.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest' +import { + CODEX_PET_ANIMATIONS, + CODEX_PET_DEFAULT_COLUMNS, + CODEX_PET_DEFAULT_FPS, + applyCodexSpriteTimingDefaults, + type CustomPetSprite +} from './codex-pet-sprite-defaults' + +function legacyCodexSprite(): CustomPetSprite { + return { + frameWidth: 192, + frameHeight: 208, + columns: 8, + rows: 9, + sheetWidth: 1536, + sheetHeight: 1872, + fps: CODEX_PET_DEFAULT_FPS, + defaultAnimation: 'idle', + // Pre-durations builds baked exactly this shape from the old defaults. + animations: Object.fromEntries( + Object.entries(CODEX_PET_ANIMATIONS).map(([name, anim]) => [ + name, + { row: anim.row, frames: anim.frames } + ]) + ) + } +} + +describe('CODEX_PET_ANIMATIONS', () => { + it('ships per-frame durations matching each frame count', () => { + for (const [name, anim] of Object.entries(CODEX_PET_ANIMATIONS)) { + expect(anim.frameDurationsMs, name).toBeDefined() + expect(anim.frameDurationsMs, name).toHaveLength(anim.frames) + for (const ms of anim.frameDurationsMs ?? []) { + expect(ms, name).toBeGreaterThan(0) + } + } + }) + + it('keeps the Codex ambient idle cycle at 6.6 seconds', () => { + const total = (CODEX_PET_ANIMATIONS.idle.frameDurationsMs ?? []).reduce( + (sum, ms) => sum + ms, + 0 + ) + expect(total).toBe(6600) + }) +}) + +describe('applyCodexSpriteTimingDefaults', () => { + it('upgrades the legacy baked-default fingerprint with durations', () => { + const upgraded = applyCodexSpriteTimingDefaults(legacyCodexSprite()) + expect(upgraded.animations).toEqual(CODEX_PET_ANIMATIONS) + expect(upgraded.animations?.idle.frameDurationsMs).toEqual([1680, 660, 660, 840, 840, 1920]) + }) + + it('leaves hand-authored animation layouts untouched', () => { + const sprite = legacyCodexSprite() + sprite.animations = { blink: { row: 0, frames: 2 } } + expect(applyCodexSpriteTimingDefaults(sprite)).toBe(sprite) + }) + + it('leaves sprites with a non-default fps untouched', () => { + const sprite = { ...legacyCodexSprite(), fps: 4 } + expect(applyCodexSpriteTimingDefaults(sprite)).toBe(sprite) + }) + + it('leaves sprites that already declare durations untouched', () => { + const sprite = legacyCodexSprite() + sprite.animations = { + ...sprite.animations, + idle: { row: 0, frames: 6, frameDurationsMs: [100, 100, 100, 100, 100, 100] } + } + expect(applyCodexSpriteTimingDefaults(sprite)).toBe(sprite) + }) + + it('leaves sprites without animations untouched', () => { + const sprite = { ...legacyCodexSprite(), animations: undefined } + expect(applyCodexSpriteTimingDefaults(sprite)).toBe(sprite) + }) + + it('leaves a hand-authored 8 fps sheet that only reuses the Codex row map untouched', () => { + // Same fps + nine Codex rows, but non-Codex frame geometry — must not retime. + const sprite: CustomPetSprite = { + ...legacyCodexSprite(), + frameWidth: 32, + frameHeight: 32, + sheetWidth: CODEX_PET_DEFAULT_COLUMNS * 32, + sheetHeight: 9 * 32 + } + expect(applyCodexSpriteTimingDefaults(sprite)).toBe(sprite) + }) + + it('leaves a sheet with a non-idle default animation untouched', () => { + const sprite = { ...legacyCodexSprite(), defaultAnimation: 'running' } + expect(applyCodexSpriteTimingDefaults(sprite)).toBe(sprite) + }) + + it('leaves a sheet with a non-Codex column count untouched', () => { + const sprite = { ...legacyCodexSprite(), columns: CODEX_PET_DEFAULT_COLUMNS + 2 } + expect(applyCodexSpriteTimingDefaults(sprite)).toBe(sprite) + }) + + it('does not throw on a corrupted animation entry that matches the outer fingerprint', () => { + const sprite = legacyCodexSprite() + // Persisted data is untrusted: a null value under a matching key must be a + // non-match, not a crash. + sprite.animations = { ...sprite.animations, idle: null as never } + expect(applyCodexSpriteTimingDefaults(sprite)).toBe(sprite) + }) +}) diff --git a/src/shared/codex-pet-sprite-defaults.ts b/src/shared/codex-pet-sprite-defaults.ts new file mode 100644 index 00000000000..b33625d6202 --- /dev/null +++ b/src/shared/codex-pet-sprite-defaults.ts @@ -0,0 +1,100 @@ +import type { CustomPet, SpriteAnimation } from './types' + +/** Codex pet sprite layout and pacing, mirroring the tables in Codex CLI's + * `tui/src/pets`. Shared because main bakes these into imported bundles and + * the renderer needs the same fingerprint to upgrade legacy persisted pets. */ + +export const CODEX_PET_SPRITESHEET_PATH = 'spritesheet.webp' +export const CODEX_PET_FRAME = { width: 192, height: 208 } as const +export const CODEX_PET_DEFAULT_ANIMATION = 'idle' +export const CODEX_PET_DEFAULT_FPS = 8 +// Codex sheets are 8 columns wide (its widest rows run 8 frames). +export const CODEX_PET_DEFAULT_COLUMNS = 8 + +// Codex's `app_state_animation`: every non-final frame holds `frameMs`, the last +// holds `finalMs`. Values below mirror `codex-rs/tui/src/pets/model.rs` exactly. +function appStateDurations(frames: number, frameMs: number, finalMs: number): number[] { + return Array.from({ length: frames }, (_, i) => (i === frames - 1 ? finalMs : frameMs)) +} + +export const CODEX_PET_ANIMATIONS: Record = { + // Idle is the ambient loop: long holds on the bookend frames, 6.6s cycle. + idle: { row: 0, frames: 6, frameDurationsMs: [1680, 660, 660, 840, 840, 1920] }, + 'running-right': { row: 1, frames: 8, frameDurationsMs: appStateDurations(8, 120, 220) }, + 'running-left': { row: 2, frames: 8, frameDurationsMs: appStateDurations(8, 120, 220) }, + waving: { row: 3, frames: 4, frameDurationsMs: appStateDurations(4, 140, 280) }, + jumping: { row: 4, frames: 5, frameDurationsMs: appStateDurations(5, 140, 280) }, + failed: { row: 5, frames: 8, frameDurationsMs: appStateDurations(8, 140, 240) }, + waiting: { row: 6, frames: 6, frameDurationsMs: appStateDurations(6, 150, 260) }, + running: { row: 7, frames: 6, frameDurationsMs: appStateDurations(6, 120, 220) }, + review: { row: 8, frames: 6, frameDurationsMs: appStateDurations(6, 150, 280) } +} + +/** The Codex row layout paced uniformly at `fps` (each frame held 1000/fps ms). + * A bundle that pins an explicit fps gets its pacing baked as durations, so the + * fps is honored (SpriteFrame prefers durations over the sheet fps) and the + * sprite carries durations — which keeps it distinct from the no-durations + * legacy fingerprint below, so the render-time upgrade never retimes it. */ +export function codexAnimationsAtUniformFps(fps: number): Record { + const frameMs = 1000 / fps + return Object.fromEntries( + Object.entries(CODEX_PET_ANIMATIONS).map(([name, { row, frames }]) => [ + name, + { row, frames, frameDurationsMs: Array.from({ length: frames }, () => frameMs) } + ]) + ) +} + +export type CustomPetSprite = NonNullable + +/** The exact sprite an old Orca build baked for an imported Codex bundle: + * 192x208 frames on an 8-wide sheet at the flat 8 fps rate, idle default, and + * the nine Codex rows carrying no per-frame durations. Matching the full + * geometry (not just the row map) keeps a hand-authored 8 fps sheet that merely + * reuses those rows from being silently retimed. A pet deliberately built on + * the Codex layout that wants to keep uniform pacing opts out by declaring any + * frameDurationsMs of its own. + * + * Rows/sheet height are intentionally excluded so v2 (11-row) Codex sheets, + * which still bake these nine animations, upgrade too. */ +function isLegacyCodexSprite(sprite: CustomPetSprite): boolean { + const animations = sprite.animations + if ( + !animations || + sprite.fps !== CODEX_PET_DEFAULT_FPS || + sprite.frameWidth !== CODEX_PET_FRAME.width || + sprite.frameHeight !== CODEX_PET_FRAME.height || + sprite.columns !== CODEX_PET_DEFAULT_COLUMNS || + sprite.defaultAnimation !== CODEX_PET_DEFAULT_ANIMATION + ) { + return false + } + const names = Object.keys(animations) + if (names.length !== Object.keys(CODEX_PET_ANIMATIONS).length) { + return false + } + return names.every((name) => { + const anim = animations[name] + const preset = CODEX_PET_ANIMATIONS[name] + // Why: `anim` is untrusted persisted data — guard it so a corrupted entry + // (e.g. a null value) is a non-match rather than a render-time throw. + return ( + !!preset && + !!anim && + anim.row === preset.row && + anim.frames === preset.frames && + anim.frameDurationsMs === undefined + ) + }) +} + +/** Pets imported before per-frame durations existed persist the legacy Codex + * fingerprint at the flat 8 fps sheet rate, so swap in the current defaults for + * those. Anything else passes through untouched. Render-time only — persisted + * data is never rewritten, so the upgrade is reversible on downgrade. */ +export function applyCodexSpriteTimingDefaults(sprite: CustomPetSprite): CustomPetSprite { + if (!isLegacyCodexSprite(sprite)) { + return sprite + } + return { ...sprite, animations: { ...CODEX_PET_ANIMATIONS } } +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 36297481741..b54496da29e 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -3602,6 +3602,8 @@ export type CustomPet = { export type SpriteAnimation = { row: number frames: number + /** Per-frame holds in ms (length === frames). Absent means uniform sheet fps. */ + frameDurationsMs?: number[] } export type PersistedTrustedOrcaHookEntry = { From 0cd50eecdb28046dd3b7e6a4896bd9468c46f832 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 16 Jul 2026 22:21:58 -0700 Subject: [PATCH 06/17] fix(pet): key sprite keyframes on the resolved track; cap render durations (#9140) Follow-up polish to #8730: key @keyframes on the resolved row/frames (so a same-row fallback like hover on a jumping-less pet doesn't restart idle), mirror the importer's 60s per-frame cap at render, and trim comments to the 1-2 line convention. --- .../PetOverlay.pointer-interaction.test.tsx | 32 ++++++++++++++----- .../src/components/pet/PetOverlay.tsx | 14 ++++---- .../src/components/pet/pet-agent-state.ts | 6 ++-- .../pet/sprite-animation-css.test.ts | 3 ++ .../components/pet/sprite-animation-css.ts | 18 ++++++----- .../pet/usePetPointerInteraction.ts | 6 ++-- 6 files changed, 49 insertions(+), 30 deletions(-) diff --git a/src/renderer/src/components/pet/PetOverlay.pointer-interaction.test.tsx b/src/renderer/src/components/pet/PetOverlay.pointer-interaction.test.tsx index 17fcdeb2cdc..5533b5f9052 100644 --- a/src/renderer/src/components/pet/PetOverlay.pointer-interaction.test.tsx +++ b/src/renderer/src/components/pet/PetOverlay.pointer-interaction.test.tsx @@ -75,8 +75,8 @@ function spriteDiv(container: HTMLElement): HTMLDivElement { return div } -// The @keyframes name is `pet---`: the -// animation name keeps a switched-to row from reusing the prior timeline, and +// The @keyframes name is `pet----`: the +// resolved track keeps a switched-to row from reusing the prior timeline, and // the generation suffix restarts a same-row grab from frame 0. function animationName(container: HTMLElement): string { return spriteDiv(container).style.animation.split(' ')[0] @@ -118,7 +118,6 @@ describe('PetOverlay grab-and-hold pointer interaction', () => { // it renders as step-end rather than steps()). expect(spriteDiv(container).style.animationPlayState).toBe('running') expect(spriteDiv(container).style.animation).toContain('step-end') - expect(animationName(container)).toContain('idle') const idleName = animationName(container) // Grab and hold still: mint a fresh restart (frame 0) and freeze there while @@ -139,18 +138,35 @@ describe('PetOverlay grab-and-hold pointer interaction', () => { // Drag right past the 4px deadzone: switch to the running-right row (row 1, // 8 frames, no durations → steps(8)) and resume animating. The keyframes - // name changes with the row, so it starts from frame 0 rather than reusing - // the idle timeline. + // name changes with the resolved row, so it starts from frame 0 rather than + // reusing the idle timeline. firePointer(wrapper, 'pointermove', 70, 80) expect(spriteDiv(container).style.animationPlayState).toBe('running') expect(spriteDiv(container).style.animation).toContain('steps(8)') - expect(animationName(container)).toContain('running-right') expect(animationName(container)).not.toBe(heldName) - // Release restores the live agent state (idle) and resumes animating. + // Release returns to the idle row (step-end) and resumes animating. Same row + // and generation as the grab-hold, so it's the held-idle identity again. firePointer(wrapper, 'pointerup', 70, 80) expect(spriteDiv(container).style.animationPlayState).toBe('running') expect(spriteDiv(container).style.animation).toContain('step-end') - expect(animationName(container)).toContain('idle') + expect(animationName(container)).toBe(heldName) + }) + + it('does not restart the idle track when hover falls back to the same row', () => { + ;({ container, root } = renderPetOverlay()) + const wrapper = container.querySelector('.pointer-events-auto') + if (!wrapper) { + throw new Error('draggable wrapper not found') + } + // The mocked sprite has no jumping row, so hover (→ jumping) resolves back to + // idle. Keying the keyframes on the resolved row means the name must not + // change — otherwise the unchanged idle animation restarts on hover. React + // synthesizes onPointerEnter/Leave from pointerover/pointerout. + const idleName = animationName(container) + firePointer(wrapper, 'pointerover', 0, 0) + expect(animationName(container)).toBe(idleName) + firePointer(wrapper, 'pointerout', 0, 0) + expect(animationName(container)).toBe(idleName) }) }) diff --git a/src/renderer/src/components/pet/PetOverlay.tsx b/src/renderer/src/components/pet/PetOverlay.tsx index a4b13c1d69b..052ff64750a 100644 --- a/src/renderer/src/components/pet/PetOverlay.tsx +++ b/src/renderer/src/components/pet/PetOverlay.tsx @@ -61,9 +61,7 @@ function SpriteFrame({ // that restarts from frame 0 even when the state row is unchanged. restartKey: number }): React.JSX.Element { - // Why: name the @keyframes per animation (+restartKey for same-row grabs) so a - // switched-to row starts at frame 0 instead of inheriting the prior timeline. - const animKeyframesId = `${useId().replace(/[^a-zA-Z0-9_-]/g, '')}-${animationName}-${restartKey}` + const baseId = useId().replace(/[^a-zA-Z0-9_-]/g, '') const anim = sprite.animations?.[animationName] || (sprite.defaultAnimation && sprite.animations?.[sprite.defaultAnimation]) || @@ -72,6 +70,11 @@ function SpriteFrame({ // Why: clamp to >=1 so an empty/invalid manifest can't produce steps(0), // which is rejected as invalid CSS and freezes the animation. const frames = Math.max(1, anim?.frames ?? sprite.columns ?? 1) + // Why: name the @keyframes by the RESOLVED track (+restartKey for same-row + // grabs), so a genuine row change starts at frame 0 while a state that falls + // back to the same row (e.g. hover on a pet without a jumping row) doesn't + // needlessly restart. + const animKeyframesId = `${baseId}-${row}-${frames}-${restartKey}` // Why: allow fractional downscaling so frames larger than maxSize shrink to // fit instead of overflowing the overlay; mirrors DetectedSpriteFrame's math. const scale = Math.min(maxSize / sprite.frameWidth, maxSize / sprite.frameHeight) @@ -406,9 +409,8 @@ export function PetOverlay(): React.JSX.Element { > {sprite ? ( - // Why: remount per pet so switching cached sprites can't inherit the - // previous pet's animation timeline (same @keyframes name → carried - // currentTime); each pet starts clean. + // Why: remount per pet so a switched-to sprite starts a fresh + // animation instead of inheriting the prior pet's currentTime. =4px horizontal move, so sub-threshold jitter -// and vertical drags keep the last direction without resetting the baseline — -// otherwise a slow diagonal drag would reset before ever crossing 4px. +// Why: direction tracks horizontal travel only; `accepted` (advance the baseline) +// fires only on a >=4px horizontal move so slow diagonal drags still accumulate. export function nextPetDragAnimation( current: PetDragAnimation, deltaX: number diff --git a/src/renderer/src/components/pet/sprite-animation-css.test.ts b/src/renderer/src/components/pet/sprite-animation-css.test.ts index 43f72c4afd2..cf694e66bd4 100644 --- a/src/renderer/src/components/pet/sprite-animation-css.test.ts +++ b/src/renderer/src/components/pet/sprite-animation-css.test.ts @@ -31,6 +31,9 @@ describe('buildSpriteAnimationCss', () => { [100, 100], [100, -1, 100], [100, Number.NaN, 100], + // Above the importer's 60s cap: corrupt/hand-edited persisted holds must + // not freeze the overlay. + [100, 70_000, 100], // Untrusted persisted data: non-arrays with a matching `length` must not // reach .every and throw. { length: 3 } as unknown as number[], diff --git a/src/renderer/src/components/pet/sprite-animation-css.ts b/src/renderer/src/components/pet/sprite-animation-css.ts index f04a8ecadf3..0970acaa7aa 100644 --- a/src/renderer/src/components/pet/sprite-animation-css.ts +++ b/src/renderer/src/components/pet/sprite-animation-css.ts @@ -1,6 +1,10 @@ // Sprite-sheet keyframe CSS for the pet overlay. Kept as a pure module so the // pacing math is unit-testable without mounting the overlay or a DOM. +// Mirror of the importer's per-frame cap (pet.ts zod schema): a hold longer than +// this at render would freeze the overlay, so reject it on the render side too. +const MAX_FRAME_DURATION_MS = 60_000 + export type SpriteAnimationCss = { keyframesCss: string animationCss: string @@ -57,13 +61,13 @@ function validFrameDurations( frameDurationsMs: number[] | undefined, frames: number ): number[] | null { - // Why: Array.isArray, not a truthiness check — persisted/RPC-synced sprites - // are untrusted, so a corrupt non-array value (e.g. { length: 6 }) must fail - // here rather than throw on .length/.every during render. + // Why: Array.isArray + bounds, not a truthiness check — persisted/RPC-synced + // sprites are untrusted, so a corrupt non-array or out-of-range hold falls back + // to uniform pacing instead of throwing or freezing the overlay. if ( Array.isArray(frameDurationsMs) && frameDurationsMs.length === frames && - frameDurationsMs.every((ms) => Number.isFinite(ms) && ms > 0) + frameDurationsMs.every((ms) => Number.isFinite(ms) && ms > 0 && ms <= MAX_FRAME_DURATION_MS) ) { return frameDurationsMs } @@ -71,10 +75,8 @@ function validFrameDurations( } // Cumulative step-end stops, one per frame. Returns null (→ uniform fallback) -// when a frame is too short to survive our 4-decimal precision: either two -// stops collapse to the same percentage, or the final stop rounds to 100% and -// so has no interval before the loop. Either way the frame would vanish, so we -// degrade to uniform pacing rather than silently drop it. +// when a frame is too short to survive 4-decimal precision (two stops collapse, +// or the final stop rounds to 100%) so no frame is silently dropped. function stepEndStops( durations: number[], totalMs: number, diff --git a/src/renderer/src/components/pet/usePetPointerInteraction.ts b/src/renderer/src/components/pet/usePetPointerInteraction.ts index 9a3afd29d39..92d3a45c6c9 100644 --- a/src/renderer/src/components/pet/usePetPointerInteraction.ts +++ b/src/renderer/src/components/pet/usePetPointerInteraction.ts @@ -35,10 +35,8 @@ export function usePetPointerInteraction( // Why: horizontal baseline for the drag-direction hysteresis, advanced only on // an accepted direction. Kept separate from dragOffsetRef (position math). const dragBaselineXRef = useRef(0) - // Why: direction and the owning pointer are read+written inside pointer - // handlers, so keep them in refs immune to React's render batching. Reading - // the direction from state would let two coalesced moves in one commit - // resurrect a stale direction; `dragAnimation` state exists only to render. + // Why: read+written inside handlers, so keep in refs immune to render batching + // (state would let two coalesced moves resurrect a stale direction). const dragDirectionRef = useRef(null) const activePointerRef = useRef(null) From 23368ee9dab15938aad58cdc4b204b065d037d06 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 16 Jul 2026 22:49:28 -0700 Subject: [PATCH 07/17] fix(shortcuts): preserve native input-source switching (#9108) * fix(shortcuts): preserve native input-source switching * fix(shortcuts): harden native input-source switch companions Build on #8305 (@gatsby74): normalize Space key identities so Chromium keypress without code still cancels companions, clear stale pending on blur/next keydown, and block insertText beforeinput while a native-only chord is live. Add #8299 regression coverage. Co-authored-by: gatsby74 <166927047+gatsby74@users.noreply.github.com> * fix(shortcuts): harden native-only event tracking --------- Co-authored-by: gatsby74 <166927047+gatsby74@users.noreply.github.com> --- .../terminal-pane/keyboard-handlers.ts | 46 +++++ ...epro-8299-shift-space-input-source.test.ts | 181 ++++++++++++++++++ .../terminal-native-only-shortcut.ts | 119 ++++++++++++ .../terminal-shortcut-policy.test.ts | 44 +++++ .../terminal-pane/terminal-shortcut-policy.ts | 8 + .../xterm-bypass-policy-non-mac.test.ts | 6 + .../terminal-pane/xterm-bypass-policy.test.ts | 6 + src/shared/keybindings.test.ts | 16 ++ src/shared/keybindings.ts | 46 ++++- 9 files changed, 469 insertions(+), 3 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/repro-8299-shift-space-input-source.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-native-only-shortcut.ts diff --git a/src/renderer/src/components/terminal-pane/keyboard-handlers.ts b/src/renderer/src/components/terminal-pane/keyboard-handlers.ts index 61a092fd56f..2578d657569 100644 --- a/src/renderer/src/components/terminal-pane/keyboard-handlers.ts +++ b/src/renderer/src/components/terminal-pane/keyboard-handlers.ts @@ -8,6 +8,7 @@ import type { PtyTransport } from './pty-transport' import { safeFind } from '../terminal-search-safe-find' import { resolveTerminalShortcutAction } from './terminal-shortcut-policy' import type { MacOptionAsAlt } from './terminal-shortcut-policy' +import { createTerminalNativeOnlyShortcutTracker } from './terminal-native-only-shortcut' import { keybindingMatchesAction, type KeybindingOverrides, @@ -260,6 +261,7 @@ export function useTerminalKeyboardShortcuts({ // held. To distinguish left vs right Option, we record the Option key's // location from its own keydown event and clear it on keyup. let optionKeyLocation = 0 + const nativeOnlyShortcutTracker = createTerminalNativeOnlyShortcutTracker() const onModifierDown = (e: KeyboardEvent): void => { if (e.key === 'Alt') { optionKeyLocation = e.location @@ -300,6 +302,9 @@ export function useTerminalKeyboardShortcuts({ } const onKeyDown = (e: KeyboardEvent): void => { + // Why: replace stale state only for this physical key so rollover cannot + // disarm a still-held native-only chord before its Kitty keyup arrives. + nativeOnlyShortcutTracker.prepareKeyDown(e) const manager = managerRef.current if (!manager) { return @@ -398,6 +403,14 @@ export function useTerminalKeyboardShortcuts({ return } + if (action.type === 'switchInputSource') { + // Why: the OS must receive its default action, while xterm must receive + // none of the keydown, keypress, or keyup sequence. + nativeOnlyShortcutTracker.armKeyDown(e) + e.stopImmediatePropagation() + return + } + if (action.type === 'sendInput') { e.preventDefault() e.stopImmediatePropagation() @@ -607,13 +620,46 @@ export function useTerminalKeyboardShortcuts({ } } + const onNativeOnlyShortcutCompanion = (e: KeyboardEvent): void => { + if (nativeOnlyShortcutTracker.consumeCompanion(e)) { + // Why: canceling only the companion keypress prevents Chromium's text + // insertion without canceling the keydown default used by the OS switch. + if (e.type === 'keypress') { + e.preventDefault() + } + e.stopImmediatePropagation() + } + } + + // Why: modern Chromium can skip keypress and insert via beforeinput; block + // only this chord's text so an IME commit in the same window remains intact. + const onNativeOnlyBeforeInput = (e: Event): void => { + if (!(e instanceof InputEvent) || !nativeOnlyShortcutTracker.shouldSuppressBeforeInput(e)) { + return + } + e.preventDefault() + e.stopImmediatePropagation() + } + + const onNativeOnlyBlur = (): void => { + nativeOnlyShortcutTracker.clear() + } + window.addEventListener('keydown', onModifierDown, { capture: true }) window.addEventListener('keyup', onModifierUp, { capture: true }) window.addEventListener('keydown', onKeyDown, { capture: true }) + window.addEventListener('keypress', onNativeOnlyShortcutCompanion, { capture: true }) + window.addEventListener('keyup', onNativeOnlyShortcutCompanion, { capture: true }) + window.addEventListener('beforeinput', onNativeOnlyBeforeInput, { capture: true }) + window.addEventListener('blur', onNativeOnlyBlur) return () => { window.removeEventListener('keydown', onModifierDown, { capture: true }) window.removeEventListener('keyup', onModifierUp, { capture: true }) window.removeEventListener('keydown', onKeyDown, { capture: true }) + window.removeEventListener('keypress', onNativeOnlyShortcutCompanion, { capture: true }) + window.removeEventListener('keyup', onNativeOnlyShortcutCompanion, { capture: true }) + window.removeEventListener('beforeinput', onNativeOnlyBeforeInput, { capture: true }) + window.removeEventListener('blur', onNativeOnlyBlur) } }, [ isActive, diff --git a/src/renderer/src/components/terminal-pane/repro-8299-shift-space-input-source.test.ts b/src/renderer/src/components/terminal-pane/repro-8299-shift-space-input-source.test.ts new file mode 100644 index 00000000000..3bdfb801ae7 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/repro-8299-shift-space-input-source.test.ts @@ -0,0 +1,181 @@ +/** + * Issue #8299 — macOS Shift+Space input-source switch also sent a literal space. + * + * Regression: with terminal.switchInputSource bound to Shift+Space, the chord + * resolves to the native-only action (no PTY bytes) while unbound Shift+Space + * stays ordinary terminal input. Companion keypress/keyup are suppressible. + * + * Re-run: + * pnpm exec vitest run --config config/vitest.config.ts \ + * src/renderer/src/components/terminal-pane/repro-8299-shift-space-input-source.test.ts + */ +import { describe, expect, it } from 'vitest' +import { + createTerminalNativeOnlyShortcutTracker, + getTerminalShortcutKeyIdentity +} from './terminal-native-only-shortcut' +import { + resolveTerminalShortcutAction, + type TerminalShortcutEvent +} from './terminal-shortcut-policy' +import { shouldBypassXtermKeyboardEvent, type XtermBypassEvent } from './xterm-bypass-policy' + +function shortcutEvent( + partial: Partial & Pick +): TerminalShortcutEvent { + return { + metaKey: false, + ctrlKey: false, + altKey: false, + shiftKey: false, + ...partial + } +} + +function bypassEvent( + partial: Partial & Pick +): XtermBypassEvent { + return { + metaKey: false, + ctrlKey: false, + altKey: false, + shiftKey: false, + ...partial + } +} + +describe('issue #8299 Shift+Space input-source switch regression', () => { + it('does not globally steal Shift+Space via xterm bypass (opt-in only)', () => { + const opts = { isMac: true, hasSelection: false } + for (const type of ['keydown', 'keyup'] as const) { + expect( + shouldBypassXtermKeyboardEvent( + bypassEvent({ type, key: ' ', code: 'Space', shiftKey: true }), + opts + ) + ).toBe(false) + } + }) + + it('configures Shift+Space as native-only input-source switch without sendInput', () => { + const overrides = { 'terminal.switchInputSource': ['Shift+Space'] } + const action = resolveTerminalShortcutAction( + shortcutEvent({ key: ' ', code: 'Space', shiftKey: true }), + true, + 'false', + 0, + false, + overrides + ) + expect(action).toEqual({ type: 'switchInputSource' }) + expect(action).not.toMatchObject({ type: 'sendInput' }) + }) + + it('leaves unbound Shift+Space as non-action on macOS/Linux/Windows', () => { + // Why: native-only behavior is opt-in; non-IME users keep ordinary Space input. + for (const [isMac, isWindows] of [ + [true, false], + [false, false], + [false, true] + ] as const) { + expect( + resolveTerminalShortcutAction( + shortcutEvent({ key: ' ', code: 'Space', shiftKey: true }), + isMac, + 'false', + 0, + isWindows + ) + ).toBeNull() + } + }) + + it('resolves held Shift+Space repeats as native-only (Korean IME chord hold)', () => { + const overrides = { 'terminal.switchInputSource': ['Shift+Space'] } + expect( + resolveTerminalShortcutAction( + shortcutEvent({ key: ' ', code: 'Space', shiftKey: true, repeat: true }), + true, + 'false', + 0, + false, + overrides + ) + ).toEqual({ type: 'switchInputSource' }) + }) + + it('supports alternate OS chords such as Ctrl+Space', () => { + const overrides = { 'terminal.switchInputSource': ['Ctrl+Space'] } + expect( + resolveTerminalShortcutAction( + shortcutEvent({ key: ' ', code: 'Space', ctrlKey: true }), + false, + 'false', + 0, + false, + overrides + ) + ).toEqual({ type: 'switchInputSource' }) + }) + + it('suppresses keypress/keyup companions including keypress without code', () => { + // Why: Chromium keypress may omit code and only report key:" ". + const tracker = createTerminalNativeOnlyShortcutTracker() + tracker.armKeyDown(shortcutEvent({ key: ' ', code: 'Space', shiftKey: true })) + expect(tracker.consumeCompanion({ type: 'keypress', key: ' ' })).toBe(true) + expect(tracker.consumeCompanion({ type: 'keyup', key: ' ' })).toBe(true) + expect(tracker.consumeCompanion({ type: 'keyup', key: ' ' })).toBe(false) + }) + + it('normalizes Space identities across key/code aliases', () => { + expect(getTerminalShortcutKeyIdentity({ key: ' ', code: 'Space' })).toBe('Space') + expect(getTerminalShortcutKeyIdentity({ key: ' ' })).toBe('Space') + expect(getTerminalShortcutKeyIdentity({ key: 'Spacebar' })).toBe('Space') + expect(getTerminalShortcutKeyIdentity({ key: 'a', code: 'KeyA' })).toBe('KeyA') + }) + + it('does not suppress unrelated companions while Space is pending', () => { + const tracker = createTerminalNativeOnlyShortcutTracker() + tracker.armKeyDown({ key: ' ', code: 'Space' }) + expect(tracker.consumeCompanion({ type: 'keypress', key: 'a', code: 'KeyA' })).toBe(false) + expect(tracker.consumeCompanion({ type: 'keydown', key: ' ', code: 'Space' })).toBe(false) + expect(tracker.consumeCompanion({ type: 'keypress', key: ' ' })).toBe(true) + }) + + it('keeps Space armed through unrelated key rollover until its keyup', () => { + const tracker = createTerminalNativeOnlyShortcutTracker() + tracker.armKeyDown({ key: ' ', code: 'Space' }) + tracker.prepareKeyDown({ key: 'a', code: 'KeyA' }) + + expect( + tracker.shouldSuppressBeforeInput({ data: 'a', inputType: 'insertText', isComposing: false }) + ).toBe(false) + expect(tracker.consumeCompanion({ type: 'keyup', key: ' ', code: 'Space' })).toBe(true) + }) + + it('replaces stale state when the same physical key is pressed again', () => { + const tracker = createTerminalNativeOnlyShortcutTracker() + tracker.armKeyDown({ key: ' ', code: 'Space' }) + tracker.prepareKeyDown({ key: ' ', code: 'Space' }) + + expect(tracker.consumeCompanion({ type: 'keyup', key: ' ', code: 'Space' })).toBe(false) + }) + + it('suppresses only the shortcut text on the beforeinput fallback', () => { + const tracker = createTerminalNativeOnlyShortcutTracker() + tracker.armKeyDown({ key: ' ', code: 'Space' }) + expect( + tracker.shouldSuppressBeforeInput({ data: ' ', inputType: 'insertText', isComposing: false }) + ).toBe(true) + expect( + tracker.shouldSuppressBeforeInput({ data: '한', inputType: 'insertText', isComposing: false }) + ).toBe(false) + expect( + tracker.shouldSuppressBeforeInput({ + data: ' ', + inputType: 'insertCompositionText', + isComposing: true + }) + ).toBe(false) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-native-only-shortcut.ts b/src/renderer/src/components/terminal-pane/terminal-native-only-shortcut.ts new file mode 100644 index 00000000000..12d04a0abe8 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-native-only-shortcut.ts @@ -0,0 +1,119 @@ +type TerminalNativeOnlyShortcutKeyEvent = { + key: string + code?: string +} + +type TerminalNativeOnlyShortcutCompanionEvent = TerminalNativeOnlyShortcutKeyEvent & { + type: string +} + +type TerminalNativeOnlyShortcutBeforeInputEvent = { + data: string | null + inputType: string + isComposing: boolean +} + +export type TerminalNativeOnlyShortcutTracker = { + prepareKeyDown: (event: TerminalNativeOnlyShortcutKeyEvent) => void + armKeyDown: (event: TerminalNativeOnlyShortcutKeyEvent) => void + consumeCompanion: (event: TerminalNativeOnlyShortcutCompanionEvent) => boolean + shouldSuppressBeforeInput: (event: TerminalNativeOnlyShortcutBeforeInputEvent) => boolean + clear: () => void +} + +/** + * Physical-key identity for pairing a native-only keydown with its keypress/keyup. + * Why: Chromium keypress can omit `code` and report only `key: " "`; normalize so + * keydown (`code: "Space"`) still matches the companion events. + */ +export function getTerminalShortcutKeyIdentity(event: TerminalNativeOnlyShortcutKeyEvent): string { + const code = event.code?.trim() + if (code) { + return code + } + // Why: Space is the reported input-source chord; bare " " must not diverge + // from code-based identities used on keydown. + if (event.key === ' ' || event.key === 'Spacebar' || event.key === 'Space') { + return 'Space' + } + return event.key +} + +function getCompanionKeyCandidates(event: TerminalNativeOnlyShortcutKeyEvent): Set { + const candidates = new Set([getTerminalShortcutKeyIdentity(event)]) + if (event.code?.trim()) { + candidates.add(event.code.trim()) + } + if (event.key === ' ' || event.key === 'Spacebar' || event.key === 'Space') { + candidates.add('Space') + } + return candidates +} + +function consumeCompanion( + event: TerminalNativeOnlyShortcutCompanionEvent, + pendingKeys: Map +): boolean { + if (event.type !== 'keypress' && event.type !== 'keyup') { + return false + } + // Why: match either normalized identity so keypress without `code` still + // pairs with a keydown that used `code: "Space"`. + const candidates = getCompanionKeyCandidates(event) + let matched: string | null = null + for (const candidate of candidates) { + if (pendingKeys.has(candidate)) { + matched = candidate + break + } + } + if (!matched) { + return false + } + if (event.type === 'keyup') { + pendingKeys.delete(matched) + // Why: drop any alias forms for the same physical key after release. + for (const candidate of candidates) { + pendingKeys.delete(candidate) + } + } + return true +} + +function getExpectedText(key: string): string | null { + return key === 'Spacebar' || key === 'Space' ? ' ' : key.length === 1 ? key : null +} + +export function createTerminalNativeOnlyShortcutTracker(): TerminalNativeOnlyShortcutTracker { + const pendingKeys = new Map() + + const shouldSuppressBeforeInput = ( + event: TerminalNativeOnlyShortcutBeforeInputEvent + ): boolean => { + if (event.inputType !== 'insertText' || event.isComposing) { + return false + } + // Why: an input-source change can also commit IME text before keyup; cancel + // only the printable text produced by a configured native-only chord. + for (const key of pendingKeys.values()) { + if (getExpectedText(key) === event.data) { + return true + } + } + return false + } + + return { + prepareKeyDown: (event) => { + // Why: replace a lost-keyup entry for this key without disarming other + // held native-only keys during normal key rollover. + pendingKeys.delete(getTerminalShortcutKeyIdentity(event)) + }, + armKeyDown: (event) => { + pendingKeys.set(getTerminalShortcutKeyIdentity(event), event.key) + }, + consumeCompanion: (event) => consumeCompanion(event, pendingKeys), + shouldSuppressBeforeInput, + clear: () => pendingKeys.clear() + } +} diff --git a/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts b/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts index 2cfe267669c..2736d5e0797 100644 --- a/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { createTerminalNativeOnlyShortcutTracker } from './terminal-native-only-shortcut' import { resolveTerminalShortcutAction, type TerminalShortcutEvent @@ -674,6 +675,49 @@ describe('resolveTerminalShortcutAction', () => { ) ).toEqual({ type: 'splitActivePane', direction: 'horizontal' }) }) + + it('resolves terminal.switchInputSource via explicit override (for OS input-source chords)', () => { + // Why: the configured chord must route to the native-only handler rather + // than the terminal shortcut paths that cancel the browser default. + const overrides = { 'terminal.switchInputSource': ['Shift+Space'] } + expect( + resolveTerminalShortcutAction( + event({ key: ' ', code: 'Space', shiftKey: true }), + true, + 'false', + 0, + false, + overrides + ) + ).toEqual({ type: 'switchInputSource' }) + + const otherChord = { 'terminal.switchInputSource': ['Ctrl+Space'] } + expect( + resolveTerminalShortcutAction( + event({ key: ' ', code: 'Space', ctrlKey: true }), + false, + 'false', + 0, + false, + otherChord + ) + ).toEqual({ type: 'switchInputSource' }) + }) + + it('does not resolve switchInputSource for ordinary chords without override', () => { + expect( + resolveTerminalShortcutAction(event({ key: ' ', code: 'Space', shiftKey: true }), true) + ).toBeNull() + }) + + it('suppresses the full companion event sequence for a native-only shortcut', () => { + const tracker = createTerminalNativeOnlyShortcutTracker() + tracker.armKeyDown(event({ key: ' ', code: 'Space', shiftKey: true })) + + expect(tracker.consumeCompanion({ type: 'keypress', key: ' ', code: 'Space' })).toBe(true) + expect(tracker.consumeCompanion({ type: 'keyup', key: ' ', code: 'Space' })).toBe(true) + expect(tracker.consumeCompanion({ type: 'keyup', key: ' ', code: 'Space' })).toBe(false) + }) }) describe('kitty keyboard protocol panes', () => { diff --git a/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts b/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts index c6d7fed2afd..976cfa9bb7a 100644 --- a/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts +++ b/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts @@ -42,6 +42,7 @@ export type TerminalShortcutAction = | { type: 'splitActivePane'; direction: 'vertical' | 'horizontal' } | { type: 'scrollViewport'; position: 'top' | 'bottom' } | { type: 'sendInput'; data: string } + | { type: 'switchInputSource' } /** Kitty keyboard protocol modifier field: 1 + shift(1) + alt(2). */ function kittyAltModifiers(shiftKey: boolean): number { @@ -96,6 +97,13 @@ export function resolveTerminalShortcutAction( isWindowsTerminalHost: () => boolean = () => isWindows ): TerminalShortcutAction | null { const platform: NodeJS.Platform = isMac ? 'darwin' : isWindows ? 'win32' : 'linux' + + // Why: native-only chords must be captured even on repeat without blocking + // the OS default that performs the input-source switch. + if (keybindingMatchesAction('terminal.switchInputSource', event, platform, keybindings)) { + return { type: 'switchInputSource' } + } + if (!event.repeat) { if (keybindingMatchesAction('terminal.copySelection', event, platform, keybindings)) { return { type: 'copySelection' } diff --git a/src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts b/src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts index 8ea502f9eda..2124ce1ad05 100644 --- a/src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts +++ b/src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts @@ -145,6 +145,12 @@ describe('shouldBypassXtermKeyboardEvent — Windows/Linux', () => { shouldBypassXtermKeyboardEvent(event({ key: 'c', code: 'KeyC', metaKey: true }), noSel) ).toBe(false) }) + + it('leaves ordinary Shift+Space available to the terminal', () => { + expect( + shouldBypassXtermKeyboardEvent(event({ key: ' ', code: 'Space', shiftKey: true }), noSel) + ).toBe(false) + }) }) describe('shouldSuppressTerminalImeKeyboardEvent — Windows/Linux', () => { diff --git a/src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts b/src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts index dc69ea2578d..d0ba5022866 100644 --- a/src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts +++ b/src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts @@ -148,6 +148,12 @@ describe('shouldBypassXtermKeyboardEvent — macOS', () => { shouldBypassXtermKeyboardEvent(event({ key: 'A', code: 'KeyA', shiftKey: true }), opts) ).toBe(false) }) + + it('leaves ordinary Shift+Space available to the terminal', () => { + expect( + shouldBypassXtermKeyboardEvent(event({ key: ' ', code: 'Space', shiftKey: true }), opts) + ).toBe(false) + }) }) describe('shouldSuppressTerminalImeKeyboardEvent — macOS', () => { diff --git a/src/shared/keybindings.test.ts b/src/shared/keybindings.test.ts index db6ef52f138..5b38914a75b 100644 --- a/src/shared/keybindings.test.ts +++ b/src/shared/keybindings.test.ts @@ -78,6 +78,22 @@ describe('keybindings', () => { }) }) + it('allows Shift-only chords only for native input-source switching', () => { + const shiftSpace = { + key: ' ', + code: 'Space', + control: false, + meta: false, + alt: false, + shift: true + } + + expect(keybindingFromInput(shiftSpace, 'darwin')).toMatchObject({ ok: false }) + expect( + keybindingFromInputForAction('terminal.switchInputSource', shiftSpace, 'darwin') + ).toEqual({ ok: true, value: 'Shift+Space' }) + }) + it('captures key events into canonical editable shortcuts', () => { expect( keybindingFromInput( diff --git a/src/shared/keybindings.ts b/src/shared/keybindings.ts index 097cb661eed..f250c39848d 100644 --- a/src/shared/keybindings.ts +++ b/src/shared/keybindings.ts @@ -112,6 +112,7 @@ export type KeybindingActionId = | 'terminal.closePane' | 'terminal.splitRight' | 'terminal.splitDown' + | 'terminal.switchInputSource' export type KeybindingOverrides = Partial> @@ -147,6 +148,7 @@ export type KeybindingDefinition = { defaultBindings: PlatformBindings allowInTerminal?: boolean allowBareKeybindings?: boolean + allowShiftOnlyKeybindings?: boolean conflictGroup?: string } @@ -180,6 +182,7 @@ type ParsedKeybinding = { type NormalizeKeybindingOptions = { allowBareKeybindings?: boolean + allowShiftOnlyKeybindings?: boolean } export type KeybindingValidationResult = { ok: true; value: string } | { ok: false; error: string } @@ -1038,6 +1041,32 @@ export const KEYBINDING_DEFINITIONS: readonly KeybindingDefinition[] = [ win32: ['Alt+Shift+D'] } }, + { + id: 'terminal.switchInputSource', + title: 'Switch input source / language (native)', + group: 'Terminal Panes', + scope: 'terminal', + searchKeywords: [ + 'shortcut', + 'input', + 'source', + 'language', + 'korean', + 'english', + 'ime', + 'switch', + 'hangul', + 'layout' + ], + defaultBindings: { + darwin: [], + linux: [], + win32: [] + }, + // Why: macOS permits Shift+Space as an input-source shortcut, while normal + // Orca actions reject Shift-only bindings to avoid stealing typed text. + allowShiftOnlyKeybindings: true + }, ...buildAgentTabKeybindingDefinitions() ] @@ -1389,13 +1418,21 @@ function normalizeKeybindingWithOptions( } const isShiftInsert = parsed.shift && parsed.key === 'Insert' const isBareAllowed = options.allowBareKeybindings === true && isSafeBareKey(parsed) + const isShiftOnlyAllowed = + options.allowShiftOnlyKeybindings === true && + parsed.shift && + !parsed.mod && + !parsed.meta && + !parsed.control && + !parsed.alt if ( !parsed.mod && !parsed.meta && !parsed.control && !parsed.alt && !isShiftInsert && - !isBareAllowed + !isBareAllowed && + !isShiftOnlyAllowed ) { return { ok: false, error: 'Include at least one modifier key.' } } @@ -1455,8 +1492,10 @@ function normalizeKeybindingArrayWithOptions( } function normalizeOptionsForAction(actionId: KeybindingActionId): NormalizeKeybindingOptions { + const definition = DEFINITIONS_BY_ID.get(actionId) return { - allowBareKeybindings: DEFINITIONS_BY_ID.get(actionId)?.allowBareKeybindings === true + allowBareKeybindings: definition?.allowBareKeybindings === true, + allowShiftOnlyKeybindings: definition?.allowShiftOnlyKeybindings === true } } @@ -1765,7 +1804,8 @@ export function keybindingFromInputForAction( function getDefaultBindings(definition: KeybindingDefinition, platform: NodeJS.Platform): string[] { return definition.defaultBindings[getKeybindingPlatform(platform)].map((binding) => { const normalized = normalizeKeybindingWithOptions(binding, { - allowBareKeybindings: definition.allowBareKeybindings === true + allowBareKeybindings: definition.allowBareKeybindings === true, + allowShiftOnlyKeybindings: definition.allowShiftOnlyKeybindings === true }) return normalized.ok ? normalized.value : binding }) From d0b090b0ff08e604809f1cd8744f64d955d2f45e Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 16 Jul 2026 23:10:40 -0700 Subject: [PATCH 08/17] fix(main): cancel unread fetch response bodies so a peer socket close cannot crash the app (#9142) Co-authored-by: Orca --- .../azure-devops/azure-devops-api-request.ts | 2 + src/main/azure-devops/client.test.ts | 19 ++++ src/main/bitbucket/client.test.ts | 16 +++ src/main/bitbucket/client.ts | 2 + src/main/gitea/client.test.ts | 16 +++ src/main/gitea/client.ts | 2 + src/main/global-fetch-call-site-audit.test.ts | 97 +++++++++++++++++++ .../lib/unread-response-body.test-fixtures.ts | 13 +++ src/main/lib/unread-response-body.test.ts | 27 ++++++ src/main/lib/unread-response-body.ts | 12 +++ .../profile-cloud-client.test.ts | 13 +++ .../orca-profiles/profile-cloud-client.ts | 2 + .../rate-limits/codex-fetcher-backend.test.ts | 23 +++++ src/main/rate-limits/codex-fetcher.ts | 4 + .../runtime/relay/relay-http-client.test.ts | 32 ++++++ src/main/runtime/relay/relay-http-client.ts | 3 + 16 files changed, 283 insertions(+) create mode 100644 src/main/global-fetch-call-site-audit.test.ts create mode 100644 src/main/lib/unread-response-body.test-fixtures.ts create mode 100644 src/main/lib/unread-response-body.test.ts create mode 100644 src/main/lib/unread-response-body.ts diff --git a/src/main/azure-devops/azure-devops-api-request.ts b/src/main/azure-devops/azure-devops-api-request.ts index 136b21ab0d1..089975cf5a2 100644 --- a/src/main/azure-devops/azure-devops-api-request.ts +++ b/src/main/azure-devops/azure-devops-api-request.ts @@ -1,5 +1,6 @@ import { Buffer } from 'node:buffer' import type { AzureDevOpsRepoRef } from './repository-ref' +import { cancelUnreadResponseBody } from '../lib/unread-response-body' const REQUEST_TIMEOUT_MS = 5000 @@ -84,6 +85,7 @@ export async function requestAzureDevOpsJsonAtBase( signal: AbortSignal.timeout(options.timeoutMs ?? REQUEST_TIMEOUT_MS) }) if (!response.ok) { + await cancelUnreadResponseBody(response) return null } return (await response.json()) as T diff --git a/src/main/azure-devops/client.test.ts b/src/main/azure-devops/client.test.ts index 1b3e5fb1196..d394962fcff 100644 --- a/src/main/azure-devops/client.test.ts +++ b/src/main/azure-devops/client.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { cancelTrackingResponse } from '../lib/unread-response-body.test-fixtures' import { getAzureDevOpsAuthStatus, getAzureDevOpsPullRequestForBranch, @@ -176,4 +177,22 @@ describe('Azure DevOps client', () => { headSha: 'newsha' }) }) + + it('cancels unread error-response bodies so bundled undici cannot crash on socket close', async () => { + gitExecFileAsyncMock.mockResolvedValue({ + stdout: 'https://dev.azure.com/acme/Project/_git/repo\n' + }) + let cancelledBodies = 0 + const fetchMock = vi.fn(async () => + cancelTrackingResponse(502, () => { + cancelledBodies += 1 + }) + ) + globalThis.fetch = fetchMock as never + + await getAzureDevOpsPullRequestForBranch('/repo', 'refs/heads/feature/azure') + + expect(fetchMock).toHaveBeenCalled() + expect(cancelledBodies).toBe(fetchMock.mock.calls.length) + }) }) diff --git a/src/main/bitbucket/client.test.ts b/src/main/bitbucket/client.test.ts index 9ab872358c6..4e552ab59cd 100644 --- a/src/main/bitbucket/client.test.ts +++ b/src/main/bitbucket/client.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { cancelTrackingResponse } from '../lib/unread-response-body.test-fixtures' const { gitExecFileAsyncMock } = vi.hoisted(() => ({ gitExecFileAsyncMock: vi.fn() @@ -122,4 +123,19 @@ describe('Bitbucket client', () => { account: 'bitbucket-user' }) }) + + it('cancels unread error-response bodies so bundled undici cannot crash on socket close', async () => { + let cancelledBodies = 0 + const fetchMock = vi.fn(async () => + cancelTrackingResponse(502, () => { + cancelledBodies += 1 + }) + ) + vi.stubGlobal('fetch', fetchMock) + + await getBitbucketPullRequestForBranch('/repo', 'refs/heads/feature/bitbucket') + + expect(fetchMock).toHaveBeenCalled() + expect(cancelledBodies).toBe(fetchMock.mock.calls.length) + }) }) diff --git a/src/main/bitbucket/client.ts b/src/main/bitbucket/client.ts index 5eea09b1662..6dbc11f65da 100644 --- a/src/main/bitbucket/client.ts +++ b/src/main/bitbucket/client.ts @@ -12,6 +12,7 @@ import { getHostedReviewLocalGitOptions, type HostedReviewExecutionOptions } from '../source-control/hosted-review-git-options' +import { cancelUnreadResponseBody } from '../lib/unread-response-body' const DEFAULT_API_BASE_URL = 'https://api.bitbucket.org/2.0' const REQUEST_TIMEOUT_MS = 5000 @@ -97,6 +98,7 @@ async function requestJson(path: string, options: RequestOptions = {}): Promi signal: AbortSignal.timeout(options.timeoutMs ?? REQUEST_TIMEOUT_MS) }) if (!response.ok) { + await cancelUnreadResponseBody(response) return null } return (await response.json()) as T diff --git a/src/main/gitea/client.test.ts b/src/main/gitea/client.test.ts index bf1fe13ca72..a0ae0b3ce4f 100644 --- a/src/main/gitea/client.test.ts +++ b/src/main/gitea/client.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { cancelTrackingResponse } from '../lib/unread-response-body.test-fixtures' const { gitExecFileAsyncMock } = vi.hoisted(() => ({ gitExecFileAsyncMock: vi.fn() @@ -301,4 +302,19 @@ describe('Gitea client', () => { }) expect(String(fetchMock.mock.calls[0]?.[0])).toBe('https://git.example.com/api/v1/user') }) + + it('cancels unread error-response bodies so bundled undici cannot crash on socket close', async () => { + let cancelledBodies = 0 + const fetchMock = vi.fn(async () => + cancelTrackingResponse(502, () => { + cancelledBodies += 1 + }) + ) + vi.stubGlobal('fetch', fetchMock) + + await getGiteaPullRequestForBranch('/repo', 'refs/heads/feature/gitea') + + expect(fetchMock).toHaveBeenCalled() + expect(cancelledBodies).toBe(fetchMock.mock.calls.length) + }) }) diff --git a/src/main/gitea/client.ts b/src/main/gitea/client.ts index 30545f83ad7..9889598b612 100644 --- a/src/main/gitea/client.ts +++ b/src/main/gitea/client.ts @@ -11,6 +11,7 @@ import { getHostedReviewLocalGitOptions, type HostedReviewExecutionOptions } from '../source-control/hosted-review-git-options' +import { cancelUnreadResponseBody } from '../lib/unread-response-body' const REQUEST_TIMEOUT_MS = 5000 // Why: self-hosted Forgejo can take ~5s to serve one /pulls page (it loads @@ -89,6 +90,7 @@ async function requestJsonAtBase( signal: AbortSignal.timeout(options.timeoutMs ?? REQUEST_TIMEOUT_MS) }) if (!response.ok) { + await cancelUnreadResponseBody(response) return null } return (await response.json()) as T diff --git a/src/main/global-fetch-call-site-audit.test.ts b/src/main/global-fetch-call-site-audit.test.ts new file mode 100644 index 00000000000..52049b16d59 --- /dev/null +++ b/src/main/global-fetch-call-site-audit.test.ts @@ -0,0 +1,97 @@ +import { readdirSync, readFileSync } from 'node:fs' +import { join, relative, sep } from 'node:path' +import { describe, expect, it } from 'vitest' + +// Global fetch (bare or via globalThis/global — unlike Electron's net.fetch) +// goes through Node's bundled undici, which can crash the whole process when +// an unread response body pauses the HTTP/1 parser and the peer closes the +// socket (nodejs/undici#5360, orca#8695). This applies to every Node process +// we ship: Electron main, the CLI, and the SSH relay. +// +// Each entry below maps an audited file to its expected number of matching +// lines. Real call sites must consume or cancel the body on every path, +// including !response.ok (see main/lib/unread-response-body.ts). A count +// change means a call site was added, removed, or moved: re-audit the file +// and update the count. +const AUDITED_GLOBAL_FETCH_LINES = new Map([ + // HTTP call sites — body consumed or cancelled on every path, including !ok + ['main/azure-devops/azure-devops-api-request.ts', 1], + ['main/bitbucket/client.ts', 1], + ['main/gitea/client.ts', 1], + ['main/orca-profiles/profile-cloud-client.ts', 1], + ['main/orca-profiles/profile-cloud-org-members-client.ts', 1], + ['main/rate-limits/codex-fetcher.ts', 3], + ['main/runtime/relay/relay-http-client.ts', 2], + ['main/source-control/hosted-review-api-request.ts', 1], + ['main/speech/openai-transcription-client.ts', 1], + // fetch appears only inside injected-page script source strings, not as a + // call this process makes + ['main/amp/hook-service.ts', 1], + ['main/opencode/hook-service.ts', 1], + ['main/pi/agent-status-extension-source.ts', 1], + // local identifiers named `fetch` (git fetch), not HTTP + ['main/ipc/worktree-remote.ts', 2], + ['relay/git-handler.ts', 1], + // fetch mentioned only in a comment + ['main/ipc/feedback.ts', 1] +]) + +// A line is a hit when it calls bare `fetch(` or touches `globalThis.fetch` / +// `global.fetch` in any way (call, alias, fallback like `input.fetch ?? +// globalThis.fetch`). `typeof globalThis.fetch` type annotations are exempt. +const GLOBAL_FETCH_LINE = /(^|[^.\w])fetch\(|(? { + const counts = new Map() + for (const root of SCANNED_ROOTS) { + for (const entry of readdirSync(join(srcRoot, root), { + recursive: true, + withFileTypes: true + })) { + if (!entry.isFile() || !entry.name.endsWith('.ts')) { + continue + } + if ( + entry.name.endsWith('.test.ts') || + entry.name.endsWith('.test-fixtures.ts') || + entry.name.endsWith('.d.ts') + ) { + continue + } + const filePath = join(entry.parentPath, entry.name) + const content = readFileSync(filePath, 'utf8') + if (!GLOBAL_FETCH_LINE.test(content)) { + continue + } + const hits = content.split('\n').filter((line) => GLOBAL_FETCH_LINE.test(line)).length + if (hits > 0) { + counts.set(relative(srcRoot, filePath).split(sep).join('/'), hits) + } + } + } + return counts +} + +describe('global fetch call-site audit (main, cli, relay)', () => { + it('keeps every global-fetch line audited with its expected count', () => { + const found = globalFetchLineCounts(join(__dirname, '..')) + + const drifted = [...found] + .filter(([file, count]) => AUDITED_GLOBAL_FETCH_LINES.get(file) !== count) + .map(([file, count]) => `${file}: found ${count} line(s)`) + .sort() + expect( + drifted, + 'Global fetch (bare, globalThis.fetch, or global.fetch) uses undici, ' + + 'where an unread response body can crash the whole process (orca#8695). ' + + 'New or moved call sites must either use Electron net.fetch or consume/' + + 'cancel the response body on ALL paths (cancelUnreadResponseBody in ' + + 'main/lib/unread-response-body.ts), then update AUDITED_GLOBAL_FETCH_LINES.' + ).toEqual([]) + + const stale = [...AUDITED_GLOBAL_FETCH_LINES.keys()].filter((file) => !found.has(file)).sort() + expect(stale, 'Remove audited entries whose global-fetch lines are gone.').toEqual([]) + }) +}) diff --git a/src/main/lib/unread-response-body.test-fixtures.ts b/src/main/lib/unread-response-body.test-fixtures.ts new file mode 100644 index 00000000000..c80ece2b374 --- /dev/null +++ b/src/main/lib/unread-response-body.test-fixtures.ts @@ -0,0 +1,13 @@ +/** Response whose body reports cancellation, for asserting that error paths + * cancel unread bodies (see unread-response-body.ts). */ +export function cancelTrackingResponse(status: number, onCancel: () => void): Response { + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('unread error body')) + }, + cancel() { + onCancel() + } + }) + return new Response(body, { status }) +} diff --git a/src/main/lib/unread-response-body.test.ts b/src/main/lib/unread-response-body.test.ts new file mode 100644 index 00000000000..5138de950b3 --- /dev/null +++ b/src/main/lib/unread-response-body.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import { cancelUnreadResponseBody } from './unread-response-body' +import { cancelTrackingResponse } from './unread-response-body.test-fixtures' + +describe('cancelUnreadResponseBody', () => { + it('cancels an unread body stream', async () => { + let cancelled = false + await cancelUnreadResponseBody( + cancelTrackingResponse(500, () => { + cancelled = true + }) + ) + expect(cancelled).toBe(true) + }) + + it('no-ops on a body-less response', async () => { + await expect( + cancelUnreadResponseBody(new Response(null, { status: 500 })) + ).resolves.toBeUndefined() + }) + + it('swallows cancellation failures on a locked stream', async () => { + const response = cancelTrackingResponse(500, () => {}) + response.body?.getReader() + await expect(cancelUnreadResponseBody(response)).resolves.toBeUndefined() + }) +}) diff --git a/src/main/lib/unread-response-body.ts b/src/main/lib/unread-response-body.ts new file mode 100644 index 00000000000..c34342482af --- /dev/null +++ b/src/main/lib/unread-response-body.ts @@ -0,0 +1,12 @@ +/** + * Cancel a fetch Response body that no code path will read. Why: leaving it + * unread can crash the whole process from inside Node's bundled undici + * (nodejs/undici#5360, orca#8695); see global-fetch-call-site-audit.test.ts. + */ +export async function cancelUnreadResponseBody(response: Response): Promise { + try { + await response.body?.cancel() + } catch { + // Cancelling an already-errored, locked, or closed stream is harmless. + } +} diff --git a/src/main/orca-profiles/profile-cloud-client.test.ts b/src/main/orca-profiles/profile-cloud-client.test.ts index d57f7a041ca..5ff6f2e13c7 100644 --- a/src/main/orca-profiles/profile-cloud-client.test.ts +++ b/src/main/orca-profiles/profile-cloud-client.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { cancelTrackingResponse } from '../lib/unread-response-body.test-fixtures' import type { OrcaCloudAuthConfig } from './profile-cloud-auth-config' import type { OrcaCloudSession } from './profile-cloud-session-store' import { @@ -248,4 +249,16 @@ describe('Orca cloud client', () => { } }) }) + + it('cancels the unread error-response body so bundled undici cannot crash on socket close', async () => { + let cancelledBodies = 0 + fetchMock.mockResolvedValue( + cancelTrackingResponse(502, () => { + cancelledBodies += 1 + }) + ) + + await expect(refreshOrcaCloudCapabilities(config, session)).rejects.toThrow() + expect(cancelledBodies).toBe(1) + }) }) diff --git a/src/main/orca-profiles/profile-cloud-client.ts b/src/main/orca-profiles/profile-cloud-client.ts index 81d88f72e22..e7657bbdb87 100644 --- a/src/main/orca-profiles/profile-cloud-client.ts +++ b/src/main/orca-profiles/profile-cloud-client.ts @@ -6,6 +6,7 @@ import type { import type { OrcaCloudAuthConfig } from './profile-cloud-auth-config' import type { OrcaCloudSession } from './profile-cloud-session-store' import type { OrcaCloudSessionExchangeResponse } from './profile-cloud-session-exchange' +import { cancelUnreadResponseBody } from '../lib/unread-response-body' type ExchangeCodeArgs = { code: string @@ -173,6 +174,7 @@ async function postJson(url: string, body: unknown, accessToken?: string): Pr signal: AbortSignal.timeout(CLOUD_REQUEST_TIMEOUT_MS) }) if (!response.ok) { + await cancelUnreadResponseBody(response) throw new OrcaCloudRequestError(response.status) } return (await response.json()) as T diff --git a/src/main/rate-limits/codex-fetcher-backend.test.ts b/src/main/rate-limits/codex-fetcher-backend.test.ts index 3028bee551e..d345d94a5bf 100644 --- a/src/main/rate-limits/codex-fetcher-backend.test.ts +++ b/src/main/rate-limits/codex-fetcher-backend.test.ts @@ -1,4 +1,5 @@ import { join } from 'node:path' +import { cancelTrackingResponse } from '../lib/unread-response-body.test-fixtures' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { childSpawnMock, readFileMock, ptySpawnMock } = vi.hoisted(() => ({ @@ -193,4 +194,26 @@ describe('Codex backend rate-limit requests', () => { }) ) }) + + it('cancels the unread error-response body so bundled undici cannot crash on socket close', async () => { + readFileMock.mockResolvedValue( + JSON.stringify({ + tokens: { access_token: 'access-token', account_id: 'account-id' } + }) + ) + let cancelledBodies = 0 + vi.mocked(fetch).mockResolvedValue( + cancelTrackingResponse(429, () => { + cancelledBodies += 1 + }) + ) + + await expect( + consumeCodexRateLimitResetCredit({ + codexHomePath: '/managed/codex-home', + idempotencyKey: 'redeem-429' + }) + ).rejects.toThrow('Codex reset failed: HTTP 429') + expect(cancelledBodies).toBe(1) + }) }) diff --git a/src/main/rate-limits/codex-fetcher.ts b/src/main/rate-limits/codex-fetcher.ts index d606a12e850..f7ce2a5db08 100644 --- a/src/main/rate-limits/codex-fetcher.ts +++ b/src/main/rate-limits/codex-fetcher.ts @@ -9,6 +9,7 @@ import type { import { spawn } from 'node:child_process' import { readFile } from 'node:fs/promises' import { homedir } from 'node:os' +import { cancelUnreadResponseBody } from '../lib/unread-response-body' import { join } from 'node:path' import { probeCodexAuthPresence } from './codex-auth-presence' import { resolveCodexCommand } from '../codex-cli/command' @@ -381,6 +382,7 @@ async function fetchBackendRateLimitResetCredits( signal }) if (!response.ok) { + await cancelUnreadResponseBody(response) return null } const payload = (await response.json()) as BackendRateLimitResetCreditsResponse @@ -448,6 +450,7 @@ export async function consumeCodexRateLimitResetCredit(options: { } ) if (!response.ok) { + await cancelUnreadResponseBody(response) throw new Error(`Codex reset failed: HTTP ${response.status}`) } const payload = (await response.json()) as BackendConsumeRateLimitResetCreditResponse @@ -531,6 +534,7 @@ async function fetchViaBackend( signal }) if (!response.ok) { + await cancelUnreadResponseBody(response) return null } const payload = (await response.json()) as BackendUsageResponse diff --git a/src/main/runtime/relay/relay-http-client.test.ts b/src/main/runtime/relay/relay-http-client.test.ts index 3a786210ba1..57beb527497 100644 --- a/src/main/runtime/relay/relay-http-client.test.ts +++ b/src/main/runtime/relay/relay-http-client.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import nacl from 'tweetnacl' +import { cancelTrackingResponse } from '../../lib/unread-response-body.test-fixtures' import { exchangeRelayAuthorization, requestRelayAssignment } from './relay-http-client' describe('relay HTTP client', () => { @@ -72,4 +73,35 @@ describe('relay HTTP client', () => { }) ).rejects.toThrow('relay_assignment_failed_502') }) + + it('cancels unread error-response bodies so bundled undici cannot crash on socket close', async () => { + const keypair = nacl.box.keyPair() + let cancelledBodies = 0 + const fetch = vi.fn(async () => + cancelTrackingResponse(503, () => { + cancelledBodies += 1 + }) + ) + + await expect( + exchangeRelayAuthorization({ + endpoint: 'https://auth.example/v1/desktop/auth/relay-token', + accessToken: 'ordinary-access-token', + keypair: { + ...keypair, + publicKeyB64: Buffer.from(keypair.publicKey).toString('base64') + }, + fetch + }) + ).rejects.toThrow() + await expect( + requestRelayAssignment({ + directorUrl: 'https://relay.example', + relayToken: 'scoped-token', + relayHostId: 'AbCdEf0123_-xyZ9', + fetch + }) + ).rejects.toThrow() + expect(cancelledBodies).toBe(2) + }) }) diff --git a/src/main/runtime/relay/relay-http-client.ts b/src/main/runtime/relay/relay-http-client.ts index ddda205d12b..47118b88cfc 100644 --- a/src/main/runtime/relay/relay-http-client.ts +++ b/src/main/runtime/relay/relay-http-client.ts @@ -1,6 +1,7 @@ import { createHash } from 'node:crypto' import { z } from 'zod' import type { E2EEKeypair } from '../e2ee-keypair' +import { cancelUnreadResponseBody } from '../../lib/unread-response-body' const RelayTokenResponseSchema = z .object({ @@ -69,6 +70,7 @@ export async function exchangeRelayAuthorization(input: { body: JSON.stringify({ relayHostId, hostPublicKeyB64: input.keypair.publicKeyB64 }) }) if (!response.ok) { + await cancelUnreadResponseBody(response) throw new RelayHttpError('token-exchange', response.status) } const parsed = RelayTokenResponseSchema.safeParse(await response.json()) @@ -96,6 +98,7 @@ export async function requestRelayAssignment(input: { body: JSON.stringify({ v: 1, relayHostId: input.relayHostId }) }) if (!response.ok) { + await cancelUnreadResponseBody(response) throw new RelayHttpError('assignment', response.status) } const parsed = AssignmentResponseSchema.safeParse(await response.json()) From c5d2275c35c62b3b5d51a14910214a5131fe5e25 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 17 Jul 2026 00:21:42 -0700 Subject: [PATCH 09/17] Add preference to show pinned worktrees in original lists (#6216) * Add setting for pinned worktree group display Co-authored-by: Orca * Fix pinned worktree host metadata Co-authored-by: Orca * Polish pinned worktree setting copy Co-authored-by: Orca * Clarify pinned worktree setting copy Co-authored-by: Orca * Use original lists in pinned setting copy Co-authored-by: Orca * Fix pinned sidebar order and render churn * Fix pinned host and inbox placement * Fix worktree host ownership consistency --------- Co-authored-by: Orca --- .../AppearanceWindowSidebarSection.tsx | 26 ++ .../settings/SettingsFormControls.tsx | 8 +- .../settings/appearance-sidebar-search.ts | 51 ++- .../settings/terminal-search.test.ts | 13 +- .../WorktreeList.lineage-child-card.test.ts | 45 ++- .../src/components/sidebar/WorktreeList.tsx | 267 ++++++++----- .../sidebar/host-section-rows.test.ts | 191 ++++++++- .../components/sidebar/host-section-rows.ts | 100 ++++- .../sidebar/sleep-worktree-flow.test.ts | 44 +-- .../components/sidebar/sleep-worktree-flow.ts | 9 +- .../sidebar/visible-worktrees.test.ts | 19 + .../components/sidebar/visible-worktrees.ts | 7 +- .../sidebar/worktree-drag-units.test.ts | 21 +- .../components/sidebar/worktree-drag-units.ts | 7 +- .../sidebar/worktree-list-groups.test.ts | 369 ++++++++++++++++-- .../sidebar/worktree-list-groups.ts | 210 ++++++++-- .../worktree-list-imported-rows.test.ts | 31 ++ .../sidebar/worktree-parent-candidates.ts | 4 +- .../worktree-sidebar-row-preference.test.ts | 102 +++++ .../worktree-sidebar-row-preference.ts | 64 +++ src/renderer/src/i18n/locales/en.json | 10 +- src/renderer/src/i18n/locales/es.json | 10 +- src/renderer/src/i18n/locales/ja.json | 10 +- src/renderer/src/i18n/locales/ko.json | 10 +- src/renderer/src/i18n/locales/zh.json | 10 +- src/shared/constants.ts | 1 + src/shared/execution-host.test.ts | 15 + src/shared/execution-host.ts | 15 +- src/shared/types.ts | 3 + 29 files changed, 1455 insertions(+), 217 deletions(-) create mode 100644 src/renderer/src/components/sidebar/worktree-sidebar-row-preference.test.ts create mode 100644 src/renderer/src/components/sidebar/worktree-sidebar-row-preference.ts diff --git a/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx b/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx index 3228c54669c..ad68464b3e1 100644 --- a/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx +++ b/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx @@ -22,6 +22,7 @@ import { USAGE_PERCENTAGE_DISPLAY_SETTING_ID } from './appearance-usage-percenta import { LeftSidebarAppearanceSetting } from './LeftSidebarAppearanceSetting' import { getLeftSidebarAppearanceEntry, + getShowPinnedWorktreesInGroupsEntry, getWorkspaceCardLayoutEntry } from './appearance-sidebar-search' import { translate } from '@/i18n/i18n' @@ -308,6 +309,31 @@ export function AppearanceWindowSidebarSection({ } /> + + + + updateSettings({ + showPinnedWorktreesInGroups: !(settings.showPinnedWorktreesInGroups === true) + }) + } + /> +
    ) : null} diff --git a/src/renderer/src/components/settings/SettingsFormControls.tsx b/src/renderer/src/components/settings/SettingsFormControls.tsx index 146e0000f62..7c55b38899c 100644 --- a/src/renderer/src/components/settings/SettingsFormControls.tsx +++ b/src/renderer/src/components/settings/SettingsFormControls.tsx @@ -62,6 +62,7 @@ type SettingsRowProps = { label: React.ReactNode description?: React.ReactNode control: React.ReactNode + className?: string /** Optional id applied to the label so the control can reference it via aria-labelledby. */ labelId?: string /** When true, top-align label/description and control. Useful for tall control columns. */ @@ -73,6 +74,7 @@ export function SettingsRow({ label, description, control, + className, labelId, alignTop }: SettingsRowProps): React.JSX.Element { @@ -81,7 +83,8 @@ export function SettingsRow({ className={cn( 'flex gap-4', description ? 'py-3' : 'py-2', - alignTop ? 'items-start' : 'items-center justify-between' + alignTop ? 'items-start' : 'items-center justify-between', + className )} >
    @@ -102,6 +105,7 @@ type SettingsSwitchRowProps = { description?: React.ReactNode checked: boolean onChange: () => void + className?: string ariaLabel?: string } @@ -110,12 +114,14 @@ export function SettingsSwitchRow({ description, checked, onChange, + className, ariaLabel }: SettingsSwitchRowProps): React.JSX.Element { return ( ({ + title: translate( + 'auto.components.settings.appearance.search.showPinnedWorktreesInGroups.title', + 'Also show pinned worktrees in their original lists' + ), + description: translate( + 'auto.components.settings.appearance.search.showPinnedWorktreesInGroups.description', + 'Pinned worktrees stay in Pinned and also appear in All, Project, Status, and PR.' + ), + keywords: [ + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.showPinnedWorktreesInGroups.pinned', + 'pinned' + ), + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.showPinnedWorktreesInGroups.worktree', + 'worktree' + ), + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.showPinnedWorktreesInGroups.workspace', + 'workspace' + ), + ...translateSearchKeyword('auto.components.settings.appearance.search.5bff6a2ef0', 'sidebar'), + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.showPinnedWorktreesInGroups.all', + 'all' + ), + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.showPinnedWorktreesInGroups.project', + 'project' + ), + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.showPinnedWorktreesInGroups.status', + 'status' + ), + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.showPinnedWorktreesInGroups.pr', + 'pr' + ), + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.showPinnedWorktreesInGroups.duplicate', + 'duplicate' + ) + ] + }) +) + export const getSidebarEntries = createLocalizedCatalog((): SettingsSearchEntry[] => [ { title: translate('auto.components.settings.appearance.search.155a1e7438', 'Show Tasks Button'), @@ -144,5 +192,6 @@ export const getSidebarEntries = createLocalizedCatalog((): SettingsSearchEntry[ ] }, getWorkspaceCardLayoutEntry(), - getLeftSidebarAppearanceEntry() + getLeftSidebarAppearanceEntry(), + getShowPinnedWorktreesInGroupsEntry() ]) diff --git a/src/renderer/src/components/settings/terminal-search.test.ts b/src/renderer/src/components/settings/terminal-search.test.ts index bf642e83735..cc82051b774 100644 --- a/src/renderer/src/components/settings/terminal-search.test.ts +++ b/src/renderer/src/components/settings/terminal-search.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from 'vitest' import { getTerminalPaneSearchEntries } from './terminal-search' import { getAppearancePaneSearchEntries, getSidebarEntries } from './appearance-search' -import { getWorkspaceCardLayoutEntry } from './appearance-sidebar-search' +import { + getShowPinnedWorktreesInGroupsEntry, + getWorkspaceCardLayoutEntry +} from './appearance-sidebar-search' import { matchesSettingsSearch } from './settings-search' describe('getTerminalPaneSearchEntries', () => { @@ -214,4 +217,12 @@ describe('getTerminalPaneSearchEntries', () => { it('matches the Appearance catalog for compact workspace card searches', () => { expect(matchesSettingsSearch('compact', getAppearancePaneSearchEntries())).toBe(true) }) + + it('includes pinned worktree duplicate display in sidebar and Appearance search', () => { + const entry = getShowPinnedWorktreesInGroupsEntry() + + expect(getSidebarEntries()).toContainEqual(entry) + expect(getAppearancePaneSearchEntries()).toContainEqual(entry) + expect(matchesSettingsSearch('duplicate', entry)).toBe(true) + }) }) diff --git a/src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts b/src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts index fba1e48b1d5..7de4eb12f95 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts +++ b/src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts @@ -10,6 +10,7 @@ import type { WorktreeLineage } from '../../../../shared/types' import { folderWorkspaceKey } from '../../../../shared/workspace-scope' +import type * as WorktreeListModule from './WorktreeList' import { cloneDefaultWorkspaceStatuses } from '../../../../shared/workspace-statuses' const mockStore = vi.hoisted(() => ({ @@ -22,6 +23,7 @@ type WorktreeListComponent = React.ComponentType<{ }> let WorktreeList: WorktreeListComponent +let getPinnedWorktreeRevealCollapsedGroupKeys: typeof WorktreeListModule.getPinnedWorktreeRevealCollapsedGroupKeys function makeFolderWorkspacePathStatusMockState(): Record { return { @@ -434,12 +436,12 @@ function setFolderWorkspaceFixtureState( } } -function setPinnedDuplicateFixtureState(): void { +function setPinnedFixtureState(): void { const repo = makeRepo() const pinned = makeWorktree({ id: 'pinned', instanceId: 'pinned-instance', - displayName: 'pinned duplicate', + displayName: 'pinned workspace', branch: 'pinned-branch', sortOrder: 20 }) @@ -850,8 +852,10 @@ function escapeRegExp(value: string): string { describe('WorktreeList lineage child card renderer', () => { beforeAll(async () => { - WorktreeList = (await import('./WorktreeList')).default as WorktreeListComponent - }, 20_000) + const module = await import('./WorktreeList') + WorktreeList = module.default as WorktreeListComponent + getPinnedWorktreeRevealCollapsedGroupKeys = module.getPinnedWorktreeRevealCollapsedGroupKeys + }, 60_000) it('renders project group headers when repos import before worktree rows load', async () => { setProjectGroupWithoutWorktreeRowsState() @@ -885,6 +889,24 @@ describe('WorktreeList lineage child card renderer', () => { expect(markup).not.toContain('data-repo-header-collapse-affordance=""') }) + it('uncollapses pinned reveal through the pinned section after host expansion', () => { + const worktree = makeWorktree({ + id: 'pinned-ssh', + instanceId: 'pinned-ssh-instance', + displayName: 'Pinned SSH workspace', + branch: 'pinned-ssh', + sortOrder: 1 + }) + worktree.isPinned = true + + expect( + getPinnedWorktreeRevealCollapsedGroupKeys({ + worktree, + collapsedGroups: new Set(['host:ssh:builder-1', 'pinned', 'done']) + }) + ).toEqual(['pinned']) + }) + it('renders a collapse chevron on status group headers with worktrees', async () => { setLineageFixtureState('workspace-status') const markup = await renderWorktreeListMarkup() @@ -1112,12 +1134,23 @@ describe('WorktreeList lineage child card renderer', () => { ).toBe(30) }) - it('points aria-activedescendant at the natural row for active pinned duplicates', async () => { - setPinnedDuplicateFixtureState() + it('points aria-activedescendant at the pinned row for active pinned workspaces', async () => { + setPinnedFixtureState() + const markup = await renderWorktreeListMarkup() + + expect(markup).toContain('aria-activedescendant="worktree-list-option-pinned%3Apinned"') + expect(markup).toContain('id="worktree-list-option-pinned%3Apinned"') + expect(markup).not.toContain('id="worktree-list-option-all%3Apinned"') + }) + + it('points aria-activedescendant at the natural duplicate when enabled', async () => { + setPinnedFixtureState() + mockStore.state.settings = { showPinnedWorktreesInGroups: true } const markup = await renderWorktreeListMarkup() expect(markup).toContain('aria-activedescendant="worktree-list-option-all%3Apinned"') expect(markup).toContain('id="worktree-list-option-pinned%3Apinned"') + expect(markup).toContain('id="worktree-list-option-all%3Apinned"') }) it('opens inline rename only for the row-scoped lineage child request', async () => { diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 948228984d8..e73e9142f3e 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -88,7 +88,9 @@ import { buildRows, getProjectGroupHeaderKey, getGroupKeysForWorktree, - getLineageGroupKey + getLineageGroupKey, + getPinnedWorktreeDisplayPolicy, + type PinnedWorktreeDisplayPolicy } from './worktree-list-groups' import { estimateRenderRowSize, @@ -213,6 +215,7 @@ import { ALL_EXECUTION_HOSTS_SCOPE, getRepoExecutionHostId, getSettingsFocusedExecutionHostId, + getWorktreeExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' import { getRepoHeaderCreateState } from './repo-header-create-state' @@ -289,6 +292,10 @@ import { getProjectGroupExecutionHostIdForRows } from './worktree-list-host-filtering' import { getFolderWorkspaceCardPrDisplay } from './folder-workspace-card-pr-display' +import { + getPreferredWorktreeRows, + getRenderedWorktreesInSidebarOrder +} from './worktree-sidebar-row-preference' export { getScrollTopToRevealBounds, @@ -449,9 +456,9 @@ function markSidebarWorktreeActiveImmediately(worktreeId: string, primaryRowKey? ? option.dataset.worktreeRowKey === primaryRowKey ? 'primary' : 'secondary' - : option.dataset.worktreeSectionKey === PINNED_GROUP_KEY - ? 'secondary' - : 'primary' + : option === nextOption + ? 'primary' + : 'secondary' const surface = option.matches('[data-worktree-card-surface]') ? option : option.querySelector('[data-worktree-card-surface]') @@ -615,6 +622,7 @@ type VirtualizedWorktreeViewportProps = { activeWorktreeId: string | null currentWorktreeId: string | null groupBy: WorktreeGroupBy + pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy projectOrderBy: ProjectOrderBy toggleGroup: (key: string) => void collapsedGroups: Set @@ -653,6 +661,7 @@ type VirtualizedWorktreeViewportProps = { worktree: Worktree ) => readonly Worktree[] repoMap: Map + defaultHostId: ExecutionHostId worktreeMap: Map worktreeLineageById: Record workspaceLineageByChildKey: Record @@ -1050,6 +1059,17 @@ export function renderRowContainsWorktree(row: RenderRow, worktreeId: string | n return row.type === 'item' && row.worktree.id === worktreeId } +function isPinnedWorktreeRow(row: WorktreeItemRow): boolean { + return row.sectionKey === PINNED_GROUP_KEY +} + +function getRenderRowWorktreeItem(row: RenderRow, worktreeId: string): WorktreeItemRow | null { + if (row.type === 'lineage-group') { + return row.rows.find((item) => item.worktree.id === worktreeId) ?? null + } + return row.type === 'item' && row.worktree.id === worktreeId ? row : null +} + function getRenderRowOptionId( row: RenderRow | undefined, worktreeId?: string | null @@ -1070,20 +1090,10 @@ function getRenderRowOptionId( return undefined } -function renderRowContainsNaturalWorktree(row: RenderRow, worktreeId: string): boolean { - if (row.type === 'lineage-group') { - return row.rows.some( - (item) => item.worktree.id === worktreeId && item.sectionKey !== PINNED_GROUP_KEY - ) - } - return ( - row.type === 'item' && row.worktree.id === worktreeId && row.sectionKey !== PINNED_GROUP_KEY - ) -} - function getActiveDescendantOptionId(args: { activeWorktreeId: string | null primaryActiveRowKey?: string + pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy renderRows: readonly RenderRow[] virtualItems: readonly { index: number }[] }): string | undefined { @@ -1099,19 +1109,67 @@ function getActiveDescendantOptionId(args: { } } } - for (const item of args.virtualItems) { - const row = args.renderRows[item.index] - if (row && renderRowContainsNaturalWorktree(row, args.activeWorktreeId)) { - return getRenderRowOptionId(row, args.activeWorktreeId) - } - } + let fallbackOptionId: string | undefined for (const item of args.virtualItems) { const row = args.renderRows[item.index] if (row && renderRowContainsWorktree(row, args.activeWorktreeId)) { - return getRenderRowOptionId(row, args.activeWorktreeId) + const optionId = getRenderRowOptionId(row, args.activeWorktreeId) + if (!optionId) { + continue + } + const itemRow = getRenderRowWorktreeItem(row, args.activeWorktreeId) + if ( + args.pinnedDisplayPolicy === 'duplicate-in-groups' && + itemRow && + !isPinnedWorktreeRow(itemRow) + ) { + return optionId + } + fallbackOptionId ??= optionId } } - return undefined + return fallbackOptionId +} + +function findPreferredRenderRowIndexForWorktree( + renderRows: readonly RenderRow[], + worktreeId: string, + pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy +): number { + let fallbackIndex = -1 + for (let index = 0; index < renderRows.length; index++) { + const row = renderRows[index] + if (!renderRowContainsWorktree(row, worktreeId)) { + continue + } + if (fallbackIndex === -1) { + fallbackIndex = index + } + const itemRow = getRenderRowWorktreeItem(row, worktreeId) + if (pinnedDisplayPolicy === 'duplicate-in-groups' && itemRow && !isPinnedWorktreeRow(itemRow)) { + return index + } + } + return fallbackIndex +} + +export function getPinnedWorktreeRevealCollapsedGroupKeys({ + worktree, + collapsedGroups +}: { + worktree: Worktree + collapsedGroups: ReadonlySet +}): string[] { + if (!worktree.isPinned) { + return [] + } + const keys: string[] = [] + // Why: the reveal effect opens the owning host first; returning it here would + // toggle the same host closed again while React state is still stale. + if (collapsedGroups.has(PINNED_GROUP_KEY)) { + keys.push(PINNED_GROUP_KEY) + } + return keys } function uniqueWorktreeIds(ids: readonly string[]): string[] { @@ -1181,6 +1239,11 @@ export function getRenderRowKey(row: RenderRow): string { export function getWorktreeDragGroups(rows: HostSectionRow[]): WorktreeDragGroup[] { const groups: WorktreeDragGroup[] = [] let current: { key: string; ids: string[] } | null = null + const naturalWorktreeIds = new Set( + rows.flatMap((row) => + row.type === 'item' && row.sectionKey !== PINNED_GROUP_KEY ? [row.worktree.id] : [] + ) + ) for (const row of rows) { if (row.type === 'header') { @@ -1197,7 +1260,7 @@ export function getWorktreeDragGroups(rows: HostSectionRow[]): WorktreeDragGroup ) { continue } - if (row.sectionKey === PINNED_GROUP_KEY) { + if (row.sectionKey === PINNED_GROUP_KEY && naturalWorktreeIds.has(row.worktree.id)) { continue } if (!current) { @@ -1217,19 +1280,27 @@ export function canKeepImportedWorktreesHidden( return row.placement === 'repo-group' && actionState?.forceVisible !== true } -function getWorktreeDragIndexes(rows: readonly HostSectionRow[]): { +export function getWorktreeDragIndexes(rows: readonly HostSectionRow[]): { groupKeyByRowKey: Map groupIndexByRowKey: Map } { const groupKeyByRowKey = new Map() const groupIndexByRowKey = new Map() const groupIndexes = new Map() + const naturalWorktreeIds = new Set( + rows.flatMap((row) => + row.type === 'item' && row.sectionKey !== PINNED_GROUP_KEY ? [row.worktree.id] : [] + ) + ) for (const row of rows) { if (row.type === 'header') { groupIndexes.set(row.key, 0) continue } - if (row.type !== 'item' || row.sectionKey === PINNED_GROUP_KEY) { + if (row.type !== 'item') { + continue + } + if (row.sectionKey === PINNED_GROUP_KEY && naturalWorktreeIds.has(row.worktree.id)) { continue } const index = groupIndexes.get(row.sectionKey) ?? 0 @@ -1254,6 +1325,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp activeWorktreeId, currentWorktreeId, groupBy, + pinnedDisplayPolicy, projectOrderBy, toggleGroup, collapsedGroups, @@ -1289,6 +1361,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp onImmediateWorktreeActivate, onContextMenuSelect, repoMap, + defaultHostId, worktreeMap, worktreeLineageById, workspaceLineageByChildKey, @@ -1466,13 +1539,25 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp useEffect(() => () => onHostDragActiveChange(false), [onHostDragActiveChange]) const worktreeDragGroups = useMemo(() => getWorktreeDragGroups(rows), [rows]) const worktreeDragUnitGroups = useMemo(() => getWorktreeDragUnitGroups(rows), [rows]) + const naturalDragWorktreeIds = useMemo( + () => + new Set( + rows.flatMap((row) => + row.type === 'item' && row.sectionKey !== PINNED_GROUP_KEY ? [row.worktree.id] : [] + ) + ), + [rows] + ) const worktreeLineageDragRows = useMemo( () => rows .filter((row): row is WorktreeItemRow => row.type === 'item') - .filter((row) => row.sectionKey !== PINNED_GROUP_KEY) + .filter( + (row) => + row.sectionKey !== PINNED_GROUP_KEY || !naturalDragWorktreeIds.has(row.worktree.id) + ) .map((row) => ({ worktreeId: row.worktree.id, depth: row.depth })), - [rows] + [naturalDragWorktreeIds, rows] ) const getReorderDraggedIds = useCallback( (draggedIds: readonly string[]) => @@ -1687,28 +1772,21 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp return rowStillVisible ? current : null }) }, [activeWorktreeId, rows]) - const activeWorktreeHasNaturalRow = useMemo( - () => - activeWorktreeId !== null && - rows.some( - (row) => - row.type === 'item' && - row.worktree.id === activeWorktreeId && - row.sectionKey !== PINNED_GROUP_KEY - ), - [activeWorktreeId, rows] - ) const getActiveSurfaceVariant = useCallback( (row: WorktreeItemRow): ActiveSurfaceVariant => { if (primaryActiveWorktreeRow?.worktreeId === row.worktree.id) { return primaryActiveWorktreeRow.rowKey === row.rowKey ? 'primary' : 'secondary' } - if (activeWorktreeHasNaturalRow && row.sectionKey === PINNED_GROUP_KEY) { + if ( + pinnedDisplayPolicy === 'duplicate-in-groups' && + activeWorktreeId === row.worktree.id && + isPinnedWorktreeRow(row) + ) { return 'secondary' } return 'primary' }, - [activeWorktreeHasNaturalRow, primaryActiveWorktreeRow] + [activeWorktreeId, pinnedDisplayPolicy, primaryActiveWorktreeRow] ) const handleImmediateWorktreeRowActivate = useCallback( (worktreeId: string, rowKey: string | undefined): void => { @@ -2036,7 +2114,14 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp } } else { const targetWorktree = worktrees.find((w) => w.id === pendingRevealWorktree.worktreeId) - if (targetWorktree && !targetWorktree.isPinned) { + const targetRepo = targetWorktree ? repoMap.get(targetWorktree.repoId) : undefined + if (targetWorktree) { + const hostId = getWorktreeExecutionHostId(targetWorktree, targetRepo, defaultHostId) + const hostGroupKey = `host:${hostId}` + if (collapsedGroups.has(hostGroupKey)) { + toggleGroup(hostGroupKey) + } + const seen = new Set() let current: Worktree | undefined = targetWorktree while (current && !seen.has(current.id)) { @@ -2057,26 +2142,23 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp } current = parent } - } - if (targetWorktree?.isPinned) { - // Why: pinned worktrees live in the dedicated "Pinned" section regardless - // of their PR-status / project group. Only uncollapse the Pinned header - // itself — expanding the underlying status group would be surprising since - // the user intentionally collapsed it. - if (collapsedGroups.has(PINNED_GROUP_KEY)) { - toggleGroup(PINNED_GROUP_KEY) - } - } else if (targetWorktree) { - const groupKeys = getGroupKeysForWorktree( - groupBy, - targetWorktree, - repoMap, - prCache, - workspaceStatuses, - settings, - projectGroups - ) + const groupKeys = + targetWorktree.isPinned && pinnedDisplayPolicy === 'single-location' + ? getPinnedWorktreeRevealCollapsedGroupKeys({ + worktree: targetWorktree, + collapsedGroups + }) + : getGroupKeysForWorktree( + groupBy, + targetWorktree, + repoMap, + prCache, + workspaceStatuses, + settings, + projectGroups, + projectGrouping + ) for (const groupKey of groupKeys) { if (collapsedGroups.has(groupKey)) { toggleGroup(groupKey) @@ -2096,8 +2178,10 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp worktrees, folderWorkspaces ) - const targetIndex = renderRows.findIndex((row) => - renderRowContainsWorktree(row, pendingRevealWorktree.worktreeId) + const targetIndex = findPreferredRenderRowIndexForWorktree( + renderRows, + pendingRevealWorktree.worktreeId, + pinnedDisplayPolicy ) const outcome = resolvePendingSidebarReveal({ targetIndex, targetWorktreeStillExists }) if (outcome === 'scroll-and-clear') { @@ -2197,8 +2281,11 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp clearPendingRevealWorktreeId, toggleGroup, collapsedGroups, + defaultHostId, workspaceStatuses, settings, + pinnedDisplayPolicy, + projectGrouping, projectGroups, pendingRevealRetryTick, flashRevealedRow, @@ -2442,16 +2529,13 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp new Map(), new Map(), [], - projectGrouping + projectGrouping, + [], + undefined, + defaultHostId, + pinnedDisplayPolicy ).filter((r): r is Extract => r.type === 'item') - const seenWorktreeIds = new Set() - const worktreeRows = allWorktreeRows.filter((row) => { - if (seenWorktreeIds.has(row.worktree.id)) { - return false - } - seenWorktreeIds.add(row.worktree.id) - return true - }) + const worktreeRows = getPreferredWorktreeRows(allWorktreeRows, pinnedDisplayPolicy) if (worktreeRows.length === 0) { return } @@ -2478,7 +2562,11 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp // it must flow through the same activation helper that records history. activateAndRevealWorktree(nextWorktreeId) - const rowIndex = renderRows.findIndex((row) => renderRowContainsWorktree(row, nextWorktreeId)) + const rowIndex = findPreferredRenderRowIndexForWorktree( + renderRows, + nextWorktreeId, + pinnedDisplayPolicy + ) if (rowIndex !== -1) { virtualizer.scrollToIndex(rowIndex, { align: 'auto' }) } @@ -2491,6 +2579,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp projectOrderBy, worktrees, repoMap, + defaultHostId, prCache, repoOrder, workspaceStatuses, @@ -2498,7 +2587,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp worktreeMap, settings, projectGroups, - projectGrouping + projectGrouping, + pinnedDisplayPolicy ] ) @@ -3740,6 +3830,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp primaryActiveWorktreeRow?.worktreeId === activeWorktreeId ? primaryActiveWorktreeRow.rowKey : undefined, + pinnedDisplayPolicy, renderRows, virtualItems }) @@ -4828,8 +4919,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp onCardDragStart={handleWorktreeCardDragStart} onCardDragEnd={clearWorktreeDrag} hideRepoBadge={groupBy === 'repo'} - // Why: pinned worktrees also render in their natural group; - // only the overlay row is the mixed-repo section needing icons. + // Why: pinned worktrees mix repos in one section; only that + // section needs the leading repo identity chip. hostContextLabel={itemRow.hostContextLabel} inPinnedSection={isPinnedOverlayRow} renameRowKey={itemRow.rowKey} @@ -5262,6 +5353,7 @@ const WorktreeList = React.memo(function WorktreeList({ useShallow((s) => selectWorktreeListReviewCacheInputs(s, groupBy, cardProps)) ) const settings = useAppStore((s) => s.settings) + const pinnedDisplayPolicy = getPinnedWorktreeDisplayPolicy(settings) const sshTargetLabels = useAppStore((s) => s.sshTargetLabels) const sshConnectionStates = useAppStore((s) => s.sshConnectionStates) const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments) @@ -5787,7 +5879,9 @@ const WorktreeList = React.memo(function WorktreeList({ pendingCreations, projectGrouping, visibleFolderWorkspacesForRows, - hostLabelById + hostLabelById, + defaultHostId, + pinnedDisplayPolicy ), [ groupBy, @@ -5795,6 +5889,7 @@ const WorktreeList = React.memo(function WorktreeList({ repoMap, prCache, effectiveCollapsedGroups, + defaultHostId, repoOrder, workspaceStatuses, projectOrderBy, @@ -5808,7 +5903,8 @@ const WorktreeList = React.memo(function WorktreeList({ importedWorktreesByRepo, newExternalWorktreesInboxByRepo, pendingCreations, - hostLabelById + hostLabelById, + pinnedDisplayPolicy ] ) const orderedHostOptions = useMemo( @@ -5891,17 +5987,8 @@ const WorktreeList = React.memo(function WorktreeList({ // order would cause Cmd+1–9 shortcuts to not match the visual card // positions when grouping is active. const renderedWorktrees = useMemo( - () => - sectionRows.flatMap((row) => { - if (row.type === 'item') { - return [row.worktree] - } - if (row.type === 'folder-workspace') { - return [folderWorkspaceToWorktree(row.folderWorkspace)] - } - return [] - }), - [sectionRows] + () => getRenderedWorktreesInSidebarOrder(sectionRows, pinnedDisplayPolicy), + [pinnedDisplayPolicy, sectionRows] ) const renderedWorktreeIds = useMemo( () => uniqueWorktreeIds(renderedWorktrees.map((worktree) => worktree.id)), @@ -6808,6 +6895,7 @@ const WorktreeList = React.memo(function WorktreeList({ activeWorktreeId={selectedSidebarWorktreeId} currentWorktreeId={currentSidebarWorktreeId} groupBy={groupBy} + pinnedDisplayPolicy={pinnedDisplayPolicy} projectOrderBy={projectOrderBy} toggleGroup={toggleGroup} collapsedGroups={effectiveCollapsedGroups} @@ -6843,6 +6931,7 @@ const WorktreeList = React.memo(function WorktreeList({ onImmediateWorktreeActivate={handleImmediateWorktreeActivate} onContextMenuSelect={selectForContextMenu} repoMap={repoMap} + defaultHostId={defaultHostId} worktreeMap={worktreeMap} worktreeLineageById={worktreeLineageById} workspaceLineageByChildKey={workspaceLineageByChildKey} diff --git a/src/renderer/src/components/sidebar/host-section-rows.test.ts b/src/renderer/src/components/sidebar/host-section-rows.test.ts index e03ddbb63cc..702356e26a6 100644 --- a/src/renderer/src/components/sidebar/host-section-rows.test.ts +++ b/src/renderer/src/components/sidebar/host-section-rows.test.ts @@ -46,6 +46,19 @@ function header(key: string, label = key): Extract { } } +function pinnedHeader( + counts: ReadonlyMap<'local' | 'ssh:ssh-1', number>, + idsByHost?: ReadonlyMap<'local' | 'ssh:ssh-1', readonly string[]> +): Extract { + return { + ...header(PINNED_GROUP_KEY, 'Pinned'), + count: Array.from(counts.values()).reduce((sum, count) => sum + count, 0), + hostWorktreeCounts: counts, + hostWorktreeIds: idsByHost, + worktreeIds: idsByHost ? Array.from(idsByHost.values()).flat() : undefined + } +} + function repoHeader(project: Repo): Extract { return { ...header(`repo:${project.id}`, project.displayName), @@ -294,17 +307,20 @@ describe('addHostSectionRows', () => { ]) }) - it('copies global pinned and all headers into each mixed-host section', () => { + it('copies global pinned and all headers into each mixed-host section without duplicating pins', () => { const local = repo('local') const ssh = repo('ssh', 'ssh-1') const rows = [ - header(PINNED_GROUP_KEY, 'Pinned'), + pinnedHeader( + new Map([ + ['local', 1], + ['ssh:ssh-1', 1] + ]) + ), pinnedItem('local-pinned', local, PINNED_GROUP_KEY), pinnedItem('ssh-pinned', ssh, PINNED_GROUP_KEY), header('all', 'All'), - pinnedItem('local-pinned', local, 'all'), item('local-normal', local), - pinnedItem('ssh-pinned', ssh, 'all'), item('ssh-normal', ssh) ] @@ -329,19 +345,182 @@ describe('addHostSectionRows', () => { 'pinned', 'pinned:local-pinned', 'all', - 'all:local-pinned', 'repo:local:local-normal', 'host:ssh:ssh-1', 'pinned', 'pinned:ssh-pinned', 'all', - 'all:ssh-pinned', 'repo:ssh:ssh-normal' ]) expect(sectioned.filter((row) => row.type === 'host-header')).toMatchObject([ { label: 'Local Mac', count: 2 }, { label: 'Builder', count: 2 } ]) + expect( + sectioned.filter((row) => row.type === 'header' && row.key === PINNED_GROUP_KEY) + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ count: 1, hostId: 'local' }), + expect.objectContaining({ count: 1, hostId: 'ssh:ssh-1' }) + ]) + ) + }) + + it('keeps collapsed pinned rows attributable to their owning hosts', () => { + const rows = [ + pinnedHeader( + new Map([ + ['local', 1], + ['ssh:ssh-1', 2] + ]) + ) + ] + + const sectioned = addHostSectionRows({ + rows, + hostOptions: [ + { + id: 'local', + kind: 'local', + label: 'Local Mac', + detail: 'This computer', + health: 'local' + }, + { id: 'ssh:ssh-1', kind: 'ssh', label: 'Builder', detail: 'SSH', health: 'available' } + ], + workspaceHostScope: 'all', + defaultHostId: 'local', + collapsedHostKeys: new Set(['host:ssh:ssh-1']) + }) + + expect(sectioned.map(rowKey)).toEqual(['host:local', 'pinned', 'host:ssh:ssh-1']) + expect(sectioned.filter((row) => row.type === 'host-header')).toMatchObject([ + { hostId: 'local', collapsed: false, count: 1 }, + { hostId: 'ssh:ssh-1', collapsed: true, count: 2 } + ]) + expect(sectioned.filter((row) => row.type === 'header')).toMatchObject([ + { key: PINNED_GROUP_KEY, count: 1, hostId: 'local' } + ]) + }) + + it('keeps a pinned row with its explicit worktree host', () => { + const local = repo('local') + const pinned = pinnedItem('ssh-pinned', local, PINNED_GROUP_KEY) + pinned.worktree.hostId = 'ssh:ssh-1' + const rows = [ + pinnedHeader(new Map([['ssh:ssh-1', 1]]), new Map([['ssh:ssh-1', ['ssh-pinned']]])), + pinned, + repoHeader(local), + item('local-wt', local) + ] + + const sectioned = addHostSectionRows({ + rows, + hostOptions: [ + { + id: 'local', + kind: 'local', + label: 'Local Mac', + detail: 'This computer', + health: 'local' + }, + { id: 'ssh:ssh-1', kind: 'ssh', label: 'Builder', detail: 'SSH', health: 'available' } + ], + workspaceHostScope: 'all', + defaultHostId: 'local' + }) + + expect(sectioned.map(rowKey)).toEqual([ + 'host:local', + 'repo:local', + 'local-wt', + 'host:ssh:ssh-1', + PINNED_GROUP_KEY, + 'ssh-pinned' + ]) + expect(sectioned.filter((row) => row.type === 'host-header')).toMatchObject([ + { hostId: 'local', count: 1 }, + { hostId: 'ssh:ssh-1', count: 1 } + ]) + }) + + it('does not double-count a collapsed pinned header and its natural duplicate row', () => { + const local = repo('local') + const ssh = repo('ssh', 'ssh-1') + const rows = [ + pinnedHeader(new Map([['local', 1]]), new Map([['local', ['wt-1']]])), + { ...header('all', 'All'), count: 1, worktreeIds: ['wt-1'] }, + item('wt-1', local), + repoHeader(ssh), + item('ssh-wt', ssh) + ] + + const sectioned = addHostSectionRows({ + rows, + hostOptions: [ + { + id: 'local', + kind: 'local', + label: 'Local Mac', + detail: 'This computer', + health: 'local' + }, + { id: 'ssh:ssh-1', kind: 'ssh', label: 'Builder', detail: 'SSH', health: 'available' } + ], + workspaceHostScope: 'all', + defaultHostId: 'local' + }) + + expect( + sectioned.find((row) => row.type === 'host-header' && row.hostId === 'local') + ).toMatchObject({ count: 1 }) + }) + + it('localizes collapsed natural headers even when the hidden rows are owned by one host', () => { + const ssh = repo('ssh', 'ssh-1') + const localHostId = 'local' as const + const rows = [ + { + ...header('all', 'All'), + count: 1, + hostWorktreeCounts: new Map([[localHostId, 1]]), + hostWorktreeIds: new Map([[localHostId, ['local-wt']]]), + worktreeIds: ['local-wt'] + }, + repoHeader(ssh), + item('ssh-wt', ssh) + ] + + const sectioned = addHostSectionRows({ + rows, + hostOptions: [ + { + id: 'local', + kind: 'local', + label: 'Local Mac', + detail: 'This computer', + health: 'local' + }, + { id: 'ssh:ssh-1', kind: 'ssh', label: 'Builder', detail: 'SSH', health: 'available' } + ], + workspaceHostScope: 'all', + defaultHostId: 'local' + }) + + expect(sectioned.map(rowKey)).toEqual([ + 'host:local', + 'all', + 'host:ssh:ssh-1', + 'repo:ssh', + 'ssh-wt' + ]) + expect(sectioned.filter((row) => row.type === 'host-header')).toMatchObject([ + { hostId: 'local', count: 1 }, + { hostId: 'ssh:ssh-1', count: 1 } + ]) + expect(sectioned.filter((row) => row.type === 'header' && row.key === 'all')).toMatchObject([ + { hostId: 'local', count: 1, worktreeIds: ['local-wt'] } + ]) }) it('groups explicitly runtime-owned repos under their owner host, not the focused host', () => { diff --git a/src/renderer/src/components/sidebar/host-section-rows.ts b/src/renderer/src/components/sidebar/host-section-rows.ts index 3283e305a04..7ca084d7520 100644 --- a/src/renderer/src/components/sidebar/host-section-rows.ts +++ b/src/renderer/src/components/sidebar/host-section-rows.ts @@ -3,6 +3,7 @@ import { LOCAL_EXECUTION_HOST_ID, getLocalExecutionHostLabel, getRepoExecutionHostId, + getWorktreeExecutionHostId, type ExecutionHostId, type ExecutionHostKind, type ExecutionHostScope @@ -11,7 +12,6 @@ import type { ExecutionHostHealth } from '../../../../shared/execution-host-regi import type { RuntimeCompatVerdict } from '../../../../shared/protocol-compat' import type { SshConnectionStatus } from '../../../../shared/ssh-types' import type { FolderWorkspace, ProjectGroup, Repo } from '../../../../shared/types' -import { PINNED_GROUP_KEY } from './worktree-list-groups' import type { Row } from './worktree-list-groups' export type HostHeaderRow = { @@ -70,7 +70,7 @@ function getFolderWorkspaceHostId( function getRowHostId(row: Row, defaultHostId: ExecutionHostId): ExecutionHostId | null { switch (row.type) { case 'item': - return getRepoHostId(row.repo, defaultHostId) + return getWorktreeExecutionHostId(row.worktree, row.repo, defaultHostId) case 'pending-creation': case 'imported-worktrees-card': case 'new-external-worktrees-inbox': @@ -99,19 +99,28 @@ function countWorktreeRows(rows: readonly Row[]): number { // while a visibly populated project sits right under it. let count = 0 const seenWorktreeIds = new Set() - let pendingHeaderCount: number | null = null + let pendingHeader: Extract | null = null let pendingHeaderHadItems = false const flushHeader = (): void => { - if (pendingHeaderCount !== null && !pendingHeaderHadItems) { - count += pendingHeaderCount + if (pendingHeader && !pendingHeaderHadItems) { + if (pendingHeader.worktreeIds) { + for (const worktreeId of pendingHeader.worktreeIds) { + if (!seenWorktreeIds.has(worktreeId)) { + count += 1 + seenWorktreeIds.add(worktreeId) + } + } + } else { + count += pendingHeader.count + } } - pendingHeaderCount = null + pendingHeader = null pendingHeaderHadItems = false } for (const row of rows) { if (row.type === 'header') { flushHeader() - pendingHeaderCount = row.key === PINNED_GROUP_KEY ? null : row.count + pendingHeader = row continue } if (row.type === 'item') { @@ -119,13 +128,45 @@ function countWorktreeRows(rows: readonly Row[]): number { count += 1 seenWorktreeIds.add(row.worktree.id) } - pendingHeaderHadItems = pendingHeaderCount !== null + pendingHeaderHadItems = pendingHeader !== null } } flushHeader() return count } +function localizePendingRowsForHost( + rows: readonly Extract[], + hostId: ExecutionHostId +): Extract[] { + const localized: Extract[] = [] + for (const row of rows) { + if (!row.hostWorktreeCounts) { + localized.push(row) + continue + } + const count = row.hostWorktreeCounts.get(hostId) + if (count !== undefined && count > 0) { + localized.push({ + ...row, + count, + hostId, + worktreeIds: row.hostWorktreeIds?.get(hostId) ?? row.worktreeIds + }) + } + } + return localized +} + +function getPendingRowsKey(rows: readonly Extract[]): string { + return rows + .map( + (pendingRow) => + `${pendingRow.key}:${pendingRow.count}:${pendingRow.worktreeIds?.join(',') ?? ''}` + ) + .join('\0') +} + export function addHostSectionRows(args: { rows: readonly Row[] hostOptions: readonly HostSectionOption[] @@ -161,18 +202,48 @@ export function addHostSectionRows(args: { let pendingRows: Extract[] = [] let pendingRowsWereUsed = false const pendingRowsKeyByHostId = new Map() + const flushUnusedPendingRows = (): void => { + if (pendingRows.length === 0 || pendingRowsWereUsed) { + return + } + const hostScopedRow = pendingRows.some((row) => row.hostWorktreeCounts) + if (!hostScopedRow) { + globalRows.push(...pendingRows) + return + } + for (const row of pendingRows) { + for (const [hostId, count] of row.hostWorktreeCounts ?? []) { + if (count <= 0) { + continue + } + const hostRows = rowsByHostId.get(hostId) ?? [] + const hostIds = row.hostWorktreeIds?.get(hostId) + hostRows.push({ + ...row, + count, + hostId, + worktreeIds: hostIds ?? row.worktreeIds + }) + rowsByHostId.set(hostId, hostRows) + } + } + } for (const row of args.rows) { const rowHostId = getRowHostId(row, args.defaultHostId) if (rowHostId) { const hostRows = rowsByHostId.get(rowHostId) ?? [] if (pendingRows.length > 0) { - const pendingRowsKey = pendingRows.map((pendingRow) => pendingRow.key).join('\0') - if (pendingRowsKeyByHostId.get(rowHostId) !== pendingRowsKey) { - hostRows.push(...pendingRows) + const localizedPendingRows = localizePendingRowsForHost(pendingRows, rowHostId) + const pendingRowsKey = getPendingRowsKey(localizedPendingRows) + if ( + localizedPendingRows.length > 0 && + pendingRowsKeyByHostId.get(rowHostId) !== pendingRowsKey + ) { + hostRows.push(...localizedPendingRows) pendingRowsKeyByHostId.set(rowHostId, pendingRowsKey) } - pendingRowsWereUsed = true + pendingRowsWereUsed = pendingRowsWereUsed || localizedPendingRows.length > 0 } hostRows.push(row) rowsByHostId.set(rowHostId, hostRows) @@ -181,6 +252,7 @@ export function addHostSectionRows(args: { // Why: status/"All" headers describe the rows that follow. Buffer them // for every host-owned run so host remains above the existing grouping. if (row.type === 'header') { + flushUnusedPendingRows() pendingRows = [row] pendingRowsWereUsed = false } else { @@ -188,9 +260,7 @@ export function addHostSectionRows(args: { } } - if (pendingRows.length > 0 && !pendingRowsWereUsed) { - globalRows.push(...pendingRows) - } + flushUnusedPendingRows() const hostOrder: ExecutionHostId[] = [] for (const host of args.hostOptions) { diff --git a/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts b/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts index 61a537be594..678816e3959 100644 --- a/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts +++ b/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts @@ -139,51 +139,32 @@ describe('runSleepWorktree', () => { expect(requestAnimationFrame).toHaveBeenCalledTimes(1) }) - it('anchors sleep restoration to the primary duplicate row', async () => { - let frameCount = 0 - const requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { - frameCount += 1 - if (frameCount === 1) { - callback(0) - } - return frameCount - }) + it('anchors sleep restoration to the natural duplicate row when no primary row is marked', async () => { + const requestAnimationFrame = vi.fn(() => 1) const scroller = { dispatchEvent: vi.fn(), scrollHeight: 100, scrollTop: 0 } + const pinnedGetBoundingClientRect = vi.fn(() => ({ top: 10 })) + const naturalGetBoundingClientRect = vi.fn(() => ({ top: 42 })) const pinnedRow = { - closest: (selector: string) => - selector === '[data-worktree-virtual-row]' ? pinnedRow : null, - getBoundingClientRect: () => ({ top: 10 }) + getBoundingClientRect: pinnedGetBoundingClientRect } - let naturalTop = 40 const naturalRow = { - closest: (selector: string) => - selector === '[data-worktree-virtual-row]' ? naturalRow : null, - getBoundingClientRect: () => ({ top: naturalTop }) + getBoundingClientRect: naturalGetBoundingClientRect } const pinnedOption = { - dataset: { - worktreeId: 'wt-1', - worktreeRowKey: 'pinned:wt-1', - worktreeSectionKey: 'pinned' - }, + dataset: { worktreeId: 'wt-1', worktreeRowKey: 'pinned:wt-1' }, closest: (selector: string) => selector === '[data-worktree-virtual-row]' ? pinnedRow : null, querySelector: () => null } const naturalOption = { - dataset: { - worktreeId: 'wt-1', - worktreeRowKey: 'all:wt-1', - worktreeSectionKey: 'all' - }, + dataset: { worktreeId: 'wt-1', worktreeRowKey: 'all:wt-1' }, closest: (selector: string) => selector === '[data-worktree-virtual-row]' ? naturalRow : null, - querySelector: (selector: string) => - selector === '[data-worktree-card-active="primary"]' ? {} : null + querySelector: () => null } vi.stubGlobal('document', { querySelector: (selector: string) => @@ -194,13 +175,10 @@ describe('runSleepWorktree', () => { vi.stubGlobal('window', { requestAnimationFrame }) mocks.state.activeWorktreeId = 'wt-1' - mocks.state.setActiveWorktree.mockImplementation(() => { - naturalTop = 45 - }) - await runSleepWorktree('wt-1') - expect(scroller.scrollTop).toBe(5) + expect(naturalGetBoundingClientRect).toHaveBeenCalled() + expect(pinnedGetBoundingClientRect).not.toHaveBeenCalled() }) it('leaves activeWorktreeId alone when sleeping a background worktree', async () => { diff --git a/src/renderer/src/components/sidebar/sleep-worktree-flow.ts b/src/renderer/src/components/sidebar/sleep-worktree-flow.ts index 5cde695f78e..905ce91901c 100644 --- a/src/renderer/src/components/sidebar/sleep-worktree-flow.ts +++ b/src/renderer/src/components/sidebar/sleep-worktree-flow.ts @@ -3,7 +3,6 @@ import { useAppStore } from '@/store' import { clearWorktreeSleepIntent, markWorktreeSleepIntent } from '@/lib/worktree-sleep-intent' import { VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT } from '@/hooks/useVirtualizedScrollAnchor' import { translate } from '@/i18n/i18n' -import { PINNED_GROUP_KEY } from './worktree-list-groups' /** * Shared "sleep worktree" flow (close all panels to free memory / CPU) @@ -28,13 +27,19 @@ function getSidebarWorktreeOptions(worktreeId: string): HTMLElement[] { ) } +function isPinnedSidebarWorktreeOption(element: HTMLElement): boolean { + // Why: duplicated pinned rows share a worktree id; the row-key prefix is the + // stable row-scoped signal that distinguishes the pinned copy from natural rows. + return element.dataset.worktreeRowKey?.startsWith('pinned:') === true +} + function findPrimarySidebarWorktreeOption(worktreeId: string): HTMLElement | null { const options = getSidebarWorktreeOptions(worktreeId) return ( options.find((element) => element.querySelector('[data-worktree-card-active="primary"]') ) ?? - options.find((element) => element.dataset.worktreeSectionKey !== PINNED_GROUP_KEY) ?? + options.find((element) => !isPinnedSidebarWorktreeOption(element)) ?? options[0] ?? null ) diff --git a/src/renderer/src/components/sidebar/visible-worktrees.test.ts b/src/renderer/src/components/sidebar/visible-worktrees.test.ts index c1da9409447..939ca46a1e9 100644 --- a/src/renderer/src/components/sidebar/visible-worktrees.test.ts +++ b/src/renderer/src/components/sidebar/visible-worktrees.test.ts @@ -329,6 +329,25 @@ describe('computeVisibleWorktreeIds', () => { expect(result).toEqual([local.id]) }) + it('uses explicit worktree ownership when it differs from the repo host', () => { + const runtime = makeWorktree('runtime', 'repo1') + runtime.hostId = 'runtime:env-1' + + const runtimeResult = computeVisibleWorktreeIds( + { repo1: [runtime] }, + [runtime.id], + visibleOptions({ workspaceHostScope: 'runtime:env-1' }) + ) + const localResult = computeVisibleWorktreeIds( + { repo1: [runtime] }, + [runtime.id], + visibleOptions({ workspaceHostScope: 'local' }) + ) + + expect(runtimeResult).toEqual([runtime.id]) + expect(localResult).toEqual([]) + }) + it('keeps every host visible when workspace host scope is all', () => { const local = makeWorktree('local', 'repo1') const remote = makeWorktree('remote', 'repo2') diff --git a/src/renderer/src/components/sidebar/visible-worktrees.ts b/src/renderer/src/components/sidebar/visible-worktrees.ts index 7ac1b111832..8809e7f7835 100644 --- a/src/renderer/src/components/sidebar/visible-worktrees.ts +++ b/src/renderer/src/components/sidebar/visible-worktrees.ts @@ -6,8 +6,8 @@ import { getAllWorktreesFromState, getRepoMapFromState } from '@/store/selectors import { DEFAULT_SHOW_SLEEPING_WORKSPACES } from '../../../../shared/constants' import { ALL_EXECUTION_HOSTS_SCOPE, - getRepoExecutionHostId, getSettingsFocusedExecutionHostId, + getWorktreeExecutionHostId, type ExecutionHostId, type ExecutionHostScope } from '../../../../shared/execution-host' @@ -153,10 +153,7 @@ export function computeVisibleWorktreeIds( if (!repo) { return false } - const hostId = - repo.connectionId || repo.executionHostId - ? getRepoExecutionHostId(repo) - : opts.defaultHostId + const hostId = getWorktreeExecutionHostId(w, repo, opts.defaultHostId) return visibleHostIdSet.has(hostId) }) } diff --git a/src/renderer/src/components/sidebar/worktree-drag-units.test.ts b/src/renderer/src/components/sidebar/worktree-drag-units.test.ts index f77ea72b2ee..9d6f230d5cd 100644 --- a/src/renderer/src/components/sidebar/worktree-drag-units.test.ts +++ b/src/renderer/src/components/sidebar/worktree-drag-units.test.ts @@ -70,7 +70,26 @@ describe('getWorktreeDragUnitGroups', () => { ]) }) - it('ignores pinned overlay rows', () => { + it('includes pinned rows when they are the only rendered copy', () => { + const groups = getWorktreeDragUnitGroups([ + header('pinned'), + item('pinned-copy', 0, 'pinned'), + item('other-pinned', 0, 'pinned') + ]) + + expect(groups).toEqual([ + { + key: 'pinned', + worktreeIds: ['pinned-copy', 'other-pinned'], + units: [ + { worktreeId: 'pinned-copy', worktreeIds: ['pinned-copy'] }, + { worktreeId: 'other-pinned', worktreeIds: ['other-pinned'] } + ] + } + ]) + }) + + it('uses natural drag units when pinned rows have duplicate natural copies', () => { const groups = getWorktreeDragUnitGroups([ header('pinned'), item('pinned-copy', 0, 'pinned'), diff --git a/src/renderer/src/components/sidebar/worktree-drag-units.ts b/src/renderer/src/components/sidebar/worktree-drag-units.ts index 39cd9f97f6a..c38d8b8643c 100644 --- a/src/renderer/src/components/sidebar/worktree-drag-units.ts +++ b/src/renderer/src/components/sidebar/worktree-drag-units.ts @@ -19,6 +19,11 @@ export function getWorktreeDragUnitGroups( ): WorktreeDragUnitGroup[] { const groups: WorktreeDragUnitGroup[] = [] let current: { key: string; units: WorktreeDragUnitGroup['units'] } | null = null + const naturalWorktreeIds = new Set( + rows.flatMap((row) => + row.type === 'item' && row.sectionKey !== PINNED_GROUP_KEY ? [row.worktree.id] : [] + ) + ) for (const row of rows) { if (row.type === 'header') { @@ -39,7 +44,7 @@ export function getWorktreeDragUnitGroups( ) { continue } - if (row.sectionKey === PINNED_GROUP_KEY) { + if (row.sectionKey === PINNED_GROUP_KEY && naturalWorktreeIds.has(row.worktree.id)) { continue } if (!current) { diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts index 4b4012f8c7c..6261443c3e6 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts @@ -12,6 +12,7 @@ import { getLineageGroupKey, getLineageRenderInfo, getPRGroupKey, + PINNED_GROUP_KEY, type PendingCreationRef } from './worktree-list-groups' import { @@ -216,10 +217,39 @@ describe('buildRows with pinned worktrees', () => { const rows = buildRows('none', [unpinned1, pinned, unpinned2], repoMap, null, new Set()) expect(rows[0]).toMatchObject({ type: 'header', key: 'pinned', label: 'Pinned' }) expect(rows[1]).toMatchObject({ type: 'item', worktree: { id: 'wt-pinned' } }) - expect(rows[2]).toMatchObject({ type: 'header', key: 'all', label: 'All', count: 3 }) + expect(rows[2]).toMatchObject({ type: 'header', key: 'all', label: 'All', count: 2 }) expect(rows[2]).toMatchObject({ type: 'header', icon: ALL_GROUP_META.icon }) }) + it('uses worktree host ownership for pinned header host counts', () => { + const runtimePinned = { + ...pinned, + hostId: 'runtime:03ef704c-b180-4b10-998d-e28fbd5de9a3' as const + } + + const rows = buildRows( + 'none', + [runtimePinned, unpinned1], + repoMap, + null, + new Set(), + undefined, + undefined, + undefined, + {}, + new Map([ + [runtimePinned.id, runtimePinned], + [unpinned1.id, unpinned1] + ]) + ) + const pinnedHeader = rows[0] + + expect(pinnedHeader).toMatchObject({ type: 'header', key: 'pinned' }) + expect(pinnedHeader.type === 'header' ? pinnedHeader.hostWorktreeCounts : undefined).toEqual( + new Map([['runtime:03ef704c-b180-4b10-998d-e28fbd5de9a3', 1]]) + ) + }) + it('groups all worktrees under All in groupBy none', () => { const rows = buildRows('none', [unpinned1, unpinned2], repoMap, null, new Set()) @@ -230,30 +260,55 @@ describe('buildRows with pinned worktrees', () => { ]) }) - it('keeps pinned worktrees above the All group', () => { + it('moves pinned worktrees out of the All group', () => { const rows = buildRows('none', [unpinned1, pinned, unpinned2], repoMap, null, new Set()) expect(rows).toMatchObject([ { type: 'header', key: 'pinned' }, { type: 'item', worktree: { id: 'wt-pinned' } }, - { type: 'header', key: 'all', count: 3 }, + { type: 'header', key: 'all', count: 2 }, { type: 'item', worktree: { id: 'wt-1' } }, - { type: 'item', worktree: { id: 'wt-pinned' } }, { type: 'item', worktree: { id: 'wt-2' } } ]) }) + it('duplicates pinned worktrees into All when the policy allows it', () => { + const rows = buildRows( + 'none', + [unpinned1, pinned, unpinned2], + repoMap, + null, + new Set(), + undefined, + undefined, + undefined, + {}, + undefined, + false, + { showPinnedWorktreesInGroups: true } as never + ) + + expect(rows).toMatchObject([ + { type: 'header', key: 'pinned' }, + { type: 'item', sectionKey: PINNED_GROUP_KEY, worktree: { id: 'wt-pinned' } }, + { type: 'header', key: 'all', count: 3 }, + { type: 'item', sectionKey: 'all', worktree: { id: 'wt-1' } }, + { type: 'item', sectionKey: 'all', worktree: { id: 'wt-pinned' } }, + { type: 'item', sectionKey: 'all', worktree: { id: 'wt-2' } } + ]) + }) + it('collapses the All group in groupBy none', () => { const rows = buildRows('none', [unpinned1, pinned, unpinned2], repoMap, null, new Set(['all'])) expect(rows).toMatchObject([ { type: 'header', key: 'pinned' }, { type: 'item', worktree: { id: 'wt-pinned' } }, - { type: 'header', key: 'all', count: 3 } + { type: 'header', key: 'all', count: 2 } ]) }) - it('emits status headers for all matching worktrees in groupBy workspace-status', () => { + it('emits status headers for unpinned matching worktrees in groupBy workspace-status', () => { const rows = buildRows( 'workspace-status', [unpinned1, pinned, unpinned2], @@ -265,21 +320,45 @@ describe('buildRows with pinned worktrees', () => { type: 'header', key: 'workspace-status:in-progress', label: 'In progress', - count: 3 + count: 2 }) expect(rows[3]).toMatchObject({ type: 'item', worktree: { id: 'wt-1' } }) - expect(rows[4]).toMatchObject({ type: 'item', worktree: { id: 'wt-pinned' } }) - expect(rows[5]).toMatchObject({ type: 'item', worktree: { id: 'wt-2' } }) + expect(rows[4]).toMatchObject({ type: 'item', worktree: { id: 'wt-2' } }) }) - it('keeps pinned items in regular groups in pr-status mode', () => { + it('duplicates pinned worktrees into status groups when the policy allows it', () => { + const rows = buildRows( + 'workspace-status', + [unpinned1, pinned], + repoMap, + null, + new Set(), + undefined, + undefined, + undefined, + {}, + undefined, + false, + { showPinnedWorktreesInGroups: true } as never + ) + + expect(rows).toMatchObject([ + { type: 'header', key: 'pinned', count: 1 }, + { type: 'item', sectionKey: PINNED_GROUP_KEY, worktree: { id: 'wt-pinned' } }, + { type: 'header', key: 'workspace-status:in-progress', count: 2 }, + { type: 'item', sectionKey: 'workspace-status:in-progress', worktree: { id: 'wt-1' } }, + { type: 'item', sectionKey: 'workspace-status:in-progress', worktree: { id: 'wt-pinned' } } + ]) + }) + + it('moves pinned items out of regular groups in pr-status mode', () => { const rows = buildRows('pr-status', [unpinned1, pinned], repoMap, null, new Set()) const pinnedHeader = rows.find((r) => r.type === 'header' && r.key === 'pinned') expect(pinnedHeader).toBeDefined() const prGroup = rows.filter((r) => r.type === 'header' && r.key.startsWith('pr:')) for (const header of prGroup) { if (header.type === 'header') { - expect(header.count).toBe(2) + expect(header.count).toBe(1) } } }) @@ -305,20 +384,14 @@ describe('buildRows with pinned worktrees', () => { ) expect(rows[0]).toMatchObject({ type: 'header', key: 'pinned' }) expect(rows[1]).toMatchObject({ type: 'header', key: 'workspace-status:in-progress' }) - expect(rows[2]).toMatchObject({ type: 'item', worktree: { id: 'wt-pinned' } }) - expect(rows[3]).toMatchObject({ type: 'item', worktree: { id: 'wt-1' } }) + expect(rows[2]).toMatchObject({ type: 'item', worktree: { id: 'wt-1' } }) }) - it('keeps status sections complete when all worktrees are pinned', () => { + it('omits status sections when all matching worktrees are pinned', () => { const allPinned = { ...unpinned1, isPinned: true } const rows = buildRows('workspace-status', [pinned, allPinned], repoMap, null, new Set()) - expect(rows.filter((r) => r.type === 'header')).toHaveLength(2) + expect(rows.filter((r) => r.type === 'header')).toHaveLength(1) expect(rows[0]).toMatchObject({ type: 'header', key: 'pinned', count: 2 }) - expect(rows[3]).toMatchObject({ - type: 'header', - key: 'workspace-status:in-progress', - count: 2 - }) }) it('preserves repo display casing in group labels', () => { @@ -1291,6 +1364,123 @@ describe('buildRows with pinned worktrees', () => { expect(rows.some((row) => row.type === 'imported-worktrees-card')).toBe(false) }) + it('places non-repo imported fallbacks after each repo last pinned row when expanded', () => { + const repoTwo: Repo = { ...repo, id: 'repo-2', displayName: 'auth-service' } + const pinnedOneA = { ...worktree, id: 'repo-1-pinned-a', isPinned: true } + const pinnedTwo = { + ...worktree, + id: 'repo-2-pinned', + repoId: repoTwo.id, + isPinned: true, + displayName: 'auth-main' + } + const pinnedOneB = { ...worktree, id: 'repo-1-pinned-b', isPinned: true } + + const rows = buildRows( + 'none', + [pinnedOneA, pinnedTwo, pinnedOneB], + new Map([ + [repo.id, repo], + [repoTwo.id, repoTwo] + ]), + null, + new Set(), + undefined, + undefined, + undefined, + {}, + new Map([ + [pinnedOneA.id, pinnedOneA], + [pinnedTwo.id, pinnedTwo], + [pinnedOneB.id, pinnedOneB] + ]), + false, + undefined, + [], + new Set(), + new Map([ + [repo.id, { repo, hiddenWorktrees: [makeDetectedWorktree({ id: 'hidden-one' })] }], + [ + repoTwo.id, + { + repo: repoTwo, + hiddenWorktrees: [makeDetectedWorktree({ id: 'hidden-two', repoId: repoTwo.id })] + } + ] + ]) + ) + + expect( + rows.map((row) => + row.type === 'item' + ? row.worktree.id + : row.type === 'imported-worktrees-card' + ? `${row.placement}:${row.repo.id}` + : row.key + ) + ).toEqual([ + 'pinned', + 'repo-1-pinned-a', + 'repo-2-pinned', + 'pinned-fallback:repo-2', + 'repo-1-pinned-b', + 'pinned-fallback:repo-1' + ]) + }) + + it('places collapsed non-repo imported fallbacks after Pinned in pinned repo order', () => { + const repoTwo: Repo = { ...repo, id: 'repo-2', displayName: 'auth-service' } + const pinnedOneA = { ...worktree, id: 'repo-1-pinned-a', isPinned: true } + const pinnedTwo = { + ...worktree, + id: 'repo-2-pinned', + repoId: repoTwo.id, + isPinned: true, + displayName: 'auth-main' + } + const pinnedOneB = { ...worktree, id: 'repo-1-pinned-b', isPinned: true } + + const rows = buildRows( + 'workspace-status', + [pinnedOneA, pinnedTwo, pinnedOneB], + new Map([ + [repo.id, repo], + [repoTwo.id, repoTwo] + ]), + null, + new Set([PINNED_GROUP_KEY]), + undefined, + undefined, + undefined, + {}, + new Map([ + [pinnedOneA.id, pinnedOneA], + [pinnedTwo.id, pinnedTwo], + [pinnedOneB.id, pinnedOneB] + ]), + false, + undefined, + [], + new Set(), + new Map([ + [repo.id, { repo, hiddenWorktrees: [makeDetectedWorktree({ id: 'hidden-one' })] }], + [ + repoTwo.id, + { + repo: repoTwo, + hiddenWorktrees: [makeDetectedWorktree({ id: 'hidden-two', repoId: repoTwo.id })] + } + ] + ]) + ) + + expect(rows).toMatchObject([ + { type: 'header', key: PINNED_GROUP_KEY, count: 3 }, + { type: 'imported-worktrees-card', placement: 'pinned-fallback', repo: { id: repo.id } }, + { type: 'imported-worktrees-card', placement: 'pinned-fallback', repo: { id: repoTwo.id } } + ]) + }) + it('emits a new external worktrees inbox row before repo worktree rows', () => { const inboxWorktrees = [ makeDetectedWorktree({ id: 'inbox-1', displayName: 'payments-refactor' }), @@ -1379,6 +1569,51 @@ describe('buildRows with pinned worktrees', () => { ]) }) + it('keeps the inbox group when the repo only has a pinned worktree', () => { + const pinnedWorktree = { ...worktree, id: 'wt-pinned', isPinned: true } + const rows = buildRows( + 'repo', + [pinnedWorktree], + repoMap, + null, + new Set(), + undefined, + undefined, + undefined, + {}, + new Map([[pinnedWorktree.id, pinnedWorktree]]), + false, + undefined, + [], + new Set(), + new Map(), + new Map([[repo.id, { repo, inboxWorktrees: [makeDetectedWorktree()] }]]) + ) + + expect(rows).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'item', + sectionKey: PINNED_GROUP_KEY, + worktree: expect.objectContaining({ id: pinnedWorktree.id }) + }), + expect.objectContaining({ type: 'header', key: `repo:${repo.id}` }), + expect.objectContaining({ + type: 'new-external-worktrees-inbox', + key: `new-external-worktrees-inbox:${repo.id}` + }) + ]) + ) + expect( + rows.some( + (row) => + row.type === 'item' && + row.sectionKey === `repo:${repo.id}` && + row.worktree.id === pinnedWorktree.id + ) + ).toBe(false) + }) + it('does not emit new external worktrees inbox rows outside repo grouping', () => { const rows = buildRows( 'workspace-status', @@ -1457,24 +1692,112 @@ describe('buildRows with pinned worktrees', () => { placement: 'repo-group' } ]) + expect( + rows.filter((row) => row.type === 'item' && row.sectionKey !== PINNED_GROUP_KEY) + ).toEqual([]) expect(rows).toEqual( expect.arrayContaining([ expect.objectContaining({ type: 'item', + sectionKey: PINNED_GROUP_KEY, worktree: expect.objectContaining({ id: 'repo-1-pinned-a' }) }), expect.objectContaining({ type: 'item', + sectionKey: PINNED_GROUP_KEY, worktree: expect.objectContaining({ id: 'repo-1-pinned-b' }) }), expect.objectContaining({ type: 'item', + sectionKey: PINNED_GROUP_KEY, worktree: expect.objectContaining({ id: 'repo-2-pinned' }) }) ]) ) }) + it('duplicates pinned worktrees into repo groups when the policy allows it', () => { + const pinnedWorktree = { ...worktree, id: 'wt-pinned', isPinned: true } + const rows = buildRows( + 'repo', + [pinnedWorktree], + repoMap, + null, + new Set(), + undefined, + undefined, + undefined, + {}, + new Map([[pinnedWorktree.id, pinnedWorktree]]), + false, + { showPinnedWorktreesInGroups: true } as never, + [], + new Set(), + new Map([[repo.id, { repo, hiddenWorktrees: [makeDetectedWorktree()] }]]) + ) + + expect(rows).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'item', + sectionKey: PINNED_GROUP_KEY, + worktree: expect.objectContaining({ id: 'wt-pinned' }) + }), + expect.objectContaining({ + type: 'item', + sectionKey: `repo:${repo.id}`, + worktree: expect.objectContaining({ id: 'wt-pinned' }) + }) + ]) + ) + }) + + it('suppresses duplicate-mode imported fallback only when a natural anchor renders', () => { + const pinnedWorktree = { ...worktree, id: 'wt-pinned', isPinned: true } + const imported = new Map([[repo.id, { repo, hiddenWorktrees: [makeDetectedWorktree()] }]]) + const expanded = buildRows( + 'none', + [pinnedWorktree], + repoMap, + null, + new Set(), + undefined, + undefined, + undefined, + {}, + new Map([[pinnedWorktree.id, pinnedWorktree]]), + false, + { showPinnedWorktreesInGroups: true } as never, + [], + new Set(), + imported + ) + const collapsedAll = buildRows( + 'none', + [pinnedWorktree], + repoMap, + null, + new Set(['all']), + undefined, + undefined, + undefined, + {}, + new Map([[pinnedWorktree.id, pinnedWorktree]]), + false, + { showPinnedWorktreesInGroups: true } as never, + [], + new Set(), + imported + ) + + expect(expanded.some((row) => row.type === 'imported-worktrees-card')).toBe(false) + expect(collapsedAll).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: 'imported-worktrees-card', placement: 'pinned-fallback' }) + ]) + ) + }) + it('suppresses pinned imported worktree fallback when the repo has visible unpinned rows', () => { const pinnedWorktree = { ...worktree, id: 'wt-pinned', isPinned: true } const rows = buildRows( @@ -1526,9 +1849,11 @@ describe('buildRows with pinned worktrees', () => { expect(rows).toMatchObject([ { type: 'header', key: 'pinned' }, { type: 'header', key: 'repo:repo-1' }, - { type: 'imported-worktrees-card', placement: 'repo-group' }, - { type: 'item', worktree: { id: 'wt-pinned' } } + { type: 'imported-worktrees-card', placement: 'repo-group' } ]) + expect( + rows.filter((row) => row.type === 'item' && row.sectionKey !== PINNED_GROUP_KEY) + ).toEqual([]) }) it('groups folder-mode workspaces under their folder name', () => { diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.ts b/src/renderer/src/components/sidebar/worktree-list-groups.ts index df31f09f8fb..41fcdaf9c3b 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.ts @@ -34,13 +34,26 @@ import type { AppState } from '../../store/types' import { getGitHubPRCacheKey, getLegacyGitHubPRCacheKey } from '../../store/slices/github-cache-key' import { getRepoDisplayLabelKey, getRepoDisplayLabelsByPath } from '@/lib/repo-display-labels' import { translate } from '@/i18n/i18n' -import { getExecutionHostLabel, getRepoExecutionHostId } from '../../../../shared/execution-host' +import { + getExecutionHostLabel, + LOCAL_EXECUTION_HOST_ID, + getRepoExecutionHostId, + getWorktreeExecutionHostId, + type ExecutionHostId +} from '../../../../shared/execution-host' import { parseWslUncPath } from '../../../../shared/wsl-paths' import { isWindowsAbsolutePathLike } from '../../../../shared/cross-platform-path' export { branchName } export type WorktreeGroupBy = 'none' | 'workspace-status' | 'repo' | 'pr-status' +export type PinnedWorktreeDisplayPolicy = 'single-location' | 'duplicate-in-groups' + +export function getPinnedWorktreeDisplayPolicy( + settings?: { showPinnedWorktreesInGroups?: boolean } | null +): PinnedWorktreeDisplayPolicy { + return settings?.showPinnedWorktreesInGroups === true ? 'duplicate-in-groups' : 'single-location' +} export type GroupHeaderRow = { type: 'header' @@ -52,6 +65,10 @@ export type GroupHeaderRow = { repo?: Repo projectGroup?: ProjectGroup | { id: null; name: 'Ungrouped'; tabOrder: number } projectGroupDepth?: number + hostId?: ExecutionHostId + hostWorktreeCounts?: ReadonlyMap + hostWorktreeIds?: ReadonlyMap + worktreeIds?: readonly string[] } export type WorktreeRow = { @@ -438,15 +455,15 @@ export function getPRGroupKey( /** * Emit a "Pinned" header + its items into `result`. * - * Why: pinned is a shortcut overlay, not the worktree's canonical grouping. - * Normal sections still include pinned worktrees so labels like "All" and - * "In progress" remain literal. + * Why: the dedicated Pinned section is always present for pinned worktrees; + * the display policy decides whether their natural group rows also render. */ function emitPinnedGroup( worktrees: Worktree[], repoMap: Map, + defaultHostId: ExecutionHostId, collapsedGroups: Set, - visibleUnpinnedRepoIds: ReadonlySet, + renderedNaturalAnchorRepoIds: ReadonlySet, importedWorktreesByRepo: ReadonlyMap, allowImportedFallback: boolean, result: Row[] @@ -455,6 +472,21 @@ function emitPinnedGroup( if (pinned.length === 0) { return } + const hostWorktreeCounts = new Map() + const hostWorktreeIds = new Map() + const pinnedRepoOrder: string[] = [] + const seenPinnedRepoIds = new Set() + for (const worktree of pinned) { + const hostId = getWorktreeExecutionHostId(worktree, repoMap.get(worktree.repoId), defaultHostId) + hostWorktreeCounts.set(hostId, (hostWorktreeCounts.get(hostId) ?? 0) + 1) + const hostIds = hostWorktreeIds.get(hostId) ?? [] + hostIds.push(worktree.id) + hostWorktreeIds.set(hostId, hostIds) + if (!seenPinnedRepoIds.has(worktree.repoId)) { + pinnedRepoOrder.push(worktree.repoId) + seenPinnedRepoIds.add(worktree.repoId) + } + } result.push({ type: 'header', @@ -462,9 +494,19 @@ function emitPinnedGroup( label: PINNED_GROUP_META.label, count: pinned.length, tone: PINNED_GROUP_META.tone, - icon: PINNED_GROUP_META.icon + icon: PINNED_GROUP_META.icon, + hostWorktreeCounts, + hostWorktreeIds, + worktreeIds: pinned.map((worktree) => worktree.id) }) - if (!collapsedGroups.has(PINNED_GROUP_KEY)) { + if (collapsedGroups.has(PINNED_GROUP_KEY)) { + for (const repoId of pinnedRepoOrder) { + const candidate = importedWorktreesByRepo.get(repoId) + if (allowImportedFallback && candidate && !renderedNaturalAnchorRepoIds.has(repoId)) { + result.push(buildImportedWorktreesCardRow(candidate, 'pinned-fallback')) + } + } + } else { const lastPinnedIndexByRepoId = new Map() pinned.forEach((worktree, index) => lastPinnedIndexByRepoId.set(worktree.repoId, index)) for (const [index, worktree] of pinned.entries()) { @@ -484,7 +526,7 @@ function emitPinnedGroup( if ( allowImportedFallback && candidate && - !visibleUnpinnedRepoIds.has(worktree.repoId) && + !renderedNaturalAnchorRepoIds.has(worktree.repoId) && lastPinnedIndexByRepoId.get(worktree.repoId) === index ) { result.push(buildImportedWorktreesCardRow(candidate, 'pinned-fallback')) @@ -689,6 +731,101 @@ function getMixedHostContextLabels( return uniqueLabels.size > 1 ? labelsByRepoId : undefined } +function getHostWorktreeCounts( + worktrees: readonly Worktree[], + repoMap: Map, + defaultHostId: ExecutionHostId +): Map | undefined { + if (worktrees.length === 0) { + return undefined + } + const counts = new Map() + const seenWorktreeIds = new Set() + for (const worktree of worktrees) { + if (seenWorktreeIds.has(worktree.id)) { + continue + } + seenWorktreeIds.add(worktree.id) + const hostId = getWorktreeExecutionHostId(worktree, repoMap.get(worktree.repoId), defaultHostId) + counts.set(hostId, (counts.get(hostId) ?? 0) + 1) + } + return counts +} + +function getHostWorktreeIds( + worktrees: readonly Worktree[], + repoMap: Map, + defaultHostId: ExecutionHostId +): Map | undefined { + if (worktrees.length === 0) { + return undefined + } + const idsByHost = new Map() + const seenWorktreeIds = new Set() + for (const worktree of worktrees) { + if (seenWorktreeIds.has(worktree.id)) { + continue + } + seenWorktreeIds.add(worktree.id) + const hostId = getWorktreeExecutionHostId(worktree, repoMap.get(worktree.repoId), defaultHostId) + const ids = idsByHost.get(hostId) ?? [] + ids.push(worktree.id) + idsByHost.set(hostId, ids) + } + return idsByHost +} + +function getRenderedNaturalAnchorRepoIds({ + groupBy, + worktrees, + repoMap, + prCache, + collapsedGroups, + workspaceStatuses, + settings, + projectGrouping +}: { + groupBy: WorktreeGroupBy + worktrees: readonly Worktree[] + repoMap: Map + prCache: Record | null + collapsedGroups: ReadonlySet + workspaceStatuses: readonly WorkspaceStatusDefinition[] + settings?: AppState['settings'] + projectGrouping?: ProjectGroupingModel +}): Set { + const renderedRepoIds = new Set() + if (groupBy === 'none') { + if (!collapsedGroups.has(ALL_GROUP_KEY)) { + for (const worktree of worktrees) { + renderedRepoIds.add(worktree.repoId) + } + } + return renderedRepoIds + } + if (groupBy === 'repo') { + for (const worktree of worktrees) { + renderedRepoIds.add(worktree.repoId) + } + return renderedRepoIds + } + for (const worktree of worktrees) { + const groupKey = getGroupKeyForWorktree( + groupBy, + worktree, + repoMap, + prCache, + workspaceStatuses, + settings, + projectGrouping + ) + if (groupKey && !collapsedGroups.has(groupKey)) { + renderedRepoIds.add(worktree.repoId) + } + } + return renderedRepoIds +} + function orderMainWorktreeFirst(worktrees: Worktree[]): Worktree[] { const mainWorktrees = worktrees.filter((worktree) => worktree.isMainWorktree) if (mainWorktrees.length === 0) { @@ -856,7 +993,9 @@ export function buildRows( pendingCreations: readonly PendingCreationRef[] = [], projectGrouping?: ProjectGroupingModel, folderWorkspaces: readonly FolderWorkspace[] = [], - hostLabelById?: ReadonlyMap + hostLabelById?: ReadonlyMap, + defaultHostId: ExecutionHostId = LOCAL_EXECUTION_HOST_ID, + pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy = getPinnedWorktreeDisplayPolicy(settings) ): Row[] { const result: Row[] = [] const projectIndex = buildProjectGroupingIndex(projectGrouping) @@ -877,34 +1016,45 @@ export function buildRows( } } - const visibleUnpinnedRepoIds = new Set( - worktrees.filter((worktree) => !worktree.isPinned).map((worktree) => worktree.repoId) - ) - const visiblePinnedRepoIds = new Set( - worktrees.filter((worktree) => worktree.isPinned).map((worktree) => worktree.repoId) - ) + const naturalWorktrees = + pinnedDisplayPolicy === 'duplicate-in-groups' + ? worktrees + : worktrees.filter((worktree) => !worktree.isPinned) + const renderedNaturalAnchorRepoIds = getRenderedNaturalAnchorRepoIds({ + groupBy, + worktrees: naturalWorktrees, + repoMap, + prCache, + collapsedGroups, + workspaceStatuses, + settings, + projectGrouping + }) emitPinnedGroup( worktrees, repoMap, + defaultHostId, collapsedGroups, - visibleUnpinnedRepoIds, + renderedNaturalAnchorRepoIds, importedWorktreesByRepo, groupBy !== 'repo', result ) - if (groupBy === 'none') { - if (worktrees.length > 0) { + if (naturalWorktrees.length > 0) { result.push({ type: 'header', key: ALL_GROUP_KEY, label: ALL_GROUP_META.label, - count: worktrees.length, + count: naturalWorktrees.length, tone: ALL_GROUP_META.tone, - icon: ALL_GROUP_META.icon + icon: ALL_GROUP_META.icon, + hostWorktreeCounts: getHostWorktreeCounts(naturalWorktrees, repoMap, defaultHostId), + hostWorktreeIds: getHostWorktreeIds(naturalWorktrees, repoMap, defaultHostId), + worktreeIds: naturalWorktrees.map((worktree) => worktree.id) }) if (!collapsedGroups.has(ALL_GROUP_KEY)) { - appendWorktreeRows(result, worktrees, repoMap, lineageById, worktreeMap, { + appendWorktreeRows(result, naturalWorktrees, repoMap, lineageById, worktreeMap, { nestLineage, collapsedGroups, groupDepth: 0, @@ -916,7 +1066,7 @@ export function buildRows( } const grouped = new Map() - for (const w of worktrees) { + for (const w of naturalWorktrees) { let key: string let label: string let repo: Repo | undefined @@ -967,7 +1117,7 @@ export function buildRows( for (const [repoId, candidate] of importedWorktreesByRepo) { const grouping = getProjectGroupingForRepo(repoId, repoMap, projectIndex) const key = grouping.key - if (!grouped.has(key) && !visiblePinnedRepoIds.has(repoId)) { + if (!grouped.has(key)) { grouped.set(key, { label: grouping.label, items: [], @@ -983,7 +1133,9 @@ export function buildRows( for (const [repoId, candidate] of newExternalWorktreesInboxByRepo) { const grouping = getProjectGroupingForRepo(repoId, repoMap, projectIndex) const key = grouping.key - if (!grouped.has(key) && !visiblePinnedRepoIds.has(repoId)) { + if (!grouped.has(key)) { + // Why: the default policy removes pinned worktrees from natural groups, + // but actionable inbox rows still need a project section to render in. grouped.set(key, { label: grouping.label, items: [], @@ -1085,7 +1237,10 @@ export function buildRows( label: definition?.label ?? workspaceStatus, count: group.items.length, tone: meta.tone, - icon: meta.icon + icon: meta.icon, + hostWorktreeCounts: getHostWorktreeCounts(group.items, repoMap, defaultHostId), + hostWorktreeIds: getHostWorktreeIds(group.items, repoMap, defaultHostId), + worktreeIds: group.items.map((worktree) => worktree.id) } })() : (() => { @@ -1097,7 +1252,10 @@ export function buildRows( label: meta.label, count: group.items.length, tone: meta.tone, - icon: meta.icon + icon: meta.icon, + hostWorktreeCounts: getHostWorktreeCounts(group.items, repoMap, defaultHostId), + hostWorktreeIds: getHostWorktreeIds(group.items, repoMap, defaultHostId), + worktreeIds: group.items.map((worktree) => worktree.id) } })() diff --git a/src/renderer/src/components/sidebar/worktree-list-imported-rows.test.ts b/src/renderer/src/components/sidebar/worktree-list-imported-rows.test.ts index 2bb4e44a9dd..67f3760c91c 100644 --- a/src/renderer/src/components/sidebar/worktree-list-imported-rows.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-imported-rows.test.ts @@ -3,6 +3,7 @@ import { canKeepImportedWorktreesHidden, getRenderRowKey, getWorktreeDragGroups, + getWorktreeDragIndexes, renderRowContainsWorktree } from './WorktreeList' import type { Repo, Worktree } from '../../../../shared/types' @@ -59,6 +60,13 @@ const makeWorktreeRow = (id: string): Extract => ({ lineageChildCount: 0 }) +const makePinnedWorktreeRow = (id: string): Extract => ({ + ...makeWorktreeRow(id), + rowKey: `pinned:${id}`, + sectionKey: 'pinned', + worktree: { ...makeWorktree(id), isPinned: true } +}) + const makeImportedCardRow = (): Extract => ({ type: 'imported-worktrees-card', key: 'imported-worktrees-card:repo-group:repo-1', @@ -86,6 +94,29 @@ describe('imported worktree virtual rows', () => { ).toEqual([{ key: 'repo:repo-1', worktreeIds: ['main', 'feature'] }]) }) + it('indexes pinned rows for sidebar drag when they are the only rendered copy', () => { + const rows = [makeHeaderRow('pinned'), makePinnedWorktreeRow('main')] + const { groupIndexByRowKey, groupKeyByRowKey } = getWorktreeDragIndexes(rows) + + expect(getWorktreeDragGroups(rows)).toEqual([{ key: 'pinned', worktreeIds: ['main'] }]) + expect(groupKeyByRowKey.get('pinned:main')).toBe('pinned') + expect(groupIndexByRowKey.get('pinned:main')).toBe(0) + }) + + it('uses natural drag metadata when pinned rows have duplicate natural copies', () => { + const rows = [ + makeHeaderRow('pinned'), + makePinnedWorktreeRow('main'), + makeHeaderRow('all'), + makeWorktreeRow('main') + ] + const { groupKeyByRowKey } = getWorktreeDragIndexes(rows) + + expect(getWorktreeDragGroups(rows)).toEqual([{ key: 'all', worktreeIds: ['main'] }]) + expect(groupKeyByRowKey.has('pinned:main')).toBe(false) + expect(groupKeyByRowKey.get('all:main')).toBe('all') + }) + it('only allows keep-hidden actions for repo-group cards that are not forced visible', () => { expect(canKeepImportedWorktreesHidden(makeImportedCardRow(), undefined)).toBe(true) expect( diff --git a/src/renderer/src/components/sidebar/worktree-parent-candidates.ts b/src/renderer/src/components/sidebar/worktree-parent-candidates.ts index 4b3daa74238..6509743ba09 100644 --- a/src/renderer/src/components/sidebar/worktree-parent-candidates.ts +++ b/src/renderer/src/components/sidebar/worktree-parent-candidates.ts @@ -1,4 +1,4 @@ -import { getRepoExecutionHostId } from '../../../../shared/execution-host' +import { getWorktreeExecutionHostId } from '../../../../shared/execution-host' import type { Repo, Worktree, WorktreeLineage } from '../../../../shared/types' import { canAssignWorktreeParent } from './worktree-parent-eligibility' @@ -15,7 +15,7 @@ function getWorktreeOwnerHostId( repoMap: Map> ): string | null { const repo = repoMap.get(worktree.repoId) - return worktree.hostId ?? (repo ? getRepoExecutionHostId(repo) : null) + return repo ? getWorktreeExecutionHostId(worktree, repo) : (worktree.hostId ?? null) } export function getEligibleWorktreeParents({ diff --git a/src/renderer/src/components/sidebar/worktree-sidebar-row-preference.test.ts b/src/renderer/src/components/sidebar/worktree-sidebar-row-preference.test.ts new file mode 100644 index 00000000000..cd4672dc9a7 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-sidebar-row-preference.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest' +import type { FolderWorkspace, ProjectGroup, Repo, Worktree } from '../../../../shared/types' +import type { HostSectionRow } from './host-section-rows' +import { getRenderedWorktreesInSidebarOrder } from './worktree-sidebar-row-preference' + +const repo: Repo = { + id: 'repo-1', + path: '/repo-1', + displayName: 'Repo 1', + badgeColor: '#737373', + addedAt: 1 +} + +function worktree(id: string, isPinned = false): Worktree { + return { + id, + repoId: repo.id, + path: `/repo-1/${id}`, + displayName: id, + branch: id, + head: 'abc123', + isBare: false, + isMainWorktree: false, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned, + sortOrder: 1, + lastActivityAt: 1 + } +} + +function item(workspace: Worktree, sectionKey: string): HostSectionRow { + return { + type: 'item', + rowKey: `${sectionKey}:${workspace.id}`, + sectionKey, + worktree: workspace, + repo, + depth: 0, + groupDepth: 0, + lineageTrail: [], + isLastLineageChild: false, + lineageChildCount: 0 + } +} + +const projectGroup: ProjectGroup = { + id: 'group-1', + name: 'Group 1', + parentPath: '/group-1', + parentGroupId: null, + createdFrom: 'folder-scan', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 +} + +const folderWorkspace: FolderWorkspace = { + id: 'folder-1', + projectGroupId: projectGroup.id, + name: 'Folder 1', + folderPath: '/group-1/folder-1', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 1, + lastActivityAt: 1, + createdAt: 1, + updatedAt: 1 +} + +describe('getRenderedWorktreesInSidebarOrder', () => { + it('keeps folder workspaces in visual order while preferring natural pinned rows', () => { + const pinned = worktree('pinned', true) + const afterFolder = worktree('after-folder') + const rows: HostSectionRow[] = [ + item(pinned, 'pinned'), + { + type: 'folder-workspace', + key: 'folder-workspace:folder-1', + folderWorkspace, + projectGroup, + depth: 0, + groupDepth: 0 + }, + item(pinned, 'repo:repo-1'), + item(afterFolder, 'repo:repo-1') + ] + + expect( + getRenderedWorktreesInSidebarOrder(rows, 'duplicate-in-groups').map(({ id }) => id) + ).toEqual(['folder:folder-1', 'pinned', 'after-folder']) + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-sidebar-row-preference.ts b/src/renderer/src/components/sidebar/worktree-sidebar-row-preference.ts new file mode 100644 index 00000000000..c73107cbed8 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-sidebar-row-preference.ts @@ -0,0 +1,64 @@ +import { folderWorkspaceToWorktree } from '../../../../shared/folder-workspace-worktree' +import type { Worktree } from '../../../../shared/types' +import type { HostSectionRow } from './host-section-rows' +import { + PINNED_GROUP_KEY, + type PinnedWorktreeDisplayPolicy, + type WorktreeRow +} from './worktree-list-groups' + +export function getPreferredWorktreeRows( + rows: readonly WorktreeRow[], + pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy +): WorktreeRow[] { + // Why: duplicated pinned rows represent one workspace to navigation and selection. + // Prefer its natural-group copy, falling back to Pinned when that copy is hidden. + if (pinnedDisplayPolicy === 'single-location') { + const seen = new Set() + return rows.filter((row) => { + if (seen.has(row.worktree.id)) { + return false + } + seen.add(row.worktree.id) + return true + }) + } + + const preferredRows: WorktreeRow[] = [] + const seen = new Set() + for (const row of rows) { + if (row.sectionKey === PINNED_GROUP_KEY || seen.has(row.worktree.id)) { + continue + } + preferredRows.push(row) + seen.add(row.worktree.id) + } + for (const row of rows) { + if (seen.has(row.worktree.id)) { + continue + } + preferredRows.push(row) + seen.add(row.worktree.id) + } + return preferredRows +} + +export function getRenderedWorktreesInSidebarOrder( + rows: readonly HostSectionRow[], + pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy +): Worktree[] { + const itemRows = rows.filter((row): row is WorktreeRow => row.type === 'item') + const preferredRowKeys = new Set( + getPreferredWorktreeRows(itemRows, pinnedDisplayPolicy).map((row) => row.rowKey) + ) + const renderedWorktrees: Worktree[] = [] + + for (const row of rows) { + if (row.type === 'item' && preferredRowKeys.has(row.rowKey)) { + renderedWorktrees.push(row.worktree) + } else if (row.type === 'folder-workspace') { + renderedWorktrees.push(folderWorkspaceToWorktree(row.folderWorkspace)) + } + } + return renderedWorktrees +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 38d542e76be..0b6f41f8173 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -5103,7 +5103,11 @@ "windowSidebarSummary": "Sidebar, status bar, and file explorer", "statusBarCount": "{{value0}} indicators visible.", "gitIgnoredGlossary": "Files matched by .gitignore.", - "statusBarDescription": "Choose which indicators appear in the status bar." + "statusBarDescription": "Choose which indicators appear in the status bar.", + "showPinnedWorktreesInGroups": { + "title": "Also show pinned worktrees in their original lists", + "description": "Pinned worktrees stay in Pinned and also appear in All, Project, Status, and PR." + } }, "AutoRenameBranchFromWorkSetting": { "1626524572": "Nautilus", @@ -7353,6 +7357,10 @@ "workspaceOptions": "workspace options", "detailed": "detailed" }, + "showPinnedWorktreesInGroups": { + "title": "Also show pinned worktrees in their original lists", + "description": "Pinned worktrees stay in Pinned and also appear in All, Project, Status, and PR." + }, "antigravityUsageTitle": "Antigravity Usage", "antigravityUsageDescription": "Show Antigravity subscription usage in the status bar.", "antigravityKeyword": "antigravity", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index d8d0681e113..2c5a35ddbb4 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -5080,7 +5080,11 @@ "windowSidebarSummary": "Barra lateral, barra de estado y explorador de archivos", "statusBarCount": "{{value0}} indicadores visibles.", "gitIgnoredGlossary": "Archivos que coinciden con .gitignore.", - "statusBarDescription": "Elige qué indicadores aparecen en la barra de estado." + "statusBarDescription": "Elige qué indicadores aparecen en la barra de estado.", + "showPinnedWorktreesInGroups": { + "title": "Mostrar también los worktrees fijados en sus listas originales", + "description": "Los worktrees fijados permanecen en Fijados y también aparecen en Todos, Proyecto, Estado y PR." + } }, "AutoRenameBranchFromWorkSetting": { "1626524572": "Nautilus", @@ -7293,6 +7297,10 @@ }, "9a115966d3": "Minimizar a la bandeja al cerrar", "4d5b9427b5": "Cuando está activado, cerrar la ventana mantiene Orca en ejecución en la bandeja del sistema en lugar de salir.", + "showPinnedWorktreesInGroups": { + "title": "Mostrar también los worktrees fijados en sus listas originales", + "description": "Los worktrees fijados permanecen en Fijados y también aparecen en Todos, Proyecto, Estado y PR." + }, "antigravityUsageTitle": "Uso de Antigravity", "antigravityUsageDescription": "Muestra el uso de suscripción de Antigravity en la barra de estado.", "antigravityKeyword": "antigravity", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 8b38bf6102e..f622982f00e 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -5065,7 +5065,11 @@ "windowSidebarSummary": "サイドバー、ステータスバー、ファイルエクスプローラー", "statusBarCount": "{{value0}} 個のインジケーターが表示中。", "gitIgnoredGlossary": ".gitignore に一致するファイル。", - "statusBarDescription": "Choose which indicators appear in the status bar." + "statusBarDescription": "Choose which indicators appear in the status bar.", + "showPinnedWorktreesInGroups": { + "title": "ピン留めしたワークツリーを元のリストにも表示", + "description": "ピン留めしたワークツリーを Pinned に残したまま、すべて、プロジェクト、ステータス、PR にも表示します。" + } }, "AutoRenameBranchFromWorkSetting": { "1626524572": "Nautilus", @@ -7315,6 +7319,10 @@ }, "9a115966d3": "閉じるときにトレイへ最小化", "4d5b9427b5": "有効にすると、ウィンドウを閉じてもOrcaは終了せず、システムトレイで実行を続けます。", + "showPinnedWorktreesInGroups": { + "title": "ピン留めしたワークツリーを元のリストにも表示", + "description": "ピン留めしたワークツリーを Pinned に残したまま、すべて、プロジェクト、ステータス、PR にも表示します。" + }, "antigravityUsageTitle": "Antigravity の使用状況", "antigravityUsageDescription": "ステータスバーに Antigravity サブスクリプションの使用量を表示します。", "antigravityKeyword": "antigravity", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 90745d32358..ea0603e7c97 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -5065,7 +5065,11 @@ "windowSidebarSummary": "사이드바, 상태 표시줄 및 파일 탐색기", "statusBarCount": "{{value0}}개 표시기가 보입니다.", "gitIgnoredGlossary": ".gitignore와 일치하는 파일.", - "statusBarDescription": "Choose which indicators appear in the status bar." + "statusBarDescription": "Choose which indicators appear in the status bar.", + "showPinnedWorktreesInGroups": { + "title": "고정된 워크트리를 원래 목록에도 표시", + "description": "고정된 워크트리는 Pinned에 유지하면서 전체, 프로젝트, 상태 및 PR에도 표시합니다." + } }, "AutoRenameBranchFromWorkSetting": { "1626524572": "Nautilus", @@ -7278,6 +7282,10 @@ "workspaceOptions": "워크스페이스 옵션", "detailed": "상세" }, + "showPinnedWorktreesInGroups": { + "title": "고정된 워크트리를 원래 목록에도 표시", + "description": "고정된 워크트리는 Pinned에 유지하면서 전체, 프로젝트, 상태 및 PR에도 표시합니다." + }, "antigravityUsageTitle": "Antigravity 사용량", "antigravityUsageDescription": "상태 표시줄에 Antigravity 구독 사용량을 표시합니다.", "antigravityKeyword": "antigravity", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 52d6e397678..f14765dabb1 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -5065,7 +5065,11 @@ "windowSidebarSummary": "侧边栏、状态栏和文件浏览器", "statusBarCount": "显示 {{value0}} 个指示器。", "gitIgnoredGlossary": "与 .gitignore 匹配的文件。", - "statusBarDescription": "选择状态栏中显示哪些指示器。" + "statusBarDescription": "选择状态栏中显示哪些指示器。", + "showPinnedWorktreesInGroups": { + "title": "也在原来的列表中显示已固定的工作树", + "description": "已固定的工作树会保留在 Pinned 中,同时也显示在全部、项目、状态和 PR 中。" + } }, "AutoRenameBranchFromWorkSetting": { "1626524572": "Nautilus", @@ -7278,6 +7282,10 @@ }, "9a115966d3": "关闭时最小化到托盘", "4d5b9427b5": "启用后,关闭窗口会让 Orca 继续在系统托盘中运行,而不是退出。", + "showPinnedWorktreesInGroups": { + "title": "也在原来的列表中显示已固定的工作树", + "description": "已固定的工作树会保留在 Pinned 中,同时也显示在全部、项目、状态和 PR 中。" + }, "antigravityUsageTitle": "Antigravity 使用量", "antigravityUsageDescription": "在状态栏中显示 Antigravity 订阅使用量。", "antigravityKeyword": "antigravity", diff --git a/src/shared/constants.ts b/src/shared/constants.ts index d5ee81998b8..71201caef74 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -297,6 +297,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings { showTasksButton: true, showAutomationsButton: true, showMobileButton: true, + showPinnedWorktreesInGroups: false, ctrlTabOrderMode: 'mru', // Why: switching worktrees and opening command surfaces from a focused // terminal is a core Orca workflow; users who prefer TUI ownership opt in. diff --git a/src/shared/execution-host.test.ts b/src/shared/execution-host.test.ts index 503962a1021..d5e18506cb5 100644 --- a/src/shared/execution-host.test.ts +++ b/src/shared/execution-host.test.ts @@ -5,6 +5,7 @@ import { getLocalExecutionHostLabel, getRepoExecutionHostId, getSettingsFocusedExecutionHostId, + getWorktreeExecutionHostId, normalizeExecutionHostOrder, normalizeExecutionHostScope, normalizeVisibleExecutionHostIds, @@ -87,6 +88,20 @@ describe('execution host identity', () => { expect(getRepoExecutionHostId({ connectionId: 'ssh-target-1' })).toBe('ssh:ssh-target-1') }) + it('prefers explicit worktree ownership before repo and focused-host fallbacks', () => { + expect( + getWorktreeExecutionHostId( + { hostId: 'runtime:workspace-owner' }, + { connectionId: 'repo-owner' }, + 'runtime:focused-host' + ) + ).toBe('runtime:workspace-owner') + expect( + getWorktreeExecutionHostId({}, { connectionId: 'repo-owner' }, 'runtime:focused-host') + ).toBe('ssh:repo-owner') + expect(getWorktreeExecutionHostId({}, {}, 'runtime:focused-host')).toBe('runtime:focused-host') + }) + it('derives focused host compatibility from active runtime settings', () => { expect(getSettingsFocusedExecutionHostId(null)).toBe(LOCAL_EXECUTION_HOST_ID) expect(getSettingsFocusedExecutionHostId({ activeRuntimeEnvironmentId: 'runtime-1' })).toBe( diff --git a/src/shared/execution-host.ts b/src/shared/execution-host.ts index a2b9ae69104..1eadaebe942 100644 --- a/src/shared/execution-host.ts +++ b/src/shared/execution-host.ts @@ -1,4 +1,4 @@ -import type { GlobalSettings, Repo } from './types' +import type { GlobalSettings, Repo, Worktree } from './types' export const LOCAL_EXECUTION_HOST_ID = 'local' export const ALL_EXECUTION_HOSTS_SCOPE = 'all' @@ -150,6 +150,19 @@ export function getRepoExecutionHostId( return connectionId ? toSshExecutionHostId(connectionId) : LOCAL_EXECUTION_HOST_ID } +export function getWorktreeExecutionHostId( + worktree: Pick, + repo: Pick | undefined, + defaultHostId: ExecutionHostId = LOCAL_EXECUTION_HOST_ID +): ExecutionHostId { + // Why: runtime and SSH snapshots can identify a more precise owner than + // the repo fallback; every sidebar host decision must use the same precedence. + return ( + worktree.hostId ?? + (repo?.connectionId || repo?.executionHostId ? getRepoExecutionHostId(repo) : defaultHostId) + ) +} + export function getSettingsFocusedExecutionHostId( settings: Pick | null | undefined ): ExecutionHostId { diff --git a/src/shared/types.ts b/src/shared/types.ts index b54496da29e..154fddc3b01 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2737,6 +2737,9 @@ export type GlobalSettings = { /** Why: Orca Mobile remains reachable from Settings; this only controls * whether the top-level sidebar shortcut is shown. */ showMobileButton?: boolean + /** Why: pinned workspaces default to one sidebar location; users can opt + * back into seeing them in their natural groups too. */ + showPinnedWorktreesInGroups?: boolean /** Controls how Ctrl+Tab chooses the next visible tab. Optional for * profiles saved before this setting existed; readers default to MRU. */ ctrlTabOrderMode?: CtrlTabOrderMode From 44686f324ce6ced7e976555031471940b24de9d6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 17 Jul 2026 00:34:46 -0700 Subject: [PATCH 10/17] feat(native-chat): add mobile default-view toggle for native chat (#9084) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(native-chat): add mobile default-view toggle for native chat Adds a per-device "Native chat" setting on mobile (Settings → Native chat) that controls whether supported agent sessions open in the native chat view or the raw terminal. Default stays terminal; flipping it on makes eligible sessions render as native chat, mirroring the desktop default-view control. - New orca:defaultSessionView preference (loadDefaultSessionView/save). - Per-tab chat set refactored into a tri-state override map so a session can be pinned to terminal or chat regardless of the default; the legacy array format migrates to chat overrides on load. - useMobileSessionViewMode resolves each tab as override ?? default and reloads the default on focus so a Settings change applies without remount. - Toggle/action-sheet builders take an isTabChatView predicate. * chore(skills): refresh skill bundle manifest for v1.4.144-rc.2 The v1.4.144-rc.2 release bump left resources/skills/current-manifest.json pointing at rc.1, so verify:skill-bundle-manifest fails on any branch that reaches it. Regenerated (appVersion only; no skill content changed) to unblock CI. Unrelated to the native-chat toggle in this PR. * fix(native-chat): harden mobile view preferences * fix(native-chat): harden default view persistence * fix(native-chat): serialize mobile view persistence * fix(native-chat): reconcile failed mobile view saves * fix(native-chat): fail closed on unreadable view overrides * fix(native-chat): honor fail-closed view races --- mobile/app/_layout.tsx | 1 + .../app/h/[hostId]/session/[worktreeId].tsx | 4 +- mobile/app/native-chat-settings.tsx | 126 ++++++ mobile/app/settings.tsx | 10 + .../mobile-native-chat-toggle-action.ts | 6 +- .../mobile-terminal-action-sheet-actions.ts | 4 +- ...le-default-session-view-preference.test.ts | 167 ++++++++ ...-mobile-default-session-view-preference.ts | 51 +++ .../use-mobile-native-chat-controller.ts | 49 +-- .../use-mobile-session-view-mode.test.ts | 384 ++++++++++++++++++ .../session/use-mobile-session-view-mode.ts | 208 ++++++++++ mobile/src/storage/preferences.test.ts | 202 ++++++++- mobile/src/storage/preferences.ts | 29 -- .../src/storage/session-view-preferences.ts | 141 +++++++ 14 files changed, 1293 insertions(+), 89 deletions(-) create mode 100644 mobile/app/native-chat-settings.tsx create mode 100644 mobile/src/session/use-mobile-default-session-view-preference.test.ts create mode 100644 mobile/src/session/use-mobile-default-session-view-preference.ts create mode 100644 mobile/src/session/use-mobile-session-view-mode.test.ts create mode 100644 mobile/src/session/use-mobile-session-view-mode.ts create mode 100644 mobile/src/storage/session-view-preferences.ts diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index 1059c95502a..fe89a82c4a0 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -184,6 +184,7 @@ export default function RootLayout() { /> + diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index d6bf1187811..eaa04a7436a 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -1199,7 +1199,7 @@ export default function SessionScreen() { nativeChatInputLeaseReady, onSendError: showNativeChatSendError }) - const { chatTabIds, toggleTabChatView, showNativeChat, showNativeChatRef } = nativeChatController + const { toggleTabChatView, showNativeChat, showNativeChatRef } = nativeChatController const dictation = useMobileDictation({ client, @@ -5334,7 +5334,7 @@ export default function SessionScreen() { actions={getMobileTerminalActionSheetActions({ target: actionTarget, tabs: sessionTabs.filter((tab) => tab.type === 'terminal'), - chatTabIds, + isTabChatView: nativeChatController.isTabChatView, nativeChatTranscriptIsLocalReadable, onDismiss: () => setActionTarget(null), onToggleChat: toggleTabChatView, diff --git a/mobile/app/native-chat-settings.tsx b/mobile/app/native-chat-settings.tsx new file mode 100644 index 00000000000..bd358162d5b --- /dev/null +++ b/mobile/app/native-chat-settings.tsx @@ -0,0 +1,126 @@ +import { View, Text, StyleSheet, Pressable, ScrollView, Switch } from 'react-native' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { useRouter } from 'expo-router' +import { ChevronLeft } from 'lucide-react-native' +import { colors, radii, spacing, typography } from '../src/theme/mobile-theme' +import { useMobileDefaultSessionViewPreference } from '../src/session/use-mobile-default-session-view-preference' + +export default function NativeChatSettingsScreen() { + const router = useRouter() + const insets = useSafeAreaInsets() + + const { defaultView, setDefaultView } = useMobileDefaultSessionViewPreference() + const chatDefault = defaultView === 'chat' + + return ( + + + router.back()} + > + + + Native chat + + + + DEFAULT VIEW + + Choose how supported agent sessions (Claude, Codex, and other chat-capable agents) open on + this device. Terminal shows the raw CLI; native chat shows a chat interface like the + desktop app. You can still switch any individual session from its long-press menu. + + + + + Open sessions in native chat + {chatDefault ? 'On' : 'Off'} + + setDefaultView(next ? 'chat' : 'terminal')} + trackColor={{ false: colors.bgRaised, true: colors.textSecondary }} + thumbColor={colors.textPrimary} + /> + + + + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.bgBase, + paddingHorizontal: spacing.lg + }, + topRow: { + flexDirection: 'row', + alignItems: 'center', + marginTop: spacing.sm, + marginBottom: spacing.lg + }, + backButton: { + width: 36, + height: 36, + borderRadius: 18, + alignItems: 'center', + justifyContent: 'center', + marginRight: spacing.sm + }, + heading: { + fontSize: 20, + fontWeight: '700', + color: colors.textPrimary + }, + groupHeading: { + fontSize: 11, + fontWeight: '600', + color: colors.textMuted, + letterSpacing: 0.5, + marginBottom: spacing.xs, + paddingHorizontal: spacing.xs + }, + groupDescription: { + fontSize: typography.bodySize - 1, + color: colors.textSecondary, + lineHeight: 20, + paddingHorizontal: spacing.xs + }, + section: { + backgroundColor: colors.bgPanel, + borderRadius: radii.card, + overflow: 'hidden' + }, + sectionTopGap: { + marginTop: spacing.sm + }, + row: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + 2, + paddingVertical: spacing.md, + paddingHorizontal: spacing.md + 2 + }, + rowContent: { + flex: 1 + }, + rowLabel: { + fontSize: typography.bodySize, + fontWeight: '500', + color: colors.textPrimary + }, + rowSublabel: { + fontSize: typography.bodySize - 2, + color: colors.textSecondary, + marginTop: 2 + } +}) diff --git a/mobile/app/settings.tsx b/mobile/app/settings.tsx index b0bd73f651f..7a406ed70ee 100644 --- a/mobile/app/settings.tsx +++ b/mobile/app/settings.tsx @@ -20,6 +20,7 @@ import { LifeBuoy, Mic, Globe, + MessageSquare, Terminal as TerminalIcon, KeyRound } from 'lucide-react-native' @@ -114,6 +115,15 @@ export default function SettingsScreen() { + [styles.row, pressed && styles.rowPressed]} + onPress={() => router.push('/native-chat-settings')} + > + + Native chat + + + [styles.row, pressed && styles.rowPressed]} onPress={() => router.push('/browser-settings')} diff --git a/mobile/src/session/mobile-native-chat-toggle-action.ts b/mobile/src/session/mobile-native-chat-toggle-action.ts index 32ae700671a..efdf4f53102 100644 --- a/mobile/src/session/mobile-native-chat-toggle-action.ts +++ b/mobile/src/session/mobile-native-chat-toggle-action.ts @@ -11,19 +11,19 @@ type ToggleTab = MobileNativeChatTab & { export function getMobileNativeChatToggleActions(args: { terminalHandle: string | null tabs: readonly ToggleTab[] - chatTabIds: ReadonlySet + isTabChatView: (tabId: string) => boolean nativeChatTranscriptIsLocalReadable: boolean onClose: () => void onToggle: (tabId: string) => void }): ActionSheetAction[] { - const { terminalHandle, tabs, chatTabIds, onClose, onToggle } = args + const { terminalHandle, tabs, isTabChatView, onClose, onToggle } = args const tab = terminalHandle ? tabs.find((candidate) => candidate.terminal === terminalHandle) : null if (!tab || !resolveMobileNativeChat(tab, args.nativeChatTranscriptIsLocalReadable)) { return [] } - const isChat = chatTabIds.has(tab.id) + const isChat = isTabChatView(tab.id) return [ { label: isChat ? 'Switch to terminal view' : 'Switch to chat view', diff --git a/mobile/src/session/mobile-terminal-action-sheet-actions.ts b/mobile/src/session/mobile-terminal-action-sheet-actions.ts index 3b29a6dfaf7..e263e32b80f 100644 --- a/mobile/src/session/mobile-terminal-action-sheet-actions.ts +++ b/mobile/src/session/mobile-terminal-action-sheet-actions.ts @@ -10,7 +10,7 @@ type TerminalTab = MobileNativeChatTab & { id: string; terminal: string | null } export function getMobileTerminalActionSheetActions(args: { target: Target | null tabs: readonly TerminalTab[] - chatTabIds: ReadonlySet + isTabChatView: (tabId: string) => boolean nativeChatTranscriptIsLocalReadable: boolean onDismiss: () => void onToggleChat: (tabId: string) => void @@ -29,7 +29,7 @@ export function getMobileTerminalActionSheetActions(): { promise: Promise; resolve: (value: T) => void } { + let resolve!: (value: T) => void + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise + }) + return { promise, resolve } +} + +vi.mock('../storage/session-view-preferences', () => ({ + DEFAULT_SESSION_VIEW: 'terminal', + loadDefaultSessionView: vi.fn(), + saveDefaultSessionView: vi.fn() +})) + +describe('useMobileDefaultSessionViewPreference', () => { + let renderer: ReactTestRenderer | null = null + let preference: MobileDefaultSessionViewPreference | null = null + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + vi.mocked(loadDefaultSessionView).mockReset().mockResolvedValue('terminal') + vi.mocked(saveDefaultSessionView).mockReset().mockResolvedValue(undefined) + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + preference = null + }) + + async function mount(): Promise { + function Harness(): null { + preference = useMobileDefaultSessionViewPreference() + return null + } + await act(async () => { + renderer = create(createElement(Harness)) + await Promise.resolve() + }) + } + + it('keeps a fast toggle authoritative over the initial read', async () => { + const initialLoad = deferred() + vi.mocked(loadDefaultSessionView).mockReturnValue(initialLoad.promise) + await mount() + + act(() => preference?.setDefaultView('chat')) + expect(preference?.defaultView).toBe('chat') + + await act(async () => { + initialLoad.resolve('terminal') + await initialLoad.promise + }) + + expect(preference?.defaultView).toBe('chat') + expect(saveDefaultSessionView).toHaveBeenCalledWith('chat') + }) + + it('submits every change immediately so shared persistence preserves event order', async () => { + const firstSave = deferred() + vi.mocked(saveDefaultSessionView) + .mockImplementationOnce(() => firstSave.promise) + .mockResolvedValue(undefined) + await mount() + + act(() => preference?.setDefaultView('chat')) + await act(async () => { + await Promise.resolve() + }) + act(() => preference?.setDefaultView('terminal')) + act(() => preference?.setDefaultView('chat')) + await act(async () => { + await Promise.resolve() + }) + expect(saveDefaultSessionView).toHaveBeenCalledTimes(3) + expect(saveDefaultSessionView).toHaveBeenNthCalledWith(2, 'terminal') + expect(saveDefaultSessionView).toHaveBeenNthCalledWith(3, 'chat') + + await act(async () => { + firstSave.resolve() + await firstSave.promise + await Promise.resolve() + }) + expect(saveDefaultSessionView).toHaveBeenCalledTimes(3) + }) + + it('reloads the persisted value when the latest write fails', async () => { + vi.mocked(loadDefaultSessionView) + .mockResolvedValueOnce('terminal') + .mockResolvedValueOnce('terminal') + vi.mocked(saveDefaultSessionView).mockRejectedValue(new Error('storage unavailable')) + await mount() + + await act(async () => { + preference?.setDefaultView('chat') + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + }) + + expect(preference?.defaultView).toBe('terminal') + expect(loadDefaultSessionView).toHaveBeenCalledTimes(2) + }) + + it('does not let an older failed write roll back a newer choice', async () => { + const recoveryLoad = deferred() + vi.mocked(loadDefaultSessionView) + .mockResolvedValueOnce('terminal') + .mockReturnValueOnce(recoveryLoad.promise) + vi.mocked(saveDefaultSessionView) + .mockRejectedValueOnce(new Error('storage unavailable')) + .mockResolvedValueOnce(undefined) + await mount() + + act(() => preference?.setDefaultView('chat')) + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) + act(() => preference?.setDefaultView('terminal')) + + await act(async () => { + recoveryLoad.resolve('chat') + await recoveryLoad.promise + await Promise.resolve() + }) + + expect(preference?.defaultView).toBe('terminal') + expect(saveDefaultSessionView).toHaveBeenNthCalledWith(2, 'terminal') + }) + + it('submits the latest write before the Settings route unmounts', async () => { + const firstSave = deferred() + vi.mocked(saveDefaultSessionView) + .mockImplementationOnce(() => firstSave.promise) + .mockResolvedValueOnce(undefined) + await mount() + + act(() => preference?.setDefaultView('chat')) + await act(async () => { + await Promise.resolve() + }) + act(() => preference?.setDefaultView('terminal')) + expect(saveDefaultSessionView).toHaveBeenNthCalledWith(2, 'terminal') + act(() => renderer?.unmount()) + renderer = null + + await act(async () => { + firstSave.resolve() + await firstSave.promise + await Promise.resolve() + }) + }) +}) diff --git a/mobile/src/session/use-mobile-default-session-view-preference.ts b/mobile/src/session/use-mobile-default-session-view-preference.ts new file mode 100644 index 00000000000..ad305d2ded4 --- /dev/null +++ b/mobile/src/session/use-mobile-default-session-view-preference.ts @@ -0,0 +1,51 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { + DEFAULT_SESSION_VIEW, + loadDefaultSessionView, + saveDefaultSessionView, + type MobileSessionView +} from '../storage/session-view-preferences' + +export type MobileDefaultSessionViewPreference = { + defaultView: MobileSessionView + setDefaultView: (view: MobileSessionView) => void +} + +/** Owns the optimistic Settings value while keeping AsyncStorage writes ordered. */ +export function useMobileDefaultSessionViewPreference(): MobileDefaultSessionViewPreference { + const [defaultView, setDefaultViewState] = useState(DEFAULT_SESSION_VIEW) + const mountedRef = useRef(false) + const mutationRevisionRef = useRef(0) + + useEffect(() => { + mountedRef.current = true + const loadRevision = mutationRevisionRef.current + let stale = false + void loadDefaultSessionView().then((view) => { + // Why: a fast toggle is authoritative over the older storage read. + if (!stale && mutationRevisionRef.current === loadRevision) { + setDefaultViewState(view) + } + }) + return () => { + stale = true + mountedRef.current = false + } + }, []) + + const setDefaultView = useCallback((view: MobileSessionView) => { + const revision = mutationRevisionRef.current + 1 + mutationRevisionRef.current = revision + setDefaultViewState(view) + // Why: persistence owns a shared queue, so invoking it at event time preserves + // mutation order even when this route unmounts and a new instance takes over. + void saveDefaultSessionView(view).catch(async () => { + const persisted = await loadDefaultSessionView() + if (mountedRef.current && mutationRevisionRef.current === revision) { + setDefaultViewState(persisted) + } + }) + }, []) + + return { defaultView, setDefaultView } +} diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index cca9ae3f923..c5d771b3635 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -1,13 +1,11 @@ import { useCallback, - useEffect, useRef, - useState, type Dispatch, type MutableRefObject, type SetStateAction } from 'react' -import { loadNativeChatTabIds, saveNativeChatTabIds } from '../storage/preferences' +import { useMobileSessionViewMode } from './use-mobile-session-view-mode' import type { RpcClient } from '../transport/rpc-client' import { parseAskFromStatus, @@ -31,7 +29,8 @@ import { useThrottledLatestValue } from './use-throttled-latest-value' const NATIVE_CHAT_STREAM_THROTTLE_MS = 50 export type MobileNativeChatController = { - chatTabIds: Set + /** Whether a tab's effective view is chat (per-tab override, else the default). */ + isTabChatView: (tabId: string) => boolean toggleTabChatView: (tabId: string) => void showNativeChat: boolean showNativeChatRef: MutableRefObject @@ -85,46 +84,10 @@ export function useMobileNativeChatController(args: { nativeChatInputLeaseReady, onSendError } = args - const [chatTabIds, setChatTabIds] = useState>(new Set()) - const chatTabIdsToggledRef = useRef(false) - - useEffect(() => { - let active = true - // Re-arm per host/worktree so the fresh load can seed the new selection. - chatTabIdsToggledRef.current = false - void loadNativeChatTabIds(hostId, worktreeId).then((ids) => { - // Why: a toggle before this load resolves is authoritative; the load must - // not revert the user's in-memory choice back to the persisted state. - if (active && !chatTabIdsToggledRef.current) { - // Why: route reuse must clear the prior worktree's selection even when - // the newly loaded worktree has no saved chat tabs. - setChatTabIds(new Set(ids)) - } - }) - return () => { - active = false - } - }, [hostId, worktreeId]) - - const toggleTabChatView = useCallback( - (tabId: string) => { - chatTabIdsToggledRef.current = true - setChatTabIds((previous) => { - const next = new Set(previous) - if (next.has(tabId)) { - next.delete(tabId) - } else { - next.add(tabId) - } - void saveNativeChatTabIds(hostId, worktreeId, [...next]) - return next - }) - }, - [hostId, worktreeId] - ) + const { isTabChatView, toggleTabChatView } = useMobileSessionViewMode({ hostId, worktreeId }) const activeChatResolution = - activeSessionTab && activeSessionTabId && chatTabIds.has(activeSessionTabId) + activeSessionTab && activeSessionTabId && isTabChatView(activeSessionTabId) ? resolveMobileNativeChat(activeSessionTab, nativeChatTranscriptIsLocalReadable) : null const showNativeChat = activeChatResolution != null @@ -288,7 +251,7 @@ export function useMobileNativeChatController(args: { ) return { - chatTabIds, + isTabChatView, toggleTabChatView, showNativeChat, showNativeChatRef, diff --git a/mobile/src/session/use-mobile-session-view-mode.test.ts b/mobile/src/session/use-mobile-session-view-mode.test.ts new file mode 100644 index 00000000000..4315bda35bf --- /dev/null +++ b/mobile/src/session/use-mobile-session-view-mode.test.ts @@ -0,0 +1,384 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + loadDefaultSessionView, + readSessionViewOverridesPreference, + updateSessionViewOverride, + type MobileSessionView +} from '../storage/session-view-preferences' +import { + useMobileSessionViewMode, + type MobileSessionViewModeController +} from './use-mobile-session-view-mode' + +const focusEffectRuntime = vi.hoisted(() => ({ + callback: null as null | (() => undefined | (() => void)) +})) + +function deferred(): { + promise: Promise + resolve: (value: T) => void + reject: (reason?: unknown) => void +} { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, resolve, reject } +} + +vi.mock('expo-router', async () => { + const react = await import('react') + return { + // Run the focus callback once on mount, mirroring a focus. + useFocusEffect: (cb: () => undefined | (() => void)) => { + focusEffectRuntime.callback = cb + react.useEffect(() => cb(), [cb]) + } + } +}) + +vi.mock('../storage/session-view-preferences', () => ({ + loadDefaultSessionView: vi.fn(), + readSessionViewOverridesPreference: vi.fn(), + updateSessionViewOverride: vi.fn() +})) + +describe('useMobileSessionViewMode', () => { + let renderer: ReactTestRenderer | null = null + let controller: MobileSessionViewModeController | null = null + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + vi.mocked(loadDefaultSessionView).mockReset().mockResolvedValue('terminal') + vi.mocked(readSessionViewOverridesPreference) + .mockReset() + .mockResolvedValue({ overrides: new Map(), loaded: true }) + vi.mocked(updateSessionViewOverride).mockReset().mockResolvedValue(undefined) + focusEffectRuntime.callback = null + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + controller = null + }) + + async function mount(args: { + defaultView: MobileSessionView + overrides?: Map + }): Promise { + vi.mocked(loadDefaultSessionView).mockResolvedValue(args.defaultView) + vi.mocked(readSessionViewOverridesPreference).mockResolvedValue({ + overrides: args.overrides ?? new Map(), + loaded: true + }) + function Harness(): null { + controller = useMobileSessionViewMode({ hostId: 'h', worktreeId: 'w' }) + return null + } + await act(async () => { + renderer = create(createElement(Harness)) + await Promise.resolve() + await Promise.resolve() + }) + } + + it('follows the default when a tab has no override', async () => { + await mount({ defaultView: 'terminal' }) + expect(controller?.isTabChatView('t1')).toBe(false) + + act(() => renderer?.unmount()) + renderer = null + await mount({ defaultView: 'chat' }) + expect(controller?.isTabChatView('t1')).toBe(true) + }) + + it('lets a per-tab override win over the default', async () => { + await mount({ + defaultView: 'chat', + overrides: new Map([['t1', 'terminal']]) + }) + expect(controller?.isTabChatView('t1')).toBe(false) + expect(controller?.isTabChatView('t2')).toBe(true) + }) + + it('reloads the default on refocus after Settings changes it', async () => { + await mount({ defaultView: 'terminal' }) + expect(controller?.isTabChatView('t1')).toBe(false) + vi.mocked(loadDefaultSessionView).mockResolvedValue('chat') + + await act(async () => { + focusEffectRuntime.callback?.() + await Promise.resolve() + }) + + expect(controller?.isTabChatView('t1')).toBe(true) + }) + + it('toggles from the effective view and persists the override', async () => { + await mount({ defaultView: 'chat' }) + + await act(async () => { + controller?.toggleTabChatView('t1') + await Promise.resolve() + }) + + expect(updateSessionViewOverride).toHaveBeenLastCalledWith('h', 'w', 't1', 'terminal') + expect(controller?.isTabChatView('t1')).toBe(false) + expect(readSessionViewOverridesPreference).toHaveBeenCalledTimes(1) + }) + + it('toggles a terminal-default tab into chat', async () => { + await mount({ defaultView: 'terminal' }) + + await act(async () => { + controller?.toggleTabChatView('t1') + await Promise.resolve() + }) + + expect(updateSessionViewOverride).toHaveBeenLastCalledWith('h', 'w', 't1', 'chat') + expect(controller?.isTabChatView('t1')).toBe(true) + }) + + it('does not expose overrides from the previous host while the next scope loads', async () => { + const nextScopeLoad = deferred>() + vi.mocked(readSessionViewOverridesPreference).mockImplementation((hostId) => + hostId === 'h1' + ? Promise.resolve({ + overrides: new Map([['same-tab-id', 'chat' as const]]), + loaded: true + }) + : nextScopeLoad.promise.then((overrides) => ({ overrides, loaded: true })) + ) + function Harness(props: { hostId: string; worktreeId: string }): null { + controller = useMobileSessionViewMode(props) + return null + } + await act(async () => { + renderer = create(createElement(Harness, { hostId: 'h1', worktreeId: 'w1' })) + await Promise.resolve() + await Promise.resolve() + }) + expect(controller?.isTabChatView('same-tab-id')).toBe(true) + + await act(async () => { + renderer?.update(createElement(Harness, { hostId: 'h2', worktreeId: 'w2' })) + await Promise.resolve() + }) + expect(controller?.isTabChatView('same-tab-id')).toBe(false) + + await act(async () => { + nextScopeLoad.resolve(new Map()) + await Promise.resolve() + }) + }) + + it('merges a toggle made during load with the other persisted overrides', async () => { + const overridesLoad = deferred>() + vi.mocked(readSessionViewOverridesPreference).mockReturnValue( + overridesLoad.promise.then((overrides) => ({ overrides, loaded: true })) + ) + function Harness(): null { + controller = useMobileSessionViewMode({ hostId: 'h', worktreeId: 'w' }) + return null + } + await act(async () => { + renderer = create(createElement(Harness)) + await Promise.resolve() + }) + + act(() => controller?.toggleTabChatView('new-tab')) + expect(controller?.isTabChatView('new-tab')).toBe(true) + expect(updateSessionViewOverride).toHaveBeenCalledWith('h', 'w', 'new-tab', 'chat') + + await act(async () => { + overridesLoad.resolve(new Map([['saved-tab', 'chat']])) + await Promise.resolve() + await Promise.resolve() + }) + + expect(controller?.isTabChatView('saved-tab')).toBe(true) + expect(controller?.isTabChatView('new-tab')).toBe(true) + }) + + it('toggles from the fail-closed view while overrides load under a chat default', async () => { + const overridesLoad = deferred>() + vi.mocked(loadDefaultSessionView).mockResolvedValue('chat') + vi.mocked(readSessionViewOverridesPreference).mockReturnValue( + overridesLoad.promise.then((overrides) => ({ overrides, loaded: true })) + ) + function Harness(): null { + controller = useMobileSessionViewMode({ hostId: 'h', worktreeId: 'w' }) + return null + } + await act(async () => { + renderer = create(createElement(Harness)) + await Promise.resolve() + await Promise.resolve() + }) + + expect(controller?.isTabChatView('new-tab')).toBe(false) + act(() => controller?.toggleTabChatView('new-tab')) + + expect(updateSessionViewOverride).toHaveBeenCalledWith('h', 'w', 'new-tab', 'chat') + expect(controller?.isTabChatView('new-tab')).toBe(true) + + await act(async () => { + overridesLoad.resolve(new Map()) + await overridesLoad.promise + await Promise.resolve() + }) + }) + + it('submits rapid mutations in event order', async () => { + await mount({ defaultView: 'terminal' }) + act(() => controller?.toggleTabChatView('t1')) + act(() => controller?.toggleTabChatView('t2')) + + expect(updateSessionViewOverride).toHaveBeenNthCalledWith(1, 'h', 'w', 't1', 'chat') + expect(updateSessionViewOverride).toHaveBeenNthCalledWith(2, 'h', 'w', 't2', 'chat') + }) + + it('reconciles the latest optimistic override when persistence fails', async () => { + vi.mocked(updateSessionViewOverride).mockRejectedValue(new Error('storage unavailable')) + vi.mocked(readSessionViewOverridesPreference) + .mockResolvedValueOnce({ + overrides: new Map([['t1', 'terminal']]), + loaded: true + }) + .mockResolvedValueOnce({ + overrides: new Map([['t1', 'terminal']]), + loaded: true + }) + await mount({ + defaultView: 'terminal', + overrides: new Map([['t1', 'terminal']]) + }) + + await act(async () => { + controller?.toggleTabChatView('t1') + await Promise.resolve() + await Promise.resolve() + }) + + expect(controller?.isTabChatView('t1')).toBe(false) + expect(readSessionViewOverridesPreference).toHaveBeenCalledTimes(2) + }) + + it('does not let an older failed override roll back a newer choice', async () => { + const recoveryLoad = deferred>() + vi.mocked(updateSessionViewOverride) + .mockRejectedValueOnce(new Error('storage unavailable')) + .mockResolvedValueOnce(undefined) + await mount({ defaultView: 'terminal' }) + vi.mocked(readSessionViewOverridesPreference).mockReturnValueOnce( + recoveryLoad.promise.then((overrides) => ({ overrides, loaded: true })) + ) + + await act(async () => { + controller?.toggleTabChatView('t1') + await Promise.resolve() + }) + act(() => controller?.toggleTabChatView('t1')) + + await act(async () => { + recoveryLoad.resolve(new Map([['t1', 'chat']])) + await recoveryLoad.promise + await Promise.resolve() + }) + + expect(controller?.isTabChatView('t1')).toBe(false) + expect(updateSessionViewOverride).toHaveBeenNthCalledWith(2, 'h', 'w', 't1', 'terminal') + }) + + it('fails closed when both an override write and its recovery read fail', async () => { + vi.mocked(loadDefaultSessionView).mockResolvedValue('chat') + vi.mocked(updateSessionViewOverride).mockRejectedValue(new Error('storage unavailable')) + vi.mocked(readSessionViewOverridesPreference) + .mockResolvedValueOnce({ + overrides: new Map([['t1', 'terminal']]), + loaded: true + }) + .mockResolvedValueOnce({ overrides: new Map(), loaded: false }) + await mount({ + defaultView: 'chat', + overrides: new Map([['t1', 'terminal']]) + }) + + await act(async () => { + controller?.toggleTabChatView('t1') + await Promise.resolve() + await Promise.resolve() + }) + + expect(controller?.isTabChatView('t1')).toBe(false) + expect(readSessionViewOverridesPreference).toHaveBeenCalledTimes(2) + }) + + it('finishes an early toggle save after the route unmounts', async () => { + const overridesLoad = deferred>() + vi.mocked(readSessionViewOverridesPreference).mockReturnValue( + overridesLoad.promise.then((overrides) => ({ overrides, loaded: true })) + ) + function Harness(): null { + controller = useMobileSessionViewMode({ hostId: 'h', worktreeId: 'w' }) + return null + } + await act(async () => { + renderer = create(createElement(Harness)) + await Promise.resolve() + }) + act(() => controller?.toggleTabChatView('new-tab')) + act(() => renderer?.unmount()) + renderer = null + + await act(async () => { + overridesLoad.resolve(new Map([['saved-tab', 'terminal']])) + await Promise.resolve() + await Promise.resolve() + }) + + expect(updateSessionViewOverride).toHaveBeenCalledWith('h', 'w', 'new-tab', 'chat') + }) + + it('does not reload a failed override after the route unmounts', async () => { + const failedWrite = deferred() + vi.mocked(updateSessionViewOverride).mockReturnValue(failedWrite.promise) + await mount({ defaultView: 'terminal' }) + + act(() => controller?.toggleTabChatView('t1')) + act(() => renderer?.unmount()) + renderer = null + await act(async () => { + failedWrite.reject(new Error('storage unavailable')) + await Promise.resolve() + await Promise.resolve() + }) + + expect(readSessionViewOverridesPreference).toHaveBeenCalledTimes(1) + }) + + it('fails closed to terminal when overrides cannot be read under a chat default', async () => { + vi.mocked(loadDefaultSessionView).mockResolvedValue('chat') + vi.mocked(readSessionViewOverridesPreference).mockResolvedValue({ + overrides: new Map(), + loaded: false + }) + + function Harness(): null { + controller = useMobileSessionViewMode({ hostId: 'h', worktreeId: 'w' }) + return null + } + await act(async () => { + renderer = create(createElement(Harness)) + await Promise.resolve() + await Promise.resolve() + }) + + expect(controller?.isTabChatView('t1')).toBe(false) + }) +}) diff --git a/mobile/src/session/use-mobile-session-view-mode.ts b/mobile/src/session/use-mobile-session-view-mode.ts new file mode 100644 index 00000000000..ef1888b1332 --- /dev/null +++ b/mobile/src/session/use-mobile-session-view-mode.ts @@ -0,0 +1,208 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { useFocusEffect } from 'expo-router' +import { + loadDefaultSessionView, + readSessionViewOverridesPreference, + updateSessionViewOverride, + type MobileSessionView, + type SessionViewOverridesPreference +} from '../storage/session-view-preferences' + +type ViewOverridesState = { + hostId: string + worktreeId: string + overrides: Map + loaded: boolean +} + +type ViewOverridesRuntime = { + hostId: string + worktreeId: string + loadPromise: Promise + currentOverrides: Map + mutationRevisions: Map +} + +function isOverrideScope(state: ViewOverridesState, hostId: string, worktreeId: string): boolean { + return state.hostId === hostId && state.worktreeId === worktreeId +} + +function mergeOverrides( + persisted: ReadonlyMap, + current: ReadonlyMap +): Map { + const merged = new Map(persisted) + for (const [tabId, view] of current) { + merged.set(tabId, view) + } + return merged +} + +export type MobileSessionViewModeController = { + /** Whether a tab's effective view is chat (per-tab override, else the default). */ + isTabChatView: (tabId: string) => boolean + toggleTabChatView: (tabId: string) => void +} + +/** Resolves each tab's terminal/chat view: a per-device default (reloaded on focus + * so a Settings change applies without remounting the route) overlaid by persisted + * per-tab overrides that pin a session regardless of what the default later becomes. */ +export function useMobileSessionViewMode(args: { + hostId: string + worktreeId: string +}): MobileSessionViewModeController { + const { hostId, worktreeId } = args + const [viewOverridesState, setViewOverridesState] = useState(() => ({ + hostId, + worktreeId, + overrides: new Map(), + loaded: false + })) + const viewOverridesStateRef = useRef(viewOverridesState) + viewOverridesStateRef.current = viewOverridesState + const viewOverridesRuntimeRef = useRef(null) + const mountedRef = useRef(true) + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + } + }, []) + const ensureViewOverridesRuntime = useCallback((scopeHostId: string, scopeWorktreeId: string) => { + const current = viewOverridesRuntimeRef.current + if (current?.hostId === scopeHostId && current.worktreeId === scopeWorktreeId) { + return current + } + const next: ViewOverridesRuntime = { + hostId: scopeHostId, + worktreeId: scopeWorktreeId, + loadPromise: readSessionViewOverridesPreference(scopeHostId, scopeWorktreeId), + currentOverrides: new Map(), + mutationRevisions: new Map() + } + viewOverridesRuntimeRef.current = next + return next + }, []) + const [defaultView, setDefaultView] = useState('terminal') + // Why: the toggle callback reads the live default without depending on it, so + // its identity stays stable and it never captures a stale default. + const defaultViewRef = useRef(defaultView) + defaultViewRef.current = defaultView + + useEffect(() => { + let active = true + const runtime = ensureViewOverridesRuntime(hostId, worktreeId) + void runtime.loadPromise.then((preference) => { + if (!active) { + return + } + // Why: toggles made during the read are authoritative, but must not + // discard unrelated persisted overrides from the same worktree. + const merged = mergeOverrides(preference.overrides, runtime.currentOverrides) + runtime.currentOverrides = merged + // Why: an unreadable override store cannot safely be treated as empty when + // the default is chat; fail closed to terminal until a user toggles. + const next = { hostId, worktreeId, overrides: merged, loaded: preference.loaded } + viewOverridesStateRef.current = next + setViewOverridesState(next) + }) + return () => { + active = false + } + }, [ensureViewOverridesRuntime, hostId, worktreeId]) + + // Why: reload on focus so returning from Settings picks up a changed default. + useFocusEffect( + useCallback(() => { + let active = true + void loadDefaultSessionView().then((view) => { + if (active) { + setDefaultView(view) + } + }) + return () => { + active = false + } + }, []) + ) + + const isTabChatView = useCallback( + (tabId: string): boolean => { + if (!isOverrideScope(viewOverridesState, hostId, worktreeId)) { + return false + } + const override = viewOverridesState.overrides.get(tabId) + // Until this scope loads, only an immediate user toggle is authoritative; + // defaulting other tabs to terminal avoids activating stale cross-host chat. + return (override ?? (viewOverridesState.loaded ? defaultView : 'terminal')) === 'chat' + }, + [defaultView, hostId, viewOverridesState, worktreeId] + ) + + const toggleTabChatView = useCallback( + (tabId: string) => { + const current = viewOverridesStateRef.current + const currentScope = isOverrideScope(current, hostId, worktreeId) + ? current + : { + hostId, + worktreeId, + overrides: new Map(), + loaded: false + } + const overrides = new Map(currentScope.overrides) + // Flip from the tab's effective view (its override, else the default), so + // a tab following a chat default can still be pinned back to terminal. + const fallbackView = currentScope.loaded ? defaultViewRef.current : 'terminal' + const currentlyChat = (overrides.get(tabId) ?? fallbackView) === 'chat' + const nextView = currentlyChat ? 'terminal' : 'chat' + overrides.set(tabId, nextView) + const next = { ...currentScope, overrides } + viewOverridesStateRef.current = next + setViewOverridesState(next) + + const runtime = ensureViewOverridesRuntime(hostId, worktreeId) + runtime.currentOverrides = overrides + const revision = (runtime.mutationRevisions.get(tabId) ?? 0) + 1 + runtime.mutationRevisions.set(tabId, revision) + // Why: enqueue the individual mutation immediately so a remounted route + // cannot reorder it or replace unrelated overrides with a stale snapshot. + void updateSessionViewOverride(hostId, worktreeId, tabId, nextView).catch(async () => { + if (!mountedRef.current || viewOverridesRuntimeRef.current !== runtime) { + return + } + const preference = await readSessionViewOverridesPreference(hostId, worktreeId) + // Why: a failed older write must not roll back a newer choice for this tab. + if ( + !mountedRef.current || + viewOverridesRuntimeRef.current !== runtime || + runtime.mutationRevisions.get(tabId) !== revision + ) { + return + } + // Why: if recovery is also unreadable, fail closed instead of treating an + // unknown store as empty or restoring an earlier optimistic mutation. + const reconciled = preference.loaded + ? mergeOverrides(preference.overrides, runtime.currentOverrides) + : new Map(runtime.currentOverrides) + const recoveredOverride = preference.loaded ? preference.overrides.get(tabId) : 'terminal' + if (recoveredOverride) { + reconciled.set(tabId, recoveredOverride) + } else { + reconciled.delete(tabId) + } + runtime.currentOverrides = reconciled + const latest = viewOverridesStateRef.current + if (!isOverrideScope(latest, hostId, worktreeId)) { + return + } + const reconciledState = { ...latest, overrides: reconciled, loaded: preference.loaded } + viewOverridesStateRef.current = reconciledState + setViewOverridesState(reconciledState) + }) + }, + [ensureViewOverridesRuntime, hostId, worktreeId] + ) + + return { isTabChatView, toggleTabChatView } +} diff --git a/mobile/src/storage/preferences.test.ts b/mobile/src/storage/preferences.test.ts index 881391db0bd..13e43d45b43 100644 --- a/mobile/src/storage/preferences.test.ts +++ b/mobile/src/storage/preferences.test.ts @@ -8,7 +8,6 @@ import { HOST_SIDEBAR_MIN_WIDTH, clampHostDockWidth, clampHostSidebarWidth, - loadNativeChatTabIds, loadDisabledTerminalLiveInputHandles, loadHostSidebarWidth, loadPushNotificationsEnabled, @@ -18,11 +17,17 @@ import { readDisabledTerminalLiveInputHandlesPreference, saveDisabledTerminalLiveInputHandles, saveHostSidebarWidth, - saveNativeChatTabIds, savePushNotificationsEnabled, saveTerminalAutocompleteEnabled, saveTerminalLinkOpenMode } from './preferences' +import { + loadDefaultSessionView, + loadSessionViewOverrides, + readSessionViewOverridesPreference, + saveDefaultSessionView, + updateSessionViewOverride +} from './session-view-preferences' vi.mock('@react-native-async-storage/async-storage', () => ({ default: { @@ -31,27 +36,204 @@ vi.mock('@react-native-async-storage/async-storage', () => ({ } })) -describe('native chat tab preference', () => { +function deferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve!: (value: T) => void + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise + }) + return { promise, resolve } +} + +describe('session view preference', () => { beforeEach(() => { vi.mocked(AsyncStorage.getItem).mockReset() vi.mocked(AsyncStorage.setItem).mockReset() }) - it('loads and saves tab ids under a host-and-worktree scoped key', async () => { - vi.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify(['tab-1', 42, 'tab-2'])) + it('defaults to terminal and persists the chat default', async () => { + vi.mocked(AsyncStorage.getItem).mockResolvedValue(null) + await expect(loadDefaultSessionView()).resolves.toBe('terminal') + expect(AsyncStorage.getItem).toHaveBeenCalledWith('orca:defaultSessionView') - await expect(loadNativeChatTabIds('host/one', 'folder:C:\\repo')).resolves.toEqual([ - 'tab-1', - 'tab-2' + await saveDefaultSessionView('chat') + expect(AsyncStorage.setItem).toHaveBeenCalledWith('orca:defaultSessionView', 'chat') + + vi.mocked(AsyncStorage.getItem).mockResolvedValue('bogus') + await expect(loadDefaultSessionView()).resolves.toBe('terminal') + }) + + it('loads and updates per-tab overrides under a host-and-worktree scoped key', async () => { + vi.mocked(AsyncStorage.getItem).mockResolvedValue( + JSON.stringify({ 'tab-1': 'chat', 'tab-2': 'terminal', 'tab-3': 'bogus' }) + ) + + const loaded = await loadSessionViewOverrides('host/one', 'folder:C:\\repo') + expect([...loaded.entries()]).toEqual([ + ['tab-1', 'chat'], + ['tab-2', 'terminal'] ]) expect(AsyncStorage.getItem).toHaveBeenCalledWith( 'orca:nativeChatTabs:host%2Fone:folder%3AC%3A%5Crepo' ) - await saveNativeChatTabIds('host/one', 'folder:C:\\repo', ['tab-2']) + await updateSessionViewOverride('host/one', 'folder:C:\\repo', 'tab-2', 'chat') expect(AsyncStorage.setItem).toHaveBeenCalledWith( 'orca:nativeChatTabs:host%2Fone:folder%3AC%3A%5Crepo', - JSON.stringify(['tab-2']) + JSON.stringify({ 'tab-1': 'chat', 'tab-2': 'chat' }) + ) + }) + + it('migrates the legacy array format to chat overrides', async () => { + vi.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify(['tab-1', 42, 'tab-2'])) + + const loaded = await loadSessionViewOverrides('host', 'wt') + expect([...loaded.entries()]).toEqual([ + ['tab-1', 'chat'], + ['tab-2', 'chat'] + ]) + }) + + it('serializes default writes across callers and makes reads wait for the latest', async () => { + let stored = 'terminal' + const firstWrite = deferred() + vi.mocked(AsyncStorage.getItem).mockImplementation(async () => stored) + vi.mocked(AsyncStorage.setItem) + .mockImplementationOnce(async (_key, value) => { + await firstWrite.promise + stored = value + }) + .mockImplementation(async (_key, value) => { + stored = value + }) + + const older = saveDefaultSessionView('chat') + await Promise.resolve() + const newer = saveDefaultSessionView('terminal') + const reloaded = loadDefaultSessionView() + await Promise.resolve() + + expect(AsyncStorage.setItem).toHaveBeenCalledTimes(1) + firstWrite.resolve() + await Promise.all([older, newer]) + + await expect(reloaded).resolves.toBe('terminal') + expect(AsyncStorage.setItem).toHaveBeenNthCalledWith(1, 'orca:defaultSessionView', 'chat') + expect(AsyncStorage.setItem).toHaveBeenNthCalledWith(2, 'orca:defaultSessionView', 'terminal') + }) + + it('continues the shared default queue after a failed write', async () => { + let stored = 'terminal' + vi.mocked(AsyncStorage.getItem).mockImplementation(async () => stored) + vi.mocked(AsyncStorage.setItem) + .mockRejectedValueOnce(new Error('storage unavailable')) + .mockImplementation(async (_key, value) => { + stored = value + }) + + const failed = expect(saveDefaultSessionView('terminal')).rejects.toThrow('storage unavailable') + const latest = saveDefaultSessionView('chat') + await failed + await latest + + await expect(loadDefaultSessionView()).resolves.toBe('chat') + expect(AsyncStorage.setItem).toHaveBeenCalledTimes(2) + }) + + it('orders per-tab mutations across callers without losing saved siblings', async () => { + let stored = JSON.stringify({ saved: 'chat' }) + const firstWrite = deferred() + vi.mocked(AsyncStorage.getItem).mockImplementation(async () => stored) + vi.mocked(AsyncStorage.setItem) + .mockImplementationOnce(async (_key, value) => { + await firstWrite.promise + stored = value + }) + .mockImplementation(async (_key, value) => { + stored = value + }) + + const first = updateSessionViewOverride('host', 'worktree', 'first', 'terminal') + await vi.waitFor(() => expect(AsyncStorage.setItem).toHaveBeenCalledTimes(1)) + const second = updateSessionViewOverride('host', 'worktree', 'second', 'chat') + const reloaded = loadSessionViewOverrides('host', 'worktree') + + expect(AsyncStorage.setItem).toHaveBeenCalledTimes(1) + firstWrite.resolve() + await Promise.all([first, second]) + + await expect(reloaded).resolves.toEqual( + new Map([ + ['saved', 'chat'], + ['first', 'terminal'], + ['second', 'chat'] + ]) + ) + }) + + it('does not globally block updates for a different host and worktree', async () => { + const blockedWrite = deferred() + vi.mocked(AsyncStorage.getItem).mockResolvedValue(null) + vi.mocked(AsyncStorage.setItem).mockImplementation(async (key) => { + if (key.includes('blocked-host')) { + await blockedWrite.promise + } + }) + + const blocked = updateSessionViewOverride('blocked-host', 'worktree', 'tab', 'chat') + await Promise.resolve() + await Promise.resolve() + await updateSessionViewOverride('other-host', 'worktree', 'tab', 'terminal') + + expect(AsyncStorage.setItem).toHaveBeenCalledWith( + 'orca:nativeChatTabs:other-host:worktree', + JSON.stringify({ tab: 'terminal' }) + ) + blockedWrite.resolve() + await blocked + }) + + it('continues a scoped override queue after a failed write', async () => { + let stored: string | null = null + vi.mocked(AsyncStorage.getItem).mockImplementation(async () => stored) + vi.mocked(AsyncStorage.setItem) + .mockRejectedValueOnce(new Error('storage unavailable')) + .mockImplementation(async (_key, value) => { + stored = value + }) + + const failed = updateSessionViewOverride('host', 'worktree', 'first', 'chat') + const latest = updateSessionViewOverride('host', 'worktree', 'second', 'terminal') + await expect(failed).rejects.toThrow('storage unavailable') + await latest + + await expect(loadSessionViewOverrides('host', 'worktree')).resolves.toEqual( + new Map([['second', 'terminal']]) + ) + }) + + it('does not replace saved overrides after a transient read failure', async () => { + vi.mocked(AsyncStorage.getItem).mockRejectedValue(new Error('storage unavailable')) + + await expect(readSessionViewOverridesPreference('host', 'worktree')).resolves.toEqual({ + overrides: new Map(), + loaded: false + }) + + await expect(updateSessionViewOverride('host', 'worktree', 'tab', 'chat')).rejects.toThrow( + 'Session view overrides could not be read' + ) + + expect(AsyncStorage.setItem).not.toHaveBeenCalled() + }) + + it('repairs invalid preference data on the next user mutation', async () => { + vi.mocked(AsyncStorage.getItem).mockResolvedValue('not-json') + + await updateSessionViewOverride('host', 'worktree', 'tab', 'chat') + + expect(AsyncStorage.setItem).toHaveBeenCalledWith( + 'orca:nativeChatTabs:host:worktree', + JSON.stringify({ tab: 'chat' }) ) }) }) diff --git a/mobile/src/storage/preferences.ts b/mobile/src/storage/preferences.ts index a5af9bf350f..5173ac5bc8a 100644 --- a/mobile/src/storage/preferences.ts +++ b/mobile/src/storage/preferences.ts @@ -2,35 +2,6 @@ import AsyncStorage from '@react-native-async-storage/async-storage' const PINS_PREFIX = 'orca:pins:' const NOTIF_KEY = 'orca:pushNotificationsEnabled' -const NATIVE_CHAT_TABS_PREFIX = 'orca:nativeChatTabs:' - -function nativeChatTabIdsKey(hostId: string, worktreeId: string): string { - return `${NATIVE_CHAT_TABS_PREFIX}${encodeURIComponent(hostId)}:${encodeURIComponent(worktreeId)}` -} - -/** Tab ids currently showing native chat, scoped to the paired host and worktree - * so colliding remote ids cannot activate a transcript watcher on another host. */ -export async function loadNativeChatTabIds(hostId: string, worktreeId: string): Promise { - try { - const raw = await AsyncStorage.getItem(nativeChatTabIdsKey(hostId, worktreeId)) - const parsed = raw ? (JSON.parse(raw) as unknown) : null - return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string') : [] - } catch { - return [] - } -} - -export async function saveNativeChatTabIds( - hostId: string, - worktreeId: string, - ids: string[] -): Promise { - try { - await AsyncStorage.setItem(nativeChatTabIdsKey(hostId, worktreeId), JSON.stringify(ids)) - } catch { - // Best-effort persistence; a failed write just forgets the view choice. - } -} export type PushNotificationsPreference = { readonly value: boolean | null diff --git a/mobile/src/storage/session-view-preferences.ts b/mobile/src/storage/session-view-preferences.ts new file mode 100644 index 00000000000..750167e3d53 --- /dev/null +++ b/mobile/src/storage/session-view-preferences.ts @@ -0,0 +1,141 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' + +/** How a supported agent session opens: the raw terminal or the native chat view. */ +export type MobileSessionView = 'terminal' | 'chat' + +const DEFAULT_SESSION_VIEW_KEY = 'orca:defaultSessionView' +const NATIVE_CHAT_TABS_PREFIX = 'orca:nativeChatTabs:' + +// Why: default stays terminal so native chat remains strictly opt-in. +export const DEFAULT_SESSION_VIEW: MobileSessionView = 'terminal' + +let defaultViewWriteBarrier: Promise | null = null +const overrideUpdateBarriers = new Map>() + +function sessionViewOverridesKey(hostId: string, worktreeId: string): string { + return `${NATIVE_CHAT_TABS_PREFIX}${encodeURIComponent(hostId)}:${encodeURIComponent(worktreeId)}` +} + +function clearDefaultViewWriteBarrier(barrier: Promise): void { + if (defaultViewWriteBarrier === barrier) { + defaultViewWriteBarrier = null + } +} + +/** Global (per-device) default for how supported agent sessions open. */ +export async function loadDefaultSessionView(): Promise { + await defaultViewWriteBarrier + try { + const raw = await AsyncStorage.getItem(DEFAULT_SESSION_VIEW_KEY) + return raw === 'chat' || raw === 'terminal' ? raw : DEFAULT_SESSION_VIEW + } catch { + return DEFAULT_SESSION_VIEW + } +} + +export function saveDefaultSessionView(view: MobileSessionView): Promise { + // Why: callers can outlive their route; a shared barrier keeps remounted + // Settings screens from letting an older write land after a newer choice. + const write = (defaultViewWriteBarrier ?? Promise.resolve()).then(() => + AsyncStorage.setItem(DEFAULT_SESSION_VIEW_KEY, view) + ) + const barrier = write.catch(() => undefined) + defaultViewWriteBarrier = barrier + void barrier.then(() => clearDefaultViewWriteBarrier(barrier)) + return write +} + +export type SessionViewOverridesPreference = { + overrides: Map + loaded: boolean +} + +async function readSessionViewOverridesStorage( + key: string +): Promise { + let raw: string | null + try { + raw = await AsyncStorage.getItem(key) + } catch { + return { overrides: new Map(), loaded: false } + } + if (!raw) { + return { overrides: new Map(), loaded: true } + } + let parsed: unknown + try { + parsed = JSON.parse(raw) as unknown + } catch { + // Invalid preference data is safe to replace on the next user mutation. + return { overrides: new Map(), loaded: true } + } + // Legacy format: an array of tab ids that were showing native chat. + if (Array.isArray(parsed)) { + return { + overrides: new Map( + parsed + .filter((id): id is string => typeof id === 'string') + .map((id) => [id, 'chat' as const]) + ), + loaded: true + } + } + if (parsed && typeof parsed === 'object') { + const entries = Object.entries(parsed as Record).filter( + (entry): entry is [string, MobileSessionView] => + entry[1] === 'terminal' || entry[1] === 'chat' + ) + return { overrides: new Map(entries), loaded: true } + } + return { overrides: new Map(), loaded: true } +} + +/** Per-tab session-view overrides that win over the global default, scoped to the + * paired host and worktree so colliding remote ids cannot activate a transcript + * watcher on another host. */ +export async function loadSessionViewOverrides( + hostId: string, + worktreeId: string +): Promise> { + return (await readSessionViewOverridesPreference(hostId, worktreeId)).overrides +} + +/** Reads overrides without conflating an empty preference with unavailable storage. */ +export async function readSessionViewOverridesPreference( + hostId: string, + worktreeId: string +): Promise { + const key = sessionViewOverridesKey(hostId, worktreeId) + await overrideUpdateBarriers.get(key) + return readSessionViewOverridesStorage(key) +} + +/** Persists one user mutation without replacing sibling overrides from another mount. */ +export async function updateSessionViewOverride( + hostId: string, + worktreeId: string, + tabId: string, + view: MobileSessionView +): Promise { + const key = sessionViewOverridesKey(hostId, worktreeId) + const previous = overrideUpdateBarriers.get(key) ?? Promise.resolve() + const update = previous.then(async () => { + const current = await readSessionViewOverridesStorage(key) + // Why: a transient read failure must not replace valid saved siblings with + // a partial map containing only the latest tab. + if (!current.loaded) { + throw new Error('Session view overrides could not be read') + } + current.overrides.set(tabId, view) + await AsyncStorage.setItem(key, JSON.stringify(Object.fromEntries(current.overrides))) + }) + const barrier = update.catch(() => undefined) + overrideUpdateBarriers.set(key, barrier) + try { + await update + } finally { + if (overrideUpdateBarriers.get(key) === barrier) { + overrideUpdateBarriers.delete(key) + } + } +} From 1c54171c5599995d70c59339f76c306dbe671d9d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:42:11 +0000 Subject: [PATCH 11/17] Update README downloads badge --- docs/assets/readme-downloads.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index d96e1bddd40..31ede357f47 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 5.9m + + downloads: 6.0m @@ -15,7 +15,7 @@ downloads downloads - 5.9m - 5.9m + 6.0m + 6.0m From b67106805fe75128b465b0b4e0482b10ed4ad6c0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:34:19 -0700 Subject: [PATCH 12/17] fix(runtime): eliminate agent-send TOCTOU that rejects live agents during startup (#9166) * fix(runtime): avoid false no-agent send rejection during agent startup * fix(renderer): let guarded note sends resolve agent status races --- src/main/runtime/rpc/methods/terminal.ts | 41 ++---- .../rpc/terminal-agent-send-guard.test.ts | 127 ++++++++++++++++++ .../runtime/rpc/terminal-agent-send-guard.ts | 54 ++++++++ .../src/lib/active-agent-note-send.test.ts | 51 +++++-- .../src/lib/active-agent-note-send.ts | 12 +- 5 files changed, 243 insertions(+), 42 deletions(-) create mode 100644 src/main/runtime/rpc/terminal-agent-send-guard.test.ts create mode 100644 src/main/runtime/rpc/terminal-agent-send-guard.ts diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 6d5879d9cf8..1be167d6e1a 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -37,6 +37,7 @@ import { MOBILE_SNAPSHOT_BYTE_BUDGET, MOBILE_SUBSCRIBE_SCROLLBACK_ROWS } from '../../scrollback-limits' +import { assertTerminalAgentSendable } from '../terminal-agent-send-guard' const REQUESTED_SNAPSHOT_BYTE_BUDGET = 2 * 1024 * 1024 const TERMINAL_STREAM_CHUNK_BYTES = 48 * 1024 @@ -406,16 +407,6 @@ function isTerminalSendGuardNotWritable(error: unknown): boolean { return message.includes('terminal_guard_not_writable') } -function isTerminalAgentStatusNotWritable(error: unknown): boolean { - const message = error instanceof Error ? error.message : String(error) - return [ - 'terminal_not_writable', - 'terminal_handle_stale', - 'terminal_gone', - 'terminal_exited' - ].some((code) => message.includes(code)) -} - function assertTerminalSendExactPtyBinding( runtime: OrcaRuntimeService, handle: string, @@ -1270,28 +1261,16 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ const assertSendPreconditions = params.requireAgentStatus === 'sendable' ? async (ptyId?: string): Promise => { - assertTerminalSendExactPtyBinding(runtime, params.terminal, ptyId) - if (ptyId && isTerminalInputLockedForClient(runtime, ptyId, params.client)) { - throw new Error('terminal_guard_not_writable') - } - let agentStatus - try { - agentStatus = await runtime.getTerminalAgentStatus(params.terminal) - } catch (error) { - if (isTerminalAgentStatusNotWritable(error)) { - throw new Error('terminal_guard_not_writable') + await assertTerminalAgentSendable({ + runtime, + handle: params.terminal, + assertWritable: () => { + assertTerminalSendExactPtyBinding(runtime, params.terminal, ptyId) + if (ptyId && isTerminalInputLockedForClient(runtime, ptyId, params.client)) { + throw new Error('terminal_guard_not_writable') + } } - throw error - } - // Why: a send callback can race a pane reconnect; status evidence - // must never authorize the callback's replacement PTY. - assertTerminalSendExactPtyBinding(runtime, params.terminal, ptyId) - if (!agentStatus.isRunningAgent) { - throw new Error('terminal_guard_no_agent') - } - if (agentStatus.status === 'permission') { - throw new Error('terminal_guard_permission') - } + }) } : undefined if (params.requireAgentStatus === 'sendable') { diff --git a/src/main/runtime/rpc/terminal-agent-send-guard.test.ts b/src/main/runtime/rpc/terminal-agent-send-guard.test.ts new file mode 100644 index 00000000000..3128e0e2dc0 --- /dev/null +++ b/src/main/runtime/rpc/terminal-agent-send-guard.test.ts @@ -0,0 +1,127 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { OrcaRuntimeService } from '../orca-runtime' +import type { RpcRequest } from './core' +import { RpcDispatcher } from './dispatcher' +import { TERMINAL_METHODS } from './methods/terminal' + +function stubRuntime(overrides: Partial): OrcaRuntimeService { + return { + getRuntimeId: () => 'test-runtime', + ...overrides + } as OrcaRuntimeService +} + +function guardedSendRequest(): RpcRequest { + return { + id: 'req-1', + authToken: 'tok', + method: 'terminal.send', + params: { + terminal: 'terminal-1', + enter: true, + requireAgentStatus: 'sendable', + client: { id: 'desktop-1', type: 'desktop' } + } + } +} + +describe('terminal agent send guard', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('rechecks a transient no-agent result before refusing a guarded send', async () => { + vi.useFakeTimers() + const getTerminalAgentStatus = vi + .fn() + .mockResolvedValueOnce({ handle: 'terminal-1', isRunningAgent: false, status: null }) + .mockResolvedValue({ handle: 'terminal-1', isRunningAgent: true, status: 'working' }) + const runtime = stubRuntime({ + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + getDriver: vi.fn().mockReturnValue({ kind: 'desktop' }), + getTerminalAgentStatus, + sendTerminal: vi.fn().mockResolvedValue({ + handle: 'terminal-1', + accepted: true, + bytesWritten: 1 + }) + }) + const responsePromise = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }).dispatch( + guardedSendRequest() + ) + + await vi.advanceTimersByTimeAsync(150) + + await expect(responsePromise).resolves.toMatchObject({ + ok: true, + result: { send: { accepted: true, bytesWritten: 1 } } + }) + expect(getTerminalAgentStatus).toHaveBeenCalledTimes(2) + }) + + it('rechecks a transient no-agent result immediately before the PTY write', async () => { + vi.useFakeTimers() + const getTerminalAgentStatus = vi + .fn() + .mockResolvedValueOnce({ handle: 'terminal-1', isRunningAgent: true, status: 'working' }) + .mockResolvedValueOnce({ handle: 'terminal-1', isRunningAgent: false, status: null }) + .mockResolvedValue({ handle: 'terminal-1', isRunningAgent: true, status: 'working' }) + const write = vi.fn() + const runtime = stubRuntime({ + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + getDriver: vi.fn().mockReturnValue({ kind: 'desktop' }), + getTerminalAgentStatus, + sendTerminal: vi.fn().mockImplementation(async (_handle, _action, options) => { + await options.beforeWrite('pty-1') + write() + return { handle: 'terminal-1', accepted: true, bytesWritten: 1 } + }) + }) + const responsePromise = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }).dispatch( + guardedSendRequest() + ) + + await vi.advanceTimersByTimeAsync(150) + + await expect(responsePromise).resolves.toMatchObject({ + ok: true, + result: { send: { accepted: true, bytesWritten: 1 } } + }) + expect(getTerminalAgentStatus).toHaveBeenCalledTimes(3) + expect(write).toHaveBeenCalledOnce() + }) + + it('still refuses after the bounded recheck window without positive evidence', async () => { + vi.useFakeTimers() + const getTerminalAgentStatus = vi.fn().mockResolvedValue({ + handle: 'terminal-1', + isRunningAgent: false, + status: null + }) + const sendTerminal = vi.fn() + const runtime = stubRuntime({ + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + getDriver: vi.fn().mockReturnValue({ kind: 'desktop' }), + getTerminalAgentStatus, + sendTerminal + }) + const responsePromise = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }).dispatch( + guardedSendRequest() + ) + + await vi.advanceTimersByTimeAsync(1_050) + + await expect(responsePromise).resolves.toMatchObject({ + ok: true, + result: { + send: { + accepted: false, + bytesWritten: 0, + refusedReason: 'no-agent' + } + } + }) + expect(getTerminalAgentStatus).toHaveBeenCalledTimes(8) + expect(sendTerminal).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/rpc/terminal-agent-send-guard.ts b/src/main/runtime/rpc/terminal-agent-send-guard.ts new file mode 100644 index 00000000000..ed556a6eb7b --- /dev/null +++ b/src/main/runtime/rpc/terminal-agent-send-guard.ts @@ -0,0 +1,54 @@ +import type { OrcaRuntimeService } from '../orca-runtime' + +const AGENT_STATUS_RECHECK_INTERVAL_MS = 150 +const AGENT_STATUS_RECHECK_TIMEOUT_MS = 1_050 + +type AssertTerminalAgentSendableOptions = { + runtime: OrcaRuntimeService + handle: string + assertWritable: () => void +} + +export async function assertTerminalAgentSendable( + options: AssertTerminalAgentSendableOptions +): Promise { + const deadline = Date.now() + AGENT_STATUS_RECHECK_TIMEOUT_MS + while (true) { + options.assertWritable() + let agentStatus + try { + agentStatus = await options.runtime.getTerminalAgentStatus(options.handle) + } catch (error) { + if (isTerminalAgentStatusNotWritable(error)) { + throw new Error('terminal_guard_not_writable') + } + throw error + } + options.assertWritable() + if (agentStatus.isRunningAgent) { + if (agentStatus.status === 'permission') { + throw new Error('terminal_guard_permission') + } + return + } + const remainingMs = deadline - Date.now() + if (remainingMs <= 0) { + throw new Error('terminal_guard_no_agent') + } + // Why: title and foreground caches refresh asynchronously; require fresh + // positive evidence within a wall-clock bound so slow SSH reads cannot multiply it. + await new Promise((resolve) => + setTimeout(resolve, Math.min(AGENT_STATUS_RECHECK_INTERVAL_MS, remainingMs)) + ) + } +} + +function isTerminalAgentStatusNotWritable(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error) + return [ + 'terminal_not_writable', + 'terminal_handle_stale', + 'terminal_gone', + 'terminal_exited' + ].some((code) => message.includes(code)) +} diff --git a/src/renderer/src/lib/active-agent-note-send.test.ts b/src/renderer/src/lib/active-agent-note-send.test.ts index 71d1e7ff94e..82083d515ab 100644 --- a/src/renderer/src/lib/active-agent-note-send.test.ts +++ b/src/renderer/src/lib/active-agent-note-send.test.ts @@ -993,8 +993,11 @@ describe('active agent note send', () => { ) }) - it('refuses explicit targets that are not recognized agents before writing', async () => { - testState.callRuntimeRpc.mockImplementation(async (_target, method) => { + it('lets guarded sends decide after transient no-agent snapshots for explicit targets', async () => { + const methods: string[] = [] + let guardedSendAccepted = true + testState.callRuntimeRpc.mockImplementation(async (_target, method, params) => { + methods.push(method) if (method === 'terminal.list') { return { terminals: [ @@ -1005,7 +1008,7 @@ describe('active agent note send', () => { branch: 'main', tabId: 'tab-9', leafId: OTHER_LEAF_ID, - title: 'zsh', + title: 'Codex', connected: true, writable: true, lastOutputAt: 1, @@ -1019,6 +1022,24 @@ describe('active agent note send', () => { if (method === 'terminal.agentStatus') { return { agentStatus: { handle: 'term-2', isRunningAgent: false, status: null } } } + if (method === 'terminal.send') { + expect(params).toMatchObject({ + terminal: 'term-2', + requireAgentStatus: 'sendable' + }) + return { + send: { + handle: 'term-2', + accepted: guardedSendAccepted, + bytesWritten: guardedSendAccepted + ? typeof params.text === 'string' + ? params.text.length + : 1 + : 0, + ...(guardedSendAccepted ? {} : { refusedReason: 'no-agent' }) + } + } + } throw new Error(`unexpected method ${method}`) }) @@ -1028,14 +1049,26 @@ describe('active agent note send', () => { prompt: 'notes', noteTarget: { tabId: 'tab-9', leafId: OTHER_LEAF_ID } }) - ).resolves.toEqual({ status: 'no-agent' }) + ).resolves.toEqual({ status: 'sent' }) - expect(testState.callRuntimeRpc).not.toHaveBeenCalledWith( - expect.anything(), + expect(methods).toEqual([ + 'terminal.list', + 'terminal.agentStatus', 'terminal.send', - expect.anything(), - expect.anything() - ) + 'terminal.agentStatus', + 'terminal.send' + ]) + + methods.length = 0 + guardedSendAccepted = false + await expect( + sendNotesToActiveAgentSession({ + worktreeId: 'wt-1', + prompt: 'notes', + noteTarget: { tabId: 'tab-9', leafId: OTHER_LEAF_ID } + }) + ).resolves.toEqual({ status: 'no-agent' }) + expect(methods).toEqual(['terminal.list', 'terminal.agentStatus', 'terminal.send']) }) it('maps explicit target first-write refusal to not-writable', async () => { diff --git a/src/renderer/src/lib/active-agent-note-send.ts b/src/renderer/src/lib/active-agent-note-send.ts index 44fdd581811..a2eb9c429d5 100644 --- a/src/renderer/src/lib/active-agent-note-send.ts +++ b/src/renderer/src/lib/active-agent-note-send.ts @@ -174,7 +174,12 @@ async function sendPromptWithGuardedPasteAndEnter( const initialAgentStatus = await getTerminalAgentSendReadiness(runtimeTarget, terminalHandle, { allowLegacyFallback: options.allowLegacyFallback }) - if (initialAgentStatus.status !== 'sendable') { + // Why: the readiness probe and write guard can observe different transient + // title/process snapshots; the guard owns the bounded no-agent recheck. + if ( + initialAgentStatus.status !== 'sendable' && + !(initialAgentStatus.status === 'no-agent' && initialAgentStatus.supportsGuardedSend) + ) { return { status: initialAgentStatus.status } } @@ -216,7 +221,10 @@ async function sendPromptWithGuardedPasteAndEnter( const submitAgentStatus = await getTerminalAgentSendReadiness(runtimeTarget, terminalHandle, { allowLegacyFallback: options.allowLegacyFallback }) - if (submitAgentStatus.status !== 'sendable') { + if ( + submitAgentStatus.status !== 'sendable' && + !(submitAgentStatus.status === 'no-agent' && submitAgentStatus.supportsGuardedSend) + ) { return { status: 'partial-submit-failed' } } } catch (error) { From 2cb5d4e149a1ef91c4cce2f9123e82d47229fa8e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:47:04 -0700 Subject: [PATCH 13/17] fix(agent-status): clear answered Claude question waits at answer time (#9074) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(agent-status): clear answered Claude question waits at answer time An answered AskUserQuestion left the amber "waiting" indicator on sidebar rows and tabs until the agent's next tool hook or turn end — unbounded linger while the model thinks or streams after the answer (measured 17s for a 1000-word reply, 44s for 3000 words). Root cause is an event-shape change: newer Claude reports the AskUserQuestion wait as PermissionRequest (not the PreToolUse shape #7852 special-cased), so the wait inherited real-permission stickiness and shouldKeepClaudePermissionVisible swallowed the answer-time PostToolUse(AskUserQuestion) working event — the identity match can never succeed because the question's PermissionRequest carries no inheritable tool_use_id. That silently undid #8311 for questions. Two scoped changes, both keyed on the tool name rather than the hook event name: - Sticky permission hold now exempts AskUserQuestion waits, so the real answer-time hook (when Claude sends one) clears the wait as #8311 intended. - New guarded inference for the hook Claude may never send: the submit keystroke (Enter or digit quick-select) into a pane whose fresh status is a waiting AskUserQuestion synthesizes the post-answer state, exactly mirroring the existing interrupt inference (renderer baseline capture, main-process re-validation, listener lead-state sync so child-driven refreshes cannot resurrect the dismissed question). Real permission waits (other tools) keep their sticky semantics; batched input and pastes never match the submit classifier. Verified live against a real claude CLI: waiting -> working within ~50ms of both Enter and digit answers, question card dropped, unanswered questions still hold amber, permission stickiness covered by tests. * fix(agent-status): guard question answer inference Keep multi-question, multi-select, and free-text selector interactions waiting until the full prompt is submitted. Wire native-chat answers into the same guarded inference only after every paced runtime write succeeds, with cancellation and delivery-failure coverage. * chore(skills): refresh manifest for rc.2 * fix(agent-status): verify native chat answer delivery * fix(agent-status): await verified question delivery * fix(agent-status): pin native-chat answer baseline before delivery The native-chat question-answered inference read the live pane status at settle time (after the paced send + remote acceptance, which can span seconds on SSH). If a replacement AskUserQuestion became current in that window, the settle callback minted a fresh baseline from the new question and the server cleared *its* wait — dismissing a question the user never answered. Capture the answered question's baseline before delivery and have the inference getter return it, so the server re-validates against the pinned baseline and rejects a changed status — the same capture-then-revalidate contract the terminal keystroke path already uses. Also hoist the shouldStepNativeChatAskAnswer predicate to a single evaluation. Regression test swaps the live status between sendAnswer and settle and asserts the answered question's baseline is used (fails against the prior live-read getter). --- ...server.claude-interactive-question.test.ts | 127 +++++++++++++ src/main/agent-hooks/server.ts | 75 +++++++- src/main/ipc/agent-hooks.ts | 8 + src/preload/api-types.ts | 4 + src/preload/index.ts | 3 + .../NativeChatInteractiveCard.test.tsx | 42 ++++- .../native-chat/NativeChatInteractiveCard.tsx | 45 +++-- .../components/native-chat/NativeChatView.tsx | 2 +- .../native-chat-runtime-send.test.ts | 38 ++++ .../native-chat/native-chat-runtime-send.ts | 33 +++- .../use-native-chat-interactive-send.test.tsx | 163 ++++++++++++++-- .../use-native-chat-interactive-send.ts | 79 ++++++-- .../agent-question-answered-inference.test.ts | 175 ++++++++++++++++++ .../agent-question-answered-inference.ts | 85 +++++++++ .../terminal-pane/pty-connection.ts | 14 ++ src/renderer/src/web/web-preload-api.ts | 1 + src/shared/agent-hook-listener.test.ts | 57 ++++++ src/shared/agent-hook-listener.ts | 39 +++- src/shared/agent-question-answered-intent.ts | 83 +++++++++ 19 files changed, 1018 insertions(+), 55 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/agent-question-answered-inference.test.ts create mode 100644 src/renderer/src/components/terminal-pane/agent-question-answered-inference.ts create mode 100644 src/shared/agent-question-answered-intent.ts diff --git a/src/main/agent-hooks/server.claude-interactive-question.test.ts b/src/main/agent-hooks/server.claude-interactive-question.test.ts index a2c31a8f18b..cc450a3c36d 100644 --- a/src/main/agent-hooks/server.claude-interactive-question.test.ts +++ b/src/main/agent-hooks/server.claude-interactive-question.test.ts @@ -57,6 +57,34 @@ describe('Claude interactive-question status transitions', () => { ]) }) + it('clears a PermissionRequest-shaped AskUserQuestion wait when later tool work starts', () => { + // Why: newer Claude reports the AskUserQuestion wait as PermissionRequest; + // the question must not inherit real-permission stickiness from that shape. + const server = new AgentHookServer() + + ingestClaudeStatus(server, { + state: 'waiting', + hookEventName: 'PermissionRequest', + toolName: 'AskUserQuestion', + toolUseId: 'tool-question' + }) + ingestClaudeStatus(server, { + state: 'working', + hookEventName: 'PreToolUse', + toolName: 'Read', + toolUseId: 'tool-after-answer' + }) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: PANE_KEY, + state: 'working', + agentType: 'claude', + toolName: 'Read' + }) + ]) + }) + it('keeps an actual permission request sticky during unrelated tool work', () => { const server = new AgentHookServer() @@ -83,3 +111,102 @@ describe('Claude interactive-question status transitions', () => { ]) }) }) + +function answeredRequestFromSnapshot( + server: AgentHookServer +): Parameters[0] { + const [entry] = server.getStatusSnapshot() + return { + paneKey: entry.paneKey, + baselineUpdatedAt: entry.receivedAt, + baselineStateStartedAt: entry.stateStartedAt, + // Why: mirror the renderer, which echoes the entry's prompt verbatim — + // these ingests carry none, and the server must strict-match that. + baselinePrompt: entry.prompt as string, + baselineAgentType: entry.agentType + } +} + +describe('inferQuestionAnswered', () => { + it('clears an AskUserQuestion wait when the submit keystroke is reported', () => { + const server = new AgentHookServer() + ingestClaudeStatus(server, { + state: 'waiting', + hookEventName: 'PreToolUse', + toolName: 'AskUserQuestion', + toolUseId: 'tool-question' + }) + + expect(server.inferQuestionAnswered(answeredRequestFromSnapshot(server))).toBe(true) + // Why: the answered question must also drop the tool identity so the + // question card cannot linger on the working row. + const [entry] = server.getStatusSnapshot() + expect(entry).toMatchObject({ paneKey: PANE_KEY, state: 'working', agentType: 'claude' }) + expect(entry.toolName).toBeUndefined() + expect(entry.interactivePrompt).toBeUndefined() + }) + + it('clears a PermissionRequest-shaped AskUserQuestion wait (newer Claude)', () => { + const server = new AgentHookServer() + ingestClaudeStatus(server, { + state: 'waiting', + hookEventName: 'PermissionRequest', + toolName: 'AskUserQuestion', + toolUseId: 'tool-question' + }) + + expect(server.inferQuestionAnswered(answeredRequestFromSnapshot(server))).toBe(true) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ paneKey: PANE_KEY, state: 'working', agentType: 'claude' }) + ]) + }) + + it('refuses when the cached status changed since the baseline was captured', () => { + const server = new AgentHookServer() + ingestClaudeStatus(server, { + state: 'waiting', + hookEventName: 'PreToolUse', + toolName: 'AskUserQuestion', + toolUseId: 'tool-question' + }) + const staleRequest = { + ...answeredRequestFromSnapshot(server), + baselineUpdatedAt: 1 + } + + expect(server.inferQuestionAnswered(staleRequest)).toBe(false) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ state: 'waiting', toolName: 'AskUserQuestion' }) + ]) + }) + + it('never clears a real permission request', () => { + const server = new AgentHookServer() + ingestClaudeStatus(server, { + state: 'waiting', + hookEventName: 'PermissionRequest', + toolName: 'Bash', + toolUseId: 'tool-needs-permission' + }) + + expect(server.inferQuestionAnswered(answeredRequestFromSnapshot(server))).toBe(false) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ state: 'waiting', toolName: 'Bash' }) + ]) + }) + + it('ignores panes that are not waiting on a question', () => { + const server = new AgentHookServer() + ingestClaudeStatus(server, { + state: 'working', + hookEventName: 'PreToolUse', + toolName: 'Read', + toolUseId: 'tool-working' + }) + + expect(server.inferQuestionAnswered(answeredRequestFromSnapshot(server))).toBe(false) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ state: 'working', toolName: 'Read' }) + ]) + }) +}) diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 4011fdabda6..3480378d32c 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -22,6 +22,7 @@ import { ORCA_HOOK_PROTOCOL_VERSION } from '../../shared/agent-hook-types' import { clearAllListenerCaches, clearPaneCacheState, + clearClaudeAnsweredQuestionWait, createHookListenerState, getEndpointFileName, hasPendingAgentResultText, @@ -57,6 +58,10 @@ import { isAgentInterruptInputIntent, type AgentInterruptInferenceRequest } from '../../shared/agent-interrupt-intent' +import { + isAskUserQuestionTool, + type AgentQuestionAnsweredInferenceRequest +} from '../../shared/agent-question-answered-intent' import { parseLegacyNumericPaneKey, parsePaneKey } from '../../shared/stable-pane-id' import type { LegacyPaneKeyAliasEntry } from '../../shared/types' import { normalizeAgentProviderSession } from '../../shared/agent-session-resume' @@ -353,7 +358,12 @@ function shouldKeepClaudePermissionVisible( return false } // Why: only real permission requests stay sticky across concurrent subagent - // activity; interactive questions clear on the next working hook. + // activity; interactive questions clear on the next working hook. Newer + // Claude reports the AskUserQuestion wait AS a PermissionRequest, so the + // tool name — not the event name — decides which rule applies. + if (isAskUserQuestionTool(previous.payload.toolName)) { + return false + } return true } @@ -626,6 +636,69 @@ export class AgentHookServer { return true } + /** Guarded fallback for a hook Claude never sends: answering AskUserQuestion + * produces no event, so the amber wait would otherwise linger until the + * agent's next tool or turn end. The renderer reports the submit keystroke; + * this re-validates its baseline against the cached status (a racing real + * hook wins) and synthesizes the post-answer state. */ + inferQuestionAnswered(request: AgentQuestionAnsweredInferenceRequest): boolean { + if (!isValidPaneKey(request.paneKey)) { + return false + } + const existing = this.state.lastStatusByPaneKey.get(request.paneKey) as + | EnrichedAgentHookEventPayload + | undefined + if (!existing) { + return false + } + const payload = existing.payload + // Why: only Claude's interactive question may clear on typed input. The + // tool name is the discriminator, not the hook event — Claude versions + // differ on whether the AskUserQuestion wait arrives as PreToolUse or + // PermissionRequest. Real permission waits (other tools) stay sticky until + // the approved tool resumes — a denied or ignored permission must keep + // demanding attention even though approving is also a keystroke. + if ( + payload.agentType !== 'claude' || + payload.state !== 'waiting' || + !isAskUserQuestionTool(payload.toolName) + ) { + return false + } + if ( + payload.agentType !== request.baselineAgentType || + payload.prompt !== request.baselinePrompt || + existing.receivedAt !== request.baselineUpdatedAt || + existing.stateStartedAt !== request.baselineStateStartedAt || + Date.now() - existing.receivedAt > AGENT_STATUS_STALE_AFTER_MS + ) { + return false + } + // Why: sync the listener's lead-turn record too — a later child lifecycle + // event would otherwise re-emit the stale waiting state and resurrect the + // dismissed question card. + const restored = clearClaudeAnsweredQuestionWait(this.state, existing.paneKey) + const inferred = this.applyNormalizedStatus({ + paneKey: existing.paneKey, + tabId: existing.tabId, + worktreeId: existing.worktreeId, + connectionId: existing.connectionId, + providerSession: existing.providerSession, + payload: { + state: restored.state, + prompt: payload.prompt, + agentType: payload.agentType, + ...(restored.state === 'done' && restored.interrupted ? { interrupted: true } : {}), + ...(payload.subagents ? { subagents: payload.subagents } : {}) + } + }) + console.debug('[agent-hooks] inferred answered question status', { + paneKey: inferred.paneKey, + state: inferred.payload.state + }) + return true + } + getStatusChangeSnapshot(): AgentHookStatusChangeEntry[] { return Array.from(this.state.lastStatusByPaneKey.entries(), ([paneKey, entry]) => { const enriched = entry as EnrichedAgentHookEventPayload diff --git a/src/main/ipc/agent-hooks.ts b/src/main/ipc/agent-hooks.ts index 76b98ccb821..5a8e3f69ae4 100644 --- a/src/main/ipc/agent-hooks.ts +++ b/src/main/ipc/agent-hooks.ts @@ -5,6 +5,7 @@ import type { MigrationUnsupportedPtyEntry } from '../../shared/agent-status-types' import type { AgentInterruptInferenceRequest } from '../../shared/agent-interrupt-intent' +import type { AgentQuestionAnsweredInferenceRequest } from '../../shared/agent-question-answered-intent' import { agentHookServer, isValidPaneKey } from '../agent-hooks/server' import { ampHookService } from '../amp/hook-service' import { @@ -67,6 +68,7 @@ export function registerAgentHookHandlers( ipcMain.removeHandler('agentHooks:kimiStatus') ipcMain.removeHandler('agentStatus:getSnapshot') ipcMain.removeHandler('agentStatus:inferInterrupt') + ipcMain.removeHandler('agentStatus:inferQuestionAnswered') ipcMain.removeHandler('agentStatus:getMigrationUnsupportedSnapshot') // Why: agentStatus:drop is sent fire-and-forget from the renderer via // ipcRenderer.send(); we listen with ipcMain.on (not handle) so we don't @@ -121,6 +123,12 @@ export function registerAgentHookHandlers( } return agentHookServer.inferInterrupt(request as AgentInterruptInferenceRequest) }) + ipcMain.handle('agentStatus:inferQuestionAnswered', (_event, request: unknown): boolean => { + if (typeof request !== 'object' || request === null) { + return false + } + return agentHookServer.inferQuestionAnswered(request as AgentQuestionAnsweredInferenceRequest) + }) ipcMain.handle( 'agentStatus:getMigrationUnsupportedSnapshot', (): MigrationUnsupportedPtyEntry[] => getMigrationUnsupportedPtySnapshot() diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 7c03b6f8879..0b2cfd98283 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -304,6 +304,7 @@ import type { MigrationUnsupportedPtyEntry } from '../shared/agent-status-types' import type { AgentInterruptInferenceRequest } from '../shared/agent-interrupt-intent' +import type { AgentQuestionAnsweredInferenceRequest } from '../shared/agent-question-answered-intent' import type { TerminalSideEffectBatch } from '../shared/terminal-side-effect-facts' import type { RuntimeBrowserDriverState, @@ -3181,6 +3182,9 @@ export type PreloadApi = { /** Return the current main-process hook cache after renderer hydration. */ getSnapshot: () => Promise inferInterrupt: (request: AgentInterruptInferenceRequest) => Promise + /** Guarded clear for an answered AskUserQuestion wait — the CLI emits no + * hook at answer time, so the renderer reports the submit keystroke. */ + inferQuestionAnswered: (request: AgentQuestionAnsweredInferenceRequest) => Promise /** Listen for PTYs that still use a legacy numeric pane key but have * registry-backed UUID pane proof. */ onMigrationUnsupported: (callback: (entry: MigrationUnsupportedPtyEntry) => void) => () => void diff --git a/src/preload/index.ts b/src/preload/index.ts index 9f0a30529bd..8084b0b1fc3 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -145,6 +145,7 @@ import type { MigrationUnsupportedPtyEntry } from '../shared/agent-status-types' import type { AgentInterruptInferenceRequest } from '../shared/agent-interrupt-intent' +import type { AgentQuestionAnsweredInferenceRequest } from '../shared/agent-question-answered-intent' import type { TerminalSideEffectBatch } from '../shared/terminal-side-effect-facts' import type { SpeechErrorEvent, @@ -4404,6 +4405,8 @@ const api = { ipcRenderer.invoke('agentStatus:getSnapshot'), inferInterrupt: (request: AgentInterruptInferenceRequest): Promise => ipcRenderer.invoke('agentStatus:inferInterrupt', request), + inferQuestionAnswered: (request: AgentQuestionAnsweredInferenceRequest): Promise => + ipcRenderer.invoke('agentStatus:inferQuestionAnswered', request), onMigrationUnsupported: ( callback: (entry: MigrationUnsupportedPtyEntry) => void ): (() => void) => { diff --git a/src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx b/src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx index 37f5e21db08..26aea01d014 100644 --- a/src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx @@ -2,7 +2,7 @@ import '@testing-library/jest-dom/vitest' -import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { NativeChatInteractiveSend } from './use-native-chat-interactive-send' @@ -73,7 +73,7 @@ describe('NativeChatInteractiveCard answer lifecycle', () => { }) it('keeps the card retryable when no PTY answer was sent', () => { - mocks.sendAnswer.mockReturnValue(0) + mocks.sendAnswer.mockReturnValue({ settleAfterMs: 0, waitsForVerifiedDelivery: false }) renderCard() chooseSpacesAndSubmit() @@ -84,7 +84,7 @@ describe('NativeChatInteractiveCard answer lifecycle', () => { }) it('cancels delayed PTY writes when the owning card unmounts', () => { - mocks.sendAnswer.mockReturnValue(5_000) + mocks.sendAnswer.mockReturnValue({ settleAfterMs: 5_000, waitsForVerifiedDelivery: false }) const rendered = renderCard() chooseSpacesAndSubmit() @@ -95,7 +95,7 @@ describe('NativeChatInteractiveCard answer lifecycle', () => { }) it('cancels delayed PTY writes when desktop send authority is lost', () => { - mocks.sendAnswer.mockReturnValue(5_000) + mocks.sendAnswer.mockReturnValue({ settleAfterMs: 5_000, waitsForVerifiedDelivery: false }) const rendered = renderCard() chooseSpacesAndSubmit() @@ -105,7 +105,7 @@ describe('NativeChatInteractiveCard answer lifecycle', () => { }) it('shows the paced send as busy and freezes the snapshotted answer', () => { - mocks.sendAnswer.mockReturnValue(5_000) + mocks.sendAnswer.mockReturnValue({ settleAfterMs: 5_000, waitsForVerifiedDelivery: false }) renderCard() chooseSpacesAndSubmit() @@ -117,7 +117,7 @@ describe('NativeChatInteractiveCard answer lifecycle', () => { }) it('cancels the old answer sequence when a replacement prompt arrives', () => { - mocks.sendAnswer.mockReturnValue(5_000) + mocks.sendAnswer.mockReturnValue({ settleAfterMs: 5_000, waitsForVerifiedDelivery: false }) const rendered = renderCard() chooseSpacesAndSubmit() @@ -135,4 +135,34 @@ describe('NativeChatInteractiveCard answer lifecycle', () => { expect(mocks.cancelPending).toHaveBeenCalledOnce() expect(screen.getByText('Choose a shell?')).toBeInTheDocument() }) + + it('keeps a verified send visible until delivery succeeds', () => { + let settleDelivery: ((delivered: boolean) => void) | undefined + mocks.sendAnswer.mockImplementation((_prompt, _selections, onDeliverySettled) => { + settleDelivery = onDeliverySettled + return { settleAfterMs: 500, waitsForVerifiedDelivery: true } + }) + renderCard() + + chooseSpacesAndSubmit() + expect(screen.getByRole('button', { name: 'Sending…' })).toBeDisabled() + + act(() => settleDelivery?.(true)) + expect(screen.queryByText('Tabs or spaces?')).not.toBeInTheDocument() + }) + + it('restores a verified send for retry when delivery is rejected', () => { + let settleDelivery: ((delivered: boolean) => void) | undefined + mocks.sendAnswer.mockImplementation((_prompt, _selections, onDeliverySettled) => { + settleDelivery = onDeliverySettled + return { settleAfterMs: 500, waitsForVerifiedDelivery: true } + }) + renderCard() + + chooseSpacesAndSubmit() + act(() => settleDelivery?.(false)) + + expect(screen.getByRole('button', { name: 'Send answer' })).toBeEnabled() + expect(screen.getByText('Tabs or spaces?')).toBeInTheDocument() + }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatInteractiveCard.tsx b/src/renderer/src/components/native-chat/NativeChatInteractiveCard.tsx index de2d1aa1a98..3c8973e6d7f 100644 --- a/src/renderer/src/components/native-chat/NativeChatInteractiveCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatInteractiveCard.tsx @@ -107,23 +107,42 @@ export function NativeChatInteractiveCard({ return } submittingRef.current = true - const settleMs = sendAnswer(card.prompt, selections) - if (settleMs <= 0) { - // Keep the actionable card visible when its PTY disappeared between - // render and submit; the next live target update can make it retryable. - submittingRef.current = false - return - } - setSubmitting(true) - // Hold the card until the paced write finishes, then mark it answered - // (which hides it and restores the composer). - dismissTimerRef.current = setTimeout(() => { - cancelPending() + const dismissAnsweredCard = (): void => { setDismissedKey(cardKey) submittingRef.current = false setSubmitting(false) dismissTimerRef.current = null - }, settleMs) + } + const keepRejectedAnswerVisible = (): void => { + submittingRef.current = false + setSubmitting(false) + } + const result = sendAnswer(card.prompt, selections, (delivered) => { + if (delivered) { + dismissAnsweredCard() + } else { + keepRejectedAnswerVisible() + } + }) + if (result.settleAfterMs <= 0) { + // Keep the actionable card visible when its PTY disappeared between + // render and submit; the next live target update can make it retryable. + keepRejectedAnswerVisible() + return + } + setSubmitting(true) + if (result.waitsForVerifiedDelivery) { + // Why: remote acceptance can outlive the keystroke pacing window. + // Keep the card until delivery is proven instead of cancelling the + // inference callback at the old fixed dismissal deadline. + return + } + // Hold the card until the paced write finishes, then mark it answered + // (which hides it and restores the composer). + dismissTimerRef.current = setTimeout(() => { + cancelPending() + dismissAnsweredCard() + }, result.settleAfterMs) }} onCancel={() => { clearDismissTimer() diff --git a/src/renderer/src/components/native-chat/NativeChatView.tsx b/src/renderer/src/components/native-chat/NativeChatView.tsx index d2267281eca..984a4919103 100644 --- a/src/renderer/src/components/native-chat/NativeChatView.tsx +++ b/src/renderer/src/components/native-chat/NativeChatView.tsx @@ -166,7 +166,7 @@ function NativeChatResolvedView({ const canSend = useNativeChatCanSend(targetPtyId) // Reuse the verified composer send path for interactive cards and composer // stop (Stop sends ESC, the agent-TUI interrupt key). - const interactiveSend = useNativeChatInteractiveSend(terminalTabId, targetPtyId, agent) + const interactiveSend = useNativeChatInteractiveSend(terminalTabId, paneKey, targetPtyId, agent) const [workingInterrupted, setWorkingInterrupted] = useState(false) // True while a question card owns the input region, so the composer is hidden. const [questionActive, setQuestionActive] = useState(false) diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts index 730ad84f1a6..4982bbda298 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts @@ -33,6 +33,7 @@ describe('sendNativeChatMessage', () => { beforeEach(() => { vi.useFakeTimers() sendRuntimePtyInput.mockClear() + sendRuntimePtyInput.mockReturnValue(true) }) afterEach(() => { vi.useRealTimers() @@ -205,6 +206,8 @@ describe('sendNativeChatAskAnswer', () => { beforeEach(() => { vi.useFakeTimers() sendRuntimePtyInput.mockClear() + sendRuntimePtyInput.mockReturnValue(true) + sendRuntimePtyInputVerified.mockReset().mockResolvedValue(true) }) afterEach(() => { vi.useRealTimers() @@ -278,4 +281,39 @@ describe('sendNativeChatAskAnswer', () => { // Only the first keystroke landed; the rest were cancelled. expect(sendRuntimePtyInput).toHaveBeenCalledTimes(1) }) + + it('reports verified delivery only after settling and suppresses it after cancellation', async () => { + const onSettled = vi.fn() + sendRuntimePtyInputVerified.mockResolvedValueOnce(true).mockResolvedValueOnce(false) + const handle = sendNativeChatAskAnswer(SETTINGS, PTY, [{ raw: '1' }, { raw: '\r' }], onSettled) + + await vi.advanceTimersByTimeAsync(handle.settleAfterMs) + expect(onSettled).toHaveBeenCalledExactlyOnceWith(false) + expect(sendRuntimePtyInput).not.toHaveBeenCalled() + + const canceledSettled = vi.fn() + const canceled = sendNativeChatAskAnswer(SETTINGS, PTY, [{ raw: '1' }], canceledSettled) + canceled.cancel() + await vi.runAllTimersAsync() + expect(canceledSettled).not.toHaveBeenCalled() + }) + + it('waits for remote acceptance before reporting delivery', async () => { + const onSettled = vi.fn() + let resolveAccepted!: (accepted: boolean) => void + sendRuntimePtyInputVerified.mockReturnValueOnce( + new Promise((resolve) => { + resolveAccepted = resolve + }) + ) + + const handle = sendNativeChatAskAnswer(SETTINGS, PTY, [{ raw: '2' }], onSettled) + await vi.advanceTimersByTimeAsync(handle.settleAfterMs) + + expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith(SETTINGS, PTY, '2') + expect(onSettled).not.toHaveBeenCalled() + + resolveAccepted(true) + await vi.waitFor(() => expect(onSettled).toHaveBeenCalledExactlyOnceWith(true)) + }) }) diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send.ts index a1dca86b65f..f067064e047 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-send.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-send.ts @@ -155,27 +155,54 @@ export function submitNativeChatPrompt( export function sendNativeChatAskAnswer( settings: ReturnType, ptyId: string, - groups: AskAnswerKeyGroup[] + groups: AskAnswerKeyGroup[], + onSettled?: (delivered: boolean) => void ): NativeChatSendHandle { if (groups.length === 0) { return { cancel: () => {}, settleAfterMs: 0 } } const timers: ReturnType[] = [] + const verifiedWrites: Promise[] = [] + let cancelled = false groups.forEach((group, index) => { timers.push( setTimeout(() => { const bytes = 'raw' in group ? group.raw : buildNativeChatPasteBytes(group.text) - sendRuntimePtyInput(settings, ptyId, bytes) + if (onSettled) { + // Why: inference must use the remote host's acceptance result, not + // the fire-and-forget renderer dispatch result. + verifiedWrites.push( + sendRuntimePtyInputVerified(settings, ptyId, bytes).catch(() => false) + ) + } else { + sendRuntimePtyInput(settings, ptyId, bytes) + } }, index * NATIVE_CHAT_QUESTION_STEP_MS) ) }) + const settleAfterMs = + (groups.length - 1) * NATIVE_CHAT_QUESTION_STEP_MS + NATIVE_CHAT_SUBMIT_DELAY_MS + if (onSettled) { + // Why: status inference must wait for every paced write and must not run + // after cancellation or a rejected runtime write. + timers.push( + setTimeout(() => { + void Promise.all(verifiedWrites).then((results) => { + if (!cancelled) { + onSettled(results.every(Boolean)) + } + }) + }, settleAfterMs) + ) + } return { cancel: () => { + cancelled = true for (const timer of timers) { clearTimeout(timer) } }, // Hold the card until the last keystroke has fired and its submit gap passed. - settleAfterMs: (groups.length - 1) * NATIVE_CHAT_QUESTION_STEP_MS + NATIVE_CHAT_SUBMIT_DELAY_MS + settleAfterMs } } diff --git a/src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx index 5a336009c5c..9b886bbbbac 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx @@ -5,9 +5,30 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ cancel: vi.fn(), + inferQuestionAnswered: vi.fn(() => Promise.resolve(true)), sendRuntimePtyInput: vi.fn(), sendNativeChatAskAnswer: vi.fn(), - sendNativeChatMessage: vi.fn() + sendNativeChatMessage: vi.fn(), + // Mutable so a test can swap the live status between sendAnswer and settle. + storeState: { agentStatusByPaneKey: {} as Record } +})) + +const PANE_KEY = 'tab-1:11111111-1111-4111-8111-111111111111' +const waitingQuestion = { + state: 'waiting' as const, + prompt: 'pick one', + updatedAt: Date.now(), + stateStartedAt: Date.now(), + agentType: 'claude' as const, + paneKey: PANE_KEY, + stateHistory: [], + toolName: 'AskUserQuestion' +} + +vi.mock('../../store', () => ({ + useAppStore: { + getState: () => mocks.storeState + } })) vi.mock('@/runtime/runtime-terminal-inspection', () => ({ @@ -33,13 +54,20 @@ const PROMPT: AskPrompt = { describe('useNativeChatInteractiveSend', () => { beforeEach(() => { vi.clearAllMocks() + mocks.storeState = { agentStatusByPaneKey: { [PANE_KEY]: waitingQuestion } } + Object.defineProperty(window, 'api', { + configurable: true, + value: { agentStatus: { inferQuestionAnswered: mocks.inferQuestionAnswered } } + }) const handle = { cancel: mocks.cancel, settleAfterMs: 500 } mocks.sendNativeChatAskAnswer.mockReturnValue(handle) mocks.sendNativeChatMessage.mockReturnValue(handle) }) it('routes a non-Claude answer through the pasted-text send path', () => { - const { result } = renderHook(() => useNativeChatInteractiveSend('tab-1', 'pty-1', 'codex')) + const { result } = renderHook(() => + useNativeChatInteractiveSend('tab-1', PANE_KEY, 'pty-1', 'codex') + ) act(() => result.current.sendAnswer(PROMPT, [{ indices: [1] }])) @@ -53,7 +81,9 @@ describe('useNativeChatInteractiveSend', () => { }) it('routes a Claude answer through the option-number keystroke path', () => { - const { result } = renderHook(() => useNativeChatInteractiveSend('tab-1', 'pty-1', 'claude')) + const { result } = renderHook(() => + useNativeChatInteractiveSend('tab-1', PANE_KEY, 'pty-1', 'claude') + ) act(() => result.current.sendAnswer(PROMPT, [{ indices: [1] }])) @@ -61,27 +91,43 @@ describe('useNativeChatInteractiveSend', () => { expect(mocks.sendNativeChatAskAnswer).toHaveBeenCalledWith( { terminalTabId: 'tab-1' }, 'pty-1', - [{ raw: '2' }] + [{ raw: '2' }], + expect.any(Function) ) expect(mocks.sendNativeChatMessage).not.toHaveBeenCalled() }) - it('does nothing when no option is answered', () => { - const { result } = renderHook(() => useNativeChatInteractiveSend('tab-1', 'pty-1', 'claude')) + it('infers OpenClaude answers through its Claude-compatible selector path', () => { + const { result } = renderHook(() => + useNativeChatInteractiveSend('tab-1', PANE_KEY, 'pty-1', 'openclaude') + ) - let settleMs = -1 + act(() => result.current.sendAnswer(PROMPT, [{ indices: [1] }])) + + const onSettled = mocks.sendNativeChatAskAnswer.mock.calls[0]?.[3] + expect(onSettled).toBeTypeOf('function') + onSettled?.(true) + expect(mocks.inferQuestionAnswered).toHaveBeenCalledOnce() + }) + + it('does nothing when no option is answered', () => { + const { result } = renderHook(() => + useNativeChatInteractiveSend('tab-1', PANE_KEY, 'pty-1', 'claude') + ) + + let resultValue: ReturnType | undefined act(() => { - settleMs = result.current.sendAnswer(PROMPT, [{ indices: [] }]) + resultValue = result.current.sendAnswer(PROMPT, [{ indices: [] }]) }) - expect(settleMs).toBe(0) + expect(resultValue).toEqual({ settleAfterMs: 0, waitsForVerifiedDelivery: false }) expect(mocks.sendNativeChatAskAnswer).not.toHaveBeenCalled() expect(mocks.sendNativeChatMessage).not.toHaveBeenCalled() }) it('cancels delayed answer writes when the PTY target changes', () => { const { result, rerender } = renderHook( - ({ targetPtyId }) => useNativeChatInteractiveSend('tab-1', targetPtyId, 'codex'), + ({ targetPtyId }) => useNativeChatInteractiveSend('tab-1', PANE_KEY, targetPtyId, 'codex'), { initialProps: { targetPtyId: 'pty-1' as string | null } } ) @@ -91,8 +137,22 @@ describe('useNativeChatInteractiveSend', () => { expect(mocks.cancel).toHaveBeenCalledOnce() }) + it('cancels delayed answer writes when the pane identity changes', () => { + const { result, rerender } = renderHook( + ({ paneKey }) => useNativeChatInteractiveSend('tab-1', paneKey, 'pty-1', 'claude'), + { initialProps: { paneKey: PANE_KEY } } + ) + + act(() => result.current.sendAnswer(PROMPT, [{ indices: [0] }])) + rerender({ paneKey: 'tab-1:22222222-2222-4222-8222-222222222222' }) + + expect(mocks.cancel).toHaveBeenCalledOnce() + }) + it('cancels delayed answer writes before interrupting the active PTY', () => { - const { result } = renderHook(() => useNativeChatInteractiveSend('tab-1', 'pty-1', 'claude')) + const { result } = renderHook(() => + useNativeChatInteractiveSend('tab-1', PANE_KEY, 'pty-1', 'claude') + ) act(() => result.current.sendAnswer(PROMPT, [{ indices: [1] }])) act(() => result.current.cancel()) @@ -106,7 +166,9 @@ describe('useNativeChatInteractiveSend', () => { }) it('can cancel delayed writes without interrupting the replacement prompt', () => { - const { result } = renderHook(() => useNativeChatInteractiveSend('tab-1', 'pty-1', 'claude')) + const { result } = renderHook(() => + useNativeChatInteractiveSend('tab-1', PANE_KEY, 'pty-1', 'claude') + ) act(() => result.current.sendAnswer(PROMPT, [{ indices: [1] }])) act(() => result.current.cancelPending()) @@ -114,4 +176,81 @@ describe('useNativeChatInteractiveSend', () => { expect(mocks.cancel).toHaveBeenCalledOnce() expect(mocks.sendRuntimePtyInput).not.toHaveBeenCalled() }) + + it('infers a Claude question answer only after every runtime write was delivered', () => { + const { result } = renderHook(() => + useNativeChatInteractiveSend('tab-1', PANE_KEY, 'pty-1', 'claude') + ) + + act(() => result.current.sendAnswer(PROMPT, [{ indices: [1] }])) + expect(mocks.inferQuestionAnswered).not.toHaveBeenCalled() + + const onSettled = mocks.sendNativeChatAskAnswer.mock.calls[0]?.[3] + expect(onSettled).toBeTypeOf('function') + onSettled?.(false) + expect(mocks.inferQuestionAnswered).not.toHaveBeenCalled() + onSettled?.(true) + + expect(mocks.inferQuestionAnswered).toHaveBeenCalledExactlyOnceWith({ + paneKey: PANE_KEY, + baselineUpdatedAt: waitingQuestion.updatedAt, + baselineStateStartedAt: waitingQuestion.stateStartedAt, + baselinePrompt: 'pick one', + baselineAgentType: 'claude' + }) + }) + + it('infers the answered question baseline, not a replacement that became current mid-send', () => { + const { result } = renderHook(() => + useNativeChatInteractiveSend('tab-1', PANE_KEY, 'pty-1', 'claude') + ) + + // Answer question A while it is the current waiting question. + act(() => result.current.sendAnswer(PROMPT, [{ indices: [1] }])) + + // A different AskUserQuestion becomes current before the paced send settles. + mocks.storeState = { + agentStatusByPaneKey: { + [PANE_KEY]: { + ...waitingQuestion, + prompt: 'second question', + updatedAt: waitingQuestion.updatedAt + 5000, + stateStartedAt: waitingQuestion.stateStartedAt + 5000 + } + } + } + + const onSettled = mocks.sendNativeChatAskAnswer.mock.calls[0]?.[3] + onSettled?.(true) + + // The baseline is question A's (captured before delivery), so the server can + // reject it against the now-current question B instead of clearing B's wait. + expect(mocks.inferQuestionAnswered).toHaveBeenCalledExactlyOnceWith({ + paneKey: PANE_KEY, + baselineUpdatedAt: waitingQuestion.updatedAt, + baselineStateStartedAt: waitingQuestion.stateStartedAt, + baselinePrompt: 'pick one', + baselineAgentType: 'claude' + }) + }) + + it('reports verified delivery settlement to the question card', () => { + const onDeliverySettled = vi.fn() + const { result } = renderHook(() => + useNativeChatInteractiveSend('tab-1', PANE_KEY, 'pty-1', 'claude') + ) + + let sendResult: ReturnType | undefined + act(() => { + sendResult = result.current.sendAnswer(PROMPT, [{ indices: [1] }], onDeliverySettled) + }) + expect(sendResult).toEqual({ settleAfterMs: 500, waitsForVerifiedDelivery: true }) + + const onSettled = mocks.sendNativeChatAskAnswer.mock.calls[0]?.[3] + onSettled?.(false) + expect(onDeliverySettled).toHaveBeenCalledExactlyOnceWith(false) + + act(() => result.current.cancelPending()) + expect(mocks.cancel).not.toHaveBeenCalled() + }) }) diff --git a/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts b/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts index cb49cd9c57d..9b22253927a 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts @@ -1,4 +1,5 @@ import { useCallback, useLayoutEffect, useRef } from 'react' +import { useAppStore } from '../../store' import { sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection' import { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft' import type { AgentType } from '../../../../shared/native-chat-types' @@ -15,16 +16,20 @@ import { sendNativeChatMessage, type NativeChatSendHandle } from './native-chat-runtime-send' +import { inferQuestionAnsweredFromCurrentStatus } from '../terminal-pane/agent-question-answered-inference' // ESC is the agent-TUI interrupt/cancel key over the PTY (matches how the // composer forwards Escape). Used to cancel a question or deny an approval. const ESC = '\x1b' export type NativeChatInteractiveSend = { - /** Deliver the answer to an AskUserQuestion prompt. Returns the ms after which - * every scheduled write has fired (0 if nothing was sent) so the caller can - * keep the card up until the send settles. */ - sendAnswer: (prompt: AskPrompt, selections: AskAnswerSelection[]) => number + /** Deliver the answer to an AskUserQuestion prompt. Claude-format selectors + * verify every runtime write before reporting settlement. */ + sendAnswer: ( + prompt: AskPrompt, + selections: AskAnswerSelection[], + onDeliverySettled?: (delivered: boolean) => void + ) => { settleAfterMs: number; waitsForVerifiedDelivery: boolean } /** Send a raw control string (e.g. an approval option number or ESC) as-is. */ sendRaw: (raw: string) => void /** Stop delayed writes without interrupting the agent. */ @@ -44,6 +49,7 @@ export type NativeChatInteractiveSend = { */ export function useNativeChatInteractiveSend( terminalTabId: string, + paneKey: string, targetPtyId: string | null, agent: AgentType ): NativeChatInteractiveSend { @@ -57,7 +63,10 @@ export function useNativeChatInteractiveSend( }, []) // Why: a split can be rebound without unmounting this view. Cancel during // commit so no delayed answer write can race the replacement PTY. - useLayoutEffect(() => cancelInFlight, [cancelInFlight, targetPtyId, terminalTabId]) + useLayoutEffect( + () => cancelInFlight, + [agent, cancelInFlight, paneKey, targetPtyId, terminalTabId] + ) const sendRaw = useCallback( (raw: string) => { @@ -70,9 +79,13 @@ export function useNativeChatInteractiveSend( ) const sendAnswer = useCallback( - (prompt: AskPrompt, selections: AskAnswerSelection[]): number => { + ( + prompt: AskPrompt, + selections: AskAnswerSelection[], + onDeliverySettled?: (delivered: boolean) => void + ): { settleAfterMs: number; waitsForVerifiedDelivery: boolean } => { if (!targetPtyId || !hasAskAnswer(prompt, selections)) { - return 0 + return { settleAfterMs: 0, waitsForVerifiedDelivery: false } } // Cancel any prior in-flight answer before starting a new one. cancelInFlight() @@ -83,13 +96,57 @@ export function useNativeChatInteractiveSend( // Other agents' question tools commit a pasted answer, so send label text. // Gate on the transcript agent (not `=== 'claude'`) so OpenClaude — which // runs the same selector — takes the keystroke path too. - const handle: NativeChatSendHandle = shouldStepNativeChatAskAnswer(agent) - ? sendNativeChatAskAnswer(settings, targetPtyId, buildAskAnswerKeys(prompt, selections)) + const stepsAnswer = shouldStepNativeChatAskAnswer(agent) + // Why: pin the answered question's baseline BEFORE delivery. A late settle + // callback (paced writes + remote acceptance can span seconds on SSH) must + // not read the live status and mint a fresh baseline for a replacement + // question that became current meanwhile — that would clear the new + // question's wait. The server re-validates this captured baseline and + // rejects a changed status, matching the terminal keystroke path. + const questionStatusBaseline = stepsAnswer + ? useAppStore.getState().agentStatusByPaneKey[paneKey] + : undefined + let settledHandle: NativeChatSendHandle | null = null + const onSettled = stepsAnswer + ? (delivered: boolean): void => { + if (settledHandle && inFlightRef.current === settledHandle) { + // Why: a completed verified send otherwise retains its timers, + // promises, and prompt callback until the next send or unmount. + inFlightRef.current = null + } + if (delivered) { + inferQuestionAnsweredFromCurrentStatus({ + paneKey, + getStatusEntry: () => questionStatusBaseline, + inferQuestionAnswered: (request) => + window.api.agentStatus.inferQuestionAnswered(request).catch((err) => { + console.warn('[agent-question] native-chat inference failed:', err) + return false + }) + }) + } + onDeliverySettled?.(delivered) + } + : undefined + const handle: NativeChatSendHandle = stepsAnswer + ? sendNativeChatAskAnswer( + settings, + targetPtyId, + buildAskAnswerKeys(prompt, selections), + onSettled + ) : sendNativeChatMessage(settings, targetPtyId, formatAskAnswer(prompt, selections)) + // Why: native-chat answer writes bypass xterm.onData. Infer only after + // every paced selector write has fired, so an early digit in a multi-step + // answer cannot dismiss the wait or cancel the remaining writes. + settledHandle = handle inFlightRef.current = handle - return handle.settleAfterMs + return { + settleAfterMs: handle.settleAfterMs, + waitsForVerifiedDelivery: onSettled !== undefined + } }, - [terminalTabId, targetPtyId, agent, cancelInFlight] + [terminalTabId, paneKey, targetPtyId, agent, cancelInFlight] ) // Stop/cancel: drop any pending answer writes, then send ESC to interrupt. diff --git a/src/renderer/src/components/terminal-pane/agent-question-answered-inference.test.ts b/src/renderer/src/components/terminal-pane/agent-question-answered-inference.test.ts new file mode 100644 index 00000000000..1460ef5b5e6 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/agent-question-answered-inference.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import { createAgentQuestionAnsweredInference } from './agent-question-answered-inference' + +const PANE_KEY = 'tab-1:11111111-1111-4111-8111-111111111111' +const SINGLE_SELECT_PROMPT = JSON.stringify({ + questions: [ + { + question: 'pick a color', + multiSelect: false, + options: [{ label: 'red' }, { label: 'blue' }] + } + ] +}) + +function makeWaitingQuestionEntry(overrides: Partial = {}): AgentStatusEntry { + return { + state: 'waiting', + prompt: 'pick a color', + updatedAt: 1_000, + stateStartedAt: 900, + agentType: 'claude', + paneKey: PANE_KEY, + stateHistory: [], + toolName: 'AskUserQuestion', + interactivePrompt: SINGLE_SELECT_PROMPT, + ...overrides + } +} + +function makeInference(entry: AgentStatusEntry | undefined) { + const inferQuestionAnswered = vi.fn() + const inference = createAgentQuestionAnsweredInference({ + paneKey: PANE_KEY, + getStatusEntry: () => entry, + inferQuestionAnswered, + now: () => 2_000 + }) + return { inference, inferQuestionAnswered } +} + +describe('agent question-answered inference', () => { + it('reports the baseline when Enter is sent to a waiting question pane', () => { + const { inference, inferQuestionAnswered } = makeInference(makeWaitingQuestionEntry()) + + inference.observeSentTerminalInput('\r') + + expect(inferQuestionAnswered).toHaveBeenCalledExactlyOnceWith({ + paneKey: PANE_KEY, + baselineUpdatedAt: 1_000, + baselineStateStartedAt: 900, + baselinePrompt: 'pick a color', + baselineAgentType: 'claude' + }) + }) + + it('accepts kitty-keyboard Enter encodings', () => { + const { inference, inferQuestionAnswered } = makeInference(makeWaitingQuestionEntry()) + + inference.observeSentTerminalInput('\x1b[13u') + + expect(inferQuestionAnswered).toHaveBeenCalledOnce() + }) + + it('accepts a bare digit quick-select, which submits without Enter', () => { + const { inference, inferQuestionAnswered } = makeInference(makeWaitingQuestionEntry()) + + inference.observeSentTerminalInput('2') + + expect(inferQuestionAnswered).toHaveBeenCalledOnce() + }) + + it('keeps waiting when a digit only advances or toggles a partial answer', () => { + const prompts = [ + JSON.stringify({ + questions: [ + { question: 'first?', multiSelect: false, options: [{ label: 'A' }] }, + { question: 'second?', multiSelect: false, options: [{ label: 'B' }] } + ] + }), + JSON.stringify({ + questions: [ + { + question: 'pick several', + multiSelect: true, + options: [{ label: 'A' }, { label: 'B' }] + } + ] + }) + ] + + for (const interactivePrompt of prompts) { + const { inference, inferQuestionAnswered } = makeInference( + makeWaitingQuestionEntry({ interactivePrompt }) + ) + inference.observeSentTerminalInput('1') + inference.observeSentTerminalInput('\r') + expect(inferQuestionAnswered).not.toHaveBeenCalled() + } + }) + + it('keeps waiting when the synthetic free-text row is selected', () => { + const { inference, inferQuestionAnswered } = makeInference(makeWaitingQuestionEntry()) + + // Two declared options means 3 opens Claude's synthetic "Type something" row. + inference.observeSentTerminalInput('3') + + expect(inferQuestionAnswered).not.toHaveBeenCalled() + }) + + it('keeps waiting when capped or malformed prompt JSON hides the question shape', () => { + const { inference, inferQuestionAnswered } = makeInference( + makeWaitingQuestionEntry({ interactivePrompt: '{"questions":[' }) + ) + + inference.observeSentTerminalInput('\r') + + expect(inferQuestionAnswered).not.toHaveBeenCalled() + }) + + it('keeps the legacy Enter fallback when the hook omitted tool input', () => { + const { inference, inferQuestionAnswered } = makeInference( + makeWaitingQuestionEntry({ interactivePrompt: undefined }) + ) + + inference.observeSentTerminalInput('\r') + + expect(inferQuestionAnswered).toHaveBeenCalledOnce() + }) + + it('does not read status for ordinary terminal input', () => { + const getStatusEntry = vi.fn(() => makeWaitingQuestionEntry()) + const inferQuestionAnswered = vi.fn() + const inference = createAgentQuestionAnsweredInference({ + paneKey: PANE_KEY, + getStatusEntry, + inferQuestionAnswered + }) + + inference.observeSentTerminalInput('ordinary typing') + + expect(getStatusEntry).not.toHaveBeenCalled() + expect(inferQuestionAnswered).not.toHaveBeenCalled() + }) + + it('ignores non-submit input, batched keystrokes, and pastes', () => { + const { inference, inferQuestionAnswered } = makeInference(makeWaitingQuestionEntry()) + + inference.observeSentTerminalInput('\x1b') + inference.observeSentTerminalInput('a') + inference.observeSentTerminalInput('0') + inference.observeSentTerminalInput('12') + inference.observeSentTerminalInput('yes\r') + inference.observeSentTerminalInput('\x1b[200~line one\nline two\x1b[201~') + + expect(inferQuestionAnswered).not.toHaveBeenCalled() + }) + + it('ignores panes without a fresh waiting AskUserQuestion status', () => { + const cases: (AgentStatusEntry | undefined)[] = [ + undefined, + makeWaitingQuestionEntry({ state: 'working' }), + makeWaitingQuestionEntry({ toolName: 'Bash' }), + makeWaitingQuestionEntry({ agentType: 'codex' }), + // Why: a stale wait past the freshness horizon no longer renders amber, + // so a keystroke must not synthesize activity for it. + makeWaitingQuestionEntry({ updatedAt: -100_000_000, stateStartedAt: -100_000_000 }) + ] + for (const entry of cases) { + const { inference, inferQuestionAnswered } = makeInference(entry) + inference.observeSentTerminalInput('\r') + expect(inferQuestionAnswered).not.toHaveBeenCalled() + } + }) +}) diff --git a/src/renderer/src/components/terminal-pane/agent-question-answered-inference.ts b/src/renderer/src/components/terminal-pane/agent-question-answered-inference.ts new file mode 100644 index 00000000000..1469648be2e --- /dev/null +++ b/src/renderer/src/components/terminal-pane/agent-question-answered-inference.ts @@ -0,0 +1,85 @@ +import { + AGENT_STATUS_STALE_AFTER_MS, + type AgentStatusEntry +} from '../../../../shared/agent-status-types' +import { + isAskUserQuestionTool, + isPotentialQuestionAnsweredSubmitInput, + isQuestionAnsweredSubmitInput, + type AgentQuestionAnsweredInferenceRequest +} from '../../../../shared/agent-question-answered-intent' +import { isExplicitAgentStatusFresh } from '@/lib/agent-status' + +export type AgentQuestionAnsweredInference = { + observeSentTerminalInput(data: string): void +} + +type AgentQuestionAnsweredInferenceDeps = { + paneKey: string + getStatusEntry: () => AgentStatusEntry | undefined + inferQuestionAnswered: ( + request: AgentQuestionAnsweredInferenceRequest + ) => boolean | Promise | void + now?: () => number +} + +function inferQuestionAnsweredFromEntry( + deps: AgentQuestionAnsweredInferenceDeps, + entry: AgentStatusEntry | undefined +): boolean { + const now = deps.now ?? Date.now + if ( + !entry || + entry.state !== 'waiting' || + entry.agentType !== 'claude' || + !isAskUserQuestionTool(entry.toolName) || + !isExplicitAgentStatusFresh(entry, now(), AGENT_STATUS_STALE_AFTER_MS) + ) { + return false + } + void deps.inferQuestionAnswered({ + paneKey: deps.paneKey, + baselineUpdatedAt: entry.updatedAt, + baselineStateStartedAt: entry.stateStartedAt, + baselinePrompt: entry.prompt, + baselineAgentType: entry.agentType + }) + return true +} + +/** Completion signal for answer surfaces that write directly to the runtime + * instead of xterm (notably native chat). The same fresh-status baseline is + * used, so a real hook that wins the race prevents the fallback IPC. */ +export function inferQuestionAnsweredFromCurrentStatus( + deps: AgentQuestionAnsweredInferenceDeps +): boolean { + return inferQuestionAnsweredFromEntry(deps, deps.getStatusEntry()) +} + +/** Sibling of the interrupt inference for a hook Claude never sends: answering + * an AskUserQuestion emits no event, so the submit keystroke into the waiting + * pane is the only "question dealt with" signal. Unlike interrupts there is + * no expected real hook to settle for, so the inference fires immediately — + * the main process re-validates the baseline, so a racing hook always wins. */ +export function createAgentQuestionAnsweredInference({ + paneKey, + getStatusEntry, + inferQuestionAnswered, + now = () => Date.now() +}: AgentQuestionAnsweredInferenceDeps): AgentQuestionAnsweredInference { + return { + observeSentTerminalInput(data) { + // Why: ordinary terminal input is the hot path. Reject it with one + // constant-time membership check before reading Zustand or parsing the + // bounded interactive-prompt JSON. + if (!isPotentialQuestionAnsweredSubmitInput(data)) { + return + } + const entry = getStatusEntry() + if (!entry || !isQuestionAnsweredSubmitInput(data, entry.interactivePrompt)) { + return + } + inferQuestionAnsweredFromEntry({ paneKey, getStatusEntry, inferQuestionAnswered, now }, entry) + } + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 859a71bba1a..5716e535dfa 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -162,6 +162,7 @@ import { isCtrlCKeyEvent, isPlainEscapeKeyEvent } from './agent-interrupt-inference' +import { createAgentQuestionAnsweredInference } from './agent-question-answered-inference' import { AGENT_INTERRUPT_SETTLE_MS, type AgentInterruptInputIntent @@ -1771,6 +1772,15 @@ export function connectPanePty( }) } }) + const questionAnsweredInference = createAgentQuestionAnsweredInference({ + paneKey: cacheKey, + getStatusEntry: () => useAppStore.getState().agentStatusByPaneKey[cacheKey], + inferQuestionAnswered: (request) => + window.api.agentStatus.inferQuestionAnswered(request).catch((err) => { + console.warn('[agent-question] inferQuestionAnswered failed:', err) + return false + }) + }) const dropCommandFinishedStatusIfSameTurn = ( entry: AgentStatusEntry | undefined, options?: { allowInferredInterrupt?: boolean } @@ -1856,6 +1866,10 @@ export function connectPanePty( if (intent === 'ctrl-c' || data === '\x03') { markTerminalBracketedPasteInterrupted(pane.terminal) } + // Why: every delivered-input path funnels through here, so this is where a + // submit keystroke into a waiting AskUserQuestion pane becomes the + // "question answered" signal no hook will ever deliver. + questionAnsweredInference.observeSentTerminalInput(data) } let pendingTerminalInputWrite: Promise | null = null const setPendingTerminalInputWrite = (promise: Promise): void => { diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 29268cee148..cddcfae9346 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -745,6 +745,7 @@ function createWebPreloadApi(): Partial { onClear: () => noopUnsubscribe, getSnapshot: () => Promise.resolve([]), inferInterrupt: () => Promise.resolve(false), + inferQuestionAnswered: () => Promise.resolve(false), onMigrationUnsupported: () => noopUnsubscribe, onMigrationUnsupportedClear: () => noopUnsubscribe, getMigrationUnsupportedSnapshot: () => Promise.resolve([]), diff --git a/src/shared/agent-hook-listener.test.ts b/src/shared/agent-hook-listener.test.ts index e9f9e265f62..e5c739ab3a7 100644 --- a/src/shared/agent-hook-listener.test.ts +++ b/src/shared/agent-hook-listener.test.ts @@ -6,6 +6,7 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } import { tmpdir } from 'node:os' import { join } from 'node:path' import { + clearClaudeAnsweredQuestionWait, clearPaneCacheState, createHookListenerState, getEndpointFileName, @@ -2781,4 +2782,60 @@ describe('shared agent-hook-listener', () => { expect(ok).toBe(false) }) }) + + describe('clearClaudeAnsweredQuestionWait', () => { + const claudeEvent = ( + payload: Record + ): ReturnType => + normalizeHookPayload(state, 'claude', { paneKey: PANE_KEY, payload }, 'production') + + it('restores working for an answered lead question and drops the card', () => { + claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'pick a color' }) + const wait = claudeEvent({ + hook_event_name: 'PreToolUse', + tool_name: 'AskUserQuestion', + tool_input: { questions: [{ question: 'Red or Blue?' }] } + }) + expect(wait?.payload.state).toBe('waiting') + expect(wait?.payload.interactivePrompt).toBeDefined() + + expect(clearClaudeAnsweredQuestionWait(state, PANE_KEY)).toEqual({ state: 'working' }) + + // Why: a child-driven refresh re-emits the cached lead state; the linger + // bug would come back if it could resurrect the dismissed question. + const childDriven = claudeEvent({ + hook_event_name: 'SubagentStart', + agent_id: 'a1', + agent_type: 'probe' + }) + expect(childDriven?.payload.state).toBe('working') + expect(childDriven?.payload.toolName).toBeUndefined() + expect(childDriven?.payload.interactivePrompt).toBeUndefined() + }) + + it('restores the stashed lead state for an answered child question', () => { + claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'go' }) + claudeEvent({ hook_event_name: 'SubagentStart', agent_id: 'a1', agent_type: 'probe' }) + claudeEvent({ hook_event_name: 'Stop' }) + const wait = claudeEvent({ + hook_event_name: 'PreToolUse', + tool_name: 'AskUserQuestion', + agent_id: 'a1', + tool_input: { questions: [{ question: 'Continue?' }] } + }) + expect(wait?.payload.state).toBe('waiting') + + // Why: the lead already finished; the answer resumes the child, so the + // emitted state is gated up to working only while that child still runs. + expect(clearClaudeAnsweredQuestionWait(state, PANE_KEY)).toEqual({ state: 'working' }) + expect(state.claudeLeadStateByPaneKey.get(PANE_KEY)).toEqual({ state: 'done' }) + + const drained = claudeEvent({ hook_event_name: 'SubagentStop', agent_id: 'a1' }) + expect(drained?.payload.state).toBe('done') + }) + + it('falls back to working when no lead record exists', () => { + expect(clearClaudeAnsweredQuestionWait(state, PANE_KEY)).toEqual({ state: 'working' }) + }) + }) }) diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index bde6baf4431..f080ce1cecf 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -34,6 +34,7 @@ import { type AgentSubagentSnapshot, type ParsedAgentStatusPayload } from './agent-status-types' +import { isAskUserQuestionTool } from './agent-question-answered-intent' import { claudeRosterHasWorkingSubagent, claudeRosterToSnapshots, @@ -744,14 +745,6 @@ function clearActiveToolFieldsUpdate(): ToolSnapshot { ) } -/** True for the AskUserQuestion tool across the casing variants different - * agents emit (`AskUserQuestion` / `ask_user_question` / `askUserQuestion`). - * Why: this is the structured "pick an option" prompt whose full input the - * clients render as a live card. */ -function isAskUserQuestionTool(toolName: string | undefined): boolean { - return toolName?.replaceAll(/[^a-z0-9]/gi, '').toLowerCase() === 'askuserquestion' -} - /** Capture the full AskUserQuestion tool input as a JSON string when the tool * is an AskUserQuestion variant; otherwise undefined so resolveToolState * clears any prior prompt. Kept agent-generic: callers pass whatever raw @@ -2525,6 +2518,36 @@ function clearClaudePendingWaitForAgent( state.claudeLeadStateByPaneKey.set(paneKey, lead.stateBeforeWait ?? { state: 'working' }) } +/** Clear an AskUserQuestion wait after the user's answer was typed into the + * terminal. Answering emits no hook event, so the caller infers it from the + * submit keystroke. Restores the stashed pre-wait lead state (child-induced + * question) or falls back to 'working' (lead question), and drops the cached + * question card so later child-driven refreshes cannot re-emit the stale + * wait. Returns the pane state to emit, gated up to 'working' while children + * still run. */ +export function clearClaudeAnsweredQuestionWait( + state: HookListenerState, + paneKey: string +): Pick { + const lead = state.claudeLeadStateByPaneKey.get(paneKey) + const restored = + lead?.state === 'waiting' + ? (lead.stateBeforeWait ?? { state: 'working' as const }) + : { state: 'working' as const } + state.claudeLeadStateByPaneKey.set(paneKey, { ...restored }) + const previousTool = state.lastToolByPaneKey.get(paneKey) + state.lastToolByPaneKey.set( + paneKey, + previousTool?.lastAssistantMessage + ? { lastAssistantMessage: previousTool.lastAssistantMessage } + : {} + ) + const roster = state.claudeSubagentRosterByPaneKey.get(paneKey) + return restored.state === 'done' && claudeRosterHasWorkingSubagent(roster) + ? { state: 'working' } + : restored +} + /** Emit a pane status refresh driven by child activity (lifecycle events and * child-origin tool events): the lead's cached state is re-emitted — gated up * to 'working' while a child works — without touching the lead's tool/prompt diff --git a/src/shared/agent-question-answered-intent.ts b/src/shared/agent-question-answered-intent.ts new file mode 100644 index 00000000000..de976291a48 --- /dev/null +++ b/src/shared/agent-question-answered-intent.ts @@ -0,0 +1,83 @@ +import type { AgentType } from './agent-status-types' + +/** Baseline snapshot the renderer captured when it observed the submit + * keystroke. The main process re-validates every field against its own + * cached status so a racing real hook always wins over the inference. */ +export type AgentQuestionAnsweredInferenceRequest = { + paneKey: string + baselineUpdatedAt: number + baselineStateStartedAt: number + baselinePrompt: string + baselineAgentType: AgentType | undefined +} + +/** True for the AskUserQuestion tool across the casing variants different + * agents emit (`AskUserQuestion` / `ask_user_question` / `askUserQuestion`). + * Why: this is the structured "pick an option" prompt whose full input the + * clients render as a live card. */ +export function isAskUserQuestionTool(toolName: string | undefined): boolean { + return toolName?.replaceAll(/[^a-z0-9]/gi, '').toLowerCase() === 'askuserquestion' +} + +const QUESTION_ANSWER_ENTER_INPUTS: ReadonlySet = new Set([ + '\r', + '\n', + '\r\n', + '\x1b[13u', + '\x1b[13;1u' +]) +const QUESTION_ANSWER_DIGIT_INPUTS: ReadonlySet = new Set('123456789') + +export function isPotentialQuestionAnsweredSubmitInput(data: string): boolean { + return QUESTION_ANSWER_ENTER_INPUTS.has(data) || QUESTION_ANSWER_DIGIT_INPUTS.has(data) +} + +function readSingleSelectOptionCount(interactivePrompt: string | undefined): number | null { + if (!interactivePrompt) { + return null + } + try { + const parsed = JSON.parse(interactivePrompt) as { questions?: unknown } + if (!Array.isArray(parsed.questions) || parsed.questions.length !== 1) { + return -1 + } + const [question] = parsed.questions as { multiSelect?: unknown; options?: unknown }[] + if (!question || question.multiSelect === true || !Array.isArray(question.options)) { + return -1 + } + return question.options.length + } catch { + // Why: malformed JSON can be a length-capped multi-question payload. It is + // not equivalent to an older hook omitting tool input, so fail closed. + return -1 + } +} + +/** True only when one keystroke is enough to finish the whole prompt. + * Why: digits merely advance multi-question prompts and toggle multi-selects; + * the synthetic "Type something" row also opens an editor instead of + * submitting. Without the prompt-shape gate those partial choices clear the + * waiting indicator while Claude is still blocked on more input. */ +export function isQuestionAnsweredSubmitInput( + data: string, + interactivePrompt: string | undefined +): boolean { + if (!isPotentialQuestionAnsweredSubmitInput(data)) { + return false + } + const optionCount = readSingleSelectOptionCount(interactivePrompt) + if (optionCount === -1) { + return false + } + if (QUESTION_ANSWER_ENTER_INPUTS.has(data)) { + // Older hook payloads can omit tool input; Enter remains the conservative + // fallback because it is the ordinary submit path for a question. + return true + } + if (optionCount === null) { + return false + } + // Claude adds a final "Type something" row after the declared options. + // Only declared option numbers complete a single-select immediately. + return Number(data) <= optionCount +} From 5b6cefa5ab63e45f4624e93ab1b69fb0f0e60f1e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 17 Jul 2026 14:25:38 -0700 Subject: [PATCH 14/17] fix(orchestration): emit message timestamps as RFC3339 UTC (#9167) --- .../db-message-timestamp.test.ts | 19 +++ src/main/runtime/orchestration/db.ts | 134 ++++++++++++------ 2 files changed, 111 insertions(+), 42 deletions(-) create mode 100644 src/main/runtime/orchestration/db-message-timestamp.test.ts diff --git a/src/main/runtime/orchestration/db-message-timestamp.test.ts b/src/main/runtime/orchestration/db-message-timestamp.test.ts new file mode 100644 index 00000000000..d4d000c48d3 --- /dev/null +++ b/src/main/runtime/orchestration/db-message-timestamp.test.ts @@ -0,0 +1,19 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { OrchestrationDb } from './db' + +describe('orchestration message timestamps', () => { + let db: OrchestrationDb | undefined + + afterEach(() => db?.close()) + + it('exposes SQLite timestamps with an explicit UTC designator', () => { + db = new OrchestrationDb(':memory:') + const message = db.insertMessage({ from: 'a', to: 'b', subject: 'timestamped' }) + + expect(message.created_at).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/) + db.markAsDelivered([message.id]) + expect(db.getMessageById(message.id)?.delivered_at).toMatch( + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/ + ) + }) +}) diff --git a/src/main/runtime/orchestration/db.ts b/src/main/runtime/orchestration/db.ts index 2525d30c851..5e8a46738fa 100644 --- a/src/main/runtime/orchestration/db.ts +++ b/src/main/runtime/orchestration/db.ts @@ -62,6 +62,29 @@ function addLifecycleRejectionMarker(payload: string | null, reason: string): st }) } +const SQLITE_UTC_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?$/ + +function exposeUtcTimestamp(timestamp: string | null): string | null { + if (!timestamp || !SQLITE_UTC_TIMESTAMP_RE.test(timestamp)) { + return timestamp + } + return `${timestamp.replace(' ', 'T')}Z` +} + +function exposeMessageTimestamps(message: MessageRow): MessageRow { + // Why: SQLite stores UTC as a timezone-less space format for internal SQL + // ordering, but RPC/CLI consumers need an explicit offset to interpret it. + return { + ...message, + created_at: exposeUtcTimestamp(message.created_at) ?? message.created_at, + delivered_at: exposeUtcTimestamp(message.delivered_at) + } +} + +function exposeMessageListTimestamps(messages: MessageRow[]): MessageRow[] { + return messages.map(exposeMessageTimestamps) +} + // Why: v1 → v2 added `'heartbeat'` to messages.type CHECK + `last_heartbeat_at` // column (preamble-hardening PR). v2 → v3 adds `delivered_at` column so // push-on-idle can distinguish queued-but-undelivered from user-acknowledged @@ -353,21 +376,27 @@ export class OrchestrationDb { msg.payload ?? null, msg.senderPaneKey ?? null ) - return this.db.prepare('SELECT * FROM messages WHERE id = ?').get(id) as MessageRow + return exposeMessageTimestamps( + this.db.prepare('SELECT * FROM messages WHERE id = ?').get(id) as MessageRow + ) } getUnreadMessages(toHandle: string, types?: MessageType[]): MessageRow[] { if (types && types.length > 0) { const placeholders = types.map(() => '?').join(',') - return this.db - .prepare( - `SELECT * FROM messages WHERE to_handle = ? AND read = 0 AND type IN (${placeholders}) ORDER BY sequence` - ) - .all(toHandle, ...types) as MessageRow[] + return exposeMessageListTimestamps( + this.db + .prepare( + `SELECT * FROM messages WHERE to_handle = ? AND read = 0 AND type IN (${placeholders}) ORDER BY sequence` + ) + .all(toHandle, ...types) as MessageRow[] + ) } - return this.db - .prepare('SELECT * FROM messages WHERE to_handle = ? AND read = 0 ORDER BY sequence') - .all(toHandle) as MessageRow[] + return exposeMessageListTimestamps( + this.db + .prepare('SELECT * FROM messages WHERE to_handle = ? AND read = 0 ORDER BY sequence') + .all(toHandle) as MessageRow[] + ) } convertLifecycleMessageToRejection(messageId: string, reason: string): MessageRow | undefined { @@ -400,27 +429,36 @@ export class OrchestrationDb { getUndeliveredUnreadMessages(toHandle: string, types?: MessageType[]): MessageRow[] { if (types && types.length > 0) { const placeholders = types.map(() => '?').join(',') - return this.db - .prepare( - `SELECT * FROM messages WHERE to_handle = ? AND read = 0 AND delivered_at IS NULL AND type IN (${placeholders}) ORDER BY sequence` - ) - .all(toHandle, ...types) as MessageRow[] - } - return this.db - .prepare( - 'SELECT * FROM messages WHERE to_handle = ? AND read = 0 AND delivered_at IS NULL ORDER BY sequence' + return exposeMessageListTimestamps( + this.db + .prepare( + `SELECT * FROM messages WHERE to_handle = ? AND read = 0 AND delivered_at IS NULL AND type IN (${placeholders}) ORDER BY sequence` + ) + .all(toHandle, ...types) as MessageRow[] ) - .all(toHandle) as MessageRow[] + } + return exposeMessageListTimestamps( + this.db + .prepare( + 'SELECT * FROM messages WHERE to_handle = ? AND read = 0 AND delivered_at IS NULL ORDER BY sequence' + ) + .all(toHandle) as MessageRow[] + ) } getAllMessages(toHandle: string, limit = 20): MessageRow[] { - return this.db - .prepare('SELECT * FROM messages WHERE to_handle = ? ORDER BY sequence DESC LIMIT ?') - .all(toHandle, limit) as MessageRow[] + return exposeMessageListTimestamps( + this.db + .prepare('SELECT * FROM messages WHERE to_handle = ? ORDER BY sequence DESC LIMIT ?') + .all(toHandle, limit) as MessageRow[] + ) } getMessageById(id: string): MessageRow | undefined { - return this.db.prepare('SELECT * FROM messages WHERE id = ?').get(id) as MessageRow | undefined + const message = this.db.prepare('SELECT * FROM messages WHERE id = ?').get(id) as + | MessageRow + | undefined + return message ? exposeMessageTimestamps(message) : undefined } markAsRead(ids: string[]): void { @@ -461,9 +499,11 @@ export class OrchestrationDb { } getInbox(limit = 20): MessageRow[] { - return this.db - .prepare('SELECT * FROM messages ORDER BY sequence DESC LIMIT ?') - .all(limit) as MessageRow[] + return exposeMessageListTimestamps( + this.db + .prepare('SELECT * FROM messages ORDER BY sequence DESC LIMIT ?') + .all(limit) as MessageRow[] + ) } // Why: used by `check --all` and `inbox --terminal ` — returns every @@ -473,15 +513,19 @@ export class OrchestrationDb { getAllMessagesForHandle(toHandle: string, limit = 100, types?: MessageType[]): MessageRow[] { if (types && types.length > 0) { const placeholders = types.map(() => '?').join(',') - return this.db - .prepare( - `SELECT * FROM messages WHERE to_handle = ? AND type IN (${placeholders}) ORDER BY sequence DESC LIMIT ?` - ) - .all(toHandle, ...types, limit) as MessageRow[] + return exposeMessageListTimestamps( + this.db + .prepare( + `SELECT * FROM messages WHERE to_handle = ? AND type IN (${placeholders}) ORDER BY sequence DESC LIMIT ?` + ) + .all(toHandle, ...types, limit) as MessageRow[] + ) } - return this.db - .prepare('SELECT * FROM messages WHERE to_handle = ? ORDER BY sequence DESC LIMIT ?') - .all(toHandle, limit) as MessageRow[] + return exposeMessageListTimestamps( + this.db + .prepare('SELECT * FROM messages WHERE to_handle = ? ORDER BY sequence DESC LIMIT ?') + .all(toHandle, limit) as MessageRow[] + ) } // Why: thread-scoped read for the `orchestration.ask` wait loop. Filtered @@ -492,15 +536,21 @@ export class OrchestrationDb { // the existing idx_thread index (see createTables) — no new index. getThreadMessagesFor(threadId: string, toHandle: string, afterSequence?: number): MessageRow[] { if (afterSequence !== undefined) { - return this.db - .prepare( - 'SELECT * FROM messages WHERE thread_id = ? AND to_handle = ? AND sequence > ? ORDER BY sequence ASC' - ) - .all(threadId, toHandle, afterSequence) as MessageRow[] + return exposeMessageListTimestamps( + this.db + .prepare( + 'SELECT * FROM messages WHERE thread_id = ? AND to_handle = ? AND sequence > ? ORDER BY sequence ASC' + ) + .all(threadId, toHandle, afterSequence) as MessageRow[] + ) } - return this.db - .prepare('SELECT * FROM messages WHERE thread_id = ? AND to_handle = ? ORDER BY sequence ASC') - .all(threadId, toHandle) as MessageRow[] + return exposeMessageListTimestamps( + this.db + .prepare( + 'SELECT * FROM messages WHERE thread_id = ? AND to_handle = ? ORDER BY sequence ASC' + ) + .all(threadId, toHandle) as MessageRow[] + ) } // ── Tasks ── From f544820552e2625624e7c2bdff9bfa08597fd4af Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 17 Jul 2026 14:27:31 -0700 Subject: [PATCH 15/17] fix(ssh): require a coherent colocated Node/npm toolchain for the relay (#9165) --- .../ssh/ssh-remote-node-resolution.test.ts | 16 +++++ src/main/ssh/ssh-remote-node-resolution.ts | 51 +++++++-------- .../ssh-remote-node-toolchain-probe.test.ts | 47 ++++++++++++++ .../ssh/ssh-remote-node-toolchain-probe.ts | 64 +++++++++++++++++++ 4 files changed, 150 insertions(+), 28 deletions(-) create mode 100644 src/main/ssh/ssh-remote-node-toolchain-probe.test.ts create mode 100644 src/main/ssh/ssh-remote-node-toolchain-probe.ts diff --git a/src/main/ssh/ssh-remote-node-resolution.test.ts b/src/main/ssh/ssh-remote-node-resolution.test.ts index 109725e549c..55a3877ff07 100644 --- a/src/main/ssh/ssh-remote-node-resolution.test.ts +++ b/src/main/ssh/ssh-remote-node-resolution.test.ts @@ -41,6 +41,22 @@ describe('resolveRemoteNodePath', () => { await expect(resolveRemoteNodePath(conn)).resolves.toBe('/usr/local/bin/node') }) + it('skips an incomplete system Node and selects a complete NVM toolchain', async () => { + execCommandMock + .mockResolvedValueOnce('/usr/bin/node\n/home/u/.nvm/versions/node/v22.22.0/bin/node\n') + .mockRejectedValueOnce(new Error('/usr/bin/npm: not found')) + .mockResolvedValueOnce('__ORCA_NODE_VERSION__\nv22.22.0\n__ORCA_NPM_VERSION__\n11.13.0\n') + + await expect(resolveRemoteNodePath(conn)).resolves.toBe( + '/home/u/.nvm/versions/node/v22.22.0/bin/node' + ) + + expect(execCommandMock.mock.calls[1]![1]).toContain("'/usr/bin/npm' --version") + expect(execCommandMock.mock.calls[2]![1]).toContain( + "'/home/u/.nvm/versions/node/v22.22.0/bin/npm' --version" + ) + }) + it('probes mise install directories', async () => { execCommandMock .mockResolvedValueOnce('/home/u/.local/share/mise/installs/node/20/bin/node\n') diff --git a/src/main/ssh/ssh-remote-node-resolution.ts b/src/main/ssh/ssh-remote-node-resolution.ts index a5041ed158d..2dd89e4a2a2 100644 --- a/src/main/ssh/ssh-remote-node-resolution.ts +++ b/src/main/ssh/ssh-remote-node-resolution.ts @@ -1,21 +1,21 @@ import type { SshConnection } from './ssh-connection' -import { createSshOperationAbortError, shellEscape } from './ssh-connection-utils' +import { createSshOperationAbortError } from './ssh-connection-utils' import type { RemoteHostPlatform } from './ssh-remote-platform' import { isWindowsRemoteHost, normalizeWindowsRemotePath } from './ssh-remote-platform' -import { powerShellCommand, powerShellLiteral } from './ssh-remote-powershell' +import { powerShellCommand } from './ssh-remote-powershell' import { buildPosixNodeInstallGuidance, type RemoteNodeResolutionOptions } from './ssh-remote-node-install-guidance' import { execCommand } from './ssh-relay-deploy-helpers' +import { + buildPosixNodeToolchainProbe, + buildWindowsNodeToolchainProbe, + nodeToolchainVersionsMeetRequirements +} from './ssh-remote-node-toolchain-probe' import { isSshSessionLimitError } from './ssh-session-limit-error' import { buildSshLoginShellCommand } from './ssh-login-shell-command' -// Why: the relay requires Node.js 18+. Version managers like nvm keep every -// installed version on disk, so a naive "highest version" glob can hand back -// Node 8/10/12 and crash the relay on launch. Gate every candidate on this. -const MIN_NODE_MAJOR = 18 - // Why: the login-shell fallback catches custom PATH setups in ~/.profile that // the path probes don't cover. Interactive configs (conda prompts, etc.) can // hang a login shell, so keep this short. @@ -53,7 +53,7 @@ export async function resolveRemoteNodePath( // Probe the on-disk install directories of every common Node version manager // plus system package-manager locations. Every probe runs unconditionally so // a missing directory prints nothing rather than short-circuiting later -// probes. Returns the first candidate that meets the minimum version. +// probes. Returns the first candidate with a complete Node/npm toolchain. async function tryResolveViaKnownPaths( conn: SshConnection, options?: RemoteNodeResolutionOptions @@ -113,7 +113,7 @@ true continue } seen.add(candidate) - if (await nodeMeetsVersionRequirement(conn, candidate, options)) { + if (await nodeToolchainMeetsRequirements(conn, candidate, options)) { console.log(`[ssh-relay] Found node via path probe: ${candidate}`) return candidate } @@ -160,7 +160,7 @@ async function tryResolveViaLoginShell( return null } - if (await nodeMeetsVersionRequirement(conn, candidate, options)) { + if (await nodeToolchainMeetsRequirements(conn, candidate, options)) { console.log(`[ssh-relay] Found node via login shell (${shell}): ${candidate}`) return candidate } @@ -174,10 +174,12 @@ async function tryResolveViaLoginShell( return null } -// Returns true if `nodePath` runs and reports Node >= MIN_NODE_MAJOR. +// Returns true if `nodePath` runs, reports Node >= 18, and has a runnable npm +// beside it. Deployment prepends this same directory before invoking npm, so +// accepting a looser pairing would recreate #8450. // Caches nothing — this runs at most a few times per resolution (one per // candidate), and the exec round-trip dominates. -async function nodeMeetsVersionRequirement( +async function nodeToolchainMeetsRequirements( conn: SshConnection, nodePath: string, options?: RemoteNodeResolutionOptions @@ -185,10 +187,12 @@ async function nodeMeetsVersionRequirement( try { const versionOutput = await execCommand( conn, - `${shellEscape(nodePath)} --version`, - commandOptions({ wrapCommand: false }, options) + buildPosixNodeToolchainProbe(nodePath), + // Why: the paired probe uses POSIX PATH assignment syntax, which fish + // and csh cannot parse when sshd delegates directly to the login shell. + commandOptions({ wrapCommand: true }, options) ) - return nodeVersionMeetsRequirement(versionOutput) + return nodeToolchainVersionsMeetRequirements(versionOutput) } catch (err) { if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) { throw err @@ -234,7 +238,7 @@ async function resolveRemoteWindowsNodePath( continue } const normalized = normalizeWindowsRemotePath(nodePath) - if (await windowsNodeMeetsVersionRequirement(conn, normalized, options)) { + if (await windowsNodeToolchainMeetsRequirements(conn, normalized, options)) { console.log(`[ssh-relay] Found Windows node at: ${normalized}`) return normalized } @@ -250,7 +254,7 @@ async function resolveRemoteWindowsNodePath( throwWindowsNodeNotFound(options) } -async function windowsNodeMeetsVersionRequirement( +async function windowsNodeToolchainMeetsRequirements( conn: SshConnection, nodePath: string, options?: RemoteNodeResolutionOptions @@ -258,10 +262,10 @@ async function windowsNodeMeetsVersionRequirement( try { const versionOutput = await execCommand( conn, - powerShellCommand(`& ${powerShellLiteral(nodePath)} --version`), + powerShellCommand(buildWindowsNodeToolchainProbe(nodePath)), commandOptions({ wrapCommand: false }, options) ) - return nodeVersionMeetsRequirement(versionOutput) + return nodeToolchainVersionsMeetRequirements(versionOutput) } catch (err) { if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) { throw err @@ -271,15 +275,6 @@ async function windowsNodeMeetsVersionRequirement( } } -function nodeVersionMeetsRequirement(versionOutput: string): boolean { - const match = versionOutput.trim().match(/^v?(\d+)/) - if (!match) { - return false - } - const major = Number.parseInt(match[1]!, 10) - return major >= MIN_NODE_MAJOR -} - async function throwNodeNotFound( conn: SshConnection, options?: RemoteNodeResolutionOptions diff --git a/src/main/ssh/ssh-remote-node-toolchain-probe.test.ts b/src/main/ssh/ssh-remote-node-toolchain-probe.test.ts new file mode 100644 index 00000000000..4fb3dda56ca --- /dev/null +++ b/src/main/ssh/ssh-remote-node-toolchain-probe.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { + buildPosixNodeToolchainProbe, + buildWindowsNodeToolchainProbe, + nodeToolchainVersionsMeetRequirements +} from './ssh-remote-node-toolchain-probe' + +describe('remote Node/npm toolchain probe', () => { + it('probes npm beside the selected POSIX Node with that directory on PATH', () => { + expect(buildPosixNodeToolchainProbe('/home/u/My Node/bin/node')).toBe( + "printf '%s\\n' '__ORCA_NODE_VERSION__' && '/home/u/My Node/bin/node' --version && " + + "printf '%s\\n' '__ORCA_NPM_VERSION__' && PATH='/home/u/My Node/bin':$PATH " + + "'/home/u/My Node/bin/npm' --version" + ) + }) + + it('probes npm.cmd beside the selected Windows Node', () => { + const probe = buildWindowsNodeToolchainProbe('C:/Program Files/nodejs/node.exe') + + expect(probe).toContain("Test-Path -LiteralPath 'C:/Program Files/nodejs/npm.cmd'") + expect(probe).toContain("$env:PATH = 'C:/Program Files/nodejs' + ';' + $env:PATH") + expect(probe).toContain("& 'C:/Program Files/nodejs/npm.cmd' --version") + }) + + it('requires marked, parseable Node and npm versions', () => { + expect( + nodeToolchainVersionsMeetRequirements( + 'banner\n__ORCA_NODE_VERSION__\nv22.22.0\n__ORCA_NPM_VERSION__\n11.13.0\n' + ) + ).toBe(true) + expect( + nodeToolchainVersionsMeetRequirements( + '__ORCA_NODE_VERSION__\nv22.22.0\n__ORCA_NPM_VERSION__\nshim did nothing\n' + ) + ).toBe(false) + expect( + nodeToolchainVersionsMeetRequirements( + '__ORCA_NODE_VERSION__\nv16.20.2\n__ORCA_NPM_VERSION__\n10.8.2\n' + ) + ).toBe(false) + }) + + it('accepts legacy Node-only output from existing proxy integrations', () => { + expect(nodeToolchainVersionsMeetRequirements('v18.0.0\n')).toBe(true) + expect(nodeToolchainVersionsMeetRequirements('v16.20.2\n')).toBe(false) + }) +}) diff --git a/src/main/ssh/ssh-remote-node-toolchain-probe.ts b/src/main/ssh/ssh-remote-node-toolchain-probe.ts new file mode 100644 index 00000000000..addf9b5752c --- /dev/null +++ b/src/main/ssh/ssh-remote-node-toolchain-probe.ts @@ -0,0 +1,64 @@ +import { posix as posixPath } from 'node:path' +import { shellEscape } from './ssh-connection-utils' +import { powerShellLiteral } from './ssh-remote-powershell' + +const MIN_NODE_MAJOR = 18 +const NODE_VERSION_MARKER = '__ORCA_NODE_VERSION__' +const NPM_VERSION_MARKER = '__ORCA_NPM_VERSION__' + +export function buildPosixNodeToolchainProbe(nodePath: string): string { + const nodeBinDir = posixPath.dirname(nodePath) + const npmPath = posixPath.join(nodeBinDir, 'npm') + return [ + `printf '%s\\n' '${NODE_VERSION_MARKER}'`, + `${shellEscape(nodePath)} --version`, + `printf '%s\\n' '${NPM_VERSION_MARKER}'`, + `PATH=${shellEscape(nodeBinDir)}:$PATH ${shellEscape(npmPath)} --version` + ].join(' && ') +} + +export function buildWindowsNodeToolchainProbe(nodePath: string): string { + const nodeBinDir = posixPath.dirname(nodePath) + const npmPath = posixPath.join(nodeBinDir, 'npm.cmd') + return [ + `if (!(Test-Path -LiteralPath ${powerShellLiteral(npmPath)} -PathType Leaf)) { exit 1 }`, + `$env:PATH = ${powerShellLiteral(nodeBinDir)} + ';' + $env:PATH`, + `Write-Output ${powerShellLiteral(NODE_VERSION_MARKER)}`, + `& ${powerShellLiteral(nodePath)} --version`, + 'if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }', + `Write-Output ${powerShellLiteral(NPM_VERSION_MARKER)}`, + `& ${powerShellLiteral(npmPath)} --version`, + 'exit $LASTEXITCODE' + ].join('; ') +} + +export function nodeToolchainVersionsMeetRequirements(versionOutput: string): boolean { + const nodeMajor = markedVersionMajor(versionOutput, NODE_VERSION_MARKER) + const npmMajor = markedVersionMajor(versionOutput, NPM_VERSION_MARKER) + if (versionOutput.includes(NODE_VERSION_MARKER)) { + return nodeMajor !== null && nodeMajor >= MIN_NODE_MAJOR && npmMajor !== null + } + + // Why: existing proxy integrations and tests may return only the Node + // version even though production probes now emit both version markers. + const legacyMatch = versionOutput.trim().match(/^v?(\d+)/) + return legacyMatch ? Number.parseInt(legacyMatch[1]!, 10) >= MIN_NODE_MAJOR : false +} + +function markedVersionMajor(output: string, marker: string): number | null { + const lines = output.split(/\r?\n/) + const markerIndex = lines.indexOf(marker) + if (markerIndex < 0) { + return null + } + for (const line of lines.slice(markerIndex + 1)) { + if (line.startsWith('__ORCA_')) { + return null + } + const match = line.trim().match(/^v?(\d+)(?:\.\d+){1,2}(?:[-+].*)?$/) + if (match) { + return Number.parseInt(match[1]!, 10) + } + } + return null +} From e6933fed847ea32817b1f87eaf9c4f993a2e5953 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 17 Jul 2026 15:09:22 -0700 Subject: [PATCH 16/17] fix(grok): honor zero-usage weekly reset (#9219) * fix(grok): honor zero-usage weekly reset * fix(grok): preserve ambiguous monthly billing fallback --- src/main/rate-limits/grok-fetcher.test.ts | 28 +++++++++++++++++++- src/main/rate-limits/grok-fetcher.ts | 31 ++++++++++++++++++----- 2 files changed, 52 insertions(+), 7 deletions(-) diff --git a/src/main/rate-limits/grok-fetcher.test.ts b/src/main/rate-limits/grok-fetcher.test.ts index b077b880576..63d7bc5cfcd 100644 --- a/src/main/rate-limits/grok-fetcher.test.ts +++ b/src/main/rate-limits/grok-fetcher.test.ts @@ -100,6 +100,31 @@ describe('fetchGrokRateLimits', () => { ) }) + it('maps an omitted protobuf percentage as zero for a weekly credits period', async () => { + authState.file = freshAuthJson() + netFetchMock.mockResolvedValueOnce( + jsonResponse({ + config: { + currentPeriod: { + type: 'USAGE_PERIOD_TYPE_WEEKLY', + start: '2026-07-17T19:38:56.948570+00:00', + end: '2026-07-24T19:38:56.948570+00:00' + }, + billingPeriodStart: '2026-07-17T19:38:56.948570+00:00', + billingPeriodEnd: '2026-07-24T19:38:56.948570+00:00', + isUnifiedBillingUser: true + } + }) + ) + + const result = await fetchGrokRateLimits() + expect(result.status).toBe('ok') + expect(result.weekly?.usedPercent).toBe(0) + expect(result.weekly?.resetsAt).toBe(Date.parse('2026-07-24T19:38:56.948570+00:00')) + expect(result.monthly).toBeUndefined() + expect(netFetchMock).toHaveBeenCalledTimes(1) + }) + it('returns unavailable when not signed in even if a token-less auth file exists', async () => { authState.file = JSON.stringify({}) const result = await fetchGrokRateLimits() @@ -119,7 +144,7 @@ describe('fetchGrokRateLimits', () => { expect(result.monthly).toBeUndefined() }) - it('maps monthly included usage for unified-billing accounts without weekly credits', async () => { + it('maps monthly included usage when a unified-billing response has an ambiguous weekly period', async () => { authState.file = freshAuthJson() netFetchMock .mockResolvedValueOnce( @@ -162,6 +187,7 @@ describe('fetchGrokRateLimits', () => { headers: expect.objectContaining({ Authorization: 'Bearer access-token' }) }) ) + expect(netFetchMock).toHaveBeenCalledTimes(2) }) // Why: 'unavailable' would make applyStalePolicy discard the last good diff --git a/src/main/rate-limits/grok-fetcher.ts b/src/main/rate-limits/grok-fetcher.ts index 1e6d20f5cce..366c75c3835 100644 --- a/src/main/rate-limits/grok-fetcher.ts +++ b/src/main/rate-limits/grok-fetcher.ts @@ -16,8 +16,8 @@ const GROK_CLI_PROXY_BASE = process.env.GROK_CLI_CHAT_PROXY_BASE_URL?.trim().replace(/\/$/, '') || 'https://cli-chat-proxy.grok.com/v1' const BILLING_CREDITS_URL = `${GROK_CLI_PROXY_BASE}/billing?format=credits` -// Why: unified-billing accounts have no weekly credits; their included monthly -// budget is only present in the default (format-less) billing view. +// Why: some unified-billing accounts expose only a monthly included budget, +// which is present in the default (format-less) billing view. const BILLING_DEFAULT_URL = `${GROK_CLI_PROXY_BASE}/billing` const API_TIMEOUT_MS = 10_000 const WEEKLY_WINDOW_MINUTES = 10_080 @@ -81,8 +81,28 @@ function parseResetDescription(isoString: string | undefined): string | null { : date.toLocaleDateString(undefined, { weekday: 'short', hour: 'numeric', minute: '2-digit' }) } +function timestampsMatch(left: string | undefined, right: string | undefined): boolean { + const leftTimestamp = left ? Date.parse(left) : Number.NaN + const rightTimestamp = right ? Date.parse(right) : Number.NaN + return Number.isFinite(leftTimestamp) && leftTimestamp === rightTimestamp +} + +function hasConfirmedWeeklyPeriod(config: GrokBillingConfig): boolean { + const period = config.currentPeriod + // Why: monthly unified-billing responses can also carry a weekly currentPeriod; + // matching billing bounds identify Grok's omitted protobuf zero unambiguously. + return ( + period?.type === 'USAGE_PERIOD_TYPE_WEEKLY' && + timestampsMatch(period.start, config.billingPeriodStart) && + timestampsMatch(period.end, config.billingPeriodEnd) + ) +} + function mapWeeklyCredits(config: GrokBillingConfig): RateLimitWindow | null { - const usedPercent = config.creditUsagePercent + const usedPercent = + config.creditUsagePercent === undefined && hasConfirmedWeeklyPeriod(config) + ? 0 + : config.creditUsagePercent if (typeof usedPercent !== 'number' || !Number.isFinite(usedPercent)) { return null } @@ -257,9 +277,8 @@ export async function fetchGrokRateLimits( if (weekly) { return billingUsageResult({ weekly }, config, session) } - // Why: unified-billing accounts report a monthly included-usage budget - // instead of weekly credits; the credits view omits creditUsagePercent - // for them, so read the default billing view before giving up. + // Why: some unified-billing accounts expose only a monthly included budget; + // their credits view omits creditUsagePercent, so read the default view. const fallback = await fetchMonthlyUsageFallback(session, options.signal) if (fallback.kind === 'result') { return fallback.result From e70842fe2c1052f3baf5babf03cc46b2a2f56005 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 17 Jul 2026 15:10:08 -0700 Subject: [PATCH 17/17] fix(terminal): recover floating input after app refocus (#9233) * fix(terminal): recover floating input after app refocus * fix(terminal): preserve newer focus ownership --- .../FloatingTerminalPanel.test.tsx | 113 +++++++++++++++++- .../FloatingTerminalPanel.tsx | 68 ++++++++++- .../regular-terminal-focus-ownership.test.ts | 98 ++++++++++++++- .../regular-terminal-focus-ownership.ts | 26 +++- ...terminal-ime-input-context-refresh.test.ts | 63 +++++++++- .../terminal-ime-input-context-refresh.ts | 23 +++- .../lib/focus-terminal-tab-surface.test.ts | 53 ++++++++ .../src/lib/focus-terminal-tab-surface.ts | 41 ++++++- 8 files changed, 471 insertions(+), 14 deletions(-) diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx index dfcf8337e3a..a29c76f1d55 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx @@ -106,12 +106,14 @@ const mocks = vi.hoisted(() => ({ getFloatingMarkdownDirectory: vi.fn(), getFloatingTerminalCwd: vi.fn(), getInstallStatus: vi.fn(), + isTerminalImeInputContextRefreshing: vi.fn(), isWebRuntimeSessionActive: vi.fn(), markFileDirty: vi.fn(), makePreviewFilePermanent: vi.fn(), openFile: vi.fn(), pickFloatingMarkdownDocument: vi.fn(), pinFile: vi.fn(), + setFloatingTerminalInputFocused: vi.fn(), setActiveTab: vi.fn(), setRenamingTabId: vi.fn(), setTabColor: vi.fn(), @@ -185,6 +187,10 @@ vi.mock('@/components/terminal-pane/TerminalPane', () => ({ } })) +vi.mock('@/components/terminal-pane/terminal-ime-input-context-refresh', () => ({ + isTerminalImeInputContextRefreshing: mocks.isTerminalImeInputContextRefreshing +})) + vi.mock('@/components/browser-pane/BrowserPane', () => ({ default: function BrowserPane() { return null @@ -744,6 +750,7 @@ describe('FloatingTerminalPanel close behavior', () => { mocks.getFloatingMarkdownDirectory.mockResolvedValue('/tmp/orca/floating-notes') mocks.getFloatingTerminalCwd.mockResolvedValue('/tmp/orca') mocks.getInstallStatus.mockResolvedValue({ state: 'installed', pathConfigured: true }) + mocks.isTerminalImeInputContextRefreshing.mockReturnValue(false) mocks.isWebRuntimeSessionActive.mockReturnValue(false) mocks.pickFloatingMarkdownDocument.mockResolvedValue(null) const localStorage = { @@ -762,7 +769,7 @@ describe('FloatingTerminalPanel close behavior', () => { }, browser: { notifyActiveTabChanged: vi.fn() }, cli: { getInstallStatus: mocks.getInstallStatus }, - ui: { setFloatingTerminalInputFocused: vi.fn() } + ui: { setFloatingTerminalInputFocused: mocks.setFloatingTerminalInputFocused } }, innerHeight: 800, innerWidth: 1200, @@ -799,6 +806,110 @@ describe('FloatingTerminalPanel close behavior', () => { expect(getPanelClassName(element)).toContain('z-30') }) + it('refreshes terminal native input focus when the floating panel opens', async () => { + setFloatingTabs([makeTab({ id: 'tab-1' })]) + + await renderPanel(true) + runEffects() + + expect(mocks.focusTerminalTabSurface).toHaveBeenCalledWith( + 'tab-1', + null, + expect.objectContaining({ + onImeRefocusSkipped: expect.any(Function), + refreshImeContext: true + }) + ) + mocks.setFloatingTerminalInputFocused.mockClear() + mocks.focusTerminalTabSurface.mock.calls[0]?.[2].onImeRefocusSkipped() + expect(mocks.setFloatingTerminalInputFocused).toHaveBeenCalledWith(false) + + const newerFloatingInput = { + classList: { contains: (token: string) => token === 'xterm-helper-textarea' }, + closest: vi.fn().mockReturnValue({}) + } + Object.setPrototypeOf(newerFloatingInput, HTMLElement.prototype) + mocks.focusTerminalTabSurface.mock.calls[0]?.[2].onImeRefocusSkipped(newerFloatingInput) + expect(mocks.setFloatingTerminalInputFocused).toHaveBeenLastCalledWith(true) + }) + + it('preserves and reclaims terminal input ownership across window blur', async () => { + setFloatingTabs([makeTab({ id: 'tab-1' })]) + const element = await renderPanel(true) + const panel = findByProp(element, 'data-floating-terminal-panel') + const panelElement = { contains: vi.fn().mockReturnValue(true), focus: vi.fn() } + const terminalInput = { + blur: vi.fn(), + classList: { contains: vi.fn((token: string) => token === 'xterm-helper-textarea') }, + closest: vi.fn((selector: string) => { + if (selector === '[data-floating-terminal-panel]') { + return panelElement + } + return selector === '[data-leaf-id]' + ? { + getAttribute: (attribute: string) => (attribute === 'data-leaf-id' ? 'leaf-1' : null) + } + : null + }), + isConnected: true + } + Object.setPrototypeOf(panelElement, HTMLElement.prototype) + Object.setPrototypeOf(terminalInput, HTMLElement.prototype) + attachRef(panel.props.ref, panelElement) + mocks.isTerminalImeInputContextRefreshing.mockReturnValueOnce(true) + const onBlurCapture = panel.props.onBlurCapture as (event: unknown) => void + onBlurCapture({ + relatedTarget: null, + target: terminalInput + }) + expect(mocks.setFloatingTerminalInputFocused).not.toHaveBeenCalled() + const documentState = { + activeElement: terminalInput as unknown as HTMLElement | null, + addEventListener: vi.fn(), + body: {} as HTMLElement, + removeEventListener: vi.fn() + } + vi.stubGlobal('document', documentState) + runEffects() + const blurListener = vi + .mocked(window.addEventListener) + .mock.calls.findLast(([type]) => type === 'blur')?.[1] as (() => void) | undefined + const focusListener = vi + .mocked(window.addEventListener) + .mock.calls.find(([type]) => type === 'focus')?.[1] as (() => void) | undefined + if (!blurListener || !focusListener) { + throw new Error('floating terminal window focus listeners not registered') + } + + blurListener() + expect(terminalInput.blur).not.toHaveBeenCalled() + + focusListener() + expect(mocks.setFloatingTerminalInputFocused).toHaveBeenCalledWith(true) + + documentState.activeElement = terminalInput as unknown as HTMLElement + blurListener() + documentState.activeElement = documentState.body + mocks.focusTerminalTabSurface.mockClear() + focusListener() + expect(mocks.focusTerminalTabSurface).not.toHaveBeenCalled() + + documentState.activeElement = terminalInput as unknown as HTMLElement + blurListener() + terminalInput.isConnected = false + documentState.activeElement = documentState.body + focusListener() + expect(mocks.focusTerminalTabSurface).toHaveBeenCalledWith( + 'tab-1', + 'leaf-1', + expect.objectContaining({ + onlyIfFocusUnclaimed: true, + onImeRefocusSkipped: expect.any(Function), + refreshImeContext: true + }) + ) + }) + it('falls back to default bounds when persisted geometry is malformed', async () => { getMockedLocalStorage().getItem.mockImplementation((key: string) => key === FLOATING_TERMINAL_PANEL_BOUNDS_STORAGE_KEY diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx index 4f0b65ea3dc..c5b509264a3 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx @@ -12,6 +12,7 @@ import { useContextualTour } from '@/components/contextual-tours/use-contextual- import TabBar from '@/components/tab-bar/TabBar' import { resolveGroupTabFromVisibleId } from '@/components/tab-group/tab-group-visible-id' import TerminalPane from '@/components/terminal-pane/TerminalPane' +import { isTerminalImeInputContextRefreshing } from '@/components/terminal-pane/terminal-ime-input-context-refresh' import { Button } from '@/components/ui/button' import { useMountedRef } from '@/hooks/useMountedRef' import { useShortcutKeyDetails, type ShortcutKeyComboDetails } from '@/hooks/useShortcutLabel' @@ -222,6 +223,10 @@ export function FloatingTerminalPanel({ } const shortcutFocusFrameRef = useRef(null) const shortcutFocusTimeoutRef = useRef(null) + const reclaimTerminalInputOnWindowFocusRef = useRef<{ + helper: HTMLElement + leafId: string | null + } | null>(null) const mountedRef = useMountedRef() const dragRef = useRef<{ pointerId: number @@ -578,7 +583,11 @@ export function FloatingTerminalPanel({ if (!open || !activeTerminalId) { return } - focusTerminalTabSurface(activeTerminalId) + focusTerminalTabSurface(activeTerminalId, null, { + onImeRefocusSkipped: (active) => + setFloatingTerminalInputFocusedInMain(isFloatingWorkspaceTerminalInputTarget(active)), + refreshImeContext: true + }) }, [activeTerminalId, open]) useEffect(() => { @@ -1313,22 +1322,69 @@ export function FloatingTerminalPanel({ const handleWindowBlur = (): void => { const panel = panelRef.current const active = document.activeElement + reclaimTerminalInputOnWindowFocusRef.current = null if (!panel || !(active instanceof HTMLElement) || !panel.contains(active)) { return } // Why: browser webviews focus out-of-process and do not emit renderer // pointerdown events, so release floating ownership on renderer blur too. setFloatingTerminalInputFocusedInMain(false) + if (isFloatingWorkspaceTerminalInputTarget(active)) { + // Why: the terminal focus lifecycle preserves this exact helper across + // app blur so macOS can rebuild its native input context on return. + reclaimTerminalInputOnWindowFocusRef.current = { + helper: active, + leafId: active.closest('[data-leaf-id]')?.getAttribute('data-leaf-id') ?? null + } + return + } active.blur() } + const handleWindowFocus = (): void => { + const reclaim = reclaimTerminalInputOnWindowFocusRef.current + if (!reclaim) { + return + } + reclaimTerminalInputOnWindowFocusRef.current = null + const panel = panelRef.current + const active = document.activeElement + if ( + panel && + active instanceof HTMLElement && + panel.contains(active) && + isFloatingWorkspaceTerminalInputTarget(active) + ) { + setFloatingTerminalInputFocusedInMain(true) + return + } + if ((active === null || active === document.body) && activeTerminalId) { + if (reclaim.helper.isConnected && panel?.contains(reclaim.helper)) { + // Why: TerminalPane owns exact-helper reclaim and IME refresh. Avoid + // racing it with a second floating-layer blur/refocus cycle. + return + } + // Why: only a helper that genuinely remounted while backgrounded needs + // tab/leaf recovery; the shared TerminalPane owner cannot reclaim it. + focusTerminalTabSurface(activeTerminalId, reclaim.leafId, { + onlyIfFocusUnclaimed: true, + onImeRefocusSkipped: (active) => + setFloatingTerminalInputFocusedInMain(isFloatingWorkspaceTerminalInputTarget(active)), + refreshImeContext: true + }) + } + } + document.addEventListener('pointerdown', handleOutsidePointerDown, true) window.addEventListener('blur', handleWindowBlur) + window.addEventListener('focus', handleWindowFocus) return () => { + reclaimTerminalInputOnWindowFocusRef.current = null document.removeEventListener('pointerdown', handleOutsidePointerDown, true) window.removeEventListener('blur', handleWindowBlur) + window.removeEventListener('focus', handleWindowFocus) } - }, [open]) + }, [activeTerminalId, open]) const handleDragStart = (event: React.PointerEvent): void => { if (maximized) { return @@ -1423,7 +1479,13 @@ export function FloatingTerminalPanel({ commitUserBounds({ ...stagedBoundsRef.current, width: rect.width, height: rect.height }) }} onFocusCapture={(event) => setFloatingTerminalInputFocused(event.target)} - onBlurCapture={(event) => setFloatingTerminalInputFocused(event.relatedTarget)} + onBlurCapture={(event) => { + // Why: keep terminal-first shortcut ownership latched during the + // synchronous macOS IME refresh blur; refocus or its skip callback settles it. + if (!isTerminalImeInputContextRefreshing(event.target)) { + setFloatingTerminalInputFocused(event.relatedTarget) + } + }} onKeyDownCapture={handleShortcutSurfaceKeyDown} >
    diff --git a/src/renderer/src/components/terminal-pane/regular-terminal-focus-ownership.test.ts b/src/renderer/src/components/terminal-pane/regular-terminal-focus-ownership.test.ts index 516a19f39c6..c10f4149843 100644 --- a/src/renderer/src/components/terminal-pane/regular-terminal-focus-ownership.test.ts +++ b/src/renderer/src/components/terminal-pane/regular-terminal-focus-ownership.test.ts @@ -154,12 +154,13 @@ describe('regular terminal focus ownership', () => { }) expect(synced).toBe(true) - expect(syncFocused).toHaveBeenCalledWith(true) + expect(syncFocused).not.toHaveBeenCalled() // Why: reclaim is deferred so a newer focus owner during reactivation wins. expect(focus).not.toHaveBeenCalled() for (const run of scheduled) { run() } + expect(syncFocused).toHaveBeenCalledWith(true) expect(focus).toHaveBeenCalledOnce() expect(document.activeElement).toBe(helper) }) @@ -203,6 +204,39 @@ describe('regular terminal focus ownership', () => { expect(document.activeElement).toBe(secondHelper) }) + it('keeps ownership clear when the released helper cannot accept focus', () => { + const pane = appendPane() + const helper = appendHelper(pane) + const syncFocused = vi.fn() + helper.focus() + + const releasedHelper = releaseTerminalFocusForWindowBlur({ + container: pane, + activeElement: helper, + syncFocused + }) + document.body.focus() + syncFocused.mockClear() + vi.spyOn(helper, 'focus').mockImplementation(() => undefined) + const scheduled: (() => void)[] = [] + + resyncTerminalFocusForWindowFocus({ + container: pane, + activeElement: document.activeElement, + syncFocused, + releasedHelper, + isMac: false, + scheduleRefocus: (callback) => scheduled.push(callback) + }) + for (const run of scheduled) { + run() + } + + expect(document.activeElement).toBe(document.body) + expect(syncFocused).toHaveBeenCalledOnce() + expect(syncFocused).toHaveBeenCalledWith(false) + }) + it('does not yank focus back into the terminal if the user clicked elsewhere during reactivation', () => { // Why: the Linux reclaim path must honor the same "newer focus owner wins" // guard the macOS path uses, so a click into the sidebar/dialog isn't stolen. @@ -220,6 +254,7 @@ describe('regular terminal focus ownership', () => { syncFocused }) document.body.focus() + syncFocused.mockClear() focus.mockClear() const scheduled: (() => void)[] = [] @@ -238,9 +273,44 @@ describe('regular terminal focus ownership', () => { } expect(focus).not.toHaveBeenCalled() + expect(syncFocused).toHaveBeenCalledWith(false) expect(document.activeElement).toBe(outside) }) + it('does not clear ownership published by a newer terminal during deferred reclaim', () => { + const pane = appendPane() + const helper = appendHelper(pane) + const newerPane = appendPane() + const newerHelper = appendHelper(newerPane) + const syncFocused = vi.fn() + helper.focus() + + const releasedHelper = releaseTerminalFocusForWindowBlur({ + container: pane, + activeElement: helper, + syncFocused + }) + document.body.focus() + syncFocused.mockClear() + const scheduled: (() => void)[] = [] + + resyncTerminalFocusForWindowFocus({ + container: pane, + activeElement: document.activeElement, + syncFocused, + releasedHelper, + isMac: false, + scheduleRefocus: (callback) => scheduled.push(callback) + }) + newerHelper.focus() + for (const run of scheduled) { + run() + } + + expect(document.activeElement).toBe(newerHelper) + expect(syncFocused).not.toHaveBeenCalled() + }) + it('does not reclaim a released helper that was detached from the DOM before refocus', () => { const pane = appendPane() const helper = appendHelper(pane) @@ -355,9 +425,35 @@ describe('regular terminal focus ownership', () => { run() } expect(focus).not.toHaveBeenCalled() + expect(syncFocused).toHaveBeenLastCalledWith(false) expect(document.activeElement).toBe(outside) }) + it('does not clear ownership published by a newer terminal during IME refresh', () => { + const pane = appendPane() + const helper = appendHelper(pane) + const newerHelper = appendHelper(appendPane()) + const syncFocused = vi.fn() + helper.focus() + const scheduled: (() => void)[] = [] + + resyncTerminalFocusForWindowFocus({ + container: pane, + activeElement: document.activeElement, + syncFocused, + isMac: true, + scheduleRefocus: (callback) => scheduled.push(callback) + }) + syncFocused.mockClear() + newerHelper.focus() + for (const run of scheduled) { + run() + } + + expect(document.activeElement).toBe(newerHelper) + expect(syncFocused).not.toHaveBeenCalled() + }) + it('skips the blur/refocus cycle on non-macOS platforms', () => { const pane = appendPane() const helper = appendHelper(pane) diff --git a/src/renderer/src/components/terminal-pane/regular-terminal-focus-ownership.ts b/src/renderer/src/components/terminal-pane/regular-terminal-focus-ownership.ts index 3f50f574f81..768b0dfb3d1 100644 --- a/src/renderer/src/components/terminal-pane/regular-terminal-focus-ownership.ts +++ b/src/renderer/src/components/terminal-pane/regular-terminal-focus-ownership.ts @@ -102,8 +102,6 @@ export function resyncTerminalFocusForWindowFocus(args: { } } - args.syncFocused(true) - const reclaimedHelper = helper // Why: defer the reclaim refocus to the next frame and only take focus if @@ -115,6 +113,7 @@ export function resyncTerminalFocusForWindowFocus(args: { const schedule = args.scheduleRefocus ?? scheduleNextFrame schedule(() => { if (!reclaimedHelper.isConnected) { + syncFocusAfterFailedReclaim(reclaimedHelper.ownerDocument.activeElement, args.syncFocused) return } const active = reclaimedHelper.ownerDocument.activeElement @@ -123,21 +122,44 @@ export function resyncTerminalFocusForWindowFocus(args: { isDocumentBodyOrNull(active, reclaimedHelper.ownerDocument) ) { reclaimedHelper.focus() + if (reclaimedHelper.ownerDocument.activeElement === reclaimedHelper) { + args.syncFocused(true) + } else { + syncFocusAfterFailedReclaim(reclaimedHelper.ownerDocument.activeElement, args.syncFocused) + } + return } + syncFocusAfterFailedReclaim(active, args.syncFocused) }) return true } + args.syncFocused(true) + // Why: macOS app reactivation leaves a stale NSTextInputContext on the // still-focused helper (electron#32307/#34952); non-mac returns false inside. refreshTerminalImeInputContext(reclaimedHelper, { isMac: args.isMac, + // Why: if another control wins during the refresh frame, the terminal + // mirror must follow that owner instead of remaining latched true. + onRefocusSkipped: (active) => syncFocusAfterFailedReclaim(active, args.syncFocused), scheduleRefocus: args.scheduleRefocus }) return true } +function syncFocusAfterFailedReclaim( + activeElement: Element | null, + syncFocused: TerminalInputFocusSync +): void { + // Why: a later xterm focusin already published terminal ownership; an older + // deferred reclaim must not overwrite that newer process-wide mirror. + if (!isXtermHelperTextarea(activeElement)) { + syncFocused(false) + } +} + function isNode(value: EventTarget | null): value is Node { return typeof Node !== 'undefined' && value instanceof Node } diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-input-context-refresh.test.ts b/src/renderer/src/components/terminal-pane/terminal-ime-input-context-refresh.test.ts index 69b73c586d9..5a31b691cdd 100644 --- a/src/renderer/src/components/terminal-pane/terminal-ime-input-context-refresh.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-ime-input-context-refresh.test.ts @@ -1,6 +1,9 @@ // @vitest-environment happy-dom import { beforeEach, describe, expect, it, vi } from 'vitest' -import { refreshTerminalImeInputContext } from './terminal-ime-input-context-refresh' +import { + isTerminalImeInputContextRefreshing, + refreshTerminalImeInputContext +} from './terminal-ime-input-context-refresh' describe('refreshTerminalImeInputContext', () => { beforeEach(() => { @@ -38,6 +41,23 @@ describe('refreshTerminalImeInputContext', () => { expect(document.activeElement).toBe(helper) }) + it('marks only the synchronous refresh blur so focus ownership can stay latched', () => { + const helper = appendHelper() + let refreshingDuringBlur = false + helper.addEventListener('blur', (event) => { + refreshingDuringBlur = isTerminalImeInputContextRefreshing(event.target) + }) + helper.focus() + + refreshTerminalImeInputContext(helper, { + isMac: true, + scheduleRefocus: vi.fn() + }) + + expect(refreshingDuringBlur).toBe(true) + expect(isTerminalImeInputContextRefreshing(helper)).toBe(false) + }) + it('does not steal focus if another element grabbed it before the refocus frame', () => { const helper = appendHelper() const outside = document.createElement('input') @@ -60,6 +80,47 @@ describe('refreshTerminalImeInputContext', () => { expect(document.activeElement).toBe(outside) }) + it('reports when a newer focus owner wins the refocus guard', () => { + const helper = appendHelper() + const outside = document.createElement('input') + document.body.appendChild(outside) + const onRefocusSkipped = vi.fn() + const scheduled: (() => void)[] = [] + helper.focus() + + refreshTerminalImeInputContext(helper, { + isMac: true, + onRefocusSkipped, + scheduleRefocus: (callback) => scheduled.push(callback) + }) + + outside.focus() + for (const run of scheduled) { + run() + } + expect(onRefocusSkipped).toHaveBeenCalledWith(outside) + }) + + it('reports when a connected helper cannot accept the scheduled focus', () => { + const helper = appendHelper() + const onRefocusSkipped = vi.fn() + const scheduled: (() => void)[] = [] + helper.focus() + + refreshTerminalImeInputContext(helper, { + isMac: true, + onRefocusSkipped, + scheduleRefocus: (callback) => scheduled.push(callback) + }) + vi.spyOn(helper, 'focus').mockImplementation(() => undefined) + for (const run of scheduled) { + run() + } + + expect(document.activeElement).toBe(document.body) + expect(onRefocusSkipped).toHaveBeenCalledWith(document.body) + }) + it('skips non-macOS platforms', () => { const helper = appendHelper() const blur = vi.spyOn(helper, 'blur') diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-input-context-refresh.ts b/src/renderer/src/components/terminal-pane/terminal-ime-input-context-refresh.ts index e86fc79bde5..debb3f39c2c 100644 --- a/src/renderer/src/components/terminal-pane/terminal-ime-input-context-refresh.ts +++ b/src/renderer/src/components/terminal-pane/terminal-ime-input-context-refresh.ts @@ -3,10 +3,18 @@ export type TerminalImeInputContextRefocusScheduler = (callback: () => void) => export type TerminalImeInputContextRefreshOptions = { /** Override the macOS check (tests). Defaults to the navigator user agent. */ isMac?: boolean + /** Called with the settled owner when the scheduled refocus does not land. */ + onRefocusSkipped?: (activeElement: Element | null) => void /** Override the refocus scheduler (tests). Defaults to requestAnimationFrame. */ scheduleRefocus?: TerminalImeInputContextRefocusScheduler } +const refreshingHelpers = new WeakSet() + +export function isTerminalImeInputContextRefreshing(target: EventTarget | null): boolean { + return target instanceof HTMLElement && refreshingHelpers.has(target) +} + function isMacUserAgent(): boolean { return typeof navigator !== 'undefined' && navigator.userAgent.includes('Mac') } @@ -38,17 +46,28 @@ export function refreshTerminalImeInputContext( const ownerDocument = helper.ownerDocument // Why: Electron/Chromium can keep a stale NSTextInputContext on the xterm // helper after focus handoffs; blur/refocus rebuilds it so CJK IMEs work. - helper.blur() + refreshingHelpers.add(helper) + try { + helper.blur() + } finally { + refreshingHelpers.delete(helper) + } const schedule = options.scheduleRefocus ?? scheduleNextFrame schedule(() => { - const active = ownerDocument.activeElement if (!helper.isConnected) { + options.onRefocusSkipped?.(ownerDocument.activeElement) return } + const active = ownerDocument.activeElement if (active === helper || isDocumentBodyOrNull(active, ownerDocument)) { helper.focus() + if (ownerDocument.activeElement !== helper) { + options.onRefocusSkipped?.(ownerDocument.activeElement) + } + return } + options.onRefocusSkipped?.(active) }) return true diff --git a/src/renderer/src/lib/focus-terminal-tab-surface.test.ts b/src/renderer/src/lib/focus-terminal-tab-surface.test.ts index 9d2313da44a..9df0ef4132d 100644 --- a/src/renderer/src/lib/focus-terminal-tab-surface.test.ts +++ b/src/renderer/src/lib/focus-terminal-tab-surface.test.ts @@ -1,8 +1,17 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { focusTerminalTabSurface } from './focus-terminal-tab-surface' +const mocks = vi.hoisted(() => ({ + refreshTerminalImeInputContext: vi.fn() +})) + +vi.mock('@/components/terminal-pane/terminal-ime-input-context-refresh', () => ({ + refreshTerminalImeInputContext: mocks.refreshTerminalImeInputContext +})) + describe('focusTerminalTabSurface', () => { afterEach(() => { + mocks.refreshTerminalImeInputContext.mockClear() vi.unstubAllGlobals() }) @@ -28,6 +37,50 @@ describe('focusTerminalTabSurface', () => { expect(textarea.focus).toHaveBeenCalled() }) + it('optionally refreshes the focused helper native input context', () => { + flushAnimationFrames() + const textarea = { focus: vi.fn() } + vi.stubGlobal('document', { + querySelector: vi.fn((selector: string) => + selector === '[data-terminal-tab-id="tab-1"] .xterm-helper-textarea' ? textarea : null + ) + }) + + focusTerminalTabSurface('tab-1', null, { refreshImeContext: true }) + + expect(textarea.focus).toHaveBeenCalledOnce() + expect(mocks.refreshTerminalImeInputContext).toHaveBeenCalledWith(textarea, { + onRefocusSkipped: undefined + }) + }) + + it('does not steal focus from a newer owner during guarded remount recovery', () => { + const frames: FrameRequestCallback[] = [] + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + frames.push(callback) + return frames.length + }) + vi.stubGlobal('cancelAnimationFrame', vi.fn()) + const textarea = { focus: vi.fn() } + const body = {} + const outside = {} + const documentState = { + activeElement: body as unknown, + body, + querySelector: vi.fn((selector: string) => + selector === '[data-terminal-tab-id="tab-1"] .xterm-helper-textarea' ? textarea : null + ) + } + vi.stubGlobal('document', documentState) + + focusTerminalTabSurface('tab-1', null, { onlyIfFocusUnclaimed: true }) + frames.shift()?.(0) + documentState.activeElement = outside + frames.shift()?.(0) + + expect(textarea.focus).not.toHaveBeenCalled() + }) + it('does not steal focus while inline tab rename is open', () => { flushAnimationFrames() const textarea = { focus: vi.fn() } diff --git a/src/renderer/src/lib/focus-terminal-tab-surface.ts b/src/renderer/src/lib/focus-terminal-tab-surface.ts index a41913c2ab8..97ceb97ae61 100644 --- a/src/renderer/src/lib/focus-terminal-tab-surface.ts +++ b/src/renderer/src/lib/focus-terminal-tab-surface.ts @@ -1,3 +1,5 @@ +import { refreshTerminalImeInputContext } from '@/components/terminal-pane/terminal-ime-input-context-refresh' + /** * Move keyboard focus into the xterm instance for a freshly-mounted terminal * tab. Handles the two-step race where React must first mount the new @@ -11,6 +13,29 @@ function cssAttributeString(value: string): string { let pendingFocusFrameIds: number[] = [] +type FocusTerminalTabSurfaceOptions = { + onlyIfFocusUnclaimed?: boolean + onImeRefocusSkipped?: (activeElement: Element | null) => void + refreshImeContext?: boolean +} + +function focusTerminalHelper(helper: HTMLElement, options: FocusTerminalTabSurfaceOptions): void { + if (options.onlyIfFocusUnclaimed) { + const active = document.activeElement + if (active !== helper && active !== null && active !== document.body) { + return + } + } + helper.focus() + if (options.refreshImeContext) { + // Why: a CSS-hidden, long-lived xterm can retain a stale macOS native text + // input context even after DOM focus returns; blur/refocus rebuilds it. + refreshTerminalImeInputContext(helper, { + onRefocusSkipped: options.onImeRefocusSkipped + }) + } +} + function cancelPendingFocusFrames(): void { if (typeof cancelAnimationFrame === 'function') { for (const frameId of pendingFocusFrameIds) { @@ -29,7 +54,11 @@ function canUseSinglePaneStaleLeafFallback(tabId: string, leafId: string): boole return expectedLeafIds?.length === 1 && !expectedLeafIds.includes(leafId) } -export function focusTerminalTabSurface(tabId: string, leafId?: string | null): void { +export function focusTerminalTabSurface( + tabId: string, + leafId?: string | null, + options: FocusTerminalTabSurfaceOptions = {} +): void { cancelPendingFocusFrames() const firstFrameId = requestAnimationFrame(() => { pendingFocusFrameIds = pendingFocusFrameIds.filter((frameId) => frameId !== firstFrameId) @@ -46,7 +75,7 @@ export function focusTerminalTabSurface(tabId: string, leafId?: string | null): : `[data-terminal-tab-id="${escapedTabId}"] .xterm-helper-textarea` const scoped = document.querySelector(scopedSelector) as HTMLElement | null if (scoped) { - scoped.focus() + focusTerminalHelper(scoped, options) return } if (leafId) { @@ -62,13 +91,17 @@ export function focusTerminalTabSurface(tabId: string, leafId?: string | null): ) if (tabScopedHelpers.length === 1) { const fallback = tabScopedHelpers.item(0) as HTMLElement | null - fallback?.focus() + if (fallback) { + focusTerminalHelper(fallback, options) + } return } return } const fallback = document.querySelector('.xterm-helper-textarea') as HTMLElement | null - fallback?.focus() + if (fallback) { + focusTerminalHelper(fallback, options) + } }) pendingFocusFrameIds.push(secondFrameId) })