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 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) + } + } +} 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/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/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/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/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/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 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 ── 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()) 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/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.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 01d6a0c7d48..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 } @@ -122,6 +122,7 @@ true if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) { throw err } + throwIfAborted(options) // Fall through to login shell. } return null @@ -159,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 } @@ -167,15 +168,18 @@ async function tryResolveViaLoginShell( if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) { throw err } + throwIfAborted(options) // Fall through. } 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 @@ -183,14 +187,17 @@ 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 } + throwIfAborted(options) // Binary missing or fails to run — not usable. return false } @@ -231,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 } @@ -240,13 +247,14 @@ async function resolveRemoteWindowsNodePath( if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) { throw err } + throwIfAborted(options) // Fall through to the shared error below. } throwWindowsNodeNotFound(options) } -async function windowsNodeMeetsVersionRequirement( +async function windowsNodeToolchainMeetsRequirements( conn: SshConnection, nodePath: string, options?: RemoteNodeResolutionOptions @@ -254,27 +262,19 @@ 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 } + throwIfAborted(options) return false } } -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 +} 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/preload/api-types.ts b/src/preload/api-types.ts index ae21f80ef76..c25c2acbc88 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, @@ -3203,6 +3204,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/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') 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/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/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 798712e9ca2..945c26701db 100644 --- a/src/renderer/src/components/native-chat/NativeChatView.tsx +++ b/src/renderer/src/components/native-chat/NativeChatView.tsx @@ -170,7 +170,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) const previousWorkingEpochRef = useRef(null) // True while a question card owns the input region, so the composer is hidden. 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 95f744baad8..e688c225458 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 @@ -40,6 +40,7 @@ describe('sendNativeChatMessage', () => { vi.useFakeTimers() sendRuntimePtyInput.mockClear() resetNativeChatPtySendQueuesForTests() + sendRuntimePtyInput.mockReturnValue(true) }) afterEach(() => { vi.useRealTimers() @@ -326,6 +327,8 @@ describe('sendNativeChatAskAnswer', () => { beforeEach(() => { vi.useFakeTimers() sendRuntimePtyInput.mockClear() + sendRuntimePtyInput.mockReturnValue(true) + sendRuntimePtyInputVerified.mockReset().mockResolvedValue(true) }) afterEach(() => { vi.useRealTimers() @@ -370,6 +373,41 @@ describe('sendNativeChatAskAnswer', () => { vi.advanceTimersByTime(NATIVE_CHAT_QUESTION_STEP_MS * 2) 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)) + }) }) describe('constants', () => { 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 de0df04763d..33e653d073a 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 @@ -205,26 +205,54 @@ export function submitNativeChatPrompt(settings: RuntimeSettings, ptyId: string) export function sendNativeChatAskAnswer( settings: RuntimeSettings, 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) } }, - settleAfterMs: (groups.length - 1) * NATIVE_CHAT_QUESTION_STEP_MS + NATIVE_CHAT_SUBMIT_DELAY_MS + // Hold the card until the last keystroke has fired and its submit gap passed. + 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/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/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..5533b5f9052 --- /dev/null +++ b/src/renderer/src/components/pet/PetOverlay.pointer-interaction.test.tsx @@ -0,0 +1,172 @@ +// @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 +// 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] +} + +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') + 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 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)).not.toBe(heldName) + + // 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)).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 89fc81dc6da..052ff64750a 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,19 @@ 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, '') + const baseId = useId().replace(/[^a-zA-Z0-9_-]/g, '') const anim = sprite.animations?.[animationName] || (sprite.defaultAnimation && sprite.animations?.[sprite.defaultAnimation]) || @@ -54,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) @@ -63,11 +84,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 +105,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 +349,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 +371,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 +394,12 @@ export function PetOverlay(): React.JSX.Element { >
    {sprite ? ( + // Why: remount per pet so a switched-to sprite starts a fresh + // animation instead of inheriting the prior pet's currentTime. ) : 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..72a0f520f66 100644 --- a/src/renderer/src/components/pet/pet-agent-state.ts +++ b/src/renderer/src/components/pet/pet-agent-state.ts @@ -1,27 +1,48 @@ 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 only on a >=4px horizontal move so slow diagonal drags still accumulate. +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 +68,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..cf694e66bd4 --- /dev/null +++ b/src/renderer/src/components/pet/sprite-animation-css.test.ts @@ -0,0 +1,86 @@ +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], + // 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[], + '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..0970acaa7aa --- /dev/null +++ b/src/renderer/src/components/pet/sprite-animation-css.ts @@ -0,0 +1,101 @@ +// 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 +} + +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 + 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 && ms <= MAX_FRAME_DURATION_MS) + ) { + return frameDurationsMs + } + return null +} + +// Cumulative step-end stops, one per frame. Returns null (→ uniform fallback) +// 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, + 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..92d3a45c6c9 --- /dev/null +++ b/src/renderer/src/components/pet/usePetPointerInteraction.ts @@ -0,0 +1,117 @@ +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: 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) + + // 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/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx b/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx index 3228c54669c..1d7e5d8ec2c 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,33 @@ 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/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/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/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/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/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-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/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/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 d6cd49a0234..f14765dabb1 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": "无法禁用自动合并", @@ -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", @@ -8738,8 +8746,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 +9107,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 +10040,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 +12772,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 +12780,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 账户用户名和密码。" } } }, 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) { 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) }) 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/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 3987fc71f3d..6ae5cfa1c6d 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -748,6 +748,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 +} 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/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/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/keybindings.test.ts b/src/shared/keybindings.test.ts index 4bf8344f499..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( @@ -197,6 +213,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..f250c39848d 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' @@ -111,6 +112,7 @@ export type KeybindingActionId = | 'terminal.closePane' | 'terminal.splitRight' | 'terminal.splitDown' + | 'terminal.switchInputSource' export type KeybindingOverrides = Partial> @@ -146,6 +148,7 @@ export type KeybindingDefinition = { defaultBindings: PlatformBindings allowInTerminal?: boolean allowBareKeybindings?: boolean + allowShiftOnlyKeybindings?: boolean conflictGroup?: string } @@ -179,6 +182,7 @@ type ParsedKeybinding = { type NormalizeKeybindingOptions = { allowBareKeybindings?: boolean + allowShiftOnlyKeybindings?: boolean } export type KeybindingValidationResult = { ok: true; value: string } | { ok: false; error: string } @@ -852,6 +856,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', @@ -1029,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() ] @@ -1380,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.' } } @@ -1446,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 } } @@ -1756,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 }) 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 } diff --git a/src/shared/types.ts b/src/shared/types.ts index 36297481741..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 @@ -3602,6 +3605,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 = { 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) + } + }) +})