From 4b2603420e55ef647c02d9c3360bcd15f856bad8 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 6 Aug 2026 20:07:55 -0700 Subject: [PATCH 001/289] fix(terminal): protect local ConPTY Ctrl+Enter without breaking TUIs (#12462) * fix(terminal): gate Ctrl+Enter CSI-u on a negotiated kitty pane Ctrl+Enter emitted \x1b[13;5u unconditionally, so a pane that never negotiated the kitty keyboard protocol (local Windows ConPTY, plain shell) printed the escape verbatim into the prompt. Mirror the Shift+Enter guard and fall back to the legacy CR every emulator sends for this chord. Keeps the intercept, so IME commit ordering and the single-send dedupe still apply. Fixes #12329 Co-authored-by: Orca * test(e2e): negotiate kitty via PTY output in the Ctrl+Enter spec The Ctrl+Enter gate reads the PTY-output kitty tracker, which enableKittyKeyboardReporting never feeds (it writes straight into xterm's parser), so the spec pressed the chord on a pane the policy still saw as un-negotiated and got the CR fallback. Negotiate from the application side like the neighbouring Shift+Enter spec, and reset the flags afterwards for the serial suite. Co-authored-by: Orca * fix(terminal): preserve trusted Ctrl+Enter routing * fix(terminal): scope IME redispatch ownership * fix(terminal): reject conflicting Ctrl+Enter evidence --------- Co-authored-by: Orca Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- .../ipc/dashboard-payload-validation.test.ts | 3 + src/main/ipc/dashboard-payload-validation.ts | 1 + .../AgentTerminalDialog.test.tsx | 1 + .../preview-terminal-options.test.ts | 2 + .../preview-terminal-shortcuts.test.ts | 41 ++++++++++++ .../preview-terminal-shortcuts.ts | 3 +- .../dashboard-card-terminal-input.test.ts | 33 ++++++++++ .../dashboard-card-terminal-input.ts | 2 + .../terminal-pane/keyboard-handlers.test.ts | 20 ++++++ .../terminal-pane/keyboard-handlers.ts | 65 ++++++++++++++---- .../terminal-pane/terminal-ctrl-enter.test.ts | 66 +++++++++++++++++++ .../terminal-pane/terminal-ctrl-enter.ts | 31 +++++++++ .../terminal-ime-deferred-newline.test.ts | 31 ++++++++- .../terminal-ime-deferred-newline.ts | 56 ++++++++++++---- .../terminal-shortcut-policy.test.ts | 55 +++++++++++----- .../terminal-pane/terminal-shortcut-policy.ts | 18 +++-- src/shared/dashboard-snapshot.ts | 2 + src/shared/tui-agent-config.ts | 8 ++- ...an-ime-terminal-shift-enter-commit.spec.ts | 32 +++++---- tests/e2e/terminal-shortcuts.spec.ts | 22 ++++++- 20 files changed, 429 insertions(+), 63 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/terminal-ctrl-enter.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-ctrl-enter.ts diff --git a/src/main/ipc/dashboard-payload-validation.test.ts b/src/main/ipc/dashboard-payload-validation.test.ts index f377ad3c9de..958fff9e8a3 100644 --- a/src/main/ipc/dashboard-payload-validation.test.ts +++ b/src/main/ipc/dashboard-payload-validation.test.ts @@ -279,6 +279,7 @@ describe('dashboard payload validation', () => { localWindowsConpty: true, osRelease: '10.0.22631', windowsShiftEnterEncoding: 'alt-enter', + ctrlEnterCsiU: false, kittyKeyboardAdvertised: false } expect( @@ -292,6 +293,7 @@ describe('dashboard payload validation', () => { { ...terminalInput, localWindowsConpty: 'true' }, { ...terminalInput, osRelease: 'x'.repeat(1_025) }, { ...terminalInput, windowsShiftEnterEncoding: 'enter' }, + { ...terminalInput, ctrlEnterCsiU: 'true' }, { ...terminalInput, kittyKeyboardAdvertised: 1 } ]) { expect( @@ -314,6 +316,7 @@ describe('dashboard payload validation', () => { hostPlatform: 'plan9', localWindowsConpty: false, windowsShiftEnterEncoding: 'csi-u', + ctrlEnterCsiU: false, kittyKeyboardAdvertised: true } } diff --git a/src/main/ipc/dashboard-payload-validation.ts b/src/main/ipc/dashboard-payload-validation.ts index f1cc0b66b7c..f819392dae1 100644 --- a/src/main/ipc/dashboard-payload-validation.ts +++ b/src/main/ipc/dashboard-payload-validation.ts @@ -307,6 +307,7 @@ function isDashboardTerminalInput(value: unknown): boolean { isOptionalBoundedString(input.osRelease, MAX_LABEL_LENGTH) && typeof input.windowsShiftEnterEncoding === 'string' && WINDOWS_SHIFT_ENTER_ENCODINGS.has(input.windowsShiftEnterEncoding) && + typeof input.ctrlEnterCsiU === 'boolean' && typeof input.kittyKeyboardAdvertised === 'boolean' ) } diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.test.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.test.tsx index e788378a6db..7022d7e5731 100644 --- a/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.test.tsx @@ -36,6 +36,7 @@ const TERMINAL_INPUT: DashboardCardTerminalInput = { localWindowsConpty: true, osRelease: '10.0.22631', windowsShiftEnterEncoding: 'csi-u', + ctrlEnterCsiU: false, kittyKeyboardAdvertised: false } diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-options.test.ts b/src/renderer/src/components/dashboard-popout/preview-terminal-options.test.ts index 790334ace6e..e31e2279c9e 100644 --- a/src/renderer/src/components/dashboard-popout/preview-terminal-options.test.ts +++ b/src/renderer/src/components/dashboard-popout/preview-terminal-options.test.ts @@ -61,6 +61,7 @@ describe('buildPreviewTerminalOptions', () => { hostPlatform: 'darwin', localWindowsConpty: false, windowsShiftEnterEncoding: 'alt-enter', + ctrlEnterCsiU: false, kittyKeyboardAdvertised: true } }) @@ -77,6 +78,7 @@ describe('buildPreviewTerminalOptions', () => { localWindowsConpty: true, osRelease: '10.0.22631', windowsShiftEnterEncoding: 'alt-enter', + ctrlEnterCsiU: false, kittyKeyboardAdvertised: false } }) diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-shortcuts.test.ts b/src/renderer/src/components/dashboard-popout/preview-terminal-shortcuts.test.ts index b76c7c14699..4846f2ee153 100644 --- a/src/renderer/src/components/dashboard-popout/preview-terminal-shortcuts.test.ts +++ b/src/renderer/src/components/dashboard-popout/preview-terminal-shortcuts.test.ts @@ -11,6 +11,7 @@ const LOCAL_MAC: DashboardCardTerminalInput = { hostPlatform: 'darwin', localWindowsConpty: false, windowsShiftEnterEncoding: 'alt-enter', + ctrlEnterCsiU: false, kittyKeyboardAdvertised: true } @@ -73,6 +74,7 @@ describe('resolvePreviewShortcutAction', () => { hostPlatform: 'win32', localWindowsConpty: true, windowsShiftEnterEncoding: 'alt-enter', + ctrlEnterCsiU: false, kittyKeyboardAdvertised: false } }) @@ -94,6 +96,7 @@ describe('resolvePreviewShortcutAction', () => { hostPlatform: 'win32', localWindowsConpty: false, windowsShiftEnterEncoding: 'csi-u', + ctrlEnterCsiU: false, kittyKeyboardAdvertised: true } }) @@ -105,6 +108,44 @@ describe('resolvePreviewShortcutAction', () => { ).toEqual({ type: 'sendInput', data: '\x1b\r' }) }) + it('protects local ConPTY shells while preserving trusted Ctrl+Enter consumers', () => { + const conpty = contextFor({ + clientPlatform: 'win32', + terminalInput: { + hostPlatform: 'win32', + localWindowsConpty: true, + windowsShiftEnterEncoding: 'alt-enter', + ctrlEnterCsiU: false, + kittyKeyboardAdvertised: false + } + }) + expect( + resolvePreviewShortcutAction(keydown({ key: 'Enter', ctrlKey: true }), { + ...conpty, + kittyKeyboardActive: () => true + }) + ).toEqual({ type: 'sendInput', data: '\x1b[13;5u' }) + expect(resolvePreviewShortcutAction(keydown({ key: 'Enter', ctrlKey: true }), conpty)).toEqual({ + type: 'sendInput', + data: '\r' + }) + expect( + resolvePreviewShortcutAction( + keydown({ key: 'Enter', ctrlKey: true }), + contextFor({ + clientPlatform: 'win32', + terminalInput: { + hostPlatform: 'win32', + localWindowsConpty: true, + windowsShiftEnterEncoding: 'alt-enter', + ctrlEnterCsiU: true, + kittyKeyboardAdvertised: false + } + }) + ) + ).toEqual({ type: 'sendInput', data: '\x1b[13;5u' }) + }) + it('reports pane-scoped chords so the caller can swallow them', () => { expect( resolvePreviewShortcutAction(keydown({ key: 'd', code: 'KeyD', metaKey: true }), contextFor()) diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-shortcuts.ts b/src/renderer/src/components/dashboard-popout/preview-terminal-shortcuts.ts index e235c8d4992..1059c6b8b52 100644 --- a/src/renderer/src/components/dashboard-popout/preview-terminal-shortcuts.ts +++ b/src/renderer/src/components/dashboard-popout/preview-terminal-shortcuts.ts @@ -53,6 +53,7 @@ export function resolvePreviewShortcutAction( () => hostPlatform === 'win32', // Why: without it a terminal-first user's remapped tab.close chord — Ctrl+W // is a shell word-kill — reaches the shell in the pane but is swallowed here. - normalizeTerminalShortcutPolicy(context.terminalShortcutPolicy) + normalizeTerminalShortcutPolicy(context.terminalShortcutPolicy), + () => context.terminalInput?.ctrlEnterCsiU === true ) } diff --git a/src/renderer/src/components/dashboard/dashboard-card-terminal-input.test.ts b/src/renderer/src/components/dashboard/dashboard-card-terminal-input.test.ts index d7aad2ce5b4..77ac13c8b15 100644 --- a/src/renderer/src/components/dashboard/dashboard-card-terminal-input.test.ts +++ b/src/renderer/src/components/dashboard/dashboard-card-terminal-input.test.ts @@ -42,6 +42,7 @@ describe('resolveDashboardCardTerminalInput', () => { hostPlatform: 'darwin', localWindowsConpty: false, windowsShiftEnterEncoding: 'alt-enter', + ctrlEnterCsiU: false, kittyKeyboardAdvertised: true }) }) @@ -62,6 +63,37 @@ describe('resolveDashboardCardTerminalInput', () => { expect(profile.kittyKeyboardAdvertised).toBe(true) }) + it('relays trusted Ctrl+Enter authority without coupling it to Shift+Enter', () => { + const droid = resolveDashboardCardTerminalInput( + stateWith({ + paneForegroundAgentByPaneKey: { + [WINDOWS_ARGS.paneKey]: { + agent: 'droid', + routingTrusted: true, + shellForeground: false + } + } + }), + WINDOWS_ARGS + ) + expect(droid.ctrlEnterCsiU).toBe(true) + + const pi = resolveDashboardCardTerminalInput( + stateWith({ + paneForegroundAgentByPaneKey: { + [WINDOWS_ARGS.paneKey]: { + agent: 'pi', + routingTrusted: true, + shellForeground: false + } + } + }), + WINDOWS_ARGS + ) + expect(pi.windowsShiftEnterEncoding).toBe('csi-u') + expect(pi.ctrlEnterCsiU).toBe(false) + }) + // Why: the pty runs Linux inside WSL, so byte protocols must follow it and // not the Windows client — the pane resolves this from its own session cwd. it('treats a WSL shell override as a non-ConPTY Linux-hosted pty', () => { @@ -125,6 +157,7 @@ describe('resolveDashboardCardTerminalInput', () => { hostPlatform: 'darwin', localWindowsConpty: false, windowsShiftEnterEncoding: 'alt-enter', + ctrlEnterCsiU: false, kittyKeyboardAdvertised: true }) }) diff --git a/src/renderer/src/components/dashboard/dashboard-card-terminal-input.ts b/src/renderer/src/components/dashboard/dashboard-card-terminal-input.ts index ab7fc611c75..b1b0f16c3ee 100644 --- a/src/renderer/src/components/dashboard/dashboard-card-terminal-input.ts +++ b/src/renderer/src/components/dashboard/dashboard-card-terminal-input.ts @@ -9,6 +9,7 @@ import { shouldDisableKittyKeyboardForTerminal } from '@/lib/pane-manager/termin import { isLocalNativeWindowsConpty } from '@/lib/pane-manager/windows-pty-compatibility' import { resolveTerminalInputHostPlatform } from '@/components/terminal-pane/terminal-input-host-platform' import { resolveWindowsShiftEnterEncodingForPane } from '@/components/terminal-pane/terminal-windows-shift-enter' +import { hasCtrlEnterCsiUAuthorityForPane } from '@/components/terminal-pane/terminal-ctrl-enter' import { getRemoteRuntimePtyEnvironmentId } from '@/runtime/runtime-terminal-stream' /** Store slices the pane's own input helpers read. */ @@ -122,6 +123,7 @@ export function resolveDashboardCardTerminalInput( localWindowsConpty: isLocalNativeWindowsConpty(windowsPtyContext), ...(args.osRelease === undefined ? {} : { osRelease: args.osRelease }), windowsShiftEnterEncoding: resolveWindowsShiftEnterEncodingForPane(state, args.paneKey), + ctrlEnterCsiU: hasCtrlEnterCsiUAuthorityForPane(state, args.paneKey), kittyKeyboardAdvertised: !shouldDisableKittyKeyboardForTerminal({ ...windowsPtyContext, // Why: launch identity is the only agent signal the board holds; it opts diff --git a/src/renderer/src/components/terminal-pane/keyboard-handlers.test.ts b/src/renderer/src/components/terminal-pane/keyboard-handlers.test.ts index e2d0dfca565..0e5e3d33ff8 100644 --- a/src/renderer/src/components/terminal-pane/keyboard-handlers.test.ts +++ b/src/renderer/src/components/terminal-pane/keyboard-handlers.test.ts @@ -110,6 +110,26 @@ describe('resolveTerminalKeyboardShortcutAction', () => { ) ).toEqual({ type: 'sendInput', data: '\x1b\r' }) }) + + it('forwards trusted Ctrl+Enter authority to the shared policy', () => { + expect( + resolveTerminalKeyboardShortcutAction( + makeKeyEvent({ key: 'Enter', ctrlKey: true }), + false, + 'false', + 0, + true, + undefined, + () => true, + () => false, + undefined, + () => 'alt-enter', + () => true, + 'orca-first', + () => true + ) + ).toEqual({ type: 'sendInput', data: '\x1b[13;5u' }) + }) }) describe('runTerminalSearchNavigation', () => { diff --git a/src/renderer/src/components/terminal-pane/keyboard-handlers.ts b/src/renderer/src/components/terminal-pane/keyboard-handlers.ts index 6519c5f8655..6580bbb81b8 100644 --- a/src/renderer/src/components/terminal-pane/keyboard-handlers.ts +++ b/src/renderer/src/components/terminal-pane/keyboard-handlers.ts @@ -46,6 +46,7 @@ import { copyTerminalSelection } from './terminal-selection-copy' import { isLocalWindowsConptyPaneForCtrlArrow } from './terminal-ctrl-arrow-conpty' import { makePaneKey } from '../../../../shared/stable-pane-id' import { resolveWindowsShiftEnterEncodingForPane } from './terminal-windows-shift-enter' +import { hasCtrlEnterCsiUAuthorityForPane } from './terminal-ctrl-enter' import { resolveTerminalInputHostPlatform } from './terminal-input-host-platform' import { markTerminalFollowOutput, @@ -65,7 +66,8 @@ export function resolveTerminalKeyboardShortcutAction( layoutBaseCharacterForCode: Parameters[8], getWindowsShiftEnterEncoding: Parameters[9], isWindowsTerminalHost: NonNullable[10]>, - terminalShortcutPolicy: Parameters[11] = 'orca-first' + terminalShortcutPolicy: Parameters[11] = 'orca-first', + hasCtrlEnterCsiUAuthority?: Parameters[12] ): ReturnType { // Why: keep the host callback required at the production boundary so a // caller cannot silently fall back to client-OS byte routing. @@ -81,7 +83,8 @@ export function resolveTerminalKeyboardShortcutAction( layoutBaseCharacterForCode, getWindowsShiftEnterEncoding, isWindowsTerminalHost, - terminalShortcutPolicy + terminalShortcutPolicy, + hasCtrlEnterCsiUAuthority ) } @@ -281,6 +284,7 @@ export function useTerminalKeyboardShortcuts({ // location from its own keydown event and clear it on keyup. let optionKeyLocation = 0 const heldImeEnterModifiers = new Set<'shift' | 'ctrl'>() + const terminalImeEnterModifierKeydowns = new Set<'shift' | 'ctrl'>() const nativeOnlyShortcutTracker = createTerminalNativeOnlyShortcutTracker() const deferredNewlineSender = createTerminalImeDeferredNewlineSender() const modifiedEnterChordOwner = createTerminalImeModifiedEnterChordOwner() @@ -334,12 +338,14 @@ export function useTerminalKeyboardShortcuts({ (!keyboardScope || keyboardEventBelongsToScope(e, keyboardScope)) && !isEditableTarget(e.target) ) { - heldImeEnterModifiers.add(e.key === 'Shift' ? 'shift' : 'ctrl') + const kind = e.key === 'Shift' ? 'shift' : 'ctrl' + heldImeEnterModifiers.add(kind) + terminalImeEnterModifierKeydowns.add(kind) } } } - // Why: foreground proof and Ctrl+Arrow translation are local ConPTY-only; + // Why: modified-Enter trust and Ctrl+Arrow translation are local ConPTY-only; // resolve lazily so session/runtime lookups stay off ordinary keystrokes. const isLocalWindowsConptyPane = (): boolean => { const manager = managerRef.current @@ -407,6 +413,22 @@ export function useTerminalKeyboardShortcuts({ return (paneKittyKeyboardModesRef?.current.get(activePane.id)?.flags ?? 0) > 0 } + const hasActivePaneCtrlEnterCsiUAuthority = (): boolean => { + const manager = managerRef.current + const activePane = manager?.getActivePane() ?? manager?.getPanes()[0] + if (!activePane) { + return false + } + const state = useAppStore.getState() + return hasCtrlEnterCsiUAuthorityForPane( + state, + makePaneKey(tabId, activePane.leafId), + isLocalWindowsConptyPane() + ? state.runtimePaneTitlesByTabId[tabId]?.[activePane.id] + : undefined + ) + } + const resolveShortcutEvent = ( event: Parameters[0] ): ReturnType => @@ -422,7 +444,8 @@ export function useTerminalKeyboardShortcuts({ getLayoutBaseCharacterForCode, getActivePaneWindowsShiftEnterEncoding, isActivePaneWindowsTerminalHost, - terminalShortcutPolicy + terminalShortcutPolicy, + hasActivePaneCtrlEnterCsiUAuthority ) const createCapturedInputSender = ( @@ -492,7 +515,8 @@ export function useTerminalKeyboardShortcuts({ e.key === 'Enter' && e.keyCode === 13 && !e.isComposing && - ((modifiedEnterChord && modifiedEnterChordOwner.absorb(modifiedEnterChord)) || + (modifiedEnterChordOwner.ownsRedispatchedEnter() || + (modifiedEnterChord && modifiedEnterChordOwner.absorb(modifiedEnterChord)) || deferredNewlineSender.absorbRedispatchedEnter(e)) ) { // Chromium can drop the modifier when re-dispatching the committing Enter. @@ -592,7 +616,13 @@ export function useTerminalKeyboardShortcuts({ if ((e.isComposing || hasPendingImeComposition) && (e.key === 'Enter' || imeProcessEnter)) { if (isWindows) { const chord = getModifiedEnterChord(e) - if (chord && !modifiedEnterChordOwner.claim(chord)) { + const claimedChord = chord + ? { + ...chord, + terminalModifierKeyDownObserved: terminalImeEnterModifierKeydowns.has(chord.kind) + } + : null + if (claimedChord && !modifiedEnterChordOwner.claim(claimedChord)) { return } } @@ -804,6 +834,7 @@ export function useTerminalKeyboardShortcuts({ if (releasedImeEnterModifier) { const kind = releasedImeEnterModifier heldImeEnterModifiers.delete(kind) + terminalImeEnterModifierKeydowns.delete(kind) modifiedEnterChordOwner.release({ kind, code: e.code, timeStamp: e.timeStamp }) } if (e.key !== 'Enter') { @@ -819,20 +850,26 @@ export function useTerminalKeyboardShortcuts({ observedEnterKeydownTimeStamps.delete(e.code) } } - const modifiedEnterKind = getImeEnterModifier(e) - if (isWindows && modifiedEnterKind && isTerminalImeEnterKeyUp(e)) { - const chord = { kind: modifiedEnterKind, code: e.code, timeStamp: e.timeStamp } - if (modifiedEnterChordOwner.absorb(chord)) { - modifiedEnterChordOwner.release(chord) + if (isWindows && isTerminalImeEnterKeyUp(e)) { + const originatingChord = modifiedEnterChordOwner.releaseForEnterKeyUp() + if (originatingChord) { e.preventDefault() e.stopImmediatePropagation() - deferredNewlineSender.releaseRedispatchedEnter(e) + const modifierStillMatches = getImeEnterModifier(e) === originatingChord.kind + deferredNewlineSender.releaseRedispatchedEnter( + e, + modifierStillMatches || originatingChord.terminalModifierKeyDownObserved + ? originatingChord + : undefined + ) return } + const modifiedEnterKind = getImeEnterModifier(e) const manager = managerRef.current const keyboardScope = keyboardScopeRef.current if ( + modifiedEnterKind && // An observed keydown makes this modifier state rollover, not a swallowed chord. !enterKeydownWasObserved && manager && @@ -864,6 +901,7 @@ export function useTerminalKeyboardShortcuts({ } } + const modifiedEnterKind = getImeEnterModifier(e) if (modifiedEnterKind) { modifiedEnterChordOwner.release({ kind: modifiedEnterKind, @@ -898,6 +936,7 @@ export function useTerminalKeyboardShortcuts({ const onNativeOnlyBlur = (): void => { nativeOnlyShortcutTracker.clear() heldImeEnterModifiers.clear() + terminalImeEnterModifierKeydowns.clear() modifiedEnterChordOwner.clear() deferredNewlineSender.clearRedispatchedEnters() observedEnterKeydownTimeStamps.clear() diff --git a/src/renderer/src/components/terminal-pane/terminal-ctrl-enter.test.ts b/src/renderer/src/components/terminal-pane/terminal-ctrl-enter.test.ts new file mode 100644 index 00000000000..27e0cce41bc --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-ctrl-enter.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import { hasCtrlEnterCsiUAuthorityForPane } from './terminal-ctrl-enter' + +const PANE_KEY = 'tab:pane' + +describe('hasCtrlEnterCsiUAuthorityForPane', () => { + it('authorizes only trusted Ctrl+Enter CSI-u consumers', () => { + for (const agent of ['droid', 'grok'] as const) { + expect( + hasCtrlEnterCsiUAuthorityForPane( + { + paneForegroundAgentByPaneKey: { + [PANE_KEY]: { agent, routingTrusted: true, shellForeground: false } + } + }, + PANE_KEY + ) + ).toBe(true) + } + expect( + hasCtrlEnterCsiUAuthorityForPane( + { + paneForegroundAgentByPaneKey: { + [PANE_KEY]: { agent: 'pi', routingTrusted: true, shellForeground: false } + } + }, + PANE_KEY + ) + ).toBe(false) + }) + + it('uses strict titles only through unrevoked trust gaps', () => { + const state = { + paneForegroundAgentByPaneKey: { + [PANE_KEY]: { agent: 'droid' as const, shellForeground: false } + } + } + expect(hasCtrlEnterCsiUAuthorityForPane(state, PANE_KEY, '⠋ Droid')).toBe(true) + expect(hasCtrlEnterCsiUAuthorityForPane(state, PANE_KEY, 'C:\\work\\grok-project')).toBe(false) + expect( + hasCtrlEnterCsiUAuthorityForPane( + { + paneForegroundAgentByPaneKey: { + [PANE_KEY]: { agent: 'pi', shellForeground: false } + } + }, + PANE_KEY, + 'Droid' + ) + ).toBe(false) + + for (const foreground of [ + { agent: 'grok' as const, routingRevoked: true, shellForeground: false }, + { agent: null, shellForeground: true }, + { agent: 'droid' as const, routingTrusted: true, shellForeground: true } + ]) { + expect( + hasCtrlEnterCsiUAuthorityForPane( + { paneForegroundAgentByPaneKey: { [PANE_KEY]: foreground } }, + PANE_KEY, + 'Grok' + ) + ).toBe(false) + } + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-ctrl-enter.ts b/src/renderer/src/components/terminal-pane/terminal-ctrl-enter.ts new file mode 100644 index 00000000000..457a1f51beb --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-ctrl-enter.ts @@ -0,0 +1,31 @@ +import { TUI_AGENT_CONFIG } from '../../../../shared/tui-agent-config' +import { resolveCommittedTitleAgentType } from '../../lib/pane-agent-evidence' +import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent' + +type CtrlEnterPaneState = { + paneForegroundAgentByPaneKey: Record +} + +function agentAcceptsCtrlEnterCsiU(agent: PaneForegroundAgentEntry['agent']): boolean { + return agent !== null && TUI_AGENT_CONFIG[agent].ctrlEnterEncoding === 'csi-u' +} + +/** Resolves pane-scoped authority for query-only CSI-u consumers such as Droid and Grok. */ +export function hasCtrlEnterCsiUAuthorityForPane( + state: CtrlEnterPaneState, + paneKey: string, + terminalTitle?: string +): boolean { + const foreground = state.paneForegroundAgentByPaneKey[paneKey] + if (foreground?.shellForeground === true || foreground?.routingRevoked === true) { + return false + } + if (foreground?.routingTrusted === true) { + return agentAcceptsCtrlEnterCsiU(foreground.agent) + } + const titleAgent = terminalTitle ? resolveCommittedTitleAgentType(terminalTitle) : null + if (foreground?.agent != null && foreground.agent !== titleAgent) { + return false + } + return agentAcceptsCtrlEnterCsiU(titleAgent) +} diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-deferred-newline.test.ts b/src/renderer/src/components/terminal-pane/terminal-ime-deferred-newline.test.ts index 12f917c8018..d1ef2b882eb 100644 --- a/src/renderer/src/components/terminal-pane/terminal-ime-deferred-newline.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-ime-deferred-newline.test.ts @@ -180,6 +180,17 @@ describe('createTerminalImeDeferredNewlineSender', () => { expect(sender.absorbRedispatchedEnter(enter(10))).toBe(true) }) + it('moves a credit to a balancing keyup with the redispatch timestamp', () => { + const el = document.createElement('div') + const sender = createSender() + + sender.defer(enter(10), el, vi.fn()) + sender.releaseRedispatchedEnter(enter(20), enter(10)) + + expect(sender.absorbRedispatchedEnter(enter(20))).toBe(true) + expect(sender.absorbRedispatchedEnter(enter(10))).toBe(false) + }) + it('releases an unused credit on a later physical keyup', () => { const el = document.createElement('div') const sender = createSender() @@ -289,6 +300,22 @@ describe('createTerminalImeModifiedEnterChordOwner', () => { expect(defer).toHaveBeenCalledTimes(1) expect(owner.absorb(chord('shift', 5037.9, 'Enter'))).toBe(true) + expect(owner.ownsRedispatchedEnter()).toBe(false) + }) + + it('owns a modifier-lost redispatch only after a terminal modifier keydown', () => { + const owner = createTerminalImeModifiedEnterChordOwner() + + expect(owner.claim({ ...chord('shift', 10), terminalModifierKeyDownObserved: true })).toBe(true) + expect(owner.ownsRedispatchedEnter()).toBe(true) + }) + + it('releases a claimed chord when the balancing Enter keyup loses its modifier', () => { + const owner = createTerminalImeModifiedEnterChordOwner() + + expect(owner.claim(chord('shift', 10))).toBe(true) + expect(owner.releaseForEnterKeyUp()).toEqual(chord('shift', 10)) + expect(owner.ownsRedispatchedEnter()).toBe(false) }) it('does not merge different modified Enter kinds into one chord', () => { @@ -303,7 +330,7 @@ describe('createTerminalImeModifiedEnterChordOwner', () => { const owner = createTerminalImeModifiedEnterChordOwner() expect(owner.claim(chord('ctrl', 10))).toBe(true) - owner.release(chord('ctrl', 30, 'Enter')) + expect(owner.release(chord('ctrl', 30, 'Enter'))).toEqual(chord('ctrl', 10)) expect(owner.absorb(chord('ctrl', 40, 'Enter'))).toBe(false) expect(owner.claim(chord('ctrl', 40, 'Enter'))).toBe(true) @@ -313,7 +340,7 @@ describe('createTerminalImeModifiedEnterChordOwner', () => { const owner = createTerminalImeModifiedEnterChordOwner() expect(owner.claim(chord('shift', 10))).toBe(true) - owner.release(chord('ctrl', 20)) + expect(owner.release(chord('ctrl', 20))).toBeNull() expect(owner.absorb(chord('shift', 30))).toBe(true) owner.clear() diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-deferred-newline.ts b/src/renderer/src/components/terminal-pane/terminal-ime-deferred-newline.ts index 5c79bd6419a..2fd141fe3ce 100644 --- a/src/renderer/src/components/terminal-pane/terminal-ime-deferred-newline.ts +++ b/src/renderer/src/components/terminal-pane/terminal-ime-deferred-newline.ts @@ -52,7 +52,10 @@ export type TerminalImeDeferredNewlineSender = { send: () => void ) => void absorbRedispatchedEnter: (enter: TerminalImeEnterIdentity) => boolean - releaseRedispatchedEnter: (enter: TerminalImeEnterIdentity) => void + releaseRedispatchedEnter: ( + enter: TerminalImeEnterIdentity, + originatingEnter?: TerminalImeEnterIdentity + ) => void clearRedispatchedEnters: () => void } @@ -62,34 +65,46 @@ export type TerminalImeModifiedEnterKind = 'shift' | 'ctrl' export type TerminalImeModifiedEnterChord = TerminalImeEnterIdentity & { kind: TerminalImeModifiedEnterKind + terminalModifierKeyDownObserved?: boolean } export type TerminalImeModifiedEnterChordOwner = { claim: (chord: TerminalImeModifiedEnterChord) => boolean absorb: (chord: TerminalImeModifiedEnterChord) => boolean - release: (chord: TerminalImeModifiedEnterChord) => void + ownsRedispatchedEnter: () => boolean + release: (chord: TerminalImeModifiedEnterChord) => TerminalImeModifiedEnterChord | null + releaseForEnterKeyUp: () => TerminalImeModifiedEnterChord | null clear: () => void } export function createTerminalImeModifiedEnterChordOwner(): TerminalImeModifiedEnterChordOwner { - let activeKind: TerminalImeModifiedEnterKind | null = null + let activeChord: TerminalImeModifiedEnterChord | null = null return { - claim: ({ kind }) => { - if (activeKind !== null) { + claim: (chord) => { + if (activeChord !== null) { return false } - activeKind = kind + activeChord = chord return true }, - absorb: ({ kind }) => activeKind === kind, + absorb: ({ kind }) => activeChord?.kind === kind, + ownsRedispatchedEnter: () => activeChord?.terminalModifierKeyDownObserved === true, release: ({ kind }) => { - if (activeKind === kind) { - activeKind = null + if (activeChord?.kind === kind) { + const releasedChord = activeChord + activeChord = null + return releasedChord } + return null + }, + releaseForEnterKeyUp: () => { + const releasedChord = activeChord + activeChord = null + return releasedChord }, clear: () => { - activeKind = null + activeChord = null } } } @@ -184,7 +199,26 @@ export function createTerminalImeDeferredNewlineSender(): TerminalImeDeferredNew cleanUpIfSettled(enter, state) return true }, - releaseRedispatchedEnter: (enter) => { + releaseRedispatchedEnter: (enter, originatingEnter) => { + if (originatingEnter) { + const originatingState = statesByEnterCode + .get(originatingEnter.code) + ?.get(originatingEnter.timeStamp) + if (originatingState && originatingState.absorbCredits > 0) { + originatingState.absorbCredits -= 1 + cleanUpIfSettled(originatingEnter, originatingState) + + const statesByTimeStamp = statesByEnterCode.get(enter.code) ?? new Map() + const state = statesByTimeStamp.get(enter.timeStamp) ?? { + inFlightSends: 0, + absorbCredits: 0 + } + state.absorbCredits += 1 + statesByTimeStamp.set(enter.timeStamp, state) + statesByEnterCode.set(enter.code, statesByTimeStamp) + return + } + } if (statesByEnterCode.get(enter.code)?.has(enter.timeStamp)) { // Chromium's balancing Process-key keyup is copied from the same native event. return 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 2736d5e0797..eec4a3cdd22 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 @@ -270,27 +270,50 @@ describe('resolveTerminalShortcutAction', () => { expect(getWindowsShiftEnterEncoding).toHaveBeenCalledTimes(2) }) - it('forwards Ctrl+Enter as the kitty CSI-u chord so TUIs can cue instead of send', () => { - // Why: xterm.js collapses Ctrl+Enter to a bare CR; intercept upstream and - // emit the kitty sequence (modifier code 5 = Ctrl) so probing TUIs receive - // the distinct chord on every platform. - expect( - resolveTerminalShortcutAction(event({ key: 'Enter', code: 'Enter', ctrlKey: true }), true) - ).toEqual({ type: 'sendInput', data: '\x1b[13;5u' }) - expect( - resolveTerminalShortcutAction(event({ key: 'Enter', code: 'Enter', ctrlKey: true }), false) - ).toEqual({ type: 'sendInput', data: '\x1b[13;5u' }) - // Windows uses the same kitty sequence for now: no TUI is known to treat the - // CSI-u Ctrl+Enter form as inert (cf. the Shift+Enter Codex-on-PowerShell case). - expect( - resolveTerminalShortcutAction( + it('protects local ConPTY shells without regressing query-only Ctrl+Enter consumers', () => { + const getWindowsShiftEnterEncoding = vi.fn(() => 'csi-u' as const) + const isLocalWindowsConptyPane = vi.fn(() => true) + const isKittyKeyboardActivePane = vi.fn(() => false) + const hasCtrlEnterCsiUAuthority = vi.fn(() => false) + const csiU = { type: 'sendInput', data: '\x1b[13;5u' } + const legacyCr = { type: 'sendInput', data: '\r' } + const resolveCtrlEnter = ( + localConpty: boolean, + kittyActive: boolean, + trustedConsumer: boolean + ) => { + isLocalWindowsConptyPane.mockReturnValue(localConpty) + isKittyKeyboardActivePane.mockReturnValue(kittyActive) + hasCtrlEnterCsiUAuthority.mockReturnValue(trustedConsumer) + return resolveTerminalShortcutAction( event({ key: 'Enter', code: 'Enter', ctrlKey: true }), false, 'false', 0, - true + true, + undefined, + isLocalWindowsConptyPane, + isKittyKeyboardActivePane, + undefined, + getWindowsShiftEnterEncoding, + () => true, + 'orca-first', + hasCtrlEnterCsiUAuthority ) - ).toEqual({ type: 'sendInput', data: '\x1b[13;5u' }) + } + + expect(resolveCtrlEnter(true, false, false)).toEqual(legacyCr) + expect(resolveCtrlEnter(true, true, false)).toEqual(csiU) + expect(resolveCtrlEnter(true, false, true)).toEqual(csiU) + hasCtrlEnterCsiUAuthority.mockClear() + expect(resolveCtrlEnter(false, false, false)).toEqual(csiU) + expect(hasCtrlEnterCsiUAuthority).not.toHaveBeenCalled() + expect(getWindowsShiftEnterEncoding).not.toHaveBeenCalled() + + // Missing pane-host context preserves the established Droid/Grok chord. + expect( + resolveTerminalShortcutAction(event({ key: 'Enter', code: 'Enter', ctrlKey: true }), false) + ).toEqual(csiU) // Modifier combos that are NOT plain Ctrl+Enter must keep falling through. expect( 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 57cc3eb4de6..6ab00a13806 100644 --- a/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts +++ b/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts @@ -96,7 +96,7 @@ export function resolveTerminalShortcutAction( optionKeyLocation: number = 0, isWindows: boolean = false, keybindings?: KeybindingOverrides, - // Why: lazy so execution-host lookup (local native Windows ConPTY) runs only on Ctrl+Arrow, not every keystroke. + // Why: lazy so local ConPTY lookup runs only for Ctrl+Arrow and Ctrl+Enter. isLocalWindowsConptyPane?: () => boolean, // Why: gates Option-as-Alt compensation on the app's own kitty-protocol (CSI > u) opt-in, so shells keep composition. isKittyKeyboardActivePane?: () => boolean, @@ -107,7 +107,9 @@ export function resolveTerminalShortcutAction( // Why: keybindings follow the client OS, but byte protocols follow the PTY host — they differ for macOS clients on Windows runtimes. isWindowsTerminalHost: () => boolean = () => isWindows, // Why: gates the tab.close pane-close alias — under terminal-first a remapped tab.close yields to the shell (terminal.closePane, scope terminal, still closes). - terminalShortcutPolicy: TerminalShortcutPolicy = 'orca-first' + terminalShortcutPolicy: TerminalShortcutPolicy = 'orca-first', + // Why: query-only Droid/Grok consumers need CSI-u even when the live kitty flags remain inactive. + hasCtrlEnterCsiUAuthority?: () => boolean ): TerminalShortcutAction | null { const platform: NodeJS.Platform = isMac ? 'darwin' : isWindows ? 'win32' : 'linux' @@ -195,8 +197,16 @@ export function resolveTerminalShortcutAction( !event.shiftKey && event.key === 'Enter' ) { - // Why: xterm.js collapses Ctrl+Enter to a bare CR, so forward kitty CSI-u (modifier 5 = Ctrl) so the chord reaches TUIs; no Windows fallback yet (#2418). - return { type: 'sendInput', data: '\x1b[13;5u' } + const localWindowsConpty = isLocalWindowsConptyPane?.() === true + // Why: preserve query-only TUI chords elsewhere; local ConPTY shells require negotiation or trusted consumer evidence (#12329). + const canSendCsiU = + !localWindowsConpty || + isKittyKeyboardActivePane?.() === true || + hasCtrlEnterCsiUAuthority?.() === true + return { + type: 'sendInput', + data: canSendCsiU ? '\x1b[13;5u' : '\r' + } } if ( diff --git a/src/shared/dashboard-snapshot.ts b/src/shared/dashboard-snapshot.ts index 4f563886774..9367393118e 100644 --- a/src/shared/dashboard-snapshot.ts +++ b/src/shared/dashboard-snapshot.ts @@ -154,6 +154,8 @@ export type DashboardCardTerminalInput = { osRelease?: string /** Shift+Enter encoding resolved from this pane's agent evidence. */ windowsShiftEnterEncoding: 'alt-enter' | 'csi-u' + /** Trusted query-only consumer accepts Ctrl+Enter CSI-u without active flags. */ + ctrlEnterCsiU: boolean /** False withholds the kitty (CSI-u) advertisement, as ConPTY panes do. */ kittyKeyboardAdvertised: boolean } diff --git a/src/shared/tui-agent-config.ts b/src/shared/tui-agent-config.ts index ffaa334fda9..e3b855a4c9e 100644 --- a/src/shared/tui-agent-config.ts +++ b/src/shared/tui-agent-config.ts @@ -41,6 +41,8 @@ export type TuiAgentConfig = { draftPasteReadySignal?: DraftPasteReadySignal /** Windows Shift+Enter encoding override; omitted agents keep the legacy Esc+CR path. */ windowsShiftEnterEncoding?: 'csi-u' + /** Ctrl+Enter encoding for agents that consume CSI-u without active kitty flags. */ + ctrlEnterEncoding?: 'csi-u' } export const TUI_AGENT_CONFIG: Record = { @@ -239,7 +241,8 @@ export const TUI_AGENT_CONFIG: Record = { expectedProcess: 'droid', promptInjectionMode: 'argv', // Why: Droid decodes CSI-u on Windows; the legacy Esc+CR fallback reads as Enter and submits instead of newline. - windowsShiftEnterEncoding: 'csi-u' + windowsShiftEnterEncoding: 'csi-u', + ctrlEnterEncoding: 'csi-u' }, kimi: { detectCmd: 'kimi', @@ -298,7 +301,8 @@ export const TUI_AGENT_CONFIG: Record = { // Why: argv (grok takes a positional prompt) so multi-line/special-char text isn't mangled as raw PTY keystrokes. promptInjectionMode: 'argv', // Why: separator so prompts like `help`/`--version` aren't parsed as Grok CLI syntax. - argvPromptSeparator: '--' + argvPromptSeparator: '--', + ctrlEnterEncoding: 'csi-u' }, devin: { detectCmd: 'devin', diff --git a/tests/e2e/korean-ime-terminal-shift-enter-commit.spec.ts b/tests/e2e/korean-ime-terminal-shift-enter-commit.spec.ts index 4c66fea3c5c..208e79a8928 100644 --- a/tests/e2e/korean-ime-terminal-shift-enter-commit.spec.ts +++ b/tests/e2e/korean-ime-terminal-shift-enter-commit.spec.ts @@ -380,23 +380,33 @@ async function assertShiftOutcome(page: Page): Promise { } async function assertCtrlOutcome(page: Page): Promise { + if (process.platform !== 'win32') { + await expect + .poll(() => readReceived(page), { + timeout: 10_000, + message: 'PTY bytes must contain committed Hangul before exactly one Ctrl+Enter chord' + }) + .toBe('하 하 하\u001b[13;5u') + expect(await readSubmitted(page), 'CSI-u must not submit the line').toEqual([]) + return + } + await expect .poll(() => readReceived(page), { timeout: 10_000, message: 'PTY bytes must contain committed Hangul before exactly one Ctrl+Enter chord' }) - .toBe('하 하 하\u001b[13;5u') + .toBe('하 하 하\r') await expect .poll(() => readPromptLine(page), { timeout: 10_000, - message: 'prompt must show the committed syllables followed by exactly one CSI-u chord' + message: 'prompt must be empty — no literal escape bytes may survive the chord' }) - .toBe('하 하 하[13;5u') + .toBe('') await page.waitForTimeout(500) - expect(await readPromptLine(page), 'Ctrl+Enter must produce exactly one CSI-u chord').toBe( - '하 하 하[13;5u' - ) - expect(await readSubmitted(page), 'CSI-u must not submit the line').toEqual([]) + expect(await readSubmitted(page), 'Ctrl+Enter must produce exactly one newline').toEqual([ + '하 하 하' + ]) } const COMMITTING_ENTER_CHORDS: CommittingEnterChordCase[] = [ @@ -416,8 +426,8 @@ const COMMITTING_ENTER_CHORDS: CommittingEnterChordCase[] = [ modifiers: 2, assertOutcome: assertCtrlOutcome, expectedAfterPlainEnter: { - received: '하 하 하\u001b[13;5u\r', - submitted: ['하 하 하\u001b[13;5u'] + received: process.platform === 'win32' ? '하 하 하\r\r' : '하 하 하\u001b[13;5u\r', + submitted: process.platform === 'win32' ? ['하 하 하', ''] : ['하 하 하\u001b[13;5u'] } }, { @@ -455,8 +465,8 @@ const COMMITTING_ENTER_CHORDS: CommittingEnterChordCase[] = [ windowsOnly: true, assertOutcome: assertCtrlOutcome, expectedAfterPlainEnter: { - received: '하 하 하\u001b[13;5u\r', - submitted: ['하 하 하\u001b[13;5u'] + received: '하 하 하\r\r', + submitted: ['하 하 하', ''] } } ] diff --git a/tests/e2e/terminal-shortcuts.spec.ts b/tests/e2e/terminal-shortcuts.spec.ts index 5c921376ca5..10114b30cb0 100644 --- a/tests/e2e/terminal-shortcuts.spec.ts +++ b/tests/e2e/terminal-shortcuts.spec.ts @@ -526,13 +526,29 @@ test.describe('Terminal Shortcuts', () => { await pressAndExpectWrite(orcaPage, electronApp, 'Control+Alt+;', '\x1b;') }) - test('Ctrl+Enter writes the kitty modified-enter chord for terminal TUIs', async ({ + test('Ctrl+Enter protects local ConPTY shells without breaking trusted TUI chords', async ({ orcaPage, electronApp }) => { await installMainProcessPtyWriteSpy(electronApp) await waitForActivePanePtyId(orcaPage) + if (process.platform === 'win32') { + await pressAndExpectWrite(orcaPage, electronApp, 'Control+Enter', '\r') + const paneKey = await setActivePaneForegroundAgent(orcaPage, 'droid') + try { + // Droid queries CSI-u without activating live flags; trusted process evidence preserves cue/queue. + await pressAndExpectWrite(orcaPage, electronApp, 'Control+Enter', '\x1b[13;5u') + } finally { + await orcaPage.evaluate( + (key) => window.__store?.getState().clearPaneForegroundAgent(key), + paneKey + ) + } + return + } + + // Preserve the established query-only Droid/Grok contract outside local ConPTY. await pressAndExpectWrite(orcaPage, electronApp, 'Control+Enter', '\x1b[13;5u') }) @@ -730,8 +746,8 @@ test.describe('Terminal Shortcuts', () => { await pressAndExpectWrite(orcaPage, electronApp, 'Alt+ArrowRight', '\x1bf') // Ctrl+←/→ on non-mac → readline backward-word / forward-word (\eb / \ef). - // Mac-gated: Ctrl+Arrow on macOS is reserved for Mission Control / Spaces. - if (!isMac) { + // macOS reserves Ctrl+Arrow; Windows ConPTY leaves it to PSReadLine. + if (!isMac && process.platform !== 'win32') { await pressAndExpectWrite(orcaPage, electronApp, 'Control+ArrowLeft', '\x1bb') await pressAndExpectWrite(orcaPage, electronApp, 'Control+ArrowRight', '\x1bf') } From 87d768058c27ab9db395da1fae301ec4facadcf1 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:18:41 -0700 Subject: [PATCH 002/289] fix(terminal): recover stale persisted owner bindings (#12976) Co-authored-by: Jinwoo-H --- config/reliability-gates.jsonc | 85 ++++++-- src/main/daemon/daemon-pty-router.test.ts | 14 +- .../daemon-session-owner-resolution.test.ts | 184 +++++++++++++++--- .../daemon/daemon-session-owner-resolution.ts | 6 +- .../degraded-daemon-pty-provider.test.ts | 77 +++++++- .../daemon/degraded-daemon-pty-provider.ts | 4 + 6 files changed, 317 insertions(+), 53 deletions(-) diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 17cfd44cc18..33945cfe8c2 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -5319,7 +5319,7 @@ "id": "terminal-provider.daemon-startup-degraded-contract", "title": "Daemon startup reconcile and degraded fallback preserve provider identity", "maturity": "experimental", - "protection": "none", + "protection": "partial", "owner": "terminal-provider", "layer": "provider-contract", "surfaces": [ @@ -5327,26 +5327,75 @@ "degraded daemon", "fallback PTY", "provider ownership", - "startup restore" + "startup restore", + "stable-pane reopen" ], "platforms": ["macos", "linux", "windows"], "providers": ["daemon", "local"], - "coveredPlatforms": [], - "coveredProviders": [], - "coverageNotes": "Registered gap on main. The fail-closed degraded-daemon hardening and its contracts exist only on the pending reliability stack. It registers here with its owning split PR.", + "coveredPlatforms": ["macos"], + "coveredProviders": ["daemon", "local"], + "coverageNotes": "Deterministic provider contracts cover complete, incomplete, conflicting, refused, and identity-invalidated owner inventories; degraded fallback/current/legacy routing; repeated stale-binding classification; and fresh-session reattach without local fallback during unresolved ownership. Main IPC contracts cover exact persisted-binding retirement and one fresh replacement after confirmed absence. Live Linux, Windows, WSL, SSH, paired-runtime, and packaged-upgrade journeys remain uncollected.", "motivatingLinks": [ "https://github.com/stablyai/orca/pull/6830", "https://github.com/stablyai/orca/pull/6866", - "https://github.com/stablyai/orca/pull/7002" + "https://github.com/stablyai/orca/pull/7002", + "https://github.com/stablyai/orca/pull/12776" + ], + "invariant": "Daemon startup reconciliation must preserve valid live daemon sessions and reap only true orphans. A persisted binding stays fail-closed while any possible owner is incomplete, conflicting, refusing attach, or identity-invalidated, but complete authoritative absence from every configured provider must retire that exact stale binding and let retry or reopen converge without attaching it through local fallback or duplicating a PTY.", + "oracle": "Hold exact fallback, current-daemon, and legacy-daemon inventories behind controlled promises. No attach may settle or dispatch until every provider answers. Complete zero-candidate inventories must report SessionNotFound; incomplete, duplicate, refused, and identity-invalidated attempts must remain owner-unverified. In degraded mode, repeated stale attempts scan each eligible provider once per attempt and dispatch no attach, then one explicit fresh spawn and exact reattach use only the recorded fresh route. Main IPC must compare-and-swap retire only the matching persisted PTY/incarnation, emit one synthetic exit, and create one different fresh PTY.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/daemon-session-owner-resolution.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/main/ipc/pty.test.ts --reporter=dot" + ], + "testFiles": [ + "src/main/daemon/daemon-session-owner-resolution.test.ts", + "src/main/daemon/daemon-pty-router.test.ts", + "src/main/daemon/degraded-daemon-pty-provider.test.ts", + "src/main/ipc/pty.test.ts" + ], + "assertionRefs": [ + { + "file": "src/main/daemon/daemon-session-owner-resolution.test.ts", + "assertions": [ + "controlled concurrent inventories wait for fallback, current, and legacy authority before classifying exact persisted PTYs absent", + "incomplete inventory, duplicate claims, owner refusal, and identity replacement stay fail-closed with exact list and spawn counts" + ] + }, + { + "file": "src/main/daemon/daemon-pty-router.test.ts", + "assertions": [ + "complete current and legacy daemon inventories prove liveness absence with one list call per adapter", + "one unavailable daemon inventory keeps the same missing PTY liveness unknown" + ] + }, + { + "file": "src/main/daemon/degraded-daemon-pty-provider.test.ts", + "assertions": [ + "repeated complete absence never dispatches the stale id to fallback or either daemon", + "one explicit fresh fallback PTY reattaches by its exact PTY and incarnation without another inventory", + "a mapped fallback-owned PTY reports live without borrowing fallback authority for unknown ids or scanning daemon inventories" + ] + }, + { + "file": "src/main/ipc/pty.test.ts", + "assertions": [ + "confirmed absence retires the exact persisted binding once and creates one differently identified fresh PTY", + "unverified ownership retains the binding and creates no fresh PTY" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-08-06", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/daemon-session-owner-resolution.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/main/ipc/pty.test.ts --reporter=dot", + "result": "passed", + "durationSeconds": 5.44, + "summary": "Four files passed 535 owner-resolution, router, degraded-provider, and IPC lifecycle tests. With only the resolver fix disabled, the unchanged four-file gate failed four stale-absence, identity-retry, and router-liveness assertions; restoring it passed 535 of 535." + } ], - "invariant": "Daemon startup reconciliation must preserve valid live daemon sessions, reap only true orphans, and degraded mode must not route an existing-looking daemon session through local fallback unless the caller explicitly marks a fresh degraded-mode spawn.", - "oracle": "The current provider-contract corpus covers valid current-worktree ids, folder/floating workspace ids, invalid removed-worktree ids, mixed live/orphan dry-run reconciliation, hyphenated worktree ids, malformed ids, daemon sessions discovered after restart, router discovery before existing-session spawn, router fail-closed behavior when legacy ownership cannot be listed or a known session has exited, degraded daemon fallback for fresh sessions, fail-closed behavior for unknown restored ids, benign inspection defaults for unknown ownership, and synthetic exits on daemon restart. Prior-worktree aliases and renamed-worktree startup wiring are promotion gaps, not current proof.", - "commands": [], - "testFiles": [], - "assertionRefs": [], - "evidenceRuns": [], "runtimeBudget": { - "p95Seconds": 20, + "p95Seconds": 45, "scope": "provider contract unit/integration test" }, "flakeHistory": { @@ -5354,22 +5403,22 @@ "evidence": "Focused provider contract tests now run locally; needs soak history before promotion." }, "redGreenEvidence": { - "status": "partial", - "evidence": "Tests assert discovered daemon sessions route to the daemon, fresh degraded-mode PTYs route to fallback only when marked new, router spawn discovers uncached existing sessions before choosing an adapter, router spawn fails closed instead of falling through to current when legacy listing fails or a known session has exited, targeted hasPty discovery caches legacy ownership before later write/resize-style operations, real daemon adapter listProcesses discovery seeds targeted hasPty liveness, folder and floating terminal workspace ids survive startup reconcile when valid, restored worktree-scoped and legacy/non-scoped ids do not fall back through spawn after ownership is lost or unknown, operations on unknown or exited existing ids fail closed, startup reconcile can dry-run orphan detection without killing live sessions, and process inspection returns benign defaults for unknown ownership. Production startup reconcile wiring remains unproven." + "status": "complete", + "evidence": "The byte-identical owner-resolver blob d35d2795d56e7e32c1ca99af6726fe6b92181881 is present in v1.4.176-rc.0@ddf64199fa, prod-release-1.4.176@8ddf575fe6, and origin/main@cb960408f2. It returns TerminalSessionOwnerUnverifiedError for both controlled complete-empty persisted PTYs and leaves degraded stale bindings unrecoverable. The candidate passes 535 of 535 registered tests. Restoring only the old predicate makes the unchanged four-file gate fail four of 535 tests; restoring the fix passes 535 of 535." }, "performanceBudget": { "required": true, - "evidence": "Reconcile must not add startup-blocking scans beyond the explicit daemon session inventory and must not leak global listing into hot paths." + "evidence": "One unresolved attempt performs one concurrent listProcesses call per eligible provider, concurrent pane attempts coalesce onto that inventory, and no polling, sleep, timer, subprocess, listener, or retry loop is added. Confirmed absence is not cached; repeated explicit attempts remain bounded to one fanout each. Reattaching the newly recorded fresh route adds no inventory or daemon spawn." }, "promotionCriteria": [ "Wire reconcileOnStartup or mark the production wiring gap explicitly.", "Cover priorWorktreeIds so renamed worktrees are not falsely reaped." ], "knownGaps": [ - "No executable coverage on main yet; the slice lives on the pending fix-terminal-reliability stack.", "Production startup reconcile wiring remains unproven.", "Prior-worktree aliases and renamed-worktree startup reconcile are not covered by the current executable corpus.", - "Real daemon restart behavior is still covered only by lower-level synthetic exit and provider-contract tests." + "Real daemon restart behavior is still covered only by lower-level synthetic exit and provider-contract tests.", + "No live Linux, Windows, WSL, SSH, headed/headless paired-runtime, folder-workspace, or mixed-version packaged-upgrade journey was run for stale-owner recovery." ], "demotionRule": "Cannot promote while restored daemon ids or routing operations can silently route to local fallback." }, diff --git a/src/main/daemon/daemon-pty-router.test.ts b/src/main/daemon/daemon-pty-router.test.ts index 1930224c2d5..11b6c53dee0 100644 --- a/src/main/daemon/daemon-pty-router.test.ts +++ b/src/main/daemon/daemon-pty-router.test.ts @@ -526,12 +526,24 @@ describe('DaemonPtyRouter', () => { it('does not report absence while any possible daemon owner is unavailable', async () => { const current = createAdapter('current') const legacy = createAdapter('legacy') - vi.mocked(legacy.probePtyLiveness).mockResolvedValue(null) + vi.mocked(legacy.listProcesses).mockRejectedValue(new Error('legacy inventory unavailable')) const router = new DaemonPtyRouter({ current, legacy: [legacy] }) await expect(router.probePtyLiveness('unknown-session')).resolves.toBeNull() }) + it('reports absence after every possible daemon owner returns a complete inventory', async () => { + const current = createAdapter('current') + const legacy = createAdapter('legacy') + const router = new DaemonPtyRouter({ current, legacy: [legacy] }) + + await expect(router.probePtyLiveness('missing-session')).resolves.toBe(false) + expect(current.listProcesses).toHaveBeenCalledOnce() + expect(legacy.listProcesses).toHaveBeenCalledOnce() + expect(current.spawn).not.toHaveBeenCalled() + expect(legacy.spawn).not.toHaveBeenCalled() + }) + it('routes an attach to a legacy session created after startup inventory', async () => { const current = createAdapter('current') const legacySessions = ['legacy-at-startup'] diff --git a/src/main/daemon/daemon-session-owner-resolution.test.ts b/src/main/daemon/daemon-session-owner-resolution.test.ts index 277c0750a84..55dc6142658 100644 --- a/src/main/daemon/daemon-session-owner-resolution.test.ts +++ b/src/main/daemon/daemon-session-owner-resolution.test.ts @@ -21,31 +21,103 @@ function provider( } describe('DaemonSessionOwnerResolver', () => { - it('coalesces concurrent all-provider inventories', async () => { - let release!: () => void - const gate = new Promise((resolve) => { - release = resolve + it('coalesces complete multi-provider absence without dispatching an attach', async () => { + let releaseFallback!: (processes: PtyProcessInfo[]) => void + let releaseCurrent!: (processes: PtyProcessInfo[]) => void + let releaseLegacy!: (processes: PtyProcessInfo[]) => void + const fallbackGate = new Promise((resolve) => { + releaseFallback = resolve }) - const firstInventory = vi.fn(async () => { - await gate - return [] + const currentGate = new Promise((resolve) => { + releaseCurrent = resolve }) - const secondInventory = vi.fn(async () => { - await gate - return [] + const legacyGate = new Promise((resolve) => { + releaseLegacy = resolve }) - const first = provider(firstInventory) - const second = provider(secondInventory) - const resolver = new DaemonSessionOwnerResolver([first, second], new Map()) + const fallbackInventory = vi.fn(() => fallbackGate) + const currentInventory = vi.fn(() => currentGate) + const legacyInventory = vi.fn(() => legacyGate) + const fallback = provider(fallbackInventory) + const current = provider(currentInventory) + const legacy = provider(legacyInventory) + const resolver = new DaemonSessionOwnerResolver([fallback, current, legacy], new Map()) - const resolutions = Promise.all([resolver.resolve('one'), resolver.resolve('two')]) - await vi.waitFor(() => expect(firstInventory).toHaveBeenCalledOnce()) - expect(secondInventory).toHaveBeenCalledOnce() - release() + const resolutions = [ + resolver.spawnAttachOnly({ + sessionId: 'pty-persisted-alpha', + expectedIncarnationId: 'incarnation-persisted-alpha', + attachOnly: true, + cols: 80, + rows: 24 + }), + resolver.spawnAttachOnly({ + sessionId: 'pty-persisted-beta', + expectedIncarnationId: 'incarnation-persisted-beta', + attachOnly: true, + cols: 80, + rows: 24 + }) + ] + let settled = 0 + for (const resolution of resolutions) { + void resolution.then( + () => { + settled += 1 + }, + () => { + settled += 1 + } + ) + } - await expect(resolutions).resolves.toEqual([{ kind: 'unknown' }, { kind: 'unknown' }]) - expect(firstInventory).toHaveBeenCalledOnce() - expect(secondInventory).toHaveBeenCalledOnce() + expect(fallbackInventory).toHaveBeenCalledOnce() + expect(currentInventory).toHaveBeenCalledOnce() + expect(legacyInventory).toHaveBeenCalledOnce() + releaseFallback([]) + releaseCurrent([]) + for (let iteration = 0; iteration < 10; iteration += 1) { + await Promise.resolve() + } + expect(settled).toBe(0) + expect(fallback.spawn).not.toHaveBeenCalled() + expect(current.spawn).not.toHaveBeenCalled() + expect(legacy.spawn).not.toHaveBeenCalled() + releaseLegacy([]) + + const results = await Promise.allSettled(resolutions) + expect(results).toEqual([ + expect.objectContaining({ + status: 'rejected', + reason: expect.any(SessionNotFoundError) + }), + expect.objectContaining({ + status: 'rejected', + reason: expect.any(SessionNotFoundError) + }) + ]) + expect(settled).toBe(2) + expect(fallbackInventory).toHaveBeenCalledOnce() + expect(currentInventory).toHaveBeenCalledOnce() + expect(legacyInventory).toHaveBeenCalledOnce() + expect(fallback.spawn).not.toHaveBeenCalled() + expect(current.spawn).not.toHaveBeenCalled() + expect(legacy.spawn).not.toHaveBeenCalled() + + await expect( + resolver.spawnAttachOnly({ + sessionId: 'pty-persisted-alpha', + expectedIncarnationId: 'incarnation-persisted-alpha', + attachOnly: true, + cols: 80, + rows: 24 + }) + ).rejects.toBeInstanceOf(SessionNotFoundError) + expect(fallbackInventory).toHaveBeenCalledTimes(2) + expect(currentInventory).toHaveBeenCalledTimes(2) + expect(legacyInventory).toHaveBeenCalledTimes(2) + expect(fallback.spawn).not.toHaveBeenCalled() + expect(current.spawn).not.toHaveBeenCalled() + expect(legacy.spawn).not.toHaveBeenCalled() }) it('bounds restore inventory requests across many panes and daemon generations', async () => { @@ -312,6 +384,10 @@ describe('DaemonSessionOwnerResolver', () => { const resolver = new DaemonSessionOwnerResolver([first, second], new Map()) await expect(resolver.resolve('session')).resolves.toEqual({ kind: 'unknown' }) + expect(first.listProcesses).toHaveBeenCalledOnce() + expect(second.listProcesses).toHaveBeenCalledOnce() + expect(first.spawn).not.toHaveBeenCalled() + expect(second.spawn).not.toHaveBeenCalled() }) it('reports confirmed absence from a sole owner', async () => { @@ -337,19 +413,19 @@ describe('DaemonSessionOwnerResolver', () => { }) it('preserves an unresolved owner when any provider inventory fails', async () => { - const resolver = new DaemonSessionOwnerResolver( - [ - provider(async () => []), - provider(async () => { - throw new Error('offline') - }) - ], - new Map() - ) + const reachable = provider(async () => []) + const unreachable = provider(async () => { + throw new Error('offline') + }) + const resolver = new DaemonSessionOwnerResolver([reachable, unreachable], new Map()) await expect( resolver.spawnAttachOnly({ sessionId: 'missing', attachOnly: true, cols: 80, rows: 24 }) ).rejects.toBeInstanceOf(TerminalSessionOwnerUnverifiedError) + expect(reachable.listProcesses).toHaveBeenCalledOnce() + expect(unreachable.listProcesses).toHaveBeenCalledOnce() + expect(reachable.spawn).not.toHaveBeenCalled() + expect(unreachable.spawn).not.toHaveBeenCalled() }) it('does not turn a raced owner refusal into absence while another provider is unresolved', async () => { @@ -372,6 +448,8 @@ describe('DaemonSessionOwnerResolver', () => { await expect( resolver.spawnAttachOnly({ sessionId: 'session', attachOnly: true, cols: 80, rows: 24 }) ).rejects.toBeInstanceOf(TerminalSessionOwnerUnverifiedError) + expect(candidate.listProcesses).toHaveBeenCalledOnce() + expect(candidate.spawn).not.toHaveBeenCalled() }) it('does not turn a post-inventory owner refusal into aggregate absence', async () => { @@ -389,6 +467,8 @@ describe('DaemonSessionOwnerResolver', () => { await expect( resolver.spawnAttachOnly({ sessionId: 'session', attachOnly: true, cols: 80, rows: 24 }) ).rejects.toBeInstanceOf(TerminalSessionOwnerUnverifiedError) + expect(candidate.listProcesses).toHaveBeenCalledOnce() + expect(candidate.spawn).toHaveBeenCalledOnce() }) it('pre-routes every uniquely inventoried session for serialized restores', async () => { @@ -429,6 +509,52 @@ describe('DaemonSessionOwnerResolver', () => { expect(inventory).toHaveBeenCalledTimes(2) }) + it('fails closed across identity replacement then confirms absence on retry', async () => { + let releaseCurrent!: (processes: PtyProcessInfo[]) => void + let releaseLegacy!: (processes: PtyProcessInfo[]) => void + const currentGate = new Promise((resolve) => { + releaseCurrent = resolve + }) + const legacyGate = new Promise((resolve) => { + releaseLegacy = resolve + }) + const currentInventory = vi + .fn<() => Promise>() + .mockReturnValueOnce(currentGate) + .mockResolvedValueOnce([]) + const legacyInventory = vi + .fn<() => Promise>() + .mockReturnValueOnce(legacyGate) + .mockResolvedValueOnce([]) + const current = provider(currentInventory) + const legacy = provider(legacyInventory) + const resolver = new DaemonSessionOwnerResolver([current, legacy], new Map()) + const attach = { + sessionId: 'pty-persisted-after-identity-change', + expectedIncarnationId: 'incarnation-before-identity-change', + attachOnly: true, + cols: 80, + rows: 24 + } as const + + const staleAttempt = resolver.spawnAttachOnly(attach) + expect(currentInventory).toHaveBeenCalledOnce() + expect(legacyInventory).toHaveBeenCalledOnce() + resolver.invalidateProvider(current) + releaseCurrent([]) + releaseLegacy([]) + + await expect(staleAttempt).rejects.toBeInstanceOf(TerminalSessionOwnerUnverifiedError) + expect(current.spawn).not.toHaveBeenCalled() + expect(legacy.spawn).not.toHaveBeenCalled() + + await expect(resolver.spawnAttachOnly(attach)).rejects.toBeInstanceOf(SessionNotFoundError) + expect(currentInventory).toHaveBeenCalledTimes(2) + expect(legacyInventory).toHaveBeenCalledTimes(2) + expect(current.spawn).not.toHaveBeenCalled() + expect(legacy.spawn).not.toHaveBeenCalled() + }) + it('restores a proven route when identity changes during exact reattach', async () => { const routes = new Map() let resolver!: DaemonSessionOwnerResolver diff --git a/src/main/daemon/daemon-session-owner-resolution.ts b/src/main/daemon/daemon-session-owner-resolution.ts index d35d2795d56..1906ebfb35b 100644 --- a/src/main/daemon/daemon-session-owner-resolution.ts +++ b/src/main/daemon/daemon-session-owner-resolution.ts @@ -223,10 +223,10 @@ export class DaemonSessionOwnerResolver { this.recordRoute(sessionId, provider, process?.incarnationId) return { kind: 'owner', provider } } - if (!inventory.complete || providers.size > 1 || this.providers.length !== 1) { - return { kind: 'unknown' } + if (inventory.complete && providers.size === 0 && this.providers.length > 0) { + return { kind: 'absent' } } - return { kind: 'absent' } + return { kind: 'unknown' } } private inventory(allowCached = true): Promise> { diff --git a/src/main/daemon/degraded-daemon-pty-provider.test.ts b/src/main/daemon/degraded-daemon-pty-provider.test.ts index af7c30d1c5d..e8a8e4d1214 100644 --- a/src/main/daemon/degraded-daemon-pty-provider.test.ts +++ b/src/main/daemon/degraded-daemon-pty-provider.test.ts @@ -4,7 +4,7 @@ import { DEGRADED_DAEMON_RECOVERY_RETRY_MS } from './degraded-daemon-fresh-spawn import type { DaemonPtyAdapter } from './daemon-pty-adapter' import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types' import type { PtyProcessInspection } from '../providers/pty-process-inspection' -import { TerminalSessionOwnerUnverifiedError } from './daemon-errors' +import { SessionNotFoundError, TerminalSessionOwnerUnverifiedError } from './daemon-errors' type ProviderMock = IPtyProvider & { probePtyLiveness: (id: string) => Promise @@ -200,6 +200,66 @@ it('keeps an attach unresolved when a legacy inventory listing fails', async () expect(current.spawn).not.toHaveBeenCalled() }) +it('confirms repeated stale-binding absence without falling back or spawning', async () => { + const current = createDaemonAdapter('daemon-current') + const legacy = createDaemonAdapter('daemon-legacy') + const fallback = createProvider('local-fallback') + vi.mocked(fallback.spawn).mockImplementation(async (opts) => + opts.attachOnly + ? { + id: opts.sessionId!, + incarnationId: 'incarnation-fresh-local', + isReattach: true + } + : { id: 'pty-fresh-local', incarnationId: 'incarnation-fresh-local' } + ) + const provider = new DegradedDaemonPtyProvider({ current, legacy: [legacy], fallback }) + const attach = { + sessionId: 'pty-persisted-missing', + expectedIncarnationId: 'incarnation-persisted-missing', + attachOnly: true, + cols: 80, + rows: 24 + } as const + + await expect(provider.spawn(attach)).rejects.toBeInstanceOf(SessionNotFoundError) + await expect(provider.spawn(attach)).rejects.toBeInstanceOf(SessionNotFoundError) + + expect(fallback.listProcesses).toHaveBeenCalledTimes(2) + expect(current.listProcesses).toHaveBeenCalledTimes(2) + expect(legacy.listProcesses).toHaveBeenCalledTimes(2) + expect(fallback.spawn).not.toHaveBeenCalled() + expect(current.spawn).not.toHaveBeenCalled() + expect(legacy.spawn).not.toHaveBeenCalled() + + const fresh = await provider.spawn({ cols: 80, rows: 24 }) + await expect( + provider.spawn({ + sessionId: fresh.id, + expectedIncarnationId: fresh.incarnationId, + attachOnly: true, + cols: 80, + rows: 24 + }) + ).resolves.toMatchObject({ + id: 'pty-fresh-local', + incarnationId: 'incarnation-fresh-local', + isReattach: true + }) + + expect(fallback.spawn).toHaveBeenCalledTimes(2) + expect(vi.mocked(fallback.spawn).mock.calls[0]?.[0]).not.toHaveProperty('sessionId') + expect(vi.mocked(fallback.spawn).mock.calls[1]?.[0]).toMatchObject({ + sessionId: 'pty-fresh-local', + attachOnly: true + }) + expect(current.spawn).not.toHaveBeenCalled() + expect(legacy.spawn).not.toHaveBeenCalled() + expect(fallback.listProcesses).toHaveBeenCalledTimes(2) + expect(current.listProcesses).toHaveBeenCalledTimes(2) + expect(legacy.listProcesses).toHaveBeenCalledTimes(2) +}) + it('rejects completion inspection instead of borrowing the fallback provider', async () => { const provider = new DegradedDaemonPtyProvider({ current: createDaemonAdapter('daemon'), @@ -415,7 +475,7 @@ describe('DegradedDaemonPtyProvider', () => { const legacy = createDaemonAdapter('legacy') const fallback = createProvider('fallback', ['unknown-session']) const provider = new DegradedDaemonPtyProvider({ current, legacy: [legacy], fallback }) - vi.mocked(legacy.probePtyLiveness).mockResolvedValue(null) + vi.mocked(legacy.listProcesses).mockRejectedValue(new Error('legacy inventory unavailable')) await expect(provider.probePtyLiveness('unknown-session')).resolves.toBeNull() expect(fallback.probePtyLiveness).not.toHaveBeenCalled() @@ -424,6 +484,19 @@ describe('DegradedDaemonPtyProvider', () => { await expect(provider.probePtyLiveness('unknown-session')).resolves.toBe(true) }) + it('reports a routed fallback PTY live before probing daemon absence', async () => { + const current = createDaemonAdapter('current') + const legacy = createDaemonAdapter('legacy') + const fallback = createProvider('fallback') + const provider = new DegradedDaemonPtyProvider({ current, legacy: [legacy], fallback }) + const fresh = await provider.spawn({ cols: 80, rows: 24 }) + + await expect(provider.probePtyLiveness(fresh.id)).resolves.toBe(true) + expect(fallback.hasPty).toHaveBeenCalledWith(fresh.id) + expect(current.listProcesses).not.toHaveBeenCalled() + expect(legacy.listProcesses).not.toHaveBeenCalled() + }) + it('routes authoritative recovery snapshots to the owning daemon', async () => { const current = createDaemonAdapter('daemon', ['daemon-session']) const fallback = createProvider('fallback') diff --git a/src/main/daemon/degraded-daemon-pty-provider.ts b/src/main/daemon/degraded-daemon-pty-provider.ts index 6b1c738b316..d1a9e90a2eb 100644 --- a/src/main/daemon/degraded-daemon-pty-provider.ts +++ b/src/main/daemon/degraded-daemon-pty-provider.ts @@ -95,6 +95,10 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { } async probePtyLiveness(id: string): Promise { + const mapped = this.sessionProviders.get(id) + if (mapped && (mapped.hasPty?.(id) ?? true)) { + return true + } return await this.ownerRecovery.probe(id) } From c9485fdded0d5ab50fcbbf81e16aecf12c4ae009 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:18:52 -0700 Subject: [PATCH 003/289] fix(computer): fence macOS HID coordinate clicks (#12981) Co-authored-by: Jinwoo-H --- .../computer-use-modifier-safety.test.mjs | 3 +- .../Sources/OrcaComputerUseMacOS/main.swift | 261 +++++++++++++++--- .../SyntheticMouseClickDelivery.swift | 66 ++++- .../SyntheticMouseClickDeliveryTests.swift | 190 +++++++++++++ src/main/runtime/rpc/errors.test.ts | 19 ++ src/main/runtime/rpc/errors.ts | 2 +- .../computer-use-error-recovery.test.ts | 13 + src/shared/computer-use-error-recovery.ts | 9 +- tests/e2e/computer-mac.e2e.ts | 85 +----- .../computer-coordinate-click-driver.ts | 131 +++++++++ 10 files changed, 674 insertions(+), 105 deletions(-) create mode 100644 tests/e2e/helpers/computer-coordinate-click-driver.ts diff --git a/config/scripts/computer-use-modifier-safety.test.mjs b/config/scripts/computer-use-modifier-safety.test.mjs index 32fdd94e938..725f17fdf4c 100644 --- a/config/scripts/computer-use-modifier-safety.test.mjs +++ b/config/scripts/computer-use-modifier-safety.test.mjs @@ -30,7 +30,8 @@ describe('computer-use modifier safety', () => { expect(mouseInput).toContain('event.flags = flags') // Every click event flows through the shared delivery plan and carries // the modifier flags on the mouse event itself. - expect(clickInput).toContain('SyntheticMouseClickDelivery.steps(clickCount: count)') + expect(clickInput).toContain('SyntheticMouseClickDelivery.deliver(') + expect(clickInput).toContain('currentSyntheticClickRecipient(') expect(clickInput).toContain('event.flags = flags') expect(clickInput).not.toContain('down: true') }) diff --git a/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift b/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift index 7236f7bab2c..7ecbdbf004a 100644 --- a/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift +++ b/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift @@ -729,10 +729,16 @@ final class Provider { let snapshot = try currentSnapshot(params: params) let button = params["mouseButton"]?.string ?? "left" let count = try positiveInteger(params["clickCount"]?.number, defaultValue: 1, name: "clickCount") + guard count <= SyntheticMouseClickDelivery.maxClickCount else { + throw ProviderError.coded( + "invalid_argument", + "clickCount must be at most \(SyntheticMouseClickDelivery.maxClickCount)" + ) + } let modifiers = try KeyMap.parseModifiers(params["modifiers"]?.string) // Why: agents expect a click into a target app to make the next // keyboard action safe, even when the click uses an AX action path. - recoverWindow(snapshot.app) + recoverWindow(snapshot.app, windowId: snapshot.windowId, windowBounds: snapshot.windowBounds) if let elementIndex = try optionalInteger(params, "elementIndex") { let record = try element(snapshot, elementIndex) if modifiers.isEmpty, count <= 1, let actionName = try performClickAction(record: record, mouseButton: button) { @@ -743,7 +749,8 @@ final class Provider { at: point, button: mouseButton(button), count: count, - modifiers: modifiers + modifiers: modifiers, + targetWindow: snapshot ) return actionMetadata( path: "synthetic", @@ -758,7 +765,8 @@ final class Provider { at: point, button: mouseButton(button), count: count, - modifiers: modifiers + modifiers: modifiers, + targetWindow: snapshot ) return actionMetadata( path: "synthetic", @@ -1123,6 +1131,124 @@ private func isTargetWindowFocused(_ snapshot: Snapshot) -> Bool { return !intersection.isNull && intersection.area >= min(frame.area, snapshot.windowBounds.area) * 0.75 } +private func currentSyntheticClickRecipient( + snapshot: Snapshot, + point: CGPoint +) -> SyntheticMouseClickDelivery.Recipient? { + let target = syntheticClickRecipient(pid: snapshot.app.pid, windowId: snapshot.windowId) + var cachedTargetCandidates: [WindowCandidate]? + func targetCandidates() -> [WindowCandidate] { + if let cachedTargetCandidates { return cachedTargetCandidates } + let candidates = WindowCapture.candidates(pid: snapshot.app.pid) + cachedTargetCandidates = candidates + return candidates + } + if let focused = focusedSyntheticClickRecipient( + targetPID: snapshot.app.pid, + targetCandidates: targetCandidates + ) { + guard focused == target else { return focused } + } else { + guard NSWorkspace.shared.frontmostApplication?.processIdentifier == snapshot.app.pid else { + return nil + } + } + return hitTestSyntheticClickRecipient( + at: point, + targetPID: snapshot.app.pid, + targetCandidates: targetCandidates + ) +} + +private func focusedSyntheticClickRecipient( + targetPID: pid_t, + targetCandidates: () -> [WindowCandidate] +) -> SyntheticMouseClickDelivery.Recipient? { + let systemWide = AXUIElementCreateSystemWide() + guard let focusedApp = copyElement(systemWide, kAXFocusedApplicationAttribute as String), + let ownerPID = pidAttribute(focusedApp), + let focusedWindow = copyElement(systemWide, kAXFocusedWindowAttribute as String) ?? + copyElement(focusedApp, kAXFocusedWindowAttribute as String) + else { + return nil + } + if let windowId = windowNumber(focusedWindow) { + return syntheticClickRecipient(pid: ownerPID, windowId: windowId) + } + guard ownerPID == targetPID else { return nil } + guard let frame = absoluteFrame(focusedWindow), + let candidate = SyntheticMouseClickDelivery.uniqueWindowCandidate( + from: targetCandidates(), + matching: { + windowFramesMatch($0.bounds, frame) + } + ) + else { + return nil + } + return syntheticClickRecipient(pid: ownerPID, windowId: candidate.windowId) +} + +private func hitTestSyntheticClickRecipient( + at point: CGPoint, + targetPID: pid_t, + targetCandidates: () -> [WindowCandidate] +) -> SyntheticMouseClickDelivery.Recipient? { + let systemWide = AXUIElementCreateSystemWide() + var hitElement: AXUIElement? + guard AXUIElementCopyElementAtPosition( + systemWide, + Float(point.x), + Float(point.y), + &hitElement + ) == .success, + let hitElement, + let ownerPID = pidAttribute(hitElement), + let window = containingWindow(hitElement) + else { + return nil + } + if let windowId = windowNumber(window) { + return syntheticClickRecipient(pid: ownerPID, windowId: windowId) + } + guard ownerPID == targetPID else { return nil } + guard let frame = absoluteFrame(window), + let candidate = SyntheticMouseClickDelivery.uniqueWindowCandidate( + from: targetCandidates(), + matching: { + windowFramesMatch($0.bounds, frame) + } + ) + else { + return nil + } + return syntheticClickRecipient(pid: ownerPID, windowId: candidate.windowId) +} + +private func containingWindow(_ element: AXUIElement) -> AXUIElement? { + var current = element + for _ in 0..<64 { + if stringAttribute(current, kAXRoleAttribute as String) == kAXWindowRole as String { + return current + } + if let window = copyElement(current, kAXWindowAttribute as String) { + return window + } + guard let parent = copyElement(current, kAXParentAttribute as String) else { + return nil + } + current = parent + } + return nil +} + +private func syntheticClickRecipient( + pid: pid_t, + windowId: CGWindowID +) -> SyntheticMouseClickDelivery.Recipient { + SyntheticMouseClickDelivery.Recipient(ownerPID: pid, windowID: windowId) +} + private func requireTargetWindowFocused(_ snapshot: Snapshot, restoreWindowRequested: Bool) throws { guard let failure = KeyboardInputSafety.syntheticInputFocusFailure( targetWindowFocused: isTargetWindowFocused(snapshot), @@ -1163,14 +1289,44 @@ private func matchingWindow(appElement: AXUIElement, capture: WindowCapture, foc } ?? focused } -private func recoverWindow(_ app: AppDescriptor) { +private func recoverWindow( + _ app: AppDescriptor, + windowId: CGWindowID? = nil, + windowBounds: CGRect? = nil +) { _ = app.app.unhide() _ = app.app.activate(options: [.activateAllWindows]) if let bundleId = app.bundleId { openBundle(bundleId) } let appElement = AXUIElementCreateApplication(app.pid) - if let window = copyElement(appElement, kAXFocusedWindowAttribute as String) ?? copyArray(appElement, kAXWindowsAttribute as String)?.first { + let focusedWindow = copyElement(appElement, kAXFocusedWindowAttribute as String) + var cachedWindows: [AXUIElement]? + func windows() -> [AXUIElement] { + if let cachedWindows { return cachedWindows } + let value = copyArray(appElement, kAXWindowsAttribute as String) ?? [] + cachedWindows = value + return value + } + let targetWindow: AXUIElement? + if let focusedWindow, + (windowId == nil && windowBounds == nil || windowMatchesCapture( + focusedWindow, + windowId: windowId, + windowBounds: windowBounds + )) { + targetWindow = focusedWindow + } else { + let exactWindow = windowId.flatMap { targetId in + windows().first { windowNumber($0) == targetId } + } + targetWindow = exactWindow ?? windowBounds.flatMap { targetBounds in + windows().first { window in + absoluteFrame(window).map { windowFramesMatch($0, targetBounds) } == true + } + } + } + if let window = targetWindow ?? focusedWindow ?? windows().first { _ = AXUIElementSetAttributeValue(window, kAXMinimizedAttribute as CFString, kCFBooleanFalse) _ = AXUIElementPerformAction(window, kAXRaiseAction as CFString) _ = AXUIElementSetAttributeValue(window, kAXMainAttribute as CFString, kCFBooleanTrue) @@ -1179,6 +1335,24 @@ private func recoverWindow(_ app: AppDescriptor) { Thread.sleep(forTimeInterval: 0.4) } +private func windowMatchesCapture( + _ window: AXUIElement, + windowId: CGWindowID?, + windowBounds: CGRect? +) -> Bool { + if let windowId, windowNumber(window) == windowId { return true } + guard let windowBounds, let frame = absoluteFrame(window) else { return false } + return windowFramesMatch(frame, windowBounds) +} + +private func windowFramesMatch(_ lhs: CGRect, _ rhs: CGRect) -> Bool { + let tolerance: CGFloat = 2 + return abs(lhs.minX - rhs.minX) <= tolerance && + abs(lhs.minY - rhs.minY) <= tolerance && + abs(lhs.width - rhs.width) <= tolerance && + abs(lhs.height - rhs.height) <= tolerance +} + private func openBundle(_ bundleId: String) { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/open") @@ -2294,7 +2468,8 @@ private enum Input { at point: CGPoint, button: MouseButton, count: Int, - modifiers: [KeyModifier] + modifiers: [KeyModifier], + targetWindow: Snapshot ) throws { guard let source = CGEventSource(stateID: .combinedSessionState) else { throw ProviderError.coded("accessibility_error", "failed to create event source") @@ -2302,32 +2477,56 @@ private enum Input { let flags = modifiers.reduce(into: CGEventFlags()) { result, modifier in result.insert(modifier.flag) } - // Why HID tap + pacing: see SyntheticMouseClickDelivery (STA-3433). - for step in SyntheticMouseClickDelivery.steps(clickCount: count) { - let type: CGEventType - switch step { - case .move: - type = .mouseMoved - case .buttonDown: - type = button.downEvent - case .buttonUp: - type = button.upEvent + let target = syntheticClickRecipient(pid: targetWindow.app.pid, windowId: targetWindow.windowId) + do { + try SyntheticMouseClickDelivery.deliver( + clickCount: count, + target: target, + currentRecipient: { + currentSyntheticClickRecipient(snapshot: targetWindow, point: point) + }, + makeEvent: { step in + let type: CGEventType + switch step { + case .move: + type = .mouseMoved + case .buttonDown: + type = button.downEvent + case .buttonUp: + type = button.upEvent + } + guard let event = CGEvent( + mouseEventSource: source, + mouseType: type, + mouseCursorPosition: point, + mouseButton: button.cgButton + ) else { + throw ProviderError.coded("accessibility_error", "failed to create mouse event") + } + event.flags = flags + let clickState = SyntheticMouseClickDelivery.clickState(for: step) + if clickState > 0 { + event.setIntegerValueField(.mouseEventClickState, value: clickState) + } + return event + }, + post: { $0.post(tap: .cghidEventTap) }, + pause: { _ = usleep($0) } + ) + } catch let failure as SyntheticMouseClickDelivery.FenceFailure { + switch failure { + case let .recipientChanged(expected, actual, deliveredPresses): + let actualDescription = actual.map { + "pid \($0.ownerPID) window \($0.windowID)" + } ?? "no focused window" + let recovery = deliveredPresses == 0 + ? "bring the target window forward, run get-app-state again, and retry" + : "\(deliveredPresses) press(es) may already have been delivered; run get-app-state and verify state before retrying" + throw ProviderError.coded( + "window_not_focused", + "coordinate click aborted because target pid \(expected.ownerPID) window \(expected.windowID) is no longer the focused topmost recipient (current: \(actualDescription)); \(recovery)" + ) } - guard let event = CGEvent( - mouseEventSource: source, - mouseType: type, - mouseCursorPosition: point, - mouseButton: button.cgButton - ) else { - throw ProviderError.coded("accessibility_error", "failed to create mouse event") - } - event.flags = flags - let clickState = SyntheticMouseClickDelivery.clickState(for: step) - if clickState > 0 { - event.setIntegerValueField(.mouseEventClickState, value: clickState) - } - event.post(tap: .cghidEventTap) - usleep(SyntheticMouseClickDelivery.interEventPauseMicroseconds) } } diff --git a/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/SyntheticMouseClickDelivery.swift b/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/SyntheticMouseClickDelivery.swift index d61e56af35c..13aa6e15ced 100644 --- a/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/SyntheticMouseClickDelivery.swift +++ b/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/SyntheticMouseClickDelivery.swift @@ -6,6 +6,22 @@ /// happens). The window server also drops a mouseUp posted back-to-back /// with its mouseDown, so consecutive events need a pause between them. public enum SyntheticMouseClickDelivery { + public static let maxClickCount = 3 + + public struct Recipient: Equatable, Sendable { + public let ownerPID: Int32 + public let windowID: UInt32 + + public init(ownerPID: Int32, windowID: UInt32) { + self.ownerPID = ownerPID + self.windowID = windowID + } + } + + public enum FenceFailure: Error, Equatable { + case recipientChanged(expected: Recipient, actual: Recipient?, deliveredPresses: Int) + } + public enum Step: Equatable { case move case buttonDown(pressIndex: Int) @@ -21,7 +37,7 @@ public enum SyntheticMouseClickDelivery { /// clicks instead of independent single clicks. public static func steps(clickCount: Int) -> [Step] { var steps: [Step] = [.move] - for press in 1...max(clickCount, 1) { + for press in 1...min(max(clickCount, 1), maxClickCount) { steps.append(.buttonDown(pressIndex: press)) steps.append(.buttonUp(pressIndex: press)) } @@ -37,4 +53,52 @@ public enum SyntheticMouseClickDelivery { return Int64(pressIndex) } } + + public static func uniqueWindowCandidate( + from candidates: [Candidate], + matching predicate: (Candidate) -> Bool + ) -> Candidate? { + var match: Candidate? + for candidate in candidates where predicate(candidate) { + guard match == nil else { return nil } + match = candidate + } + return match + } + + public static func deliver( + clickCount: Int, + target: Recipient, + currentRecipient: () -> Recipient?, + makeEvent: (Step) throws -> Event, + post: (Event) -> Void, + pause: (UInt32) -> Void + ) throws { + post(try makeEvent(.move)) + pause(interEventPauseMicroseconds) + for pressIndex in 1...min(max(clickCount, 1), maxClickCount) { + let beforeDown = currentRecipient() + guard beforeDown == target else { + throw FenceFailure.recipientChanged( + expected: target, + actual: beforeDown, + deliveredPresses: pressIndex - 1 + ) + } + let down = try makeEvent(.buttonDown(pressIndex: pressIndex)) + let up = try makeEvent(.buttonUp(pressIndex: pressIndex)) + post(down) + pause(interEventPauseMicroseconds) + post(up) + let afterUp = currentRecipient() + guard afterUp == target else { + throw FenceFailure.recipientChanged( + expected: target, + actual: afterUp, + deliveredPresses: pressIndex + ) + } + pause(interEventPauseMicroseconds) + } + } } diff --git a/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/SyntheticMouseClickDeliveryTests.swift b/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/SyntheticMouseClickDeliveryTests.swift index d80a5e9e8ee..5870e740880 100644 --- a/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/SyntheticMouseClickDeliveryTests.swift +++ b/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/SyntheticMouseClickDeliveryTests.swift @@ -29,6 +29,18 @@ final class SyntheticMouseClickDeliveryTests: XCTestCase { } } + func testExcessiveClickCountIsCappedAtTripleClick() { + XCTAssertEqual( + SyntheticMouseClickDelivery.steps(clickCount: Int.max), + [ + .move, + .buttonDown(pressIndex: 1), .buttonUp(pressIndex: 1), + .buttonDown(pressIndex: 2), .buttonUp(pressIndex: 2), + .buttonDown(pressIndex: 3), .buttonUp(pressIndex: 3), + ] + ) + } + func testClickStateMatchesPressIndexAndSkipsMove() { XCTAssertEqual(SyntheticMouseClickDelivery.clickState(for: .move), 0) XCTAssertEqual(SyntheticMouseClickDelivery.clickState(for: .buttonDown(pressIndex: 1)), 1) @@ -40,4 +52,182 @@ final class SyntheticMouseClickDeliveryTests: XCTestCase { // turning the click into a hover-only no-op (STA-3433). XCTAssertGreaterThan(SyntheticMouseClickDelivery.interEventPauseMicroseconds, 0) } + + func testAmbiguousWindowFrameFallbackDoesNotSelectRecipient() { + let candidates = [(windowID: 101, frame: 7), (windowID: 202, frame: 7)] + + XCTAssertNil(SyntheticMouseClickDelivery.uniqueWindowCandidate( + from: candidates, + matching: { $0.frame == 7 } + )) + XCTAssertNil(SyntheticMouseClickDelivery.uniqueWindowCandidate( + from: candidates, + matching: { $0.frame == 8 } + )) + XCTAssertEqual( + SyntheticMouseClickDelivery.uniqueWindowCandidate( + from: candidates, + matching: { $0.windowID == 101 } + )?.windowID, + 101 + ) + } + + func testRecipientChangeBeforeMouseDownPostsNoClickAndReportsBothWindows() { + let target = SyntheticMouseClickDelivery.Recipient(ownerPID: 41, windowID: 101) + let intruder = SyntheticMouseClickDelivery.Recipient(ownerPID: 52, windowID: 202) + var posted: [SyntheticMouseClickDelivery.Step] = [] + + XCTAssertThrowsError( + try SyntheticMouseClickDelivery.deliver( + clickCount: 1, + target: target, + currentRecipient: { intruder }, + makeEvent: { $0 }, + post: { posted.append($0) }, + pause: { _ in } + ) + ) { error in + XCTAssertEqual( + error as? SyntheticMouseClickDelivery.FenceFailure, + .recipientChanged(expected: target, actual: intruder, deliveredPresses: 0) + ) + } + XCTAssertEqual(posted, [.move]) + } + + func testRecipientChangeAfterMouseUpStopsUntilStateIsVerified() throws { + let target = SyntheticMouseClickDelivery.Recipient(ownerPID: 41, windowID: 101) + let intruder = SyntheticMouseClickDelivery.Recipient(ownerPID: 52, windowID: 202) + var recipients = [target, intruder, target, target] + var firstAttempt: [SyntheticMouseClickDelivery.Step] = [] + + XCTAssertThrowsError( + try SyntheticMouseClickDelivery.deliver( + clickCount: 1, + target: target, + currentRecipient: { recipients.removeFirst() }, + makeEvent: { $0 }, + post: { firstAttempt.append($0) }, + pause: { _ in } + ) + ) { error in + XCTAssertEqual( + error as? SyntheticMouseClickDelivery.FenceFailure, + .recipientChanged(expected: target, actual: intruder, deliveredPresses: 1) + ) + } + XCTAssertEqual(firstAttempt, [ + .move, + .buttonDown(pressIndex: 1), + .buttonUp(pressIndex: 1), + ]) + + var retry: [SyntheticMouseClickDelivery.Step] = [] + try SyntheticMouseClickDelivery.deliver( + clickCount: 1, + target: target, + currentRecipient: { recipients.removeFirst() }, + makeEvent: { $0 }, + post: { retry.append($0) }, + pause: { _ in } + ) + XCTAssertEqual(retry, [.move, .buttonDown(pressIndex: 1), .buttonUp(pressIndex: 1)]) + } + + func testMouseUpPostsBeforeSecondRecipientCheck() throws { + let target = SyntheticMouseClickDelivery.Recipient(ownerPID: 41, windowID: 101) + var trace: [String] = [] + + try SyntheticMouseClickDelivery.deliver( + clickCount: 1, + target: target, + currentRecipient: { + trace.append("recipient") + return target + }, + makeEvent: { $0 }, + post: { + switch $0 { + case .move: + trace.append("move") + case .buttonDown: + trace.append("down") + case .buttonUp: + trace.append("up") + } + }, + pause: { _ in } + ) + + XCTAssertEqual(trace, ["move", "recipient", "down", "up", "recipient"]) + } + + func testRecipientChangeBeforeLaterPressReportsCompletedPresses() { + let target = SyntheticMouseClickDelivery.Recipient(ownerPID: 41, windowID: 101) + let intruder = SyntheticMouseClickDelivery.Recipient(ownerPID: 52, windowID: 202) + var recipients = [target, target, intruder] + var posted: [SyntheticMouseClickDelivery.Step] = [] + + XCTAssertThrowsError( + try SyntheticMouseClickDelivery.deliver( + clickCount: 2, + target: target, + currentRecipient: { recipients.removeFirst() }, + makeEvent: { $0 }, + post: { posted.append($0) }, + pause: { _ in } + ) + ) { error in + XCTAssertEqual( + error as? SyntheticMouseClickDelivery.FenceFailure, + .recipientChanged(expected: target, actual: intruder, deliveredPresses: 1) + ) + } + XCTAssertEqual(posted, [ + .move, + .buttonDown(pressIndex: 1), + .buttonUp(pressIndex: 1), + ]) + } + + func testMultiClickRevalidatesBeforeEveryPressAndAfterEveryRelease() throws { + let target = SyntheticMouseClickDelivery.Recipient(ownerPID: 41, windowID: 101) + var validationCount = 0 + var posted: [SyntheticMouseClickDelivery.Step] = [] + + try SyntheticMouseClickDelivery.deliver( + clickCount: 2, + target: target, + currentRecipient: { + validationCount += 1 + return target + }, + makeEvent: { $0 }, + post: { posted.append($0) }, + pause: { _ in } + ) + + XCTAssertEqual(validationCount, 4) + XCTAssertEqual(posted, SyntheticMouseClickDelivery.steps(clickCount: 2)) + } + + func testButtonPairIsPreparedBeforeMouseDownPosts() { + enum PreparationFailure: Error { case mouseUp } + let target = SyntheticMouseClickDelivery.Recipient(ownerPID: 41, windowID: 101) + var posted: [SyntheticMouseClickDelivery.Step] = [] + + XCTAssertThrowsError(try SyntheticMouseClickDelivery.deliver( + clickCount: 1, + target: target, + currentRecipient: { target }, + makeEvent: { step in + if case .buttonUp = step { throw PreparationFailure.mouseUp } + return step + }, + post: { posted.append($0) }, + pause: { _ in } + )) + XCTAssertEqual(posted, [.move]) + } } diff --git a/src/main/runtime/rpc/errors.test.ts b/src/main/runtime/rpc/errors.test.ts index ddd0541c57b..23a6caaed42 100644 --- a/src/main/runtime/rpc/errors.test.ts +++ b/src/main/runtime/rpc/errors.test.ts @@ -137,6 +137,25 @@ describe('mapRuntimeError', () => { }) }) + it('does not recommend a blind retry after a coordinate press may have landed', () => { + const message = + 'coordinate click aborted because the recipient changed; 1 press(es) may already have been delivered' + const error = Object.assign(new Error(message), { code: 'window_not_focused' }) + + const response = mapRuntimeError('req_1', { runtimeId: 'runtime-1' }, error) + + expect(response.error).toMatchObject({ + code: 'window_not_focused', + message, + data: { + nextSteps: [ + expect.stringContaining('verify whether the intended action already occurred'), + expect.stringContaining('Do not retry the click if it already took effect') + ] + } + }) + }) + it('preserves structured lineage error codes and data for CLI recovery hints', () => { const response = mapRuntimeError( 'req_1', diff --git a/src/main/runtime/rpc/errors.ts b/src/main/runtime/rpc/errors.ts index 930a056a6ac..a2e02e6ccc4 100644 --- a/src/main/runtime/rpc/errors.ts +++ b/src/main/runtime/rpc/errors.ts @@ -108,7 +108,7 @@ export function mapRuntimeError(id: string, meta: RpcEnvelopeMeta, error: unknow COMPUTER_PASSTHROUGH_CODES.has((error as { code: string }).code) ) { const code = (error as { code: string }).code - return errorResponse(id, meta, code, message, computerErrorData(code)) + return errorResponse(id, meta, code, message, computerErrorData(code, message)) } if ( error instanceof Error && diff --git a/src/shared/computer-use-error-recovery.test.ts b/src/shared/computer-use-error-recovery.test.ts index 20c870cf300..dbeaf58af27 100644 --- a/src/shared/computer-use-error-recovery.test.ts +++ b/src/shared/computer-use-error-recovery.test.ts @@ -40,6 +40,19 @@ describe('computerUseErrorRecoveryData', () => { ]) }) + it('requires state verification when a focus failure may follow a delivered press', () => { + const recovery = computerUseErrorRecoveryData( + 'window_not_focused', + 'coordinate click aborted; 1 press(es) may already have been delivered' + ) + + expect(recovery?.nextSteps).toEqual([ + expect.stringContaining('verify whether the intended action already occurred'), + expect.stringContaining('Do not retry the click if it already took effect') + ]) + expect(recovery?.nextSteps.join('\n')).not.toContain('Retry once with `--restore-window`') + }) + it('keeps missing web app recovery within computer-use desktop app targeting', () => { const recovery = computerUseErrorRecoveryData('app_not_found') diff --git a/src/shared/computer-use-error-recovery.ts b/src/shared/computer-use-error-recovery.ts index 7d5434934a0..7803738fb1a 100644 --- a/src/shared/computer-use-error-recovery.ts +++ b/src/shared/computer-use-error-recovery.ts @@ -3,7 +3,8 @@ export type ComputerUseErrorRecoveryData = { } export function computerUseErrorRecoveryData( - code: string + code: string, + message?: string ): ComputerUseErrorRecoveryData | undefined { switch (code) { case 'app_not_found': @@ -24,6 +25,12 @@ export function computerUseErrorRecoveryData( 'If no window is listed, open or focus the app first; `orca computer` does not launch closed desktop apps.' ) case 'window_not_focused': + if (message?.includes('may already have been delivered')) { + return recoverWith( + 'Run `orca computer get-app-state --app --json` and verify whether the intended action already occurred before retrying.', + 'Do not retry the click if it already took effect; otherwise bring the target window forward and use fresh state before trying again.' + ) + } return recoverWith( 'Retry once with `--restore-window`.', 'If `--restore-window` was already used, stop retrying restore; bring the app forward manually, check permissions, or prefer `set-value` for editable fields.' diff --git a/tests/e2e/computer-mac.e2e.ts b/tests/e2e/computer-mac.e2e.ts index 6aa162cee40..5c6d4e381fe 100644 --- a/tests/e2e/computer-mac.e2e.ts +++ b/tests/e2e/computer-mac.e2e.ts @@ -13,6 +13,10 @@ import { parseJsonOutput, runOrcaCli } from './helpers/computer-driver' +import { + clickCapturedTextEditOpenDialog, + doubleClickTextEditWord +} from './helpers/computer-coordinate-click-driver' const isMac = process.platform === 'darwin' const e2eOptIn = process.env.ORCA_COMPUTER_E2E === '1' @@ -72,81 +76,22 @@ describe.skipIf(!isMac || !e2eOptIn)('computer-use macOS e2e (TextEdit)', () => }) test('coordinate double-click activates a control, not just hover (STA-3433)', async () => { - // Ten identical short lines: any first-lines click hits a word, and the - // whole document stays inside the treeText value preview after editing. - const filler = Array(10).fill('wordword').join('\n') - await runOrcaCli([ - 'computer', - 'hotkey', - '--app', - 'TextEdit', - '--key', - 'CmdOrCtrl+A', - '--no-screenshot' - ]) - await runOrcaCli([ - 'computer', - 'paste-text', - '--app', - 'TextEdit', - '--text', - filler, - '--no-screenshot' - ]) + const result = await doubleClickTextEditWord() - // Word-select via coordinates: 40px in, 70px down lands inside a - // "wordword" on one of the first lines whether or not the ruler is shown. - const clicked = parseJsonOutput<{ result: ComputerActionResult }>( - ( - await runOrcaCli([ - 'computer', - 'click', - '--app', - 'TextEdit', - '--x', - '40', - '--y', - '70', - '--click-count', - '2', - '--no-screenshot', - '--json' - ]) - ).stdout - ) - expect(clicked.result.action?.path).toBe('synthetic') - expect(clicked.result.action?.verification).toMatchObject({ + expect(result.action?.path).toBe('synthetic') + expect(result.action?.verification).toMatchObject({ state: 'unverified', reason: 'synthetic_input' }) + expect(result.replacedWord).toBe(true) + }) - const marker = `zz${Date.now()}zz` - await runOrcaCli([ - 'computer', - 'type-text', - '--app', - 'TextEdit', - '--text', - marker, - '--no-screenshot' - ]) - - const after = parseJsonOutput<{ result: ComputerSnapshotResult }>( - ( - await runOrcaCli([ - 'computer', - 'get-app-state', - '--app', - 'TextEdit', - '--no-screenshot', - '--json' - ]) - ).stdout - ) - // The double-click selected a mid-document word, so the typed marker must - // be followed by more filler. A dropped press leaves the caret at the end - // of the document and the marker only ever appends (the STA-3433 no-op). - expect(after.result.snapshot.treeText).toMatch(new RegExp(`${marker}\\s+wordword`)) + test('coordinate click reaches the captured native dialog window', async () => { + expect(await clickCapturedTextEditOpenDialog()).toMatchObject({ + clickPath: 'synthetic', + dialogClosed: true, + dialogWasNew: true + }) }) test('paste-text and hotkey verify TextEdit text replacement', async () => { diff --git a/tests/e2e/helpers/computer-coordinate-click-driver.ts b/tests/e2e/helpers/computer-coordinate-click-driver.ts new file mode 100644 index 00000000000..6b17e7bca15 --- /dev/null +++ b/tests/e2e/helpers/computer-coordinate-click-driver.ts @@ -0,0 +1,131 @@ +import type { + ComputerActionResult, + ComputerSnapshotResult +} from '../../../src/shared/runtime-types' +import { parseJsonOutput, runOrcaCli } from './computer-driver' + +export async function doubleClickTextEditWord(): Promise<{ + action: ComputerActionResult['action'] + replacedWord: boolean +}> { + const filler = Array(10).fill('wordword').join('\n') + await runOrcaCli([ + 'computer', + 'hotkey', + '--app', + 'TextEdit', + '--key', + 'CmdOrCtrl+A', + '--no-screenshot' + ]) + await runOrcaCli([ + 'computer', + 'paste-text', + '--app', + 'TextEdit', + '--text', + filler, + '--no-screenshot' + ]) + + const clicked = parseJsonOutput<{ result: ComputerActionResult }>( + ( + await runOrcaCli([ + 'computer', + 'click', + '--app', + 'TextEdit', + '--x', + '40', + '--y', + '70', + '--click-count', + '2', + '--no-screenshot', + '--json' + ]) + ).stdout + ) + const marker = `zz${Date.now()}zz` + await runOrcaCli([ + 'computer', + 'type-text', + '--app', + 'TextEdit', + '--text', + marker, + '--no-screenshot' + ]) + + const after = parseJsonOutput<{ result: ComputerSnapshotResult }>( + ( + await runOrcaCli([ + 'computer', + 'get-app-state', + '--app', + 'TextEdit', + '--no-screenshot', + '--json' + ]) + ).stdout + ) + return { + action: clicked.result.action, + replacedWord: new RegExp(`${marker}\\s+wordword`).test(after.result.snapshot.treeText) + } +} + +export async function clickCapturedTextEditOpenDialog(): Promise<{ + clickPath: string | undefined + dialogClosed: boolean + dialogWasNew: boolean +}> { + const before = parseJsonOutput<{ + result: { windows: { id?: number | null }[] } + }>((await runOrcaCli(['computer', 'list-windows', '--app', 'TextEdit', '--json'])).stdout) + const existingWindowIds = new Set(before.result.windows.map((window) => window.id)) + + const opened = parseJsonOutput<{ result: ComputerActionResult }>( + ( + await runOrcaCli([ + 'computer', + 'hotkey', + '--app', + 'TextEdit', + '--key', + 'CmdOrCtrl+O', + '--restore-window', + '--no-screenshot', + '--json' + ]) + ).stdout + ) + const dialog = opened.result.snapshot.window + const clicked = parseJsonOutput<{ result: ComputerActionResult }>( + ( + await runOrcaCli([ + 'computer', + 'click', + '--app', + 'TextEdit', + '--window-id', + String(dialog.id), + '--x', + String(dialog.width - 140), + '--y', + String(dialog.height - 30), + '--no-screenshot', + '--json' + ]) + ).stdout + ) + const after = parseJsonOutput<{ + result: { windows: { id?: number | null }[] } + }>((await runOrcaCli(['computer', 'list-windows', '--app', 'TextEdit', '--json'])).stdout) + + return { + clickPath: clicked.result.action?.path, + dialogClosed: !after.result.windows.some((window) => window.id === dialog.id), + dialogWasNew: !existingWindowIds.has(dialog.id) + } +} From 7e60b338ea0bcd32f3702fefe697646adcbf5427 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:19:04 -0700 Subject: [PATCH 004/289] fix(persistence): fail closed when safeStorage cannot encrypt (#12983) Co-authored-by: Jinwoo-H --- ...tence-protected-secret-fail-closed.test.ts | 427 ++++++++++++++++++ ...stence-protected-secret-write-race.test.ts | 106 +++++ .../persistence-proxy-secret-recovery.test.ts | 22 +- src/main/persistence-single-serialize.test.ts | 4 +- src/main/persistence.ts | 202 ++++++--- src/main/protected-secret-persistence.test.ts | 66 +++ src/main/protected-secret-persistence.ts | 139 ++++++ 7 files changed, 883 insertions(+), 83 deletions(-) create mode 100644 src/main/persistence-protected-secret-fail-closed.test.ts create mode 100644 src/main/persistence-protected-secret-write-race.test.ts create mode 100644 src/main/protected-secret-persistence.test.ts create mode 100644 src/main/protected-secret-persistence.ts diff --git a/src/main/persistence-protected-secret-fail-closed.test.ts b/src/main/persistence-protected-secret-fail-closed.test.ts new file mode 100644 index 00000000000..42d348af61c --- /dev/null +++ b/src/main/persistence-protected-secret-fail-closed.test.ts @@ -0,0 +1,427 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { randomUUID } from 'node:crypto' + +type FailureMode = 'availability-throws' | 'encryption-throws' | 'unavailable' + +const testState = { dir: '' } +const cipherState = { + availability: 'available' as 'available' | 'throws' | 'unavailable', + encryptionThrows: false, + decryptionThrows: false +} + +vi.mock('./ssh/ssh-config-parser', () => ({ + loadUserSshConfig: vi.fn(), + sshConfigHostsToTargets: vi.fn() +})) + +vi.mock('electron', () => ({ + app: { getPath: () => testState.dir }, + safeStorage: { + isEncryptionAvailable: () => { + if (cipherState.availability === 'throws') { + throw new Error('keychain access denied') + } + return cipherState.availability === 'available' + }, + encryptString: (plaintext: string) => { + if (cipherState.encryptionThrows) { + throw new Error('keychain encryption failed') + } + return Buffer.from(`enc:${randomUUID()}:${plaintext}`, 'utf-8') + }, + decryptString: (ciphertext: Buffer) => { + if (cipherState.decryptionThrows) { + throw new Error('keychain decryption failed') + } + const decoded = ciphertext.toString('utf-8') + if (!decoded.startsWith('enc:')) { + throw new Error('invalid ciphertext') + } + return decoded.slice('enc:'.length + 36 + 1) + } + } +})) + +vi.mock('./telemetry/client', () => ({ track: vi.fn() })) +vi.mock('./telemetry/cohort-classifier', () => ({ + getCohortAtEmit: vi.fn().mockReturnValue({ nth_repo_added: 2 }) +})) + +async function createStore() { + vi.resetModules() + const { Store, initDataPath } = await import('./persistence') + initDataPath() + return new Store() +} + +function dataFile(): string { + return join(testState.dir, 'orca-data.json') +} + +type ProtectedState = { + settings: { + httpProxyUrl: string + httpProxyBypassRules: string + opencodeSessionCookie: string + } + ui: { browserKagiSessionLink: string | null } + sshPtyConsumerRecoveries: { ownerLease: string }[] +} + +function readState(path = dataFile()): ProtectedState { + return JSON.parse(readFileSync(path, 'utf-8')) +} + +const ORIGINAL = { + proxy: 'http://old-user:old-pass@proxy.test:8080', + cookie: 'old-opencode-cookie', + kagi: 'https://kagi.test/session/old-token', + ownerLease: `old-ssh-owner-lease-${'x'.repeat(480)}` +} as const +const PENDING = { + proxy: 'http://new-user:new-pass@proxy.test:8080', + cookie: 'new-opencode-cookie', + kagi: 'https://kagi.test/session/new-token', + ownerLease: `new-ssh-owner-lease-${'y'.repeat(480)}` +} as const + +function setFailure(mode: FailureMode): void { + cipherState.availability = + mode === 'availability-throws' ? 'throws' : mode === 'unavailable' ? 'unavailable' : 'available' + cipherState.encryptionThrows = mode === 'encryption-throws' +} + +async function writeProtectedState( + store: Awaited>, + values: typeof ORIGINAL | typeof PENDING, + bypassRules: string +): Promise { + store.updateSettings({ + httpProxyUrl: values.proxy, + httpProxyBypassRules: bypassRules, + opencodeSessionCookie: values.cookie + }) + store.updateUI({ browserKagiSessionLink: values.kagi }) + await store.upsertSshPtyConsumerRecovery({ + targetId: 'ssh-1', + clientInstanceId: 'client-1', + serverBuildId: 'relay-build-1', + clientGeneration: 1, + ownerGeneration: 1, + ownerLease: values.ownerLease + }) +} + +function expectPlaintextsAbsent(raw: string, values: typeof ORIGINAL | typeof PENDING): void { + for (const plaintext of Object.values(values)) { + expect.soft(raw).not.toContain(plaintext) + } +} + +async function settleSave(store: Awaited>): Promise { + vi.advanceTimersByTime(2_000) + await store.waitForPendingWrite() +} + +describe('protected persistence when safeStorage fails', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-safe-storage-test-')) + cipherState.availability = 'available' + cipherState.encryptionThrows = false + cipherState.decryptionThrows = false + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + rmSync(testState.dir, { recursive: true, force: true }) + }) + + it.each(['availability-throws', 'encryption-throws', 'unavailable'])( + 'omits newly introduced protected values during a persistent failure: %s', + async (failureMode) => { + setFailure(failureMode) + const store = await createStore() + await writeProtectedState(store, PENDING, 'non-secret-saved') + + const raw = readFileSync(dataFile(), 'utf-8') + const persisted = readState() + expectPlaintextsAbsent(raw, PENDING) + expect(persisted.settings.httpProxyUrl).toBe('') + expect(persisted.settings.opencodeSessionCookie).toBe('') + expect(persisted.ui.browserKagiSessionLink).toBe('') + expect(persisted.sshPtyConsumerRecoveries[0]?.ownerLease).toBe('') + expect(persisted.settings.httpProxyBypassRules).toBe('non-secret-saved') + } + ) + + it.each(['availability-throws', 'encryption-throws', 'unavailable'])( + 'persists the pending protected state after same-instance recovery: %s', + async (failureMode) => { + const store = await createStore() + await writeProtectedState(store, ORIGINAL, 'before') + setFailure(failureMode) + await writeProtectedState(store, PENDING, 'during-failure') + + cipherState.availability = 'available' + cipherState.encryptionThrows = false + await writeProtectedState(store, PENDING, 'during-failure') + + const restarted = await createStore() + expect(restarted.getSettings().httpProxyUrl).toBe(PENDING.proxy) + expect(restarted.getSettings().opencodeSessionCookie).toBe(PENDING.cookie) + expect(restarted.getUI().browserKagiSessionLink).toBe(PENDING.kagi) + expect(restarted.getSshPtyConsumerRecovery('ssh-1')?.ownerLease).toBe(PENDING.ownerLease) + } + ) + + it('honors explicit clears during an outage without later resurrecting ciphertext', async () => { + const store = await createStore() + await writeProtectedState(store, ORIGINAL, 'before') + cipherState.availability = 'unavailable' + + store.updateSettings({ httpProxyUrl: '', opencodeSessionCookie: '' }) + store.updateUI({ browserKagiSessionLink: null }) + await store.removeSshPtyConsumerRecovery('ssh-1') + await settleSave(store) + + await writeProtectedState(store, PENDING, 'after-clear') + const persisted = readState() + expect(persisted.settings.httpProxyUrl).toBe('') + expect(persisted.settings.opencodeSessionCookie).toBe('') + expect(persisted.ui.browserKagiSessionLink).toBe('') + expect(persisted.sshPtyConsumerRecoveries[0]?.ownerLease).toBe('') + + cipherState.availability = 'available' + const restarted = await createStore() + expect(restarted.getSettings().httpProxyUrl).toBe('') + expect(restarted.getSettings().opencodeSessionCookie).toBe('') + expect(restarted.getUI().browserKagiSessionLink).toBe('') + expect(restarted.getSshPtyConsumerRecovery('ssh-1')).toBeNull() + }) + + it('persists clears after a healthy save preserves sealed ciphertext', async () => { + const initial = await createStore() + await writeProtectedState(initial, ORIGINAL, 'before') + const originalCiphertext = readState() + + cipherState.availability = 'unavailable' + const sealed = await createStore() + expect(sealed.getSettings().httpProxyUrl).toBe('') + expect(sealed.getSettings().opencodeSessionCookie).toBe('') + + cipherState.availability = 'available' + sealed.updateSettings({ httpProxyBypassRules: 'healthy-preserve' }) + await settleSave(sealed) + expect(readState().settings.httpProxyUrl).toBe(originalCiphertext.settings.httpProxyUrl) + expect(readState().settings.opencodeSessionCookie).toBe( + originalCiphertext.settings.opencodeSessionCookie + ) + + sealed.updateSettings({ httpProxyUrl: '', opencodeSessionCookie: '' }) + await settleSave(sealed) + expect(readState().settings.httpProxyUrl).toBe('') + expect(readState().settings.opencodeSessionCookie).toBe('') + }) + + it('JSON-escapes retained legacy plaintext while storage is unavailable', async () => { + const legacyCookie = 'legacy-"cookie\\value' + writeFileSync(dataFile(), JSON.stringify({ settings: { opencodeSessionCookie: legacyCookie } })) + cipherState.availability = 'unavailable' + const store = await createStore() + + store.updateSettings({ opencodeSessionCookie: PENDING.cookie }) + await settleSave(store) + + expect(readState().settings.opencodeSessionCookie).toBe(legacyCookie) + }) + + it('evicts ciphertext when decrypted SSH recovery validation rejects the record', async () => { + const oversizedLease = 'z'.repeat(513) + const encryptedLease = Buffer.from(`enc:${randomUUID()}:${oversizedLease}`).toString('base64') + writeFileSync( + dataFile(), + JSON.stringify({ + sshPtyConsumerRecoveries: [ + { + targetId: 'ssh-1', + clientInstanceId: 'client-1', + serverBuildId: 'relay-build-1', + clientGeneration: 1, + ownerGeneration: 1, + ownerLease: encryptedLease + } + ] + }) + ) + const store = await createStore() + expect(store.getSshPtyConsumerRecovery('ssh-1')).toBeNull() + + cipherState.availability = 'unavailable' + await store.upsertSshPtyConsumerRecovery({ + targetId: 'ssh-1', + clientInstanceId: 'client-1', + serverBuildId: 'relay-build-1', + clientGeneration: 2, + ownerGeneration: 2, + ownerLease: 'replacement-owner-lease' + }) + + expect(readState().sshPtyConsumerRecoveries[0]?.ownerLease).toBe('') + }) + + it('keeps loaded ciphertext sealed through same-instance recovery', async () => { + const initial = await createStore() + await writeProtectedState(initial, ORIGINAL, 'before') + const originalCiphertext = readState() + + cipherState.availability = 'unavailable' + const sealed = await createStore() + expect(sealed.getSettings().httpProxyUrl).toBe('') + expect(sealed.getSettings().opencodeSessionCookie).toBe('') + expect(sealed.getUI().browserKagiSessionLink).toBe('') + expect(sealed.getSshPtyConsumerRecovery('ssh-1')).toBeNull() + + sealed.updateSettings({ httpProxyBypassRules: 'during-outage' }) + await settleSave(sealed) + expect(readState().settings.opencodeSessionCookie).toBe( + originalCiphertext.settings.opencodeSessionCookie + ) + + cipherState.availability = 'available' + sealed.updateSettings({ httpProxyBypassRules: 'after-recovery' }) + await settleSave(sealed) + expect(readState()).toMatchObject({ + settings: { + httpProxyUrl: originalCiphertext.settings.httpProxyUrl, + opencodeSessionCookie: originalCiphertext.settings.opencodeSessionCookie + }, + ui: { browserKagiSessionLink: originalCiphertext.ui.browserKagiSessionLink }, + sshPtyConsumerRecoveries: [ + { ownerLease: originalCiphertext.sshPtyConsumerRecoveries[0]?.ownerLease } + ] + }) + + const restarted = await createStore() + expect(restarted.getSettings().httpProxyUrl).toBe(ORIGINAL.proxy) + expect(restarted.getSettings().opencodeSessionCookie).toBe(ORIGINAL.cookie) + expect(restarted.getUI().browserKagiSessionLink).toBe(ORIGINAL.kagi) + expect(restarted.getSshPtyConsumerRecovery('ssh-1')?.ownerLease).toBe(ORIGINAL.ownerLease) + expect(restarted.getSettings().httpProxyBypassRules).toBe('after-recovery') + }) + + it('keeps undecryptable ciphertext sealed from consumers and later saves', async () => { + const initial = await createStore() + await writeProtectedState(initial, ORIGINAL, 'before') + const originalCiphertext = readState() + + cipherState.decryptionThrows = true + const sealed = await createStore() + expect(sealed.getSettings().httpProxyUrl).toBe('') + expect(sealed.getSettings().opencodeSessionCookie).toBe('') + expect(sealed.getUI().browserKagiSessionLink).toBe('') + expect(sealed.getSshPtyConsumerRecovery('ssh-1')).toBeNull() + + sealed.updateSettings({ httpProxyBypassRules: 'decryption-failed' }) + await settleSave(sealed) + expect(readState()).toMatchObject({ + settings: { + httpProxyUrl: originalCiphertext.settings.httpProxyUrl, + opencodeSessionCookie: originalCiphertext.settings.opencodeSessionCookie + }, + ui: { browserKagiSessionLink: originalCiphertext.ui.browserKagiSessionLink }, + sshPtyConsumerRecoveries: [ + { ownerLease: originalCiphertext.sshPtyConsumerRecoveries[0]?.ownerLease } + ] + }) + + cipherState.decryptionThrows = false + const restarted = await createStore() + expect(restarted.getSettings().httpProxyUrl).toBe(ORIGINAL.proxy) + expect(restarted.getSettings().opencodeSessionCookie).toBe(ORIGINAL.cookie) + expect(restarted.getUI().browserKagiSessionLink).toBe(ORIGINAL.kagi) + expect(restarted.getSshPtyConsumerRecovery('ssh-1')?.ownerLease).toBe(ORIGINAL.ownerLease) + }) + + it('accepts validated legacy plaintext for OpenCode and SSH migration', async () => { + const legacyCookie = 'auth=Fe26.2**legacy-token' + const legacyOwnerLease = '9ab3f53d-0de9-4b80-af38-0cc15f62a6ba' + writeFileSync( + dataFile(), + JSON.stringify({ + settings: { opencodeSessionCookie: legacyCookie }, + sshPtyConsumerRecoveries: [ + { + targetId: 'ssh-1', + clientInstanceId: 'client-1', + serverBuildId: 'relay-build-1', + clientGeneration: 1, + ownerGeneration: 1, + ownerLease: legacyOwnerLease + } + ] + }) + ) + + const store = await createStore() + expect(store.getSettings().opencodeSessionCookie).toBe(legacyCookie) + expect(store.getSshPtyConsumerRecovery('ssh-1')?.ownerLease).toBe(legacyOwnerLease) + }) + + it.each(['availability-throws', 'encryption-throws', 'unavailable'])( + 'saves non-secrets without exposing or destroying protected values: %s', + async (failureMode) => { + const store = await createStore() + await writeProtectedState(store, ORIGINAL, 'before') + const originalCiphertext = readState() + expectPlaintextsAbsent(readFileSync(dataFile(), 'utf-8'), ORIGINAL) + + vi.advanceTimersByTime(60 * 60 * 1_000 + 1) + setFailure(failureMode) + await writeProtectedState(store, PENDING, 'during-failure') + + const primaryRaw = readFileSync(dataFile(), 'utf-8') + const backupRaw = readFileSync(`${dataFile()}.bak.0`, 'utf-8') + const persisted = readState() + expectPlaintextsAbsent(primaryRaw, PENDING) + expectPlaintextsAbsent(backupRaw, PENDING) + expect.soft(persisted.settings.httpProxyUrl).toBe(originalCiphertext.settings.httpProxyUrl) + expect + .soft(persisted.settings.opencodeSessionCookie) + .toBe(originalCiphertext.settings.opencodeSessionCookie) + expect + .soft(persisted.ui.browserKagiSessionLink) + .toBe(originalCiphertext.ui.browserKagiSessionLink) + expect + .soft(persisted.sshPtyConsumerRecoveries[0]?.ownerLease) + .toBe(originalCiphertext.sshPtyConsumerRecoveries[0]?.ownerLease) + expect.soft(persisted.settings.httpProxyBypassRules).toBe('during-failure') + + const loadedDuringFailure = await createStore() + expect(loadedDuringFailure.getSettings().httpProxyBypassRules).toBe('during-failure') + await settleSave(loadedDuringFailure) + expectPlaintextsAbsent(readFileSync(dataFile(), 'utf-8'), PENDING) + + cipherState.availability = 'available' + cipherState.encryptionThrows = false + const recovered = await createStore() + expect(recovered.getSettings().httpProxyUrl).toBe(ORIGINAL.proxy) + expect(recovered.getSettings().opencodeSessionCookie).toBe(ORIGINAL.cookie) + expect(recovered.getUI().browserKagiSessionLink).toBe(ORIGINAL.kagi) + expect(recovered.getSshPtyConsumerRecovery('ssh-1')?.ownerLease).toBe(ORIGINAL.ownerLease) + expect(recovered.getSettings().httpProxyBypassRules).toBe('during-failure') + + await writeProtectedState(recovered, PENDING, 'recovered') + const restarted = await createStore() + expect(restarted.getSettings().httpProxyUrl).toBe(PENDING.proxy) + expect(restarted.getSettings().opencodeSessionCookie).toBe(PENDING.cookie) + expect(restarted.getUI().browserKagiSessionLink).toBe(PENDING.kagi) + expect(restarted.getSshPtyConsumerRecovery('ssh-1')?.ownerLease).toBe(PENDING.ownerLease) + expect(restarted.getSettings().httpProxyBypassRules).toBe('recovered') + } + ) +}) diff --git a/src/main/persistence-protected-secret-write-race.test.ts b/src/main/persistence-protected-secret-write-race.test.ts new file mode 100644 index 00000000000..20bbe78d5cf --- /dev/null +++ b/src/main/persistence-protected-secret-write-race.test.ts @@ -0,0 +1,106 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import type * as NodeFsPromises from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +const testState = { dir: '' } +const cipherState = { available: true } + +const renameGate = vi.hoisted(() => ({ + sourcePrefix: '', + release: null as Promise | null, + started: null as (() => void) | null +})) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + rename: async (source: string, destination: string) => { + if (renameGate.release && source.startsWith(renameGate.sourcePrefix)) { + const release = renameGate.release + renameGate.release = null + renameGate.started?.() + await release + } + return actual.rename(source, destination) + } + } +}) + +vi.mock('./ssh/ssh-config-parser', () => ({ + loadUserSshConfig: vi.fn(), + sshConfigHostsToTargets: vi.fn() +})) + +vi.mock('./telemetry/client', () => ({ track: vi.fn() })) +vi.mock('./telemetry/cohort-classifier', () => ({ + getCohortAtEmit: vi.fn().mockReturnValue({ nth_repo_added: 2 }) +})) + +vi.mock('electron', () => ({ + app: { getPath: () => testState.dir }, + safeStorage: { + isEncryptionAvailable: () => cipherState.available, + encryptString: (plaintext: string) => Buffer.from(`enc:${plaintext}`, 'utf-8'), + decryptString: (ciphertext: Buffer) => ciphertext.toString('utf-8').slice('enc:'.length) + } +})) + +async function createStore() { + vi.resetModules() + const { Store, initDataPath } = await import('./persistence') + initDataPath() + return new Store() +} + +function deferred(): { promise: Promise; resolve: () => void } { + let resolve!: () => void + const promise = new Promise((next) => { + resolve = next + }) + return { promise, resolve } +} + +describe('protected-secret async write retention', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-protected-secret-write-race-')) + cipherState.available = true + renameGate.sourcePrefix = join(testState.dir, 'orca-data.json') + renameGate.release = null + renameGate.started = null + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + rmSync(testState.dir, { recursive: true, force: true }) + }) + + it('does not retain ciphertext from a superseded async secret write', async () => { + const store = await createStore() + store.updateSettings({ opencodeSessionCookie: 'durable-cookie' }) + vi.advanceTimersByTime(1_000) + await store.waitForPendingWrite() + + const renameRelease = deferred() + const renameStarted = deferred() + renameGate.release = renameRelease.promise + renameGate.started = renameStarted.resolve + + store.updateSettings({ opencodeSessionCookie: 'intermediate-cookie' }) + vi.advanceTimersByTime(1_000) + await renameStarted.promise + + store.updateSettings({ opencodeSessionCookie: 'replacement-cookie' }) + cipherState.available = false + vi.advanceTimersByTime(1_000) + renameRelease.resolve() + await store.waitForPendingWrite() + + cipherState.available = true + const restarted = await createStore() + expect(restarted.getSettings().opencodeSessionCookie).toBe('durable-cookie') + }) +}) diff --git a/src/main/persistence-proxy-secret-recovery.test.ts b/src/main/persistence-proxy-secret-recovery.test.ts index bc6e03483bb..769ebec69ba 100644 --- a/src/main/persistence-proxy-secret-recovery.test.ts +++ b/src/main/persistence-proxy-secret-recovery.test.ts @@ -2,9 +2,9 @@ // On macOS a keychain reset/denial makes decryptString throw at load, and the // raw ciphertext then masqueraded as a configured proxy: applyElectronProxySettings // silently fell back to DIRECT and the garbage re-persisted forever. These tests -// pin the recovery contract: undecryptable values are cleared (never applied, -// never re-saved as garbage), plaintext values survive as the upgrade path, and -// a throwing safeStorage.isEncryptionAvailable() cannot kill the whole save. +// pin the recovery contract: undecryptable values stay sealed (never applied +// or destroyed), plaintext values survive as the upgrade path, and safeStorage +// failures cannot expose the proxy secret or kill unrelated saves. import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { existsSync, mkdirSync, readFileSync, rmSync, mkdtempSync, writeFileSync } from 'node:fs' import { dirname, join } from 'node:path' @@ -131,8 +131,9 @@ describe('httpProxyUrl secret recovery (STA-3442)', () => { }) }) - it('clears an undecryptable httpProxyUrl instead of surfacing ciphertext as settings', async () => { + it('seals an undecryptable httpProxyUrl without destroying its ciphertext', async () => { await seedConfiguredProxy() + const originalCiphertext = JSON.parse(readFileSync(dataFile(), 'utf-8')).settings.httpProxyUrl // Keychain reset/denial: every decrypt now fails. cipherState.decryptAlwaysThrows = true @@ -142,13 +143,14 @@ describe('httpProxyUrl secret recovery (STA-3442)', () => { // Bypass rules are not encrypted and must survive. expect(reloaded.getSettings().httpProxyBypassRules).toBe(BYPASS_RULES) - // The cleanup must reach disk so garbage never round-trips back in. + // Unrelated durable changes must not erase ciphertext that can recover later. + reloaded.updateSettings({ httpProxyBypassRules: 'localhost' }) vi.advanceTimersByTime(2000) await reloaded.waitForPendingWrite() const persisted = JSON.parse(readFileSync(dataFile(), 'utf-8')) as { settings: { httpProxyUrl: string } } - expect(persisted.settings.httpProxyUrl).toBe('') + expect(persisted.settings.httpProxyUrl).toBe(originalCiphertext) }) it('keeps a plaintext httpProxyUrl on disk readable (pre-encryption/hand-edited upgrade path)', async () => { @@ -164,7 +166,7 @@ describe('httpProxyUrl secret recovery (STA-3442)', () => { expect(store.getSettings().httpProxyBypassRules).toBe(BYPASS_RULES) }) - it('still saves and reloads the proxy when safeStorage.isEncryptionAvailable throws', async () => { + it('saves non-secret proxy settings without plaintext when availability throws', async () => { cipherState.availabilityThrows = true const store = await createStore() @@ -172,15 +174,15 @@ describe('httpProxyUrl secret recovery (STA-3442)', () => { vi.advanceTimersByTime(1000) await store.waitForPendingWrite() - // Encryption degraded to plaintext passthrough; the save must not be lost. expect(existsSync(dataFile())).toBe(true) const persisted = JSON.parse(readFileSync(dataFile(), 'utf-8')) as { settings: { httpProxyUrl: string; httpProxyBypassRules: string } } - expect(persisted.settings.httpProxyUrl).toBe(PROXY_URL) + expect(persisted.settings.httpProxyUrl).toBe('') expect(persisted.settings.httpProxyBypassRules).toBe(BYPASS_RULES) const reloaded = await createStore() - expect(reloaded.getSettings().httpProxyUrl).toBe(PROXY_URL) + expect(reloaded.getSettings().httpProxyUrl).toBe('') + expect(reloaded.getSettings().httpProxyBypassRules).toBe(BYPASS_RULES) }) }) diff --git a/src/main/persistence-single-serialize.test.ts b/src/main/persistence-single-serialize.test.ts index 66c3b4ab0f6..c638ac12224 100644 --- a/src/main/persistence-single-serialize.test.ts +++ b/src/main/persistence-single-serialize.test.ts @@ -145,7 +145,7 @@ describe('persistence single-serialize save guard', () => { expect(reloaded.getUI().browserKagiSessionLink).toBe(KAGI_LINK) }) - it('handles empty secrets and unavailable encryption (payload stays plaintext, guard still skips)', async () => { + it('omits new secrets when encryption is unavailable and still skips an identical state', async () => { cipherState.encryptionAvailable = false const store = await createStore() store.updateSettings({ ...SECRETS, opencodeSessionCookie: '' }) @@ -156,7 +156,7 @@ describe('persistence single-serialize save guard', () => { settings: { opencodeSessionCookie: string; httpProxyUrl: string } } expect(persisted.settings.opencodeSessionCookie).toBe('') - expect(persisted.settings.httpProxyUrl).toBe(SECRETS.httpProxyUrl) + expect(persisted.settings.httpProxyUrl).toBe('') const inoBefore = statSync(dataFile()).ino store.updateSettings({ httpProxyUrl: SECRETS.httpProxyUrl }) diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 04188206ad7..08697613c31 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -1,5 +1,5 @@ /* eslint-disable max-lines -- Why: persistence keeps schema defaults, migration, and load/save/flush in one file so the storage contract reviews as a unit. */ -import { app, safeStorage } from 'electron' +import { app } from 'electron' import { readFileSync, writeFileSync, @@ -39,6 +39,7 @@ import { import { getAutomationLegacyRepoId } from '../shared/automation-run-identity' import { normalizeAutomationPrecheck } from '../shared/automation-precheck' import { normalizeProxyUrl } from '../shared/network-proxy' +import { normalizeKagiSessionLink } from '../shared/browser-url' import type { PersistedState, Project, @@ -280,47 +281,23 @@ import { import { track } from './telemetry/client' import { getCohortAtEmit } from './telemetry/cohort-classifier' import { isStartupDiagnosticsEnabled, logStartupDiagnostic } from './startup/startup-diagnostics' +import { + PROTECTED_SECRET_SLOT, + ProtectedSecretPersistence, + sshPtyOwnerLeaseSecretSlot, + type ProtectedSecretRetentionUpdate +} from './protected-secret-persistence' -// Why (STA-3442): isEncryptionAvailable() itself can throw (keychain/API errors, pre-ready -// use); an uncaught throw here failed the entire save/load, silently losing every setting. -function safeStorageEncryptionAvailable(): boolean { - try { - return safeStorage.isEncryptionAvailable() - } catch (err) { - console.warn('[persistence] safeStorage availability check failed:', err) - return false - } +function isLegacyOpenCodeSessionCookie(value: string): boolean { + const trimmed = value.trim() + return ( + trimmed.startsWith('Fe26.2**') || + trimmed.split(';').some((pair) => /^(?:auth|__Host-auth)=\S+$/i.test(pair.trim())) + ) } -function encrypt(plaintext: string): string { - if (!plaintext || !safeStorageEncryptionAvailable()) { - return plaintext - } - try { - return safeStorage.encryptString(plaintext).toString('base64') - } catch (err) { - console.error('[persistence] Encryption failed:', err) - return plaintext - } -} - -function decrypt(ciphertext: string): string { - if (!ciphertext || !safeStorageEncryptionAvailable()) { - return ciphertext - } - try { - return safeStorage.decryptString(Buffer.from(ciphertext, 'base64')) - } catch { - // Why: decrypt failure usually means plaintext (pre-encryption) or a changed keychain; return raw so the cookie survives upgrade. - console.warn( - '[persistence] safeStorage decryption failed — returning ciphertext as-is. Possible keychain reset.' - ) - return ciphertext - } -} - -function decryptOptionalSecret(value: string | null | undefined): string | null { - return value ? decrypt(value) : null +function isLegacySshPtyOwnerLease(value: string): boolean { + return /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value) } function retireLegacyInstructionsForClearedTextActionRecipes( @@ -2813,6 +2790,7 @@ export class Store { private pendingGithubCacheWrite: Promise | null = null private readonly staleGithubCacheTempCleanup: Promise private gitUsernameCache = new Map() + private readonly protectedSecrets = new ProtectedSecretPersistence() private loadNeedsSave = false private settingsChangeListeners = new Set< ( @@ -3075,25 +3053,43 @@ export class Store { // Why: secrets are stored encrypted via safeStorage; decrypt at the load boundary so the app sees plaintext. if (parsed.settings?.opencodeSessionCookie) { - parsed.settings.opencodeSessionCookie = decrypt(parsed.settings.opencodeSessionCookie) + parsed.settings.opencodeSessionCookie = this.protectedSecrets.decrypt( + PROTECTED_SECRET_SLOT.opencodeSessionCookie, + parsed.settings.opencodeSessionCookie, + isLegacyOpenCodeSessionCookie + ) } if (parsed.settings?.httpProxyUrl) { - const decryptedProxyUrl = decrypt(parsed.settings.httpProxyUrl) + const decryptedProxy = this.protectedSecrets.decryptWithStatus( + PROTECTED_SECRET_SLOT.httpProxyUrl, + parsed.settings.httpProxyUrl, + (value) => normalizeProxyUrl(value).ok + ) // Why (STA-3442): after a keychain reset decrypt returns raw ciphertext; a non-URL // value must not masquerade as a configured proxy (silent DIRECT fallback) or // re-persist as garbage. Plaintext URLs still pass, preserving the upgrade path. - if (normalizeProxyUrl(decryptedProxyUrl).ok) { - parsed.settings.httpProxyUrl = decryptedProxyUrl + if ( + decryptedProxy.status === 'unavailable' || + (decryptedProxy.status === 'failed' && !decryptedProxy.plaintext) + ) { + parsed.settings.httpProxyUrl = '' + } else if (normalizeProxyUrl(decryptedProxy.plaintext).ok) { + parsed.settings.httpProxyUrl = decryptedProxy.plaintext } else { console.warn( '[persistence] httpProxyUrl could not be decrypted — clearing the stored proxy URL. Re-enter it in Settings > Advanced > Network.' ) parsed.settings.httpProxyUrl = '' + this.protectedSecrets.removeRetainedBlob(PROTECTED_SECRET_SLOT.httpProxyUrl) this.loadNeedsSave = true } } if (parsed.ui?.browserKagiSessionLink) { - parsed.ui.browserKagiSessionLink = decryptOptionalSecret(parsed.ui.browserKagiSessionLink) + parsed.ui.browserKagiSessionLink = this.protectedSecrets.decrypt( + PROTECTED_SECRET_SLOT.browserKagiSessionLink, + parsed.ui.browserKagiSessionLink, + (value) => normalizeKagiSessionLink(value) !== null + ) } parsed.sshPtyConsumerRecoveries = ( Array.isArray(parsed.sshPtyConsumerRecoveries) ? parsed.sshPtyConsumerRecoveries : [] @@ -3102,8 +3098,23 @@ export class Store { normalizeSshPtyConsumerRecovery(record, ENCRYPTED_SSH_PTY_OWNER_LEASE_MAX_LENGTH) ) .filter((record): record is SshPtyConsumerRecovery => record !== null) - .map((record) => ({ ...record, ownerLease: decrypt(record.ownerLease) })) - .map((record) => normalizeSshPtyConsumerRecovery(record)) + .map((record) => { + const slot = sshPtyOwnerLeaseSecretSlot(record.targetId) + const decrypted = this.protectedSecrets.decryptWithStatus( + slot, + record.ownerLease, + isLegacySshPtyOwnerLease + ) + const normalized = + decrypted.status === 'unavailable' || + (decrypted.status === 'failed' && !decrypted.plaintext) + ? record + : normalizeSshPtyConsumerRecovery({ ...record, ownerLease: decrypted.plaintext }) + if (!normalized) { + this.protectedSecrets.removeRetainedBlob(slot) + } + return normalized + }) .filter((record): record is SshPtyConsumerRecovery => record !== null) // Merge with defaults in case new fields were added @@ -3904,8 +3915,12 @@ export class Store { return durable } - // Why: build payload synchronously so hash and serialized bytes reflect the same state tick (no await interleave). One full-state stringify serves both the on-disk payload and the no-op-write guard hash: each secret slot is serialized as a fresh unguessable sentinel, then sentinels are substituted to ciphertext for the payload and to plaintext for the hash. The hash is thus a pure function of plaintext state (skips a byte-identical rewrite) without a second stringify. - private buildStateToSave(): { payload: string; stateHash: string } { + // Why: build payload synchronously so hash and bytes reflect one state tick. A degraded prefix makes the first healthy retry durable even when plaintext state is unchanged. + private buildStateToSave(): { + payload: string + stateHash: string + protectedSecretUpdates: ProtectedSecretRetentionUpdate[] + } { // Why sentinels (not a blob/key string match): the substitution must be // position-exact. A plain search for the ciphertext — or even for a // `"key":"blob"` token — can be mimicked by user-controlled state (e.g. an @@ -3915,54 +3930,79 @@ export class Store { // on deterministic-IV platforms (macOS/legacy-Linux OSCrypt). A per-slot // random UUID can't occur anywhere else in the serialized state (the user // sets their data before it is minted), so it appears exactly once. - const secretSubs: { sentinel: string; blob: string; plaintext: string }[] = [] - const encryptToSentinel = (plaintext: string): string => { - const blob = encrypt(plaintext) - // Deterministic already (empty secret / safeStorage unavailable / encrypt - // failure): blob === plaintext, so no normalization — and no sentinel, - // which also avoids substituting an empty or plaintext-shaped slot. - if (blob === plaintext) { + const secretSubs: { sentinel: string; blob: string; hashValue: string }[] = [] + const protectedSecretUpdates: ProtectedSecretRetentionUpdate[] = [] + let protectedStorageDegraded = false + const encryptToSentinel = (slot: string, plaintext: string): string => { + const encrypted = this.protectedSecrets.encrypt(slot, plaintext) + if (encrypted.retentionUpdate) { + protectedSecretUpdates.push(encrypted.retentionUpdate) + } + protectedStorageDegraded ||= encrypted.degraded + const { blob, hashValue = plaintext } = encrypted + // Values already identical in payload and hash need no sentinel substitution. + if (blob === plaintext && hashValue === plaintext) { return blob } const sentinel = `orca-secret-slot-${randomUUID()}` - secretSubs.push({ sentinel, blob, plaintext }) + secretSubs.push({ sentinel, blob, hashValue }) return sentinel } + const encryptOptionalToSentinel = ( + slot: string, + plaintext: string | null | undefined + ): string | null => { + const encrypted = encryptToSentinel(slot, plaintext ?? '') + return encrypted || null + } // Why: clone before encrypting secrets so in-memory this.state stays plaintext. const stateToSave = { ...this.getDurableState(), sshPtyConsumerRecoveries: (this.state.sshPtyConsumerRecoveries ?? []).map((record) => ({ ...record, - ownerLease: encryptToSentinel(record.ownerLease) + ownerLease: encryptToSentinel( + sshPtyOwnerLeaseSecretSlot(record.targetId), + record.ownerLease + ) })), settings: { ...this.state.settings, - opencodeSessionCookie: encryptToSentinel(this.state.settings.opencodeSessionCookie), - httpProxyUrl: encryptToSentinel(this.state.settings.httpProxyUrl ?? '') + opencodeSessionCookie: encryptToSentinel( + PROTECTED_SECRET_SLOT.opencodeSessionCookie, + this.state.settings.opencodeSessionCookie + ), + httpProxyUrl: encryptToSentinel( + PROTECTED_SECRET_SLOT.httpProxyUrl, + this.state.settings.httpProxyUrl ?? '' + ) }, ui: { ...this.state.ui, - browserKagiSessionLink: this.state.ui.browserKagiSessionLink - ? encryptToSentinel(this.state.ui.browserKagiSessionLink) - : null + browserKagiSessionLink: encryptOptionalToSentinel( + PROTECTED_SECRET_SLOT.browserKagiSessionLink, + this.state.ui.browserKagiSessionLink + ) } } // Why compact: ~20% fewer bytes and less serialize time; all readers JSON.parse so formatting is irrelevant. // One full-state stringify; secret slots currently hold sentinels. const serialized = JSON.stringify(stateToSave) // Substitute each unique sentinel exactly once: ciphertext for the on-disk - // payload, plaintext for the guard hash. Function-form replacement keeps - // `$` in blob/plaintext inert; both sides read the sentinel as JSON-escaped + // payload, a stable normalized value for the guard hash. Function-form + // replacement keeps `$` inert; both sides read the sentinel as JSON-escaped // in `serialized`, so each replace is byte-for-byte position-exact. let payload = serialized let hashInput = serialized - for (const { sentinel, blob, plaintext } of secretSubs) { + for (const { sentinel, blob, hashValue } of secretSubs) { const escapedSentinel = JSON.stringify(sentinel).slice(1, -1) - payload = payload.replace(escapedSentinel, () => blob) - hashInput = hashInput.replace(escapedSentinel, () => JSON.stringify(plaintext).slice(1, -1)) + payload = payload.replace(escapedSentinel, () => JSON.stringify(blob).slice(1, -1)) + hashInput = hashInput.replace(escapedSentinel, () => JSON.stringify(hashValue).slice(1, -1)) } - const stateHash = createHash('sha1').update(hashInput).digest('hex') - return { payload, stateHash } + const stateHash = createHash('sha1') + .update(protectedStorageDegraded ? 'safeStorage-degraded\0' : '') + .update(hashInput) + .digest('hex') + return { payload, stateHash, protectedSecretUpdates } } // Why: async writes avoid blocking the main Electron thread on every debounced save. @@ -3971,7 +4011,7 @@ export class Store { return } const gen = this.writeGeneration - const { payload, stateHash } = this.buildStateToSave() + const { payload, stateHash, protectedSecretUpdates } = this.buildStateToSave() // Why: don't rewrite a byte-identical multi-MB file when state nets out to already-persisted. if (stateHash === this.lastWrittenStateHash) { this.lastDurableWriteGeneration = Math.max(this.lastDurableWriteGeneration, gen) @@ -4013,6 +4053,7 @@ export class Store { // Why re-check gen: a mutation or sync flush during rename makes the installed hash ambiguous; invalidate the no-op guard. if (renamed && this.writeGeneration === gen) { this.lastWrittenStateHash = stateHash + this.protectedSecrets.commitRetentionUpdates(protectedSecretUpdates) } else if (renamed) { this.lastWrittenStateHash = null } @@ -4039,7 +4080,7 @@ export class Store { if (this.writesFrozen) { return } - const { payload, stateHash } = this.buildStateToSave() + const { payload, stateHash, protectedSecretUpdates } = this.buildStateToSave() // Why: matching hash means the file already holds this state; force overrides when an async rename may be racing past the gen check. if (!opts.force && stateHash === this.lastWrittenStateHash) { return @@ -4059,6 +4100,7 @@ export class Store { writeFileDurableSync(tmpFile, dataFile, payload) renamed = true this.lastWrittenStateHash = stateHash + this.protectedSecrets.commitRetentionUpdates(protectedSecretUpdates) this.lastDurableWriteGeneration = Math.max( this.lastDurableWriteGeneration, this.writeGeneration @@ -5724,6 +5766,12 @@ export class Store { options: { notifyListeners?: boolean; originWebContentsId?: number } = {} ): GlobalSettings { const sanitizedUpdates = stripLegacyTerminalScrollbackBytes(updates) + if ('opencodeSessionCookie' in updates && !updates.opencodeSessionCookie) { + this.protectedSecrets.removeRetainedBlob(PROTECTED_SECRET_SLOT.opencodeSessionCookie) + } + if ('httpProxyUrl' in updates && !updates.httpProxyUrl) { + this.protectedSecrets.removeRetainedBlob(PROTECTED_SECRET_SLOT.httpProxyUrl) + } // Why: coerce to boolean here (not the IPC edge) so every write path is covered and a truthy non-bool can't persist as "tray-minimize on". if ('minimizeToTrayOnClose' in updates) { sanitizedUpdates.minimizeToTrayOnClose = updates.minimizeToTrayOnClose === true @@ -5948,6 +5996,9 @@ export class Store { } updateUI(updates: Partial): void { + if ('browserKagiSessionLink' in updates && !updates.browserKagiSessionLink) { + this.protectedSecrets.removeRetainedBlob(PROTECTED_SECRET_SLOT.browserKagiSessionLink) + } const sanitizedUpdates = stripMainOwnedTelemetryMarkerFromUI(updates) const { activeView, ...durableUpdates } = sanitizedUpdates const activeViewChanged = this.activeViewPreference.set(activeView) @@ -6853,6 +6904,7 @@ export class Store { } this.state.sshTargets = nextTargets this.state.sshPtyConsumerRecoveries = nextRecoveries + this.protectedSecrets.removeRetainedBlob(sshPtyOwnerLeaseSecretSlot(id)) this.scheduleSave() } @@ -7003,6 +7055,7 @@ export class Store { const retainedRecoveries = recoveries.filter((record) => record.targetId !== oldTargetId) if (retainedRecoveries.length !== recoveries.length) { this.state.sshPtyConsumerRecoveries = retainedRecoveries + this.protectedSecrets.removeRetainedBlob(sshPtyOwnerLeaseSecretSlot(oldTargetId)) carrierChanged = true } let setupsChanged = false @@ -7045,6 +7098,12 @@ export class Store { const record = (this.state.sshPtyConsumerRecoveries ?? []).find( (candidate) => candidate.targetId === targetId ) + if ( + record && + this.protectedSecrets.isSealed(sshPtyOwnerLeaseSecretSlot(record.targetId), record.ownerLease) + ) { + return null + } return record ? structuredClone(record) : null } @@ -7068,6 +7127,7 @@ export class Store { return } this.state.sshPtyConsumerRecoveries = next + this.protectedSecrets.removeRetainedBlob(sshPtyOwnerLeaseSecretSlot(targetId)) await this.flushSshPtyConsumerRecovery() } diff --git a/src/main/protected-secret-persistence.test.ts b/src/main/protected-secret-persistence.test.ts new file mode 100644 index 00000000000..815edc5d477 --- /dev/null +++ b/src/main/protected-secret-persistence.test.ts @@ -0,0 +1,66 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const cipherState = { available: true } + +vi.mock('electron', () => ({ + safeStorage: { + isEncryptionAvailable: () => cipherState.available, + encryptString: (plaintext: string) => Buffer.from(`encrypted:${plaintext}`), + decryptString: (ciphertext: Buffer) => ciphertext.toString().slice('encrypted:'.length) + } +})) + +describe('ProtectedSecretPersistence', () => { + beforeEach(() => { + cipherState.available = true + }) + + it('evicts dynamic slots across repeated SSH recovery lifecycles', async () => { + const { ProtectedSecretPersistence, sshPtyOwnerLeaseSecretSlot } = + await import('./protected-secret-persistence') + const secrets = new ProtectedSecretPersistence() + const slots = Array.from({ length: 100 }, (_, index) => + sshPtyOwnerLeaseSecretSlot(`ssh-${index}`) + ) + + for (const slot of slots) { + expect(secrets.encrypt(slot, 'owner-lease').blob).not.toBe('owner-lease') + secrets.removeRetainedBlob(slot) + } + + cipherState.available = false + for (const slot of slots) { + expect(secrets.encrypt(slot, 'replacement-lease')).toEqual({ blob: '', degraded: true }) + } + }) + + it('keeps unavailable ciphertext sealed across recovery until replacement or clear', async () => { + const { ProtectedSecretPersistence } = await import('./protected-secret-persistence') + const secrets = new ProtectedSecretPersistence() + const slot = 'protected-slot' + const ciphertext = Buffer.from('encrypted:original').toString('base64') + + cipherState.available = false + expect(secrets.decryptWithStatus(slot, ciphertext)).toEqual({ + plaintext: '', + status: 'unavailable' + }) + expect(secrets.isSealed(slot, ciphertext)).toBe(true) + expect(secrets.encrypt(slot, '')).toEqual({ + blob: ciphertext, + degraded: true, + hashValue: ciphertext + }) + + cipherState.available = true + expect(secrets.encrypt(slot, '')).toEqual({ + blob: ciphertext, + degraded: false, + hashValue: ciphertext + }) + expect(secrets.encrypt(slot, 'replacement').blob).not.toBe(ciphertext) + + secrets.removeRetainedBlob(slot) + expect(secrets.encrypt(slot, '')).toEqual({ blob: '', degraded: false }) + }) +}) diff --git a/src/main/protected-secret-persistence.ts b/src/main/protected-secret-persistence.ts new file mode 100644 index 00000000000..85ab3153043 --- /dev/null +++ b/src/main/protected-secret-persistence.ts @@ -0,0 +1,139 @@ +import { safeStorage } from 'electron' + +export const PROTECTED_SECRET_SLOT = { + opencodeSessionCookie: 'settings.opencodeSessionCookie', + httpProxyUrl: 'settings.httpProxyUrl', + browserKagiSessionLink: 'ui.browserKagiSessionLink' +} as const + +export function sshPtyOwnerLeaseSecretSlot(targetId: string): string { + return `sshPtyConsumerRecoveries.ownerLease:${targetId}` +} + +export type ProtectedSecretDecryption = { + plaintext: string + status: 'decrypted' | 'failed' | 'unavailable' +} + +export type ProtectedSecretRetentionUpdate = { + slot: string + blob: string | null +} + +export type LegacyPlaintextValidator = (value: string) => boolean + +type ProtectedSecretEncryption = { + blob: string + degraded: boolean + hashValue?: string + retentionUpdate?: ProtectedSecretRetentionUpdate +} + +// Preserve prior ciphertext or omit a new secret so unrelated state can still save safely. +export class ProtectedSecretPersistence { + private readonly retainedBlobs = new Map() + private readonly sealedSlots = new Set() + + removeRetainedBlob(slot: string): void { + this.retainedBlobs.delete(slot) + this.sealedSlots.delete(slot) + } + + isSealed(slot: string, value: string): boolean { + return this.sealedSlots.has(slot) && this.retainedBlobs.get(slot) === value + } + + commitRetentionUpdates(updates: readonly ProtectedSecretRetentionUpdate[]): void { + for (const update of updates) { + if (update.blob === null) { + this.removeRetainedBlob(update.slot) + } else { + this.retainedBlobs.set(update.slot, update.blob) + this.sealedSlots.delete(update.slot) + } + } + } + + encrypt(slot: string, plaintext: string): ProtectedSecretEncryption { + const retained = this.retainedBlobs.get(slot) ?? '' + if (!plaintext && !retained) { + return { blob: '', degraded: false } + } + if (!this.encryptionAvailable()) { + return { + blob: retained, + degraded: true, + ...(!plaintext && retained ? { hashValue: retained } : {}) + } + } + if (this.isSealed(slot, plaintext) || (!plaintext && this.sealedSlots.has(slot))) { + return { blob: retained, degraded: false, hashValue: retained } + } + if (!plaintext) { + return { + blob: '', + degraded: false, + retentionUpdate: { slot, blob: null } + } + } + try { + const blob = safeStorage.encryptString(plaintext).toString('base64') + return { + blob, + degraded: false, + retentionUpdate: { slot, blob } + } + } catch (err) { + console.error('[persistence] Encryption failed; retaining the prior protected value:', err) + return { blob: retained, degraded: true } + } + } + + decrypt(slot: string, ciphertext: string, isLegacyPlaintext?: LegacyPlaintextValidator): string { + return this.decryptWithStatus(slot, ciphertext, isLegacyPlaintext).plaintext + } + + decryptWithStatus( + slot: string, + ciphertext: string, + isLegacyPlaintext?: LegacyPlaintextValidator + ): ProtectedSecretDecryption { + if (!ciphertext) { + this.removeRetainedBlob(slot) + return { plaintext: '', status: 'decrypted' } + } + this.retainedBlobs.set(slot, ciphertext) + if (!this.encryptionAvailable()) { + this.sealedSlots.add(slot) + return { plaintext: '', status: 'unavailable' } + } + try { + const decrypted = { + plaintext: safeStorage.decryptString(Buffer.from(ciphertext, 'base64')), + status: 'decrypted' as const + } + this.sealedSlots.delete(slot) + return decrypted + } catch { + if (isLegacyPlaintext?.(ciphertext)) { + this.sealedSlots.delete(slot) + console.warn('[persistence] safeStorage decryption failed; accepting legacy plaintext.') + return { plaintext: ciphertext, status: 'failed' } + } + this.sealedSlots.add(slot) + console.warn( + '[persistence] safeStorage decryption failed; retaining the protected value without exposing it.' + ) + return { plaintext: '', status: 'failed' } + } + } + + private encryptionAvailable(): boolean { + try { + return safeStorage.isEncryptionAvailable() + } catch (err) { + console.warn('[persistence] safeStorage availability check failed:', err) + return false + } + } +} From 2b42de1f522ae3bb8fe397b9217e223e3c5cf6f6 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 6 Aug 2026 22:50:09 -0700 Subject: [PATCH 005/289] fix(orchestration): wake coordinators with mail pointers (#12988) Wake idle Run coordinators with durable orchestration mail pointers while keeping message payloads in the store until check consumes them. Preserve waiter, Cursor, restart, real Codex title, and PTY replacement behavior.\n\nPart of #12953. --- src/main/runtime/orca-runtime.test.ts | 240 +++++++++++++++--- src/main/runtime/orca-runtime.ts | 178 ++++++++----- .../runtime/orchestration/formatter.test.ts | 14 +- src/main/runtime/orchestration/formatter.ts | 5 + .../terminal-send-stale-leaf-liveness.test.ts | 97 +++---- src/shared/agent-title-status.ts | 10 +- src/shared/terminal-output-side-effects.ts | 7 +- .../helpers/orchestration-mail-pane-agent.ts | 6 +- tests/e2e/helpers/orchestration-mail-store.ts | 16 +- .../orchestration-idle-mail-delivery.spec.ts | 132 ++++++++-- .../orchestration-idle-mail-restore.spec.ts | 18 +- 11 files changed, 517 insertions(+), 206 deletions(-) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index cabbb9ac044..90b7bb45f81 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -1029,6 +1029,11 @@ class InMemoryOrchestrationMessages { private messages: MessageRow[] = [] + private runs = new Map< + string, + { id: string; coordinator_handle: string | null; coordinator_pane_key: string | null } + >() + insertMessage(msg: { from: string to: string @@ -1084,6 +1089,30 @@ class InMemoryOrchestrationMessages { return this.activeCoordinatorRun } + setRun(run: { + id: string + coordinator_handle: string | null + coordinator_pane_key?: string | null + }): void { + this.runs.set(run.id, { coordinator_pane_key: null, ...run }) + } + + getRun( + id: string + ): + | { id: string; coordinator_handle: string | null; coordinator_pane_key: string | null } + | undefined { + return this.runs.get(id) + } + + getCurrentRunForPane( + paneKey: string + ): + | { id: string; coordinator_handle: string | null; coordinator_pane_key: string | null } + | undefined { + return [...this.runs.values()].find((run) => run.coordinator_pane_key === paneKey) + } + markAsDelivered(ids: string[]): void { const deliveredIds = new Set(ids) for (const message of this.messages) { @@ -18468,23 +18497,24 @@ describe('OrcaRuntimeService', () => { runtime.deliverPendingMessagesForHandle(terminal.handle) - expect(write).toHaveBeenCalledWith('pty-1', expect.stringContaining('Subject: hello')) - // Why: the split Enter write lands after the 500ms delay; advance past it before asserting on delivered_at. + expect(write).toHaveBeenCalledWith( + 'pty-1', + expect.stringContaining('You have 1 orchestration message') + ) await vi.advanceTimersByTimeAsync(500) expect(write).toHaveBeenCalledWith('pty-1', '\r') - // Why: design doc §3.2 splits delivered vs. read — push-on-idle stamps delivered_at but must not flip read; only the agent's check consumes, so rows stay unread. const unread = db.getUnreadMessages(terminal.handle) expect(unread).toHaveLength(1) expect(unread[0].read).toBe(0) - expect(unread[0].delivered_at).not.toBeNull() + expect(unread[0].delivered_at).toBeNull() db.close() } finally { vi.useRealTimers() } }) - it('injects pending orchestration messages into the active coordinator without auto-submitting', async () => { + it('submits the mail pointer in an active coordinator pane', async () => { vi.useFakeTimers() try { const runtime = new OrcaRuntimeService(store) @@ -18512,18 +18542,18 @@ describe('OrcaRuntimeService', () => { expect(write).toHaveBeenCalledWith( 'pty-1', - expect.stringContaining('Subject: hello coordinator') + expect.stringContaining('You have 1 orchestration message') ) await vi.advanceTimersByTimeAsync(500) const submitWrites = write.mock.calls.filter( ([ptyId, text]) => ptyId === 'pty-1' && text === '\r' ) - expect(submitWrites).toHaveLength(0) + expect(submitWrites).toHaveLength(1) const unread = db.getUnreadMessages(terminal.handle) expect(unread).toHaveLength(1) expect(unread[0].read).toBe(0) - expect(unread[0].delivered_at).not.toBeNull() + expect(unread[0].delivered_at).toBeNull() db.close() } finally { vi.useRealTimers() @@ -18551,18 +18581,20 @@ describe('OrcaRuntimeService', () => { runtime.deliverPendingMessagesForHandle(terminal.handle) - expect(write).toHaveBeenCalledWith('pty-1', expect.stringContaining('Subject: hello cursor')) + expect(write).toHaveBeenCalledWith( + 'pty-1', + expect.stringContaining('You have 1 orchestration message') + ) await vi.advanceTimersByTimeAsync(500) const submitWrites = write.mock.calls.filter( ([ptyId, text]) => ptyId === 'pty-1' && text === '\r' ) expect(submitWrites).toHaveLength(0) - // Why: Cursor Agent treats injected PTY text as editable prompt input, so the user submits manually but the banner must not replay on the next idle transition. const unread = db.getUnreadMessages(terminal.handle) expect(unread).toHaveLength(1) expect(unread[0].read).toBe(0) - expect(unread[0].delivered_at).not.toBeNull() + expect(unread[0].delivered_at).toBeNull() db.close() } finally { vi.useRealTimers() @@ -18591,7 +18623,10 @@ describe('OrcaRuntimeService', () => { runtime.deliverPendingMessagesForHandle(terminal.handle) await vi.advanceTimersByTimeAsync(500) - expect(write).toHaveBeenCalledWith('pty-1', expect.stringContaining('Subject: hello claude')) + expect(write).toHaveBeenCalledWith( + 'pty-1', + expect.stringContaining('You have 1 orchestration message') + ) expect(write).toHaveBeenCalledWith('pty-1', '\r') db.close() } finally { @@ -18622,16 +18657,16 @@ describe('OrcaRuntimeService', () => { await vi.advanceTimersByTimeAsync(500) const firstInjections = write.mock.calls.filter( - (c) => typeof c[1] === 'string' && c[1].includes('Subject: hello') + (c) => typeof c[1] === 'string' && c[1].includes('orca orchestration check') ).length expect(firstInjections).toBe(1) - // Second idle transition: the row is unread but already delivered, so push-on-idle must skip it to avoid the replay bug. + // The row remains pending, so the in-memory sequence watermark prevents replay. runtime.deliverPendingMessagesForHandle(terminal.handle) await vi.advanceTimersByTimeAsync(500) const totalInjections = write.mock.calls.filter( - (c) => typeof c[1] === 'string' && c[1].includes('Subject: hello') + (c) => typeof c[1] === 'string' && c[1].includes('orca orchestration check') ).length expect(totalInjections).toBe(1) db.close() @@ -33284,16 +33319,124 @@ describe('OrcaRuntimeService', () => { // The push is deferred one microtask so it lands behind any resolved check. await Promise.resolve() - expect(write).toHaveBeenCalledWith('pty-1', expect.stringContaining('Subject: after wait')) + expect(write).toHaveBeenCalledWith( + 'pty-1', + expect.stringContaining('You have 1 orchestration message') + ) + expect(write).not.toHaveBeenCalledWith('pty-1', expect.stringContaining('after wait')) await vi.advanceTimersByTimeAsync(500) expect(write).toHaveBeenCalledWith('pty-1', '\r') - expect(message.delivered_at).not.toBeNull() + expect(message.delivered_at).toBeNull() db.close() } finally { vi.useRealTimers() } }) + it('points a Run mailbox at its live-idle coordinator without replaying pending rows', async () => { + vi.useFakeTimers() + try { + const runtime = new OrcaRuntimeService(store) + const db = new InMemoryOrchestrationMessages() + const write = vi.fn().mockReturnValue(true) + setInMemoryOrchestrationMessages(runtime, db) + runtime.setPtyController({ + write, + kill: vi.fn(), + getForegroundProcess: async () => null + }) + syncSinglePty(runtime) + + const [terminal] = (await runtime.listTerminals()).terminals + db.setRun({ + id: 'run_mailbox', + coordinator_handle: terminal.handle, + coordinator_pane_key: `${terminal.tabId}:${terminal.leafId}` + }) + runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100) + const message = db.insertMessage({ + from: 'term_worker', + to: 'run:run_mailbox', + subject: 'one P3 finding', + body: 'private worker report', + type: 'worker_done' + }) + + runtime.notifyMessageArrived('run:run_mailbox', 'worker_done') + await Promise.resolve() + expect(write).not.toHaveBeenCalled() + + runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101) + expect(write).toHaveBeenCalledWith( + 'pty-1', + '\nYou have 1 orchestration message. Run `orca orchestration check`.\n' + ) + expect(write).not.toHaveBeenCalledWith( + 'pty-1', + expect.stringContaining('private worker report') + ) + await vi.advanceTimersByTimeAsync(500) + expect(write).toHaveBeenCalledWith('pty-1', '\r') + expect(message.delivered_at).toBeNull() + + runtime.notifyMessageArrived('run:run_mailbox', 'worker_done') + await Promise.resolve() + await vi.advanceTimersByTimeAsync(500) + expect( + write.mock.calls.filter( + ([, payload]) => + typeof payload === 'string' && payload.includes('orca orchestration check') + ) + ).toHaveLength(1) + db.close() + } finally { + vi.useRealTimers() + } + }) + + it('points already-idle Run mail after Codex replaces its completion title', async () => { + const runtime = new OrcaRuntimeService(store) + const db = new InMemoryOrchestrationMessages() + const write = vi.fn().mockReturnValue(true) + setInMemoryOrchestrationMessages(runtime, db) + runtime.setPtyController({ + write, + kill: vi.fn(), + getForegroundProcess: async () => 'codex' + }) + syncSinglePty(runtime) + + const [terminal] = (await runtime.listTerminals()).terminals + db.setRun({ + id: 'run_codex_native_title', + coordinator_handle: terminal.handle, + coordinator_pane_key: `${terminal.tabId}:${terminal.leafId}` + }) + runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;Codex ready\x07') + runtime.onPtyData('pty-1', '\x1b]0;fix-12953-orchestration-mail-pointer\x07', 101) + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + db.insertMessage({ + from: 'term_worker', + to: 'run:run_codex_native_title', + subject: 'real-agent smoke complete', + body: 'The package name is orca.', + type: 'worker_done' + }) + + runtime.notifyMessageArrived('run:run_codex_native_title', 'worker_done') + await Promise.resolve() + + await vi.waitFor(() => { + expect(write).toHaveBeenCalledWith( + 'pty-1', + '\nYou have 1 orchestration message. Run `orca orchestration check`.\n' + ) + }) + db.close() + }) + it('does not inject pending mail on notify when the recipient is still working', async () => { const runtime = new OrcaRuntimeService(store) const db = new InMemoryOrchestrationMessages() @@ -33355,9 +33498,12 @@ describe('OrcaRuntimeService', () => { // is no transition — only the liveness edge can release the row (#12536). runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 100) - expect(write).toHaveBeenCalledWith('pty-1', expect.stringContaining('Subject: restored idle')) + expect(write).toHaveBeenCalledWith( + 'pty-1', + expect.stringContaining('You have 1 orchestration message') + ) await vi.advanceTimersByTimeAsync(600) - expect(message.delivered_at).not.toBeNull() + expect(message.delivered_at).toBeNull() db.close() } finally { vi.useRealTimers() @@ -33399,9 +33545,12 @@ describe('OrcaRuntimeService', () => { // The first live idle frame authorizes it and the row still delivers. runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100) runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101) - expect(write).toHaveBeenCalledWith('pty-1', expect.stringContaining('Subject: seeded idle')) + expect(write).toHaveBeenCalledWith( + 'pty-1', + expect.stringContaining('You have 1 orchestration message') + ) await vi.advanceTimersByTimeAsync(600) - expect(message.delivered_at).not.toBeNull() + expect(message.delivered_at).toBeNull() db.close() } finally { vi.useRealTimers() @@ -33503,9 +33652,11 @@ describe('OrcaRuntimeService', () => { const payloads = write.mock.calls .map(([, data]) => data) .filter((data): data is string => typeof data === 'string') - expect(payloads.some((data) => data.includes('Subject: unclaimed status'))).toBe(true) - expect(payloads.some((data) => data.includes('Subject: reserved completion'))).toBe(false) - expect(status.delivered_at).not.toBeNull() + expect(payloads).toContain( + '\nYou have 1 orchestration message. Run `orca orchestration check`.\n' + ) + expect(payloads.some((data) => data.includes('reserved completion'))).toBe(false) + expect(status.delivered_at).toBeNull() expect(done.delivered_at).toBeNull() db.close() } finally { @@ -33684,10 +33835,10 @@ describe('OrcaRuntimeService', () => { expect(write).toHaveBeenCalledWith( 'pty-1', - expect.stringContaining('Subject: after republish') + expect.stringContaining('You have 1 orchestration message') ) await vi.advanceTimersByTimeAsync(600) - expect(message.delivered_at).not.toBeNull() + expect(message.delivered_at).toBeNull() db.close() } finally { vi.useRealTimers() @@ -33738,10 +33889,10 @@ describe('OrcaRuntimeService', () => { runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 200) expect(write).toHaveBeenCalledWith( 'pty-1', - expect.stringContaining('Subject: after same id respawn') + expect.stringContaining('You have 1 orchestration message') ) await vi.advanceTimersByTimeAsync(600) - expect(message.delivered_at).not.toBeNull() + expect(message.delivered_at).toBeNull() db.close() } finally { vi.useRealTimers() @@ -33787,10 +33938,10 @@ describe('OrcaRuntimeService', () => { await Promise.resolve() expect(write).toHaveBeenCalledWith( 'pty-1', - expect.stringContaining('Subject: unfiltered status') + expect.stringContaining('You have 1 orchestration message') ) await vi.advanceTimersByTimeAsync(600) - expect(message.delivered_at).not.toBeNull() + expect(message.delivered_at).toBeNull() // The filtered waiter stays blocked; the push did not consume its wake. await vi.advanceTimersByTimeAsync(5_000) @@ -33865,10 +34016,10 @@ describe('OrcaRuntimeService', () => { runtime.notifyMessageArrived(terminal.handle, 'status') await Promise.resolve() - const payloadWrites = write.mock.calls.filter( - ([, payload]) => typeof payload === 'string' && payload.includes('Subject: once only') + const pointerWrites = write.mock.calls.filter( + ([, payload]) => typeof payload === 'string' && payload.includes('orca orchestration check') ) - expect(payloadWrites).toHaveLength(1) + expect(pointerWrites).toHaveLength(1) await vi.advanceTimersByTimeAsync(500) const enterWrites = write.mock.calls.filter(([, payload]) => payload === '\r') @@ -33910,9 +34061,10 @@ describe('OrcaRuntimeService', () => { await Promise.resolve() expect( write.mock.calls.filter( - ([, payload]) => typeof payload === 'string' && payload.includes('Subject: second') + ([, payload]) => + typeof payload === 'string' && payload.includes('orca orchestration check') ) - ).toHaveLength(0) + ).toHaveLength(1) expect(second.delivered_at).toBeNull() // Why: release must not require another agent-status OSC — only the @@ -33920,13 +34072,18 @@ describe('OrcaRuntimeService', () => { // timer-only settle (CodeRabbit settling-timeout gap, #12584). await vi.advanceTimersByTimeAsync(3_000) expect(write).toHaveBeenCalledWith('pty-1', '\r') - expect(first.delivered_at).not.toBeNull() + expect(first.delivered_at).toBeNull() expect( write.mock.calls.filter( - ([, payload]) => typeof payload === 'string' && payload.includes('Subject: second') + ([, payload]) => + typeof payload === 'string' && payload.includes('orca orchestration check') ) - ).toHaveLength(1) - expect(second.delivered_at).not.toBeNull() + ).toHaveLength(2) + expect(write).toHaveBeenCalledWith( + 'pty-1', + expect.stringContaining('You have 2 orchestration messages') + ) + expect(second.delivered_at).toBeNull() db.close() } finally { vi.useRealTimers() @@ -33953,7 +34110,10 @@ describe('OrcaRuntimeService', () => { runtime.deliverPendingMessagesForHandle(terminal.handle) - expect(write).toHaveBeenCalledWith('pty-1', expect.stringContaining('Subject: after wait')) + expect(write).toHaveBeenCalledWith( + 'pty-1', + expect.stringContaining('You have 1 orchestration message') + ) db.close() }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index ea146b52380..7983ebacb40 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -134,7 +134,7 @@ import type { OrchestrationWorkerServer } from './orchestration/environment-transport' import { syncFederatedDispatch } from './orchestration/federation-sync' -import { formatMessagesForInjection } from './orchestration/formatter' +import { formatMessagePointer } from './orchestration/formatter' import { selectExactWorkerProviderSession } from './orchestration/worker-provider-session' import type { Automation, @@ -2878,6 +2878,8 @@ export class OrcaRuntimeService { private handles = new Map() private handleByLeafKey = new Map() private handleByPtyId = new Map() + // Why: pointer state is process-local; one harmless replay after restart avoids a wire or schema change. + private readonly lastPointedMessageSequenceByHandle = new Map() private syntheticTerminalHandles = new Set() private detachedPreAllocatedLeaves = new Map() private graphSyncCallbacks: (() => void)[] = [] @@ -10318,7 +10320,7 @@ export class OrcaRuntimeService { // prompt) then shows no transition — the row would strand, which is // exactly #12536. Waiter semantics stay transition-only above. if (agentStatus === 'idle' && (prevStatus !== 'idle' || !prevObservedLive)) { - this.deliverPendingMessages(leaf) + this.deliverPendingMessagesForLeaf(leaf) } } return ptyRecordChanged @@ -16793,7 +16795,22 @@ export class OrcaRuntimeService { result.available && recognizeAgentProcess(result.process) !== null ) { - this.ptyTitleTrackersByPtyId.get(ptyId)?.tracker.restoreLastAgentExit() + const restoredStatus = this.ptyTitleTrackersByPtyId + .get(ptyId) + ?.tracker.restoreLastAgentExit() + if (restoredStatus !== null && restoredStatus !== undefined) { + current.lastAgentStatus = restoredStatus + for (const leaf of this.getLeavesForPty(ptyId)) { + if (leaf.lastAgentStatus !== null) { + continue + } + // Why: the foreground agent disproved the neutral title's exit signal; keep runtime delivery state aligned with the restored tracker. + leaf.lastAgentStatus = restoredStatus + if (restoredStatus === 'idle') { + this.deliverPendingMessagesForLeaf(leaf) + } + } + } return } this.recordTerminalSideEffectFact(ptyId, { kind: 'agent-exited' }) @@ -31118,25 +31135,40 @@ export class OrcaRuntimeService { } deliverPendingMessagesForHandle(handle: string, reservedTypes?: ReadonlySet): void { - // Why before the try: `dispatch:`/`run:` mailbox addresses are never terminal - // handles, and federation sync notifies once per relayed item — letting each - // one build and discard a `terminal_handle_stale` Error (stack capture) is - // pure waste. getLiveLeafForHandle would reject them on the same lookup. - if (!this.handles.has(handle)) { - return + let terminalHandle = handle + if (!this.handles.has(terminalHandle)) { + const runId = handle.startsWith('run:') ? handle.slice('run:'.length) : '' + const coordinatorHandle = runId + ? this._orchestrationDb?.getRun(runId)?.coordinator_handle + : null + if (!coordinatorHandle || !this.handles.has(coordinatorHandle)) { + return + } + terminalHandle = coordinatorHandle } try { - const { leaf } = this.getLiveLeafForHandle(handle) + const { leaf } = this.getLiveLeafForHandle(terminalHandle) // Why lastAgentStatusObservedLive: a cold restore seeds `idle` from the // title persisted at snapshot time, so an agent that went busy across the // relaunch still reads idle until its first live frame. Pushing on that - // would type a message plus Enter into a working agent and stamp the row - // delivered. Seeded state waits for a live observation to authorize it. + // would type a message plus Enter into a working agent. Seeded state waits + // for a live observation to authorize it. if (leaf.lastAgentStatus === 'idle' && leaf.lastAgentStatusObservedLive) { - this.deliverPendingMessages(leaf, false, reservedTypes) + this.deliverPendingMessages(leaf, { mailboxHandle: handle, reservedTypes }) } } catch { - // Unknown/stale handles can't be pushed now; the persisted message stays available via explicit check or future idle delivery. + // Unknown/stale handles can't be pointed now; the persisted message stays available via explicit check or future idle delivery. + } + } + + private deliverPendingMessagesForLeaf(leaf: RuntimeLeafRecord): void { + this.deliverPendingMessages(leaf) + if (!this._orchestrationDb) { + return + } + const run = this._orchestrationDb.getCurrentRunForPane?.(`${leaf.tabId}:${leaf.leafId}`) + if (run) { + this.deliverPendingMessages(leaf, { mailboxHandle: `run:${run.id}` }) } } @@ -31147,10 +31179,8 @@ export class OrcaRuntimeService { // deliver now (#12536). deliverPendingMessagesForHandle no-ops when the // leaf is not idle. Main's messageDeliveryFlights serialize mid-Enter // re-notifies without a separate settle barrier. - // Why skip when a waiter will consume this: deliverPendingMessages stamps - // delivered_at but not read, so a blocked orchestration.check --wait would - // still re-read the row and the pane would also receive it (double - // delivery). The pull wins; the push stays pending for a later notify. + // Why skip when a waiter will consume this: a blocked check owns the row, + // so also pointing and submitting the pane would wake it twice. The pull wins. // Why "will consume" and not "exists": a waiter filtered to other types // never returns this row — check re-reads under the same filter on timeout // — so treating it as the consumer strands the message in exactly the @@ -31767,10 +31797,8 @@ export class OrcaRuntimeService { return null } - // Why: delivered_at for Claude targets stamps only in the delayed-Enter - // callback, so the whole write→settle span must be single-flight per pty — - // a second read inside it would re-inject the same unread rows. Triggers - // landing mid-flight park the latest leaf and re-run once on settle. The + // Why: the whole pointer→Enter span must be single-flight per pty. Triggers + // landing mid-flight park their mailbox and re-run once on settle. The // flight object is the settle identity: a stale settle surviving an exit // retire must not clear a newer same-id flight or flush its parked trigger. private readonly messageDeliveryFlightsByPtyId = new Map< @@ -31778,7 +31806,10 @@ export class OrcaRuntimeService { { enterTimer: ReturnType | null } >() - private readonly parkedMessageRedeliveryLeavesByPtyId = new Map() + private readonly parkedMessageRedeliveriesByPtyId = new Map< + string, + Map }> + >() private settlePendingMessageDelivery( ptyId: string, @@ -31788,31 +31819,47 @@ export class OrcaRuntimeService { return } this.messageDeliveryFlightsByPtyId.delete(ptyId) - const parkedLeaf = this.parkedMessageRedeliveryLeavesByPtyId.get(ptyId) - if (!parkedLeaf) { + const parked = this.parkedMessageRedeliveriesByPtyId.get(ptyId) + if (!parked) { return } - this.parkedMessageRedeliveryLeavesByPtyId.delete(ptyId) - this.deliverPendingMessages(parkedLeaf) + this.parkedMessageRedeliveriesByPtyId.delete(ptyId) + for (const [mailboxHandle, delivery] of parked) { + this.deliverPendingMessages(delivery.leaf, { + mailboxHandle, + reservedTypes: delivery.reservedTypes + }) + } } - // Why: an Enter armed for a dead session must not fire into a same-id cold - // restore — it would inject \r and stamp rows the replacement never saw. - // Retire without stamping; the rows re-deliver on the replacement's next idle. + // Why: a dead session's Enter or watermark must not affect a same-id cold restore. private retirePendingMessageDeliveryForPty(ptyId: string): void { const flight = this.messageDeliveryFlightsByPtyId.get(ptyId) if (flight?.enterTimer != null) { clearTimeout(flight.enterTimer) } this.messageDeliveryFlightsByPtyId.delete(ptyId) - this.parkedMessageRedeliveryLeavesByPtyId.delete(ptyId) + this.parkedMessageRedeliveriesByPtyId.delete(ptyId) + for (const leaf of this.getLeavesForPty(ptyId)) { + const handle = this.handleByLeafKey.get(this.getLeafKey(leaf.tabId, leaf.leafId)) + if (handle) { + this.lastPointedMessageSequenceByHandle.delete(handle) + } + const run = this._orchestrationDb?.getCurrentRunForPane?.(`${leaf.tabId}:${leaf.leafId}`) + if (run) { + this.lastPointedMessageSequenceByHandle.delete(`run:${run.id}`) + } + } } // Why: push-on-idle delivery is event-driven (no polling) because the runtime owns both the message store and terminal status detection. private deliverPendingMessages( leaf: RuntimeLeafRecord, - skipAbsenceProbe = false, - reservedTypes?: ReadonlySet + options: { + mailboxHandle?: string + reservedTypes?: ReadonlySet + skipAbsenceProbe?: boolean + } = {} ): void { if (!this._orchestrationDb) { return @@ -31822,10 +31869,20 @@ export class OrcaRuntimeService { if (!handle) { return } + const mailboxHandle = options.mailboxHandle ?? handle - // Why before reading rows: rows read mid-flight are the not-yet-stamped ones. if (leaf.ptyId && this.messageDeliveryFlightsByPtyId.has(leaf.ptyId)) { - this.parkedMessageRedeliveryLeavesByPtyId.set(leaf.ptyId, leaf) + let parked = this.parkedMessageRedeliveriesByPtyId.get(leaf.ptyId) + if (!parked) { + parked = new Map() + this.parkedMessageRedeliveriesByPtyId.set(leaf.ptyId, parked) + } + const priorReservedTypes = parked.get(mailboxHandle)?.reservedTypes + const reservedTypes = + priorReservedTypes || options.reservedTypes + ? new Set([...(priorReservedTypes ?? []), ...(options.reservedTypes ?? [])]) + : undefined + parked.set(mailboxHandle, { leaf, reservedTypes }) return } @@ -31834,12 +31891,13 @@ export class OrcaRuntimeService { // into the pane AND returned by that pull's check. Live waiters cover the // still-blocked case; reservedTypes carries the notify-time snapshot for a // waiter resolved later in the same drain, which is already gone from the map. - const waiters = this.messageWaitersByHandle.get(handle) + const waiters = this.messageWaitersByHandle.get(mailboxHandle) const unread = this._orchestrationDb - .getUndeliveredUnreadMessages(handle) + .getUndeliveredUnreadMessages(mailboxHandle) .filter( (message) => - !reservedTypes?.has(message.type) && !messageTypeHasLiveWaiter(waiters, message.type) + !options.reservedTypes?.has(message.type) && + !messageTypeHasLiveWaiter(waiters, message.type) ) if (unread.length === 0) { return @@ -31848,9 +31906,16 @@ export class OrcaRuntimeService { if (!leaf.writable || !leaf.ptyId) { return } + const newestSequence = unread.at(-1)?.sequence + if ( + newestSequence === undefined || + newestSequence <= (this.lastPointedMessageSequenceByHandle.get(mailboxHandle) ?? -1) + ) { + return + } if ( - !skipAbsenceProbe && + !options.skipAbsenceProbe && this.ptyController?.probePtyLiveness && !this.controllerKnowsPtyIsLive(leaf.ptyId) ) { @@ -31859,8 +31924,7 @@ export class OrcaRuntimeService { // them queued for a future surface; unknown liveness still delivers. const probedPtyId = leaf.ptyId // Why: triggers arriving mid-probe must not each arm a continuation — the - // Claude Enter delay stamps delivered_at late, so every continuation would - // re-read the same unread rows and double-deliver. The single armed + // Every continuation would re-read the same unread rows. The single armed // continuation re-reads fresh rows when it fires, so nothing is lost. if (this.probeDeferredDeliveryPtyIds.has(probedPtyId)) { return @@ -31882,16 +31946,18 @@ export class OrcaRuntimeService { // Why current state, not the closure: the gate that authorized this // push ran before the probe. A same-id cold restore inside the probe // window keeps ptyId identical and makes the leaf writable again, so - // an id-only check would type the payload plus Enter into a process - // whose idle was never observed — and stamp the row delivered, which - // loses it. Re-read the leaf and re-apply the live-idle gate. + // an id-only check would type the pointer plus Enter into a process + // whose idle was never observed. Re-read the live-idle gate. const currentLeaf = this.leaves.get(this.getLeafKey(leaf.tabId, leaf.leafId)) if ( currentLeaf?.ptyId === probedPtyId && currentLeaf.lastAgentStatus === 'idle' && currentLeaf.lastAgentStatusObservedLive ) { - this.deliverPendingMessages(currentLeaf, true) + this.deliverPendingMessages(currentLeaf, { + mailboxHandle, + skipAbsenceProbe: true + }) } }, 0) } @@ -31905,32 +31971,25 @@ export class OrcaRuntimeService { const deliveryPtyId = leaf.ptyId const flight: { enterTimer: ReturnType | null } = { enterTimer: null } this.messageDeliveryFlightsByPtyId.set(deliveryPtyId, flight) - // Why: every sync outcome — failed write, sync-stamped branch, or a throw — + // Why: every sync outcome — failed write, Cursor branch, or a throw — // must end the flight here, or a leaked flag parks this pty's deliveries // forever. Only an armed Enter hands settling to its own callback. let settlesInEnterCallback = false try { - const payload = formatMessagesForInjection(unread) + const payload = formatMessagePointer(unread.length) const wrote = this.ptyController?.write(deliveryPtyId, payload) ?? false if (!wrote) { return } - - // The active coordinator prompt is user-owned input, so push-on-idle must not synthesize Enter. - if (this._orchestrationDb.getActiveCoordinatorRun()?.coordinator_handle === handle) { - this._orchestrationDb.markAsDelivered(unread.map((m) => m.id)) - return - } + this.lastPointedMessageSequenceByHandle.set(mailboxHandle, newestSequence) const tabTitle = this.tabs.get(leaf.tabId)?.title if (isCursorAgentOrchestrationTarget(leaf, tabTitle)) { // Why: Cursor Agent treats injected PTY text as editable prompt input, so submitting must stay under user control. - this._orchestrationDb.markAsDelivered(unread.map((m) => m.id)) return } - // Why: Claude Code treats a large PTY write as a paste and swallows a \r in the same write; send Enter separately after a delay, stamping delivered_at only once \r is confirmed. - // Important (design doc §3.2, feedback #2): stamp delivered_at, not read — read means "a check-caller consumed this"; flipping it would hide the message from check --unread. + // Why: agent TUIs can swallow a \r in the same PTY write; submit separately after a delay. flight.enterTimer = setTimeout(() => { try { // Why current state, not the closure: graph resync replaces leaf @@ -31943,12 +32002,9 @@ export class OrcaRuntimeService { if (!currentLeaf || currentLeaf.ptyId !== deliveryPtyId || !currentLeaf.writable) { return } - const submitted = this.ptyController?.write(deliveryPtyId, '\r') ?? false - if (submitted) { - this._orchestrationDb?.markAsDelivered(unread.map((m) => m.id)) - } + this.ptyController?.write(deliveryPtyId, '\r') } catch { - // Terminal may have closed during the delay — messages stay queued (delivered_at NULL) and re-deliver on next idle. + // Terminal may have closed during the delay; mail remains queued for check. } finally { // Why finally: every outcome — submit, refusal, throw — ends the flight, // and settle re-runs any trigger parked during it so nothing strands. diff --git a/src/main/runtime/orchestration/formatter.test.ts b/src/main/runtime/orchestration/formatter.test.ts index b4732f04ac2..f7d9c14d880 100644 --- a/src/main/runtime/orchestration/formatter.test.ts +++ b/src/main/runtime/orchestration/formatter.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { formatMessageBanner, formatMessagesForInjection } from './formatter' +import { formatMessageBanner, formatMessagePointer, formatMessagesForInjection } from './formatter' import type { MessageRow } from './types' function makeMessage(overrides: Partial = {}): MessageRow { @@ -183,3 +183,15 @@ describe('formatMessagesForInjection', () => { expect(result).toContain(`${bannerA}\n\n${bannerB}`) }) }) + +describe('formatMessagePointer', () => { + it('formats a singular pointer without message content', () => { + expect(formatMessagePointer(1)).toBe( + '\nYou have 1 orchestration message. Run `orca orchestration check`.\n' + ) + }) + + it('pluralizes a batched pointer', () => { + expect(formatMessagePointer(3)).toContain('3 orchestration messages') + }) +}) diff --git a/src/main/runtime/orchestration/formatter.ts b/src/main/runtime/orchestration/formatter.ts index 269ba29302f..678c5e08499 100644 --- a/src/main/runtime/orchestration/formatter.ts +++ b/src/main/runtime/orchestration/formatter.ts @@ -107,3 +107,8 @@ export function formatMessagesForInjection(messages: MessageRow[]): string { const banners = messages.map(formatMessageBanner).join('\n\n') return `\n--- Orchestration Messages (${messages.length}) ---\n${banners}\n---\n` } + +export function formatMessagePointer(count: number): string { + const noun = count === 1 ? 'message' : 'messages' + return `\nYou have ${count} orchestration ${noun}. Run \`orca orchestration check\`.\n` +} diff --git a/src/main/runtime/terminal-send-stale-leaf-liveness.test.ts b/src/main/runtime/terminal-send-stale-leaf-liveness.test.ts index 823c04e5a6d..41e272ecd29 100644 --- a/src/main/runtime/terminal-send-stale-leaf-liveness.test.ts +++ b/src/main/runtime/terminal-send-stale-leaf-liveness.test.ts @@ -267,6 +267,7 @@ function makeOrchestrationDbStub(toHandle: () => string) { getUndeliveredUnreadMessages: (handle: string) => rows.filter((row) => row.to_handle === handle && row.read === 0 && !row.delivered_at), getActiveCoordinatorRun: () => null, + getCurrentRunForPane: () => undefined, // Consulted by onPtyExit's dispatch-failure path. getActiveDispatchForTerminal: () => null, markAsDelivered, @@ -327,7 +328,7 @@ describe('push-on-idle orchestration delivery absence gate', () => { expect(write).toHaveBeenCalledWith( STALE_PTY_ID, - expect.stringContaining('Subject: for the old session') + expect.stringContaining('You have 1 orchestration message') ) }) @@ -369,7 +370,10 @@ describe('push-on-idle orchestration delivery absence gate', () => { const payloads = write.mock.calls .map(([, data]) => data) .filter((data): data is string => typeof data === 'string') - expect(payloads.some((data) => data.includes('Subject: unclaimed status'))).toBe(true) + const pointers = payloads.filter((data) => data.includes('orca orchestration check')) + expect(pointers).toHaveLength(1) + expect(pointers[0]).toContain('You have 1 orchestration message') + expect(payloads.some((data) => data.includes('unclaimed status'))).toBe(false) expect(payloads.some((data) => data.includes('Subject: worker completion'))).toBe(false) }) @@ -411,8 +415,11 @@ describe('push-on-idle orchestration delivery absence gate', () => { const payloads = write.mock.calls .map(([, data]) => data) .filter((data): data is string => typeof data === 'string') - expect(payloads.some((data) => data.includes('Subject: unclaimed status'))).toBe(true) - expect(payloads.some((data) => data.includes('Subject: late completion'))).toBe(true) + const pointers = payloads.filter((data) => data.includes('orca orchestration check')) + expect(pointers).toHaveLength(1) + expect(pointers[0]).toContain('You have 2 orchestration messages') + expect(payloads.some((data) => data.includes('unclaimed status'))).toBe(false) + expect(payloads.some((data) => data.includes('late completion'))).toBe(false) }) it('keeps messages queued instead of marking a proven-absent pty delivered', async () => { @@ -440,12 +447,14 @@ describe('push-on-idle orchestration delivery absence gate', () => { // Why twice: the probe continuation yields a turn before delivering. await new Promise((resolve) => setTimeout(resolve, 0)) - expect(write).toHaveBeenCalledWith(STALE_PTY_ID, expect.stringContaining('Subject: hello')) + expect(write).toHaveBeenCalledWith( + STALE_PTY_ID, + expect.stringContaining('You have 1 orchestration message') + ) }) - // Why: delivered_at stamps only in the delayed-Enter callback, so the whole - // write→settle span — not just the probe — must be single-flight; a trigger - // landing inside the 500ms window would re-read the same un-stamped rows. + // Why: the whole pointer→Enter span must be single-flight; a trigger inside + // the 500ms window must park until the sequence watermark advances. it('delivers once across concurrent probe triggers and an in-window re-trigger, then flushes parked rows', async () => { vi.useFakeTimers() try { @@ -464,36 +473,33 @@ describe('push-on-idle orchestration delivery absence gate', () => { resolveProbe(null) await vi.advanceTimersByTimeAsync(0) - const firstSubjectWrites = () => + const pointerWrites = () => write.mock.calls.filter( - ([, data]) => typeof data === 'string' && data.includes('Subject: exactly once') + ([, data]) => typeof data === 'string' && data.includes('orca orchestration check') ) - expect(firstSubjectWrites()).toHaveLength(1) + expect(pointerWrites()).toHaveLength(1) + expect(pointerWrites()[0]?.[1]).toContain('You have 1 orchestration message') - // Re-trigger INSIDE the 500ms Enter window: the first batch is written but - // not yet stamped, so a fresh probe cycle would re-inject it. (On the - // fixed code no new probe is armed — the trigger parks; resolveProbe then - // re-resolves the settled first probe, a no-op.) + // Re-trigger inside the Enter window parks; resolving the settled first + // probe again is a no-op. stub.insert('second message') runtime.deliverPendingMessagesForHandle(handle) resolveProbe(null) await vi.advanceTimersByTimeAsync(0) - expect(firstSubjectWrites()).toHaveLength(1) + expect(pointerWrites()).toHaveLength(1) - // Enter fires, delivered_at stamps, the flight settles, and the parked - // trigger re-runs on its own — arming a fresh probe for the new row. + // Enter settles the flight and re-runs the parked trigger, arming a fresh + // probe for the newer sequence. await vi.advanceTimersByTimeAsync(500) resolveProbe(null) await vi.advanceTimersByTimeAsync(0) - const secondSubjectWrites = write.mock.calls.filter( - ([, data]) => typeof data === 'string' && data.includes('Subject: second message') - ) - expect(secondSubjectWrites).toHaveLength(1) - expect(firstSubjectWrites()).toHaveLength(1) + expect(pointerWrites()).toHaveLength(2) + expect(pointerWrites()[1]?.[1]).toContain('You have 2 orchestration messages') await vi.advanceTimersByTimeAsync(500) - expect(stub.rows.every((row) => row.delivered_at !== null)).toBe(true) + expect(stub.markAsDelivered).not.toHaveBeenCalled() + expect(stub.rows.every((row) => row.delivered_at === null)).toBe(true) } finally { vi.useRealTimers() } @@ -514,35 +520,30 @@ describe('push-on-idle orchestration delivery absence gate', () => { stub.insert('second') runtime.deliverPendingMessagesForHandle(handle) - const firstSubjectWrites = () => + const pointerWrites = () => write.mock.calls.filter( - ([, data]) => typeof data === 'string' && data.includes('Subject: first') + ([, data]) => typeof data === 'string' && data.includes('orca orchestration check') ) - expect(firstSubjectWrites()).toHaveLength(1) + expect(pointerWrites()).toHaveLength(1) + expect(pointerWrites()[0]?.[1]).toContain('You have 1 orchestration message') expect(probe).not.toHaveBeenCalled() - // Settle flushes the parked trigger; the second row delivers alone — - // its batch must not re-contain the already-stamped first row. + // Settle flushes the parked trigger; both still-pending rows are counted, + // while the newer sequence authorizes exactly one fresh pointer. await vi.advanceTimersByTimeAsync(500) - const secondOnlyWrites = write.mock.calls.filter( - ([, data]) => - typeof data === 'string' && - data.includes('Subject: second') && - !data.includes('Subject: first') - ) - expect(secondOnlyWrites).toHaveLength(1) - expect(firstSubjectWrites()).toHaveLength(1) + expect(pointerWrites()).toHaveLength(2) + expect(pointerWrites()[1]?.[1]).toContain('You have 2 orchestration messages') await vi.advanceTimersByTimeAsync(500) - expect(stub.rows.every((row) => row.delivered_at !== null)).toBe(true) + expect(stub.markAsDelivered).not.toHaveBeenCalled() + expect(stub.rows.every((row) => row.delivered_at === null)).toBe(true) } finally { vi.useRealTimers() } }) // Why: cold restore respawns under the SAME session id. An Enter armed for - // the dead incarnation must not fire into the replacement — it would inject - // \r and stamp rows the new session never received. + // the dead incarnation must not submit stale input into the replacement. it('retires an armed Enter when the pty exits and respawns under the same id inside the window', async () => { vi.useFakeTimers() try { @@ -569,18 +570,19 @@ describe('push-on-idle orchestration delivery absence gate', () => { runtime.deliverPendingMessagesForHandle(handle) expect( write.mock.calls.filter( - ([, data]) => typeof data === 'string' && data.includes('Subject: for the old session') + ([, data]) => typeof data === 'string' && data.includes('orca orchestration check') ) ).toHaveLength(1) runtime.onPtyData(STALE_PTY_ID, '\x1b]0;Codex working\x07', 200) runtime.onPtyData(STALE_PTY_ID, '\x1b]0;Codex done\x07', 201) const payloadWrites = write.mock.calls.filter( - ([, data]) => typeof data === 'string' && data.includes('Subject: for the old session') + ([, data]) => typeof data === 'string' && data.includes('orca orchestration check') ) expect(payloadWrites).toHaveLength(2) await vi.advanceTimersByTimeAsync(500) expect(write.mock.calls.filter(([, data]) => data === '\r')).toHaveLength(1) - expect(stub.rows[0].delivered_at).not.toBeNull() + expect(stub.markAsDelivered).not.toHaveBeenCalled() + expect(stub.rows[0].delivered_at).toBeNull() } finally { vi.useRealTimers() } @@ -595,18 +597,21 @@ describe('push-on-idle orchestration delivery absence gate', () => { }) const internals = runtime as unknown as { messageDeliveryFlightsByPtyId: Map - parkedMessageRedeliveryLeavesByPtyId: Map + parkedMessageRedeliveriesByPtyId: Map + lastPointedMessageSequenceByHandle: Map } stub.insert('first') runtime.deliverPendingMessagesForHandle(handle) stub.insert('second') runtime.deliverPendingMessagesForHandle(handle) expect(internals.messageDeliveryFlightsByPtyId.size).toBe(1) - expect(internals.parkedMessageRedeliveryLeavesByPtyId.size).toBe(1) + expect(internals.parkedMessageRedeliveriesByPtyId.size).toBe(1) + expect(internals.lastPointedMessageSequenceByHandle.size).toBe(1) runtime.onPtyExit(STALE_PTY_ID, 0) expect(internals.messageDeliveryFlightsByPtyId.size).toBe(0) - expect(internals.parkedMessageRedeliveryLeavesByPtyId.size).toBe(0) + expect(internals.parkedMessageRedeliveriesByPtyId.size).toBe(0) + expect(internals.lastPointedMessageSequenceByHandle.size).toBe(0) await vi.advanceTimersByTimeAsync(500) expect(write.mock.calls.filter(([, data]) => data === '\r')).toHaveLength(0) diff --git a/src/shared/agent-title-status.ts b/src/shared/agent-title-status.ts index 68c9dd4633a..09ffc07a677 100644 --- a/src/shared/agent-title-status.ts +++ b/src/shared/agent-title-status.ts @@ -56,7 +56,7 @@ export function createAgentStatusTracker( ): { handleTitle: (title: string) => void seedTitle: (title: string) => void - restoreLastExit: () => void + restoreLastExit: () => AgentStatus | null reset: () => void } { // Why: trackers restored mid-session need a last-known status without firing @@ -92,11 +92,13 @@ export function createAgentStatusTracker( lastStatus = detectAgentStatusFromTitle(title) restorableExitStatus = null }, - restoreLastExit(): void { - if (lastStatus === null && restorableExitStatus !== null) { - lastStatus = restorableExitStatus + restoreLastExit(): AgentStatus | null { + const restoredStatus = lastStatus === null ? restorableExitStatus : null + if (restoredStatus !== null) { + lastStatus = restoredStatus } restorableExitStatus = null + return restoredStatus }, reset(): void { lastStatus = null diff --git a/src/shared/terminal-output-side-effects.ts b/src/shared/terminal-output-side-effects.ts index 227df5bd4e3..32dc33724ba 100644 --- a/src/shared/terminal-output-side-effects.ts +++ b/src/shared/terminal-output-side-effects.ts @@ -5,6 +5,7 @@ */ import { + type AgentStatus, clearWorkingIndicators, createAgentStatusTracker, detectAgentStatusFromTitle, @@ -91,7 +92,7 @@ export type TerminalTitleTracker = { */ seedInitialTitle: (rawTitle: string) => void /** Restore the status consumed by the latest exit candidate when process evidence disproves it. */ - restoreLastAgentExit: () => void + restoreLastAgentExit: () => AgentStatus | null /** Last title surfaced through onTitle, after normalization. */ getLastNormalizedTitle: () => string | null /** @@ -264,8 +265,8 @@ export function createTerminalTitleTracker( lastEmittedTitle = normalizeTerminalTitle(rawTitle) agentTracker?.seedTitle(rawTitle) }, - restoreLastAgentExit(): void { - agentTracker?.restoreLastExit() + restoreLastAgentExit(): AgentStatus | null { + return agentTracker?.restoreLastExit() ?? null }, getLastNormalizedTitle: () => lastEmittedTitle, setTransientFactScanningSuppressed(suppressed: boolean): void { diff --git a/tests/e2e/helpers/orchestration-mail-pane-agent.ts b/tests/e2e/helpers/orchestration-mail-pane-agent.ts index f948bfa8c25..df9d0311d8c 100644 --- a/tests/e2e/helpers/orchestration-mail-pane-agent.ts +++ b/tests/e2e/helpers/orchestration-mail-pane-agent.ts @@ -6,7 +6,7 @@ * writing into the pane's foreground process. A shell echoes rather than * records, so it can prove the gate but never the payload. This process owns * both sides — the test drives its title through a control file and it appends - * every stdin chunk to a ledger, which is what makes "the banner and the Enter + * every stdin chunk to a ledger, which is what makes "the pointer and the Enter * reached the agent" an assertion instead of an inference. * * Titles come from a polled file, not stdin, because orchestration writes to @@ -54,12 +54,12 @@ log({ event: 'start' }) // Raw mode is what every agent TUI does, and it is load-bearing here: a cooked // PTY applies ICRNL, so the synthesized Enter would arrive as \\n and be -// indistinguishable from the banner's own newlines. +// indistinguishable from the pointer's own newlines. if (process.stdin.isTTY) { process.stdin.setRawMode(true) } -// Every byte orchestration pushes lands here — banner text and Enter alike. +// Every byte orchestration pushes lands here — pointer text and Enter alike. process.stdin.on('data', (chunk) => log({ event: 'stdin', data: chunk.toString() })) process.stdin.resume() diff --git a/tests/e2e/helpers/orchestration-mail-store.ts b/tests/e2e/helpers/orchestration-mail-store.ts index 520498dad69..7232c8d6a37 100644 --- a/tests/e2e/helpers/orchestration-mail-store.ts +++ b/tests/e2e/helpers/orchestration-mail-store.ts @@ -3,9 +3,8 @@ * * Why read SQLite instead of `orchestration.check`: check is itself a consumer — * it marks rows read and backfills `delivered_at` — so using it to observe would - * destroy the very distinction these specs exist to test. The two markers are - * independent on purpose: `delivered_at` means a push typed the row into a pane, - * `read` means a pull consumed it. Only an out-of-band read can tell them apart. + * destroy the very distinction these specs exist to test. A pointer changes + * neither marker; only an out-of-band read can prove that before check consumes. */ import path from 'node:path' import Database from '../../../src/main/sqlite/sync-database' @@ -49,13 +48,10 @@ export function readMailbox(userDataDir: string, toHandle: string): MailRow[] { } /** - * Mark `handle` as the running coordinator — the state that makes push delivery - * withhold the synthesized Enter, because that prompt holds user-typed input. + * Mark `handle` as a legacy running coordinator to prove it no longer suppresses Enter. * - * Why seed the row instead of calling `orchestration.run`: that RPC also starts - * a live coordinator loop which dispatches workers on a timer, and its - * scheduling would race every assertion here. The carve-out reads nothing but - * this row. + * Why seed instead of calling `orchestration.run`: that RPC starts a coordinator + * loop whose scheduling would race the assertion. */ export function startCoordinatorRun(userDataDir: string, handle: string): void { withMailDb(userDataDir, (db) => { @@ -67,7 +63,7 @@ export function startCoordinatorRun(userDataDir: string, handle: string): void { } /** - * How a row was consumed, if at all. + * How a row was consumed under either the current or historical push behavior. * * `read` is checked first because a pull backfills `delivered_at` via COALESCE, * so a pulled row also carries a delivery stamp — the stamp alone cannot prove diff --git a/tests/e2e/orchestration-idle-mail-delivery.spec.ts b/tests/e2e/orchestration-idle-mail-delivery.spec.ts index 2d1ec41b6bf..fcfe81005f2 100644 --- a/tests/e2e/orchestration-idle-mail-delivery.spec.ts +++ b/tests/e2e/orchestration-idle-mail-delivery.spec.ts @@ -9,7 +9,7 @@ * * These specs drive real PTYs: the recipient is a fake `codex` on PATH whose OSC * titles the test controls through a file, and which appends every stdin chunk - * to a ledger. That ledger is the oracle — it proves the banner and the + * to a ledger. That ledger is the oracle — it proves the pointer and the * synthesized Enter reached the agent process, which no store or DB read can. * * The ordering fixes on this path (microtask deferral, probe-window respawn, @@ -19,6 +19,7 @@ */ import { test, expect } from './helpers/orca-app' import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import { randomUUID } from 'node:crypto' import { waitForSessionReady, waitForActiveWorktree, ensureTerminalVisible } from './helpers/store' import { execInTerminal, @@ -37,16 +38,16 @@ import { } from './helpers/orchestration-mail-pane-agent' import { mailDisposition, + readMailbox, readMailRow, startCoordinatorRun } from './helpers/orchestration-mail-store' import { waitForPtyShellEcho } from './terminal-pty-readiness' -/** The wrapper `formatMessagesForInjection` puts around every pushed batch. */ -const BANNER_PREFIX = '--- Orchestration Messages' +const POINTER_COMMAND = 'orca orchestration check' // Why generous: the push runs a microtask behind the send, may defer once more -// behind a liveness probe, and only stamps delivered_at after a 500ms Enter. +// behind a liveness probe, and submits Enter after a 500ms delay. const DELIVERY_TIMEOUT_MS = 20_000 // Why 3s: long enough to cover that same chain, so "still pending" means the // gate refused rather than that the push had not run yet. @@ -175,18 +176,19 @@ async function sendMail( return sent.result.message.id } -async function expectPushed(pane: AgentPane, subject: string): Promise { +async function expectPointed(pane: AgentPane, count = 1): Promise { await expect .poll(() => pane.agent.readStdin(), { timeout: DELIVERY_TIMEOUT_MS, - message: 'banner never reached the agent process' + message: 'mail pointer never reached the agent process' }) - .toContain(BANNER_PREFIX) - expect(pane.agent.readStdin()).toContain(`Subject: ${subject}`) + .toContain(POINTER_COMMAND) + const noun = count === 1 ? 'message' : 'messages' + expect(pane.agent.readStdin()).toContain(`${count} orchestration ${noun}`) } /** - * The synthesized Enter is a separate write ~500ms after the banner. The banner + * The synthesized Enter is a separate write ~500ms after the pointer. The pointer * itself is `\n`-joined, so a `\r` anywhere in stdin can only be that submit — * which keeps the assertion independent of how the PTY chunks the two writes. */ @@ -219,7 +221,7 @@ async function expectStaysPending( expect(readMailRow(userDataDir, messageId)).toBeDefined() await page.waitForTimeout(NO_DELIVERY_SETTLE_MS) expect(mailDisposition(readMailRow(userDataDir, messageId))).toBe('pending') - expect(pane.agent.readStdin()).not.toContain(BANNER_PREFIX) + expect(pane.agent.readStdin()).not.toContain(POINTER_COMMAND) } test.describe('orchestration push-on-idle mail delivery', () => { @@ -237,13 +239,13 @@ test.describe('orchestration push-on-idle mail delivery', () => { const subject = 'Already idle delivery' const messageId = await sendMail(client, pane.handle, { subject }) - await expectPushed(pane, subject) + await expectPointed(pane) await expectSubmitted(pane) await expect .poll(() => mailDisposition(readMailRow(userDataDir, messageId)), { timeout: DELIVERY_TIMEOUT_MS }) - .toBe('pushed') + .toBe('pending') }) test('holds mail while the agent is working and releases it on the idle frame', async ({ @@ -263,12 +265,12 @@ test.describe('orchestration push-on-idle mail delivery', () => { // Releasing the gate proves the silence above was the working status and not // a harness that never wired the send to this pane at all. pane.agent.setTitle(CODEX_IDLE_TITLE) - await expectPushed(pane, subject) + await expectPointed(pane) await expect .poll(() => mailDisposition(readMailRow(userDataDir, messageId)), { timeout: DELIVERY_TIMEOUT_MS }) - .toBe('pushed') + .toBe('pending') }) // Guards the null→idle path rather than reproducing #12536: a fresh pane has @@ -291,7 +293,7 @@ test.describe('orchestration push-on-idle mail delivery', () => { // Idle is this pane's FIRST live status, so there is no busy→idle edge here // either; delivery has to hang off the liveness of the observation. pane.agent.setTitle(CODEX_IDLE_TITLE) - await expectPushed(pane, subject) + await expectPointed(pane) await expectSubmitted(pane) }) @@ -325,7 +327,7 @@ test.describe('orchestration push-on-idle mail delivery', () => { const pulled = await waiting expect(pulled.result.messages.map((message) => message.subject)).toContain(subject) - expect(pane.agent.readStdin()).not.toContain(BANNER_PREFIX) + expect(pane.agent.readStdin()).not.toContain(POINTER_COMMAND) // Pending, not pushed: the pull won, and the push stays available for a // later notify rather than racing this one. expect(mailDisposition(readMailRow(userDataDir, messageId))).toBe('pending') @@ -355,16 +357,95 @@ test.describe('orchestration push-on-idle mail delivery', () => { const subject = 'Filtered waiter' const messageId = await sendMail(client, pane.handle, { subject, type: 'status' }) - await expectPushed(pane, subject) + await expectPointed(pane) await expect .poll(() => mailDisposition(readMailRow(userDataDir, messageId)), { timeout: DELIVERY_TIMEOUT_MS }) - .toBe('pushed') + .toBe('pending') await waiting }) - test('writes the banner but never Enter for the active coordinator pane', async ({ + test('worker completion points and wakes its idle Run coordinator without consuming mail', async ({ + orcaPage, + electronApp + }) => { + test.setTimeout(180_000) + const { client, userDataDir, openAgentPane } = await setUpMailFixture(orcaPage, electronApp) + const pane = await openAgentPane() + await driveToLiveIdle(client, pane) + + const run = await client.call<{ run: { id: string } }>('orchestration.runCreate', { + objective: 'Verify worker completion pointer delivery', + from: pane.handle + }) + const task = await client.call<{ task: { id: string } }>('orchestration.taskCreate', { + spec: 'Report one P3 finding', + run: run.result.run.id, + callerTerminalHandle: pane.handle + }) + const dispatched = await client.call<{ dispatch: { id: string } }>('orchestration.dispatch', { + task: task.result.task.id, + run: run.result.run.id, + from: pane.handle, + to: pane.handle + }) + const body = 'full private review finding must remain in SQLite' + const payload = JSON.stringify({ + taskId: task.result.task.id, + dispatchId: dispatched.result.dispatch.id, + outcome: 'succeeded' + }) + const sendParams = { + from: pane.handle, + to: pane.handle, + subject: 'review: one P3 finding', + body, + type: 'worker_done', + payload + } + const orchestrationRequestId = randomUUID() + const sent = await client.call<{ message: { id: string; to_handle: string } }>( + 'orchestration.send', + sendParams, + { orchestrationRequestId } + ) + const runAddress = `run:${run.result.run.id}` + expect(sent.result.message.to_handle).toBe(runAddress) + + await expectPointed(pane) + await expectSubmitted(pane) + expect(pane.agent.readStdin()).not.toContain(body) + const pointedRow = readMailRow(userDataDir, sent.result.message.id) + expect(pointedRow).toMatchObject({ to_handle: runAddress, read: 0, delivered_at: null }) + + const stdinAfterFirstPointer = pane.agent.readStdin() + const duplicate = await client.call<{ message: { id: string } }>( + 'orchestration.send', + sendParams, + { orchestrationRequestId } + ) + pane.agent.setTitle(CODEX_WORKING_TITLE) + await waitForObservedTitle(client, pane.handle, CODEX_WORKING_TITLE) + pane.agent.setTitle(CODEX_IDLE_TITLE) + await waitForObservedTitle(client, pane.handle, CODEX_IDLE_TITLE) + await orcaPage.waitForTimeout(NO_DELIVERY_SETTLE_MS) + expect(pane.agent.readStdin()).toBe(stdinAfterFirstPointer) + expect(duplicate.result.message.id).toBe(sent.result.message.id) + expect(readMailbox(userDataDir, runAddress).filter((row) => row.read === 0)).toEqual([ + expect.objectContaining({ id: sent.result.message.id }) + ]) + + const checked = await client.call<{ messages: { id: string; body: string }[] }>( + 'orchestration.check', + { terminal: pane.handle, run: run.result.run.id } + ) + expect(checked.result.messages).toEqual([ + expect.objectContaining({ id: sent.result.message.id, body }) + ]) + }) + + test('writes and submits the pointer for the active coordinator pane', async ({ orcaPage, electronApp }) => { @@ -374,17 +455,14 @@ test.describe('orchestration push-on-idle mail delivery', () => { await driveToLiveIdle(client, pane) startCoordinatorRun(userDataDir, pane.handle) - // The coordinator prompt is user-owned input; synthesizing Enter there would - // submit whatever the human was mid-way through typing (#7337). - const subject = 'Coordinator no-submit' + const subject = 'Coordinator pointer submit' await sendMail(client, pane.handle, { subject }) - await expectPushed(pane, subject) - await orcaPage.waitForTimeout(2_000) - expectNotSubmitted(pane) + await expectPointed(pane) + await expectSubmitted(pane) }) - test('writes the banner but never Enter for a Cursor agent pane', async ({ + test('writes the pointer but never Enter for a Cursor agent pane', async ({ orcaPage, electronApp }) => { @@ -399,7 +477,7 @@ test.describe('orchestration push-on-idle mail delivery', () => { const subject = 'Cursor no-submit' await sendMail(client, pane.handle, { subject }) - await expectPushed(pane, subject) + await expectPointed(pane) await orcaPage.waitForTimeout(2_000) expectNotSubmitted(pane) }) diff --git a/tests/e2e/orchestration-idle-mail-restore.spec.ts b/tests/e2e/orchestration-idle-mail-restore.spec.ts index 6e38b9fd872..4e528614fa6 100644 --- a/tests/e2e/orchestration-idle-mail-restore.spec.ts +++ b/tests/e2e/orchestration-idle-mail-restore.spec.ts @@ -1,12 +1,12 @@ /** * Mail must survive a restart: never injected on restored state alone, always - * delivered once the agent speaks again (#12536). + * pointed once the agent speaks again (#12536). * * Push-on-idle now fires when mail arrives rather than only on a busy→idle edge, * which puts restart squarely on the delivery path — a pane comes back carrying * the title it had at snapshot time, and anything the runtime infers from that * is a memory, not an observation. Typing on it would submit into an agent that - * may be mid-turn and stamp the row delivered, losing it. + * may be mid-turn. * * Scope, stated plainly: this covers the restart path, not the * `lastAgentStatusObservedLive` gate itself. The seed only reaches leaves that @@ -39,7 +39,7 @@ import { import { mailDisposition, readMailRow } from './helpers/orchestration-mail-store' import { waitForPtyShellEcho } from './terminal-pty-readiness' -const BANNER_PREFIX = '--- Orchestration Messages' +const POINTER_COMMAND = 'orca orchestration check' const NO_DELIVERY_SETTLE_MS = 5_000 const DELIVERY_TIMEOUT_MS = 20_000 @@ -159,10 +159,10 @@ test('keeps mail pending across a restart and delivers it when the agent reports expect(readMailRow(session.userDataDir, messageId)).toBeDefined() await second.page.waitForTimeout(NO_DELIVERY_SETTLE_MS) expect(mailDisposition(readMailRow(session.userDataDir, messageId))).toBe('pending') - expect(agent.readStdin()).not.toContain(BANNER_PREFIX) + expect(agent.readStdin()).not.toContain(POINTER_COMMAND) // Re-emitting the SAME idle title changes no status — only its liveness — so - // the row moving here is delivery resuming on the agent's own signal. + // the pointer appearing here is delivery resuming on the agent's own signal. agent.setTitle(CODEX_IDLE_TITLE) await expect .poll(() => agent.titleEmitCount(), { timeout: 30_000 }) @@ -172,12 +172,8 @@ test('keeps mail pending across a restart and delivers it when the agent reports timeout: DELIVERY_TIMEOUT_MS, message: 'live idle frame never released the pending mail' }) - .toContain(BANNER_PREFIX) - await expect - .poll(() => mailDisposition(readMailRow(session.userDataDir, messageId)), { - timeout: DELIVERY_TIMEOUT_MS - }) - .toBe('pushed') + .toContain(POINTER_COMMAND) + expect(mailDisposition(readMailRow(session.userDataDir, messageId))).toBe('pending') } finally { if (firstApp) { await session.close(firstApp) From 595097b5fcac8df2b2e3b908c2d7f385c522c269 Mon Sep 17 00:00:00 2001 From: Shani Singh Date: Fri, 7 Aug 2026 11:20:51 +0530 Subject: [PATCH 006/289] fix(clipboard): stream the temp root when sweeping expired staged files (#12917) cleanupExpiredRemoteClipboardFiles read the entire OS temp root with readdir({ withFileTypes: true }) and mapped every entry into Promise.all, so the prefix filter only ran after a promise already existed per entry. The sweep is fire-and-forget from registerClipboardIpcHandlers at startup, so a large %TEMP% froze the main process before the window came up. Stream the root with opendir, skip foreign entries before allocating, and cap in-flight removals at 8. --- .../window/clipboard-ipc-handlers.test.ts | 46 ++---- .../clipboard-remote-file-cleanup.test.ts | 153 ++++++++++++++++++ src/main/window/clipboard-remote-file-copy.ts | 57 ++++--- 3 files changed, 201 insertions(+), 55 deletions(-) create mode 100644 src/main/window/clipboard-remote-file-cleanup.test.ts diff --git a/src/main/window/clipboard-ipc-handlers.test.ts b/src/main/window/clipboard-ipc-handlers.test.ts index 406949ac69e..b1da7e9e226 100644 --- a/src/main/window/clipboard-ipc-handlers.test.ts +++ b/src/main/window/clipboard-ipc-handlers.test.ts @@ -13,7 +13,7 @@ const { childStdinEndMock, resolveAuthorizedPathMock, fsMkdirMock, - fsReaddirMock, + fsOpendirMock, fsRmMock, fsWriteFileMock, fsOpenMock, @@ -46,7 +46,7 @@ const { }), resolveAuthorizedPathMock: vi.fn(), fsMkdirMock: vi.fn(), - fsReaddirMock: vi.fn(), + fsOpendirMock: vi.fn(), fsRmMock: vi.fn(), fsWriteFileMock: vi.fn(), fsOpenMock: vi.fn(), @@ -69,7 +69,7 @@ vi.mock('node:child_process', () => ({ vi.mock('node:fs/promises', () => ({ mkdir: fsMkdirMock, - readdir: fsReaddirMock, + opendir: fsOpendirMock, rm: fsRmMock, open: fsOpenMock, stat: fsStatMock, @@ -133,7 +133,6 @@ import { registerClipboardHandlers, setTrustedClipboardRendererWebContentsId } from './clipboard-ipc-handlers' -import { cleanupExpiredRemoteClipboardFiles } from './clipboard-remote-file-copy' function getRegisteredHandlers(): Map unknown> { const handlers = new Map unknown>() @@ -173,10 +172,6 @@ function trackPromiseSettled(promise: Promise): () => boolean { return () => settled } -function dirent(name: string, directory = true): { name: string; isDirectory: () => boolean } { - return { name, isDirectory: () => directory } -} - function shellIdListArray(childCount: number): Buffer { const value = Buffer.alloc(4 + 4 * (childCount + 1)) value.writeUInt32LE(childCount) @@ -194,8 +189,12 @@ describe('registerClipboardHandlers', () => { resolveAuthorizedPathMock.mockImplementation(async (path: string) => path) fsMkdirMock.mockReset() fsMkdirMock.mockResolvedValue(undefined) - fsReaddirMock.mockReset() - fsReaddirMock.mockResolvedValue([]) + fsOpendirMock.mockReset() + // Why: handler registration kicks off the expired-staging sweep; an empty temp root keeps it inert. + fsOpendirMock.mockImplementation(async () => ({ + async *[Symbol.asyncIterator]() {}, + close: vi.fn().mockResolvedValue(undefined) + })) fsRmMock.mockReset() fsRmMock.mockResolvedValue(undefined) fsWriteFileMock.mockReset() @@ -307,33 +306,6 @@ describe('registerClipboardHandlers', () => { } }) - it('sweeps expired remote clipboard staging directories', async () => { - const nowMs = 1760000000000 - fsReaddirMock.mockResolvedValue([ - dirent('orca-clipboard-file-expired'), - dirent('orca-clipboard-file-fresh'), - dirent('orca-clipboard-file-plain-file', false), - dirent('unrelated-temp') - ]) - fsStatMock.mockImplementation(async (targetPath: string) => { - if (targetPath.endsWith('expired')) { - return { mtimeMs: nowMs - 60 * 60 * 1000 - 1 } - } - if (targetPath.endsWith('fresh')) { - return { mtimeMs: nowMs - 1000 } - } - throw new Error(`unexpected stat: ${targetPath}`) - }) - - await cleanupExpiredRemoteClipboardFiles(nowMs) - - expect(fsRmMock).toHaveBeenCalledTimes(1) - expect(fsRmMock).toHaveBeenCalledWith(join('/tmp', 'orca-clipboard-file-expired'), { - recursive: true, - force: true - }) - }) - it('materializes remote files before writing them to the OS clipboard', async () => { const provider = { stat: vi.fn().mockResolvedValue({ size: 12, type: 'file', mtime: 123 }), diff --git a/src/main/window/clipboard-remote-file-cleanup.test.ts b/src/main/window/clipboard-remote-file-cleanup.test.ts new file mode 100644 index 00000000000..56700cc025e --- /dev/null +++ b/src/main/window/clipboard-remote-file-cleanup.test.ts @@ -0,0 +1,153 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { join } from 'node:path' + +const { opendirMock, rmMock, statMock } = vi.hoisted(() => ({ + opendirMock: vi.fn(), + rmMock: vi.fn(), + statMock: vi.fn() +})) + +vi.mock('node:fs/promises', () => ({ + mkdir: vi.fn(), + opendir: opendirMock, + rm: rmMock, + stat: statMock +})) +vi.mock('electron', () => ({ app: { getPath: () => '/tmp' } })) +vi.mock('../providers/ssh-filesystem-dispatch', () => ({ + requireSshFilesystemProvider: vi.fn() +})) +vi.mock('./clipboard-file-copy', () => ({ writeFileToClipboard: vi.fn() })) + +import { cleanupExpiredRemoteClipboardFiles } from './clipboard-remote-file-copy' + +const TTL_MS = 60 * 60 * 1000 +const NOW_MS = 1_760_000_000_000 + +function mockTempRoot(entries: Iterable<{ name: string; isDirectory: () => boolean }>): void { + opendirMock.mockResolvedValue({ + async *[Symbol.asyncIterator]() { + yield* entries + }, + close: vi.fn().mockResolvedValue(undefined) + }) +} + +function* orcaStagingDirs(count: number): Generator<{ name: string; isDirectory: () => boolean }> { + for (let index = 0; index < count; index += 1) { + yield { name: `orca-clipboard-file-expired-${index}`, isDirectory: () => true } + } +} + +function* unrelatedTempEntries( + count: number +): Generator<{ name: string; isDirectory: () => boolean }> { + for (let index = 0; index < count; index += 1) { + // Mirrors a real temp root: mostly foreign entries, both files and directories. + yield { name: `unrelated-${index}`, isDirectory: () => index % 2 === 0 } + } +} + +describe('cleanupExpiredRemoteClipboardFiles', () => { + beforeEach(() => { + vi.clearAllMocks() + rmMock.mockResolvedValue(undefined) + statMock.mockResolvedValue({ mtimeMs: NOW_MS - TTL_MS - 1 }) + }) + + it('streams all entries with at most eight cleanups in flight', async () => { + mockTempRoot(orcaStagingDirs(257)) + let active = 0 + let peak = 0 + statMock.mockImplementation(async () => { + active += 1 + peak = Math.max(peak, active) + await new Promise((resolve) => setImmediate(resolve)) + active -= 1 + return { mtimeMs: NOW_MS - TTL_MS - 1 } + }) + + await cleanupExpiredRemoteClipboardFiles(NOW_MS) + + expect(rmMock).toHaveBeenCalledTimes(257) + expect(peak).toBe(8) + }) + + it('does no per-entry work for foreign temp-root entries', async () => { + mockTempRoot(unrelatedTempEntries(200_000)) + + await cleanupExpiredRemoteClipboardFiles(NOW_MS) + + expect(statMock).not.toHaveBeenCalled() + expect(rmMock).not.toHaveBeenCalled() + }) + + it('acts on owned directories while the temp root is still being enumerated', async () => { + // Regression: the sweep materialized the whole temp root and built one promise + // per entry before doing any work, so a %TEMP% with ~1.2M unrelated entries + // froze the main process at startup (#12835). Streaming means an owned + // directory is swept before enumeration reaches the end. + let sweptDuringEnumeration = false + function* enumeration(): Generator<{ name: string; isDirectory: () => boolean }> { + yield { name: 'orca-clipboard-file-expired', isDirectory: () => true } + for (const entry of unrelatedTempEntries(1_000)) { + sweptDuringEnumeration ||= rmMock.mock.calls.length > 0 + yield entry + } + } + mockTempRoot(enumeration()) + + await cleanupExpiredRemoteClipboardFiles(NOW_MS) + + expect(sweptDuringEnumeration).toBe(true) + expect(rmMock).toHaveBeenCalledTimes(1) + }) + + it('removes only expired staging directories it owns', async () => { + function* interleaved(): Generator<{ name: string; isDirectory: () => boolean }> { + yield* unrelatedTempEntries(50_000) + yield { name: 'orca-clipboard-file-expired', isDirectory: () => true } + yield* unrelatedTempEntries(50_000) + yield { name: 'orca-clipboard-file-fresh', isDirectory: () => true } + yield { name: 'orca-clipboard-file-plain-file', isDirectory: () => false } + } + mockTempRoot(interleaved()) + statMock.mockImplementation(async (targetPath: string) => ({ + mtimeMs: targetPath.endsWith('expired') ? NOW_MS - TTL_MS - 1 : NOW_MS - 1000 + })) + + await cleanupExpiredRemoteClipboardFiles(NOW_MS) + + expect(statMock).toHaveBeenCalledTimes(2) + expect(rmMock).toHaveBeenCalledTimes(1) + expect(rmMock).toHaveBeenCalledWith(join('/tmp', 'orca-clipboard-file-expired'), { + recursive: true, + force: true + }) + }) + + it('closes the temp-root handle and still sweeps when enumeration fails midway', async () => { + const close = vi.fn().mockResolvedValue(undefined) + opendirMock.mockResolvedValue({ + async *[Symbol.asyncIterator]() { + yield { name: 'orca-clipboard-file-expired', isDirectory: () => true } + throw new Error('EIO') + }, + close + }) + + await expect(cleanupExpiredRemoteClipboardFiles(NOW_MS)).resolves.toBeUndefined() + + expect(rmMock).toHaveBeenCalledTimes(1) + expect(close).toHaveBeenCalledTimes(1) + }) + + it('returns quietly when the temp root cannot be opened', async () => { + opendirMock.mockRejectedValue(new Error('EACCES')) + + await expect(cleanupExpiredRemoteClipboardFiles(NOW_MS)).resolves.toBeUndefined() + + expect(statMock).not.toHaveBeenCalled() + expect(rmMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/window/clipboard-remote-file-copy.ts b/src/main/window/clipboard-remote-file-copy.ts index 052fce31ef3..2afc60cda30 100644 --- a/src/main/window/clipboard-remote-file-copy.ts +++ b/src/main/window/clipboard-remote-file-copy.ts @@ -1,6 +1,6 @@ import { randomUUID } from 'node:crypto' -import type { Dirent } from 'node:fs' -import { mkdir, readdir, rm, stat } from 'node:fs/promises' +import type { Dir } from 'node:fs' +import { mkdir, opendir, rm, stat } from 'node:fs/promises' import { join } from 'node:path' import { app } from 'electron' @@ -17,6 +17,7 @@ type RemoteClipboardFileDeps = Omit const REMOTE_CLIPBOARD_FILE_TTL_MS = 60 * 60 * 1000 const REMOTE_CLIPBOARD_FILE_PREFIX = 'orca-clipboard-file-' +const REMOTE_CLIPBOARD_CLEANUP_CONCURRENCY = 8 const WINDOWS_RESERVED_LOCAL_BASENAME = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i const LOCAL_FILENAME_REPLACEMENT_CHARS = new Set(['<', '>', ':', '"', '/', '\\', '|', '?', '*']) @@ -79,32 +80,52 @@ export async function writeRemoteFileToClipboard({ } } +// Why: the OS temp root is shared with every other program and routinely holds +// millions of unrelated entries on long-lived machines, so this startup sweep +// streams it and only ever retains work for entries it actually owns. export async function cleanupExpiredRemoteClipboardFiles(nowMs = Date.now()): Promise { const tempRoot = app.getPath('temp') - let entries: Dirent[] + let tempRootDir: Dir try { - entries = await readdir(tempRoot, { withFileTypes: true }) + tempRootDir = await opendir(tempRoot) } catch { return } - await Promise.all( - entries.map(async (entry) => { + const pending = new Set>() + try { + for await (const entry of tempRootDir) { if (!entry.isDirectory() || !entry.name.startsWith(REMOTE_CLIPBOARD_FILE_PREFIX)) { - return + continue } - const tempDir = join(tempRoot, entry.name) - try { - const tempStats = await stat(tempDir) - if (nowMs - tempStats.mtimeMs < REMOTE_CLIPBOARD_FILE_TTL_MS) { - return - } - await rm(tempDir, { recursive: true, force: true }) - } catch { - // Why: stale staged SSH files should not make startup cleanup noisy. + const cleanup = cleanupExpiredRemoteClipboardDirectory(join(tempRoot, entry.name), nowMs) + pending.add(cleanup) + void cleanup.finally(() => pending.delete(cleanup)) + if (pending.size >= REMOTE_CLIPBOARD_CLEANUP_CONCURRENCY) { + await Promise.race(pending) } - }) - ) + } + } catch { + // Why: a partial best-effort sweep beats failing startup on a temp-root read error. + } finally { + // Why: exhausting the iterator already closes the handle; closing again is harmless. + await tempRootDir.close().catch(() => undefined) + } + await Promise.all(pending) +} + +async function cleanupExpiredRemoteClipboardDirectory( + tempDir: string, + nowMs: number +): Promise { + try { + const tempStats = await stat(tempDir) + if (nowMs - tempStats.mtimeMs >= REMOTE_CLIPBOARD_FILE_TTL_MS) { + await rm(tempDir, { recursive: true, force: true }) + } + } catch { + // Why: stale staged SSH files should not make startup cleanup noisy. + } } function sanitizeLocalClipboardFilename(remoteBasename: string): string { From f057cbc85f5418088b4956ba774c62bd22e940d1 Mon Sep 17 00:00:00 2001 From: Wooseong Kim <2222333+innocarpe@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:56:34 +0900 Subject: [PATCH 007/289] fix(serve): recognize CLI-form serve args on the Electron process (#12818) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(serve): recognize CLI-form serve args on the Electron process When the binary is launched as `… serve --port …` without the CLI rewrite that injects `--serve`, normalize argv so isServeMode, headless GPU flags, and serve option parsing all engage. Preserves existing `--serve*` flag behavior for the CLI-spawned path. Fixes #12677 * fix(serve): treat only CLI subcommand position as serve Parse bare `serve` as the first positional token after flags/values so an option value named `serve` cannot enable headless mode. Addresses CodeRabbit on #12818. * fix(serve): keep CLI redirects ahead of the serve argv rewrite Rewriting argv before maybeRedirectAppImageCliLaunch replaced the `serve` positional with `--serve`, so the redirect's command-name lookup saw a port number and bailed — dropping AppImage serve launches out of the CLI path. Also translate `--port=6768` (the CLI accepts it, getServeOptions only reads the next token) and the mixed `--serve --port` form, so a security-shaped flag like `--no-pairing` can no longer read as accepted while pairing stays on. Map lookups replace `in` on object literals, which turned a stray `serve toString` positional into a function spliced onto argv. * fix(serve): close the CLI-form serve gaps found in review second-instance: shouldActivateDesktopForSecondInstance matched only `--serve`, so a duplicate ` serve --port …` — the ExecStart shape documented in docs/reference/headless-linux-server.md — promoted the live headless server to a desktop window, un-fixing #11935 on exactly the launch shape this PR legitimizes. findServeSubcommandIndex consumed a flag's value unconditionally while the rewrite consumed it only when the next token was not flag-shaped. The two could disagree and swallow the `serve` token, leaving `--serve` uninjected: #12677 again in a new shape (`--port --port serve`, `--port -- serve`). Both scans now share one definition of value consumption. ` serve --help` / `serve help` bound a network-exposed runtime server with pairing on and printed nothing; the AppImage redirect already routes those three tokens to the CLI, so refuse them here too. `--no-pairing=false` translated to `--serve-no-pairing` with the value dropped, disabling pairing for an operator who asked for the opposite. The CLI reads its serve booleans as `flags.get(name) === true`, so a boolean is now translated only in its bare form and the `=` form rides through as the CLI treats it. Tests: spec-derived parity between src/cli/specs/serve.ts and the rewrite, covering both ends of the contract (serveOrcaApp and getServeOptions); a source-text lock on the index.ts redirect/rewrite ordering, which reverted silently green before; an exhaustive self-consistency property test; and the real GUI launch argv shapes that must never enter serve mode. --------- Co-authored-by: Jinwoo-H --- config/tsconfig.cli.json | 3 + config/tsconfig.tc.web.json | 1 + src/cli/serve-electron-flag-parity.test.ts | 79 +++++++ src/main/index.ts | 45 ++-- ...serve-mode-argv-cli-redirect-order.test.ts | 64 ++++++ src/main/startup/serve-mode-argv.test.ts | 208 ++++++++++++++++++ src/main/startup/serve-mode-argv.ts | 167 ++++++++++++++ src/main/startup/single-instance-lock.test.ts | 19 ++ src/main/startup/single-instance-lock.ts | 10 +- 9 files changed, 574 insertions(+), 22 deletions(-) create mode 100644 src/cli/serve-electron-flag-parity.test.ts create mode 100644 src/main/startup/serve-mode-argv-cli-redirect-order.test.ts create mode 100644 src/main/startup/serve-mode-argv.test.ts create mode 100644 src/main/startup/serve-mode-argv.ts diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 3e0e59381a4..386fb6a1594 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -73,6 +73,9 @@ "../src/main/openclaude/hook-service.ts", "../src/main/rolling-file-backup.ts", "../src/main/startup/hydrate-shell-path.ts", + // Why: serve-electron-flag-parity.test.ts checks the Electron-side serve argv rewrite against this + // project's serve spec; the module has no imports, so listing it pulls in nothing else. + "../src/main/startup/serve-mode-argv.ts", "../src/main/runtime/runtime-metadata.ts", "../src/main/win32-utils.ts" ], diff --git a/config/tsconfig.tc.web.json b/config/tsconfig.tc.web.json index 7c4bb7c359e..f576fa77dc0 100644 --- a/config/tsconfig.tc.web.json +++ b/config/tsconfig.tc.web.json @@ -16,6 +16,7 @@ "../src/main/wsl-distro-retry.ts", "../src/main/wsl.ts", "../src/main/startup/serve-desktop-activation.ts", + "../src/main/startup/serve-mode-argv.ts", "../src/main/startup/single-instance-lock.ts", "../src/main/startup/startup-diagnostics.ts", "../src/main/window/focus-existing-window.ts", diff --git a/src/cli/serve-electron-flag-parity.test.ts b/src/cli/serve-electron-flag-parity.test.ts new file mode 100644 index 00000000000..1abcf84ef64 --- /dev/null +++ b/src/cli/serve-electron-flag-parity.test.ts @@ -0,0 +1,79 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { normalizeServeModeArgv } from '../main/startup/serve-mode-argv' +import { BOOLEAN_FLAGS, GLOBAL_FLAGS } from './args' +import { SERVE_COMMAND_SPECS } from './specs/serve' + +// Why this test lives under src/cli: the Electron rewrite duplicates the CLI's serve flag list because +// the main tsconfig cannot import the CLI project, so the parity check has to run from the CLI side. +// It fails when `orca serve` grows a flag that a direct ` serve …` launch would silently drop (#12677). + +// Global flags with no `--serve-*` counterpart: +// - help: a help launch is refused outright, not translated (see serve-mode-argv's HELP_FLAGS). +// - pairing-code / environment: nothing in src/main reads them, so they ride through untouched. +const UNTRANSLATED_GLOBAL_FLAGS = new Set(['help', 'pairing-code', 'environment']) + +const serveSpec = SERVE_COMMAND_SPECS.find((spec) => spec.path.join(' ') === 'serve') +const translatedFlags = [...new Set(serveSpec?.allowedFlags ?? [])].filter( + (flag) => !UNTRANSLATED_GLOBAL_FLAGS.has(flag) +) + +describe('serve flag parity between the CLI spec and the Electron argv rewrite', () => { + it('has a serve spec to compare against', () => { + expect(translatedFlags.length).toBeGreaterThan(0) + }) + + it.each(translatedFlags)('rewrites CLI-form --%s into the --serve-* form', (flag) => { + const takesValue = !BOOLEAN_FLAGS.has(flag) + const argv = takesValue + ? ['/AppRun', 'serve', `--${flag}`, 'value'] + : ['/AppRun', 'serve', `--${flag}`] + const expected = takesValue + ? ['/AppRun', '--serve', `--serve-${flag}`, 'value'] + : ['/AppRun', '--serve', `--serve-${flag}`] + + expect(normalizeServeModeArgv(argv)).toEqual(expected) + if (takesValue) { + // The equals form is the other shape `orca serve` accepts, and getServeOptions only reads the next token. + expect(normalizeServeModeArgv(['/AppRun', 'serve', `--${flag}=value`])).toEqual(expected) + } else { + // A boolean with an attached value is not a truthy assertion: the CLI reads these as + // `flags.get(name) === true`, so `--no-pairing=false` must not disable pairing here either. + // (`--json` is the one flag the CLI reads with `.has()`; erring toward the literal value is + // the safe direction and only changes output format.) + expect(normalizeServeModeArgv(['/AppRun', 'serve', `--${flag}=false`])).toEqual([ + '/AppRun', + '--serve', + `--${flag}=false` + ]) + } + // Idempotent: `orca serve` spawns the app already in this shape, and the rewrite runs over it too. + expect(normalizeServeModeArgv(expected)).toEqual(expected) + }) + + it('emits the same --serve-* names the CLI spawns with and the main process reads', () => { + // Why source text: serveOrcaApp spawns a real process and getServeOptions is not exported, so + // both ends of the contract are only readable statically. Without this leg the rewrite could + // emit a name nothing reads and every behavioural assertion above would still pass. + const launchSource = readFileSync(join(process.cwd(), 'src/cli/runtime/launch.ts'), 'utf8') + const mainSource = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + const start = mainSource.indexOf('function getServeOptions(') + // Why bound the anchor: an unresolved indexOf slices to EOF and passes vacuously. + expect(start).toBeGreaterThanOrEqual(0) + const end = mainSource.indexOf('\n}', start) + expect(end).toBeGreaterThan(start) + const getServeOptionsBody = mainSource.slice(start, end) + + for (const flag of translatedFlags) { + expect(launchSource).toContain(`'--serve-${flag}'`) + expect(getServeOptionsBody).toContain(`'--serve-${flag}'`) + } + }) + + it('keeps the untranslated allowlist tied to real global flags', () => { + for (const flag of UNTRANSLATED_GLOBAL_FLAGS) { + expect(GLOBAL_FLAGS).toContain(flag) + } + }) +}) diff --git a/src/main/index.ts b/src/main/index.ts index 790a8a2ed69..e51db18a9c6 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -127,6 +127,7 @@ import { installUnhandledRejectionLogging } from './startup/main-process-error-guards' import { enableRendererHeapHeadroom } from './startup/renderer-heap-headroom' +import { argvRequestsServeMode, normalizeServeModeArgv } from './startup/serve-mode-argv' import { ensureVirtualDisplayForHeadlessServe } from './startup/ensure-virtual-display' import { readActiveGpuFallbackMarker, @@ -383,6 +384,33 @@ let gpuFeatureStatus: Electron.GPUFeatureStatus | null = null let localPtyStartupReady: Promise = Promise.resolve() let localPtyProviderStartupReady: Promise = Promise.resolve() const AGENT_STATE_CRASH_BREADCRUMB_MIN_INTERVAL_MS = 30_000 +// Why: on Windows a CLI launch that lost ELECTRON_RUN_AS_NODE would boot the GUI and exit silently; redirect to node mode before the lock gate below. +// Both redirects run before the serve-argv rewrite so they still match on the launch argv verbatim. +// It is load-bearing for the AppImage one: rewriting first replaces the `serve` positional, so its +// command-name lookup finds a port number and strands the launch in an in-process serve. The +// packaged-CLI one matches on the entry path instead, so order cannot affect it either way. +const packagedCliEntryRedirect = maybeRedirectPackagedCliEntryLaunch({ + isPackaged: app.isPackaged, + resourcesPath: process.resourcesPath, + execPath: process.execPath +}) +if (packagedCliEntryRedirect.redirected) { + app.exit(packagedCliEntryRedirect.status) +} +const appImageCliRedirect = maybeRedirectAppImageCliLaunch({ + isPackaged: app.isPackaged, + resourcesPath: process.resourcesPath, + execPath: process.execPath +}) +if (appImageCliRedirect.redirected) { + app.exit(appImageCliRedirect.status) +} +// Why: extracted AppRun / binary launches can land CLI-form `serve` args on the +// Electron process without the CLI rewrite that injects `--serve` (#12677). +// Guarded so a normal GUI launch keeps its original argv array identity. +if (argvRequestsServeMode(process.argv)) { + process.argv = normalizeServeModeArgv(process.argv) +} const isServeMode = process.argv.includes('--serve') function updateGpuAccelerationAboutPanel(): void { @@ -416,23 +444,6 @@ const desktopActivationGate = createServeDesktopActivationGate({ }, onBlocked: (reason) => console.error(`[serve] Desktop activation blocked: ${reason}`) }) -// Why: on Windows a CLI launch that lost ELECTRON_RUN_AS_NODE would boot the GUI and exit silently; redirect to node mode before the lock gate below. -const packagedCliEntryRedirect = maybeRedirectPackagedCliEntryLaunch({ - isPackaged: app.isPackaged, - resourcesPath: process.resourcesPath, - execPath: process.execPath -}) -if (packagedCliEntryRedirect.redirected) { - app.exit(packagedCliEntryRedirect.status) -} -const appImageCliRedirect = maybeRedirectAppImageCliLaunch({ - isPackaged: app.isPackaged, - resourcesPath: process.resourcesPath, - execPath: process.execPath -}) -if (appImageCliRedirect.redirected) { - app.exit(appImageCliRedirect.status) -} // Kill switch for the first-work on-disk folder rename; the renderer reconciles the id change (migrateWorktreeIdentity) so it isn't mistaken for a deletion. const ENABLE_FIRST_WORK_FOLDER_RENAME = false diff --git a/src/main/startup/serve-mode-argv-cli-redirect-order.test.ts b/src/main/startup/serve-mode-argv-cli-redirect-order.test.ts new file mode 100644 index 00000000000..3a389bb80d4 --- /dev/null +++ b/src/main/startup/serve-mode-argv-cli-redirect-order.test.ts @@ -0,0 +1,64 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { getAppImageCliArgs } from './appimage-cli-redirect' +import { argvRequestsServeMode, normalizeServeModeArgv } from './serve-mode-argv' + +// Why: index.ts must run both CLI redirects before rewriting argv. Rewriting +// first replaces the `serve` positional with `--serve`, so the redirect's +// command-name lookup finds a port number instead of a command and bails — +// silently dropping AppImage serve launches out of the CLI path (#12677). + +const REDIRECT_OPTIONS = { + platform: 'linux' as const, + isPackaged: true, + commandNames: ['serve', 'status'] +} +// A mounted AppImage is the case where the runtime does export these. +const MOUNTED_APPIMAGE_ENV = { APPIMAGE: '/opt/orca/Orca.AppImage', APPDIR: '/tmp/.mount_ab12' } + +function rewriteAsIndexDoes(argv: string[]): string[] { + return argvRequestsServeMode(argv) ? normalizeServeModeArgv(argv) : argv +} + +describe('serve argv rewrite vs AppImage CLI redirect ordering', () => { + const launchArgv = ['/opt/orca/orca-ide', '--no-sandbox', 'serve', '--port', '7777', '--json'] + + it('hands the launch argv to the CLI when the redirect runs first', () => { + expect(getAppImageCliArgs(launchArgv, MOUNTED_APPIMAGE_ENV, REDIRECT_OPTIONS)).toEqual([ + 'serve', + '--port', + '7777', + '--json' + ]) + }) + + it('loses the redirect if the rewrite runs first', () => { + const rewritten = rewriteAsIndexDoes(launchArgv) + expect(rewritten).toContain('--serve') + expect(getAppImageCliArgs(rewritten, MOUNTED_APPIMAGE_ENV, REDIRECT_OPTIONS)).toBeNull() + }) + + it('leaves non-serve CLI commands redirectable either way', () => { + const argv = ['/opt/orca/orca-ide', 'status'] + expect(rewriteAsIndexDoes(argv)).toEqual(argv) + expect(getAppImageCliArgs(argv, MOUNTED_APPIMAGE_ENV, REDIRECT_OPTIONS)).toEqual(['status']) + }) + + // Why source text: the ordering only exists as statement order at index.ts module scope, and the + // cases above stay green if it is reversed — nothing else would catch the regression. + it('keeps index.ts running both CLI redirects before the argv rewrite', () => { + const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + const packagedRedirect = source.indexOf('maybeRedirectPackagedCliEntryLaunch({') + const appImageRedirect = source.indexOf('maybeRedirectAppImageCliLaunch({') + const rewrite = source.indexOf('process.argv = normalizeServeModeArgv(process.argv)') + const serveModeCheck = source.indexOf("const isServeMode = process.argv.includes('--serve')") + + expect(packagedRedirect).toBeGreaterThanOrEqual(0) + expect(appImageRedirect).toBeGreaterThanOrEqual(0) + expect(rewrite).toBeGreaterThan(packagedRedirect) + expect(rewrite).toBeGreaterThan(appImageRedirect) + // The rewrite is pointless unless it lands before the flag it exists to inject is read. + expect(serveModeCheck).toBeGreaterThan(rewrite) + }) +}) diff --git a/src/main/startup/serve-mode-argv.test.ts b/src/main/startup/serve-mode-argv.test.ts new file mode 100644 index 00000000000..e82c02189d8 --- /dev/null +++ b/src/main/startup/serve-mode-argv.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, it } from 'vitest' +import { + argvRequestsServeMode, + findServeSubcommandIndex, + normalizeServeModeArgv +} from './serve-mode-argv' + +describe('serve-mode-argv', () => { + it('detects --serve and bare serve subcommand', () => { + expect(argvRequestsServeMode(['orca', '--serve'])).toBe(true) + expect(argvRequestsServeMode(['/AppRun', 'serve'])).toBe(true) + expect(argvRequestsServeMode(['/AppRun', '--no-sandbox', 'serve', '--port', '8080'])).toBe(true) + expect(argvRequestsServeMode(['orca'])).toBe(false) + }) + + it('does not treat serve as a subcommand when it is an option value', () => { + expect(findServeSubcommandIndex(['app', '--user-data-dir', 'serve'])).toBe(-1) + expect(argvRequestsServeMode(['app', '--user-data-dir', 'serve'])).toBe(false) + expect(normalizeServeModeArgv(['app', '--user-data-dir', 'serve'])).toEqual([ + 'app', + '--user-data-dir', + 'serve' + ]) + // The value is skipped, not the subcommand after it. + expect(findServeSubcommandIndex(['app', '--user-data-dir', '/tmp/x', 'serve'])).toBe(3) + }) + + it('refuses a help launch instead of binding a server', () => { + // Why: `--help` is not a serve flag, so it used to be swallowed and the launch bound a + // network-exposed runtime server with pairing on. The AppImage redirect routes help to the CLI. + for (const argv of [ + ['/AppRun', 'serve', '--help'], + ['/AppRun', 'serve', '-h'], + ['/AppRun', '--help', 'serve'], + ['/AppRun', 'help', 'serve'], + // Bare `help` too — the AppImage redirect's own help set includes it. + ['/AppRun', 'serve', 'help'] + ]) { + expect(argvRequestsServeMode(argv), argv.join(' ')).toBe(false) + expect(normalizeServeModeArgv(argv)).toEqual(argv) + } + // A directory named `help` is a value, not a help request. + expect(argvRequestsServeMode(['/AppRun', 'serve', '--project-root', 'help'])).toBe(true) + // Past an operator's terminator nothing is reinterpreted, help flags included. + expect(argvRequestsServeMode(['/AppRun', 'serve', '--', '--help'])).toBe(true) + }) + + it('does not turn `--no-pairing=false` into no-pairing', () => { + // Why: the CLI reads serve booleans as `=== true`, so `=false` leaves pairing ON. Translating + // the token dropped the value and disabled pairing instead — the inversion of the bug this + // module exists to fix. + expect( + normalizeServeModeArgv(['/AppRun', 'serve', '--port', '6768', '--no-pairing=false']) + ).toEqual(['/AppRun', '--serve', '--serve-port', '6768', '--no-pairing=false']) + expect(normalizeServeModeArgv(['/AppRun', 'serve', '--mobile-pairing=0'])).toEqual([ + '/AppRun', + '--serve', + '--mobile-pairing=0' + ]) + }) + + it('leaves real GUI launch argv alone', () => { + // Why: a false positive here is the expensive direction — the window never opens and a runtime + // server binds instead. These are the argv shapes the desktop actually receives. + for (const argv of [ + ['/Applications/Orca.app/Contents/MacOS/Orca', '-psn_0_123456'], + ['C:\\Program Files\\Orca\\Orca.exe', '--squirrel-firstrun'], + ['C:\\Program Files\\Orca\\Orca.exe', 'orca://worktree/serve'], + ['/opt/orca/orca-ide', '/home/u/serve'], + // `--pairing-code` takes the next token, so its value is never the subcommand. + ['/opt/orca/orca-ide', '--pairing-code', 'serve'], + ['/opt/orca/orca-ide', '--environment=serve'], + ['/opt/orca/orca-ide', '--', 'serve'], + ['/opt/orca/orca-ide', 'Serve'] + ]) { + expect(argvRequestsServeMode(argv), argv.join(' ')).toBe(false) + expect(normalizeServeModeArgv(argv)).toEqual(argv) + } + }) + + it('does not treat a later positional serve as the subcommand after another command', () => { + expect(findServeSubcommandIndex(['app', 'status', 'serve'])).toBe(-1) + expect(argvRequestsServeMode(['app', 'status', 'serve'])).toBe(false) + }) + + it('rewrites CLI-form serve flags into --serve* form', () => { + expect( + normalizeServeModeArgv([ + '/AppRun', + 'serve', + '--port', + '9090', + '--json', + '--pairing-address', + '0.0.0.0', + '--no-pairing' + ]) + ).toEqual([ + '/AppRun', + '--serve', + '--serve-port', + '9090', + '--serve-json', + '--serve-pairing-address', + '0.0.0.0', + '--serve-no-pairing' + ]) + }) + + it('leaves already-normalized argv unchanged', () => { + // Why every value flag: the CLI's own `orca serve` spawns the app with exactly this shape + // (serveOrcaApp), and the rewrite now runs over it too — a bad mapping would drop the port here. + const argv = [ + 'orca', + '--serve', + '--serve-json', + '--serve-port', + '6768', + '--serve-pairing-address', + '100.64.1.20', + '--serve-no-pairing', + '--serve-mobile-pairing', + '--serve-recipe-json', + '--serve-project-root', + '/srv/repo' + ] + expect(normalizeServeModeArgv(argv)).toEqual(argv) + }) + + it('splits `--flag=value` CLI form, which getServeOptions cannot read', () => { + expect( + normalizeServeModeArgv(['/AppRun', 'serve', '--port=9090', '--pairing-address=0.0.0.0']) + ).toEqual(['/AppRun', '--serve', '--serve-port', '9090', '--serve-pairing-address', '0.0.0.0']) + }) + + it('translates serve flags in the mixed `--serve --port` form', () => { + // Why: leaving these untranslated silently kept pairing enabled despite --no-pairing. + expect(normalizeServeModeArgv(['orca', '--serve', '--port', '9090', '--no-pairing'])).toEqual([ + 'orca', + '--serve', + '--serve-port', + '9090', + '--serve-no-pairing' + ]) + }) + + it('leaves a non-serve launch untouched', () => { + const argv = ['orca', '--no-sandbox', '/home/u/project'] + expect(argvRequestsServeMode(argv)).toBe(false) + expect(normalizeServeModeArgv(argv)).toEqual(argv) + }) + + it('does not splice prototype members onto argv for stray positionals', () => { + expect(normalizeServeModeArgv(['/AppRun', 'serve', 'toString'])).toEqual([ + '/AppRun', + '--serve', + 'toString' + ]) + }) + + it('passes args after `--` through verbatim', () => { + expect(normalizeServeModeArgv(['/AppRun', 'serve', '--json', '--', '--port', '1'])).toEqual([ + '/AppRun', + '--serve', + '--serve-json', + '--', + '--port', + '1' + ]) + }) + + it('never disagrees with itself about whether a launch is serve mode', () => { + // Why exhaustive: index.ts asks `argvRequestsServeMode` and then reads `--serve` out of the + // rewrite. If the two halves consume values differently one can swallow the `serve` token the + // other is rewriting, and the launch boots a desktop window with every serve flag dropped — + // #12677 again. Found by fuzzing `--port --port serve` and `--port -- serve`. + const alphabet = [ + 'serve', + '--serve', + '--port', + '--json', + '--pairing-code', + '--user-data-dir', + '--', + '-h', + 'help', + 'value', + '--port=1', + '--no-pairing=false' + ] + const disagreements: string[][] = [] + const walk = (tail: string[]): void => { + const argv = ['/AppRun', ...tail] + if (argvRequestsServeMode(argv) !== normalizeServeModeArgv(argv).includes('--serve')) { + disagreements.push(argv) + } + if (tail.length === 4) { + return + } + for (const token of alphabet) { + walk([...tail, token]) + } + } + walk([]) + + expect(disagreements).toEqual([]) + }) +}) diff --git a/src/main/startup/serve-mode-argv.ts b/src/main/startup/serve-mode-argv.ts new file mode 100644 index 00000000000..3ba25fd8d85 --- /dev/null +++ b/src/main/startup/serve-mode-argv.ts @@ -0,0 +1,167 @@ +/** + * Detect and normalize headless-serve argv for both Electron flags (`--serve`) + * and CLI-form subcommands (`serve --port …`) that land on the main process + * when the AppImage/CLI redirect did not rewrite them. + */ + +const SERVE_FLAG = '--serve' + +// Why Map, not a record: `'toString' in {}` is true, so an object lookup turns a +// stray `serve toString` positional into a function spliced onto argv. +const CLI_TO_SERVE_FLAG = new Map([ + ['--json', '--serve-json'], + ['--no-pairing', '--serve-no-pairing'], + ['--mobile-pairing', '--serve-mobile-pairing'], + ['--recipe-json', '--serve-recipe-json'] +]) + +const CLI_TO_SERVE_VALUE_FLAG = new Map([ + ['--port', '--serve-port'], + ['--pairing-address', '--serve-pairing-address'], + ['--project-root', '--serve-project-root'] +]) + +/** + * Flags that consume the next argv token as a value (CLI-form + Electron passthrough). + * Residual class: a flag outside this list whose space-separated value is literally `serve` would + * read as the subcommand. Chromium switches are `--flag=value` only, so no real launch does that. + */ +const VALUE_TAKING_FLAGS = new Set([ + ...CLI_TO_SERVE_VALUE_FLAG.keys(), + '--serve-port', + '--serve-pairing-address', + '--serve-project-root', + '--user-data-dir', + '--environment', + '--pairing-code' +]) + +// Why: a CLI-form `serve` is not a serve launch when help was asked for. The AppImage redirect +// already hands these to the CLI (the same three tokens); without the refusal here, +// ` serve --help` binds a network-exposed runtime server with pairing on and prints nothing. +// Scoped to the subcommand form on purpose: `--serve` is the CLI's own contract and never carries +// `--help`, so widening this would risk the one path that already works. +const HELP_FLAGS = new Set(['--help', '-h', 'help']) + +function isFlagToken(token: string | undefined): boolean { + return Boolean(token && token.startsWith('-')) +} + +/** + * Value consumption for the two scans that walk argv looking for a position: a flag takes the next + * token only when that token is not itself flag-shaped. `normalizeServeModeArgv` consumes a strict + * subset of this (only the CLI value flags it translates), which is the property + * `findServeSubcommandIndex` documents below. + */ +function indexAfterToken(argv: readonly string[], i: number): number { + return i + (VALUE_TAKING_FLAGS.has(argv[i]!) && !isFlagToken(argv[i + 1]) ? 2 : 1) +} + +function requestsHelp(argv: readonly string[]): boolean { + let i = 1 + while (i < argv.length) { + const token = argv[i]! + if (token === '--') { + return false + } + // Skipping values matters for the bare `help` token: `serve --project-root help` names a + // directory, it does not ask for help. + if (HELP_FLAGS.has(token)) { + return true + } + i = indexAfterToken(argv, i) + } + return false +} + +/** + * Index of the CLI `serve` subcommand: the first positional token after flags + * (and their values). Option *values* named `serve` are never treated as the + * subcommand. + * + * What the tokens this skips must satisfy: they are a superset of the ones `normalizeServeModeArgv` + * consumes as values. That one-directional property is what keeps the serve index off a token the + * rewrite treats as a value — if the two ever disagreed, one half would swallow the `serve` token + * the other half is rewriting and `--serve` would never be injected: #12677 again in a new shape. + * Shrinking VALUE_TAKING_FLAGS to "restore symmetry" breaks it (see `--user-data-dir serve`). + */ +export function findServeSubcommandIndex(argv: readonly string[]): number { + if (requestsHelp(argv)) { + return -1 + } + let i = 1 + while (i < argv.length) { + const token = argv[i] + if (token === '--') { + return -1 + } + if (!isFlagToken(token)) { + return token === 'serve' ? i : -1 + } + i = indexAfterToken(argv, i) + } + return -1 +} + +/** True when argv already has `--serve` or a bare `serve` CLI subcommand. */ +export function argvRequestsServeMode(argv: readonly string[]): boolean { + return argv.includes(SERVE_FLAG) || findServeSubcommandIndex(argv) !== -1 +} + +/** + * Rewrite CLI-form `serve` invocations into the `--serve*` flag shape that + * `getServeOptions` already understands. Idempotent when already in flag form. + * + * Serve flags are translated whenever serve mode is requested, including the + * mixed `--serve --port 6768` form: leaving them untranslated is what makes a + * security-shaped flag like `--no-pairing` read as accepted while pairing stays + * on (#12677). + */ +export function normalizeServeModeArgv(argv: readonly string[]): string[] { + const serveIndex = findServeSubcommandIndex(argv) + if (serveIndex === -1 && !argv.includes(SERVE_FLAG)) { + return [...argv] + } + + const next: string[] = [] + for (let i = 0; i < argv.length; i += 1) { + const token = argv[i]! + if (i === serveIndex) { + next.push(SERVE_FLAG) + continue + } + if (token === '--') { + next.push(...argv.slice(i)) + break + } + // Why: the CLI accepts `--port=6768` as well as `--port 6768`, but + // getServeOptions only reads the next token, so `=` must be split apart. + const eq = token.indexOf('=') + const name = eq === -1 ? token : token.slice(0, eq) + // Why only the bare form: the CLI reads its serve booleans as `flags.get(name) === true` + // (src/cli/handlers/core.ts), so `--no-pairing=false` is NOT no-pairing there. Translating it + // anyway dropped the value and disabled pairing for an operator who wrote `=false` — the + // security-shaped inversion of #12677. Passing it through leaves pairing on, as the CLI does. + const booleanFlag = eq === -1 ? CLI_TO_SERVE_FLAG.get(name) : undefined + if (booleanFlag) { + next.push(booleanFlag) + continue + } + const valueFlag = CLI_TO_SERVE_VALUE_FLAG.get(name) + if (!valueFlag) { + next.push(token) + continue + } + if (eq !== -1) { + next.push(valueFlag, token.slice(eq + 1)) + continue + } + next.push(valueFlag) + const value = argv[i + 1] + if (value !== undefined && !isFlagToken(value)) { + next.push(value) + i += 1 + } + } + return next +} diff --git a/src/main/startup/single-instance-lock.test.ts b/src/main/startup/single-instance-lock.test.ts index b3058c91a66..e023d7895f5 100644 --- a/src/main/startup/single-instance-lock.test.ts +++ b/src/main/startup/single-instance-lock.test.ts @@ -85,6 +85,25 @@ describe('shouldActivateDesktopForSecondInstance', () => { expect(shouldActivateDesktopForSecondInstance(['/Applications/Orca.app/orca'])).toBe(true) }) + it('ignores a duplicate CLI-form serve launch the CLI redirect never rewrote', () => { + // Why: the documented systemd unit is ` serve --port 6768 …`; an extracted AppRun/binary + // start reaches Electron in that shape, so a flag-only check would open a window on the live server. + expect( + shouldActivateDesktopForSecondInstance([ + '/opt/orca/squashfs-root/orca-ide', + 'serve', + '--port', + '6768', + '--pairing-address', + '100.64.1.20' + ]) + ).toBe(false) + // A path argument that merely contains `serve` is still a desktop launch. + expect( + shouldActivateDesktopForSecondInstance(['/opt/orca/orca-ide', '/home/u/serve-repo']) + ).toBe(true) + }) + it('fails open when no argv is available', () => { expect(shouldActivateDesktopForSecondInstance([])).toBe(true) expect(shouldActivateDesktopForSecondInstance()).toBe(true) diff --git a/src/main/startup/single-instance-lock.ts b/src/main/startup/single-instance-lock.ts index 4a08fa61e73..69404b42e80 100644 --- a/src/main/startup/single-instance-lock.ts +++ b/src/main/startup/single-instance-lock.ts @@ -1,4 +1,5 @@ import type { App } from 'electron' +import { argvRequestsServeMode } from './serve-mode-argv' import { writeStartupDiagnosticLine, type StartupDiagnosticSink } from './startup-diagnostics' export const SINGLE_INSTANCE_LOCK_FAILURE_MESSAGE = @@ -10,13 +11,12 @@ export const SINGLE_INSTANCE_LOCK_BYPASS_MESSAGE = // Why: stable "another process owns this profile" contract that systemd RestartPreventExitStatus= keys off; changing it silently un-fixes #11935. export const SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE = 3 -// Why: `serve` is a CLI subcommand, never Electron argv — an AppImage launched as `orca serve` exits -// at the CLI redirect before requesting the lock, and the CLI re-spawns the Electron child with `--serve`. -const SERVE_MODE_ARG = '--serve' - // Why: a duplicate `orca serve` is a supervisor artifact, not a user asking for a window; fail open when argv is unavailable. +// Why not `argv.includes('--serve')`: the documented systemd unit runs ` serve --port …`, so a +// duplicate start hands this handler CLI-form argv the CLI redirect never rewrote (#12677) — matching only +// the flag form would promote the live headless server to a desktop window, un-fixing #11935. export function shouldActivateDesktopForSecondInstance(argv: readonly string[] = []): boolean { - return !argv.includes(SERVE_MODE_ARG) + return !argvRequestsServeMode(argv) } /** From 9e4e6ddae57b254f796a44d013be6663786a461e Mon Sep 17 00:00:00 2001 From: plotarmordev Date: Fri, 7 Aug 2026 15:58:29 +0800 Subject: [PATCH 008/289] feat(native-chat): render omp transcripts (#11523) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(native-chat): render omp transcripts omp already ships as a first-class launchable agent with session_id resume, but its transcripts had no decoder, so native chat could not render it — the agent runs and the conversation stays a raw terminal. This adds the decoder and wires it through the same path Claude, Codex and Grok use. omp writes one envelope per line, `{ type, id, parentId, timestamp, … }`, where conversation turns are `type: 'message'` and the rest is session bookkeeping. Reasoning arrives as a `thinking` content block inside the assistant turn, so the mapping follows Claude rather than Codex: thinking becomes a text block on an assistant message, where Codex and Grok emit a separate reasoning role only because their transcripts carry dedicated reasoning records. - toolCall -> tool-call, arguments passed through as the object omp writes - toolResult -> tool role, isError preserved - developer -> system, matching the Codex non-user/non-assistant fallback - blob-handle images drop, as the Claude mapper drops an image record with neither path nor url - bookkeeping and unrecognized types skip rather than throw Session files are `_.jsonl` under a per-cwd directory, so the resolver matches the id as a base-name suffix the way Codex rollout files are matched, and honors OMP_CODING_AGENT_DIR through normalizeAgentSessionsDir so it stays consistent with the AI Vault scanner. omp records no interruption or abort event, so unlike Claude and Codex there is no NATIVE_CHAT_INTERRUPTED_STATUS_TEXT path. Verified against 94,603 lines of real omp transcripts across four sessions: 50,546 records decoded, zero malformed, zero thrown. * fix(native-chat): complete omp record coverage and gate remote transcripts Review fixes on the omp transcript decoder. omp writes several record types with no `content` field, so they decoded to zero blocks and disappeared from the chat view entirely: - `bashExecution` / `pythonExecution`: TUI `!command` runs, now a tool turn - `fileMention`: `@path` attachments, listed by path (never `files[].content`, which is an auto-read dump) - `custom_message` and legacy `custom` / `hookMessage` rows, gated on `display` the way omp's own renderer gates them Also: - `stopReason: 'aborted'` turns now surface as the interrupted row, matching the Claude and Codex decoders. An abort carrying partial content keeps it. - A cancelled command cell now reads as errored. Every omp cancel path emits `exitCode: undefined`, which JSON drops, so an `exitCode !== 0` check read a cancelled run as a clean success. - omp joins Grok in requiring a locally readable transcript. Its hook reports no transcript path, so under Model-A SSH the chat view opened against a disk this process cannot read and never loaded. Applies on mobile too, which shares the same allowlist. - The session-file walk prunes omp's per-session subagent artifact directories, matching the AI Vault scanner. It was returning a subagent transcript instead of the parent session, and cost a full recursive readdir on every resolve. * style(native-chat): apply oxfmt to the omp review fixes Mobile CI gates `oxfmt --check`; the two root files were unformatted too, just ungated there. Line wrapping only, no behavior change. --------- Co-authored-by: plotarmordev <299844489+plotarmordev@users.noreply.github.com> Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> --- .../mobile-native-chat-eligibility.test.ts | 18 ++ .../session/mobile-native-chat-eligibility.ts | 12 +- .../native-chat/session-file-resolver.test.ts | 52 ++++ src/main/native-chat/session-file-resolver.ts | 43 +++ .../transcript-line-decoders-omp.ts | 227 ++++++++++++++ .../transcript-line-decoders.omp.test.ts | 280 ++++++++++++++++++ .../native-chat/transcript-line-decoders.ts | 1 + src/main/native-chat/transcript-reader.ts | 6 +- .../native-chat/transcript-tail-reader.ts | 6 +- .../native-chat-availability.test.ts | 15 + .../native-chat/native-chat-availability.ts | 10 +- .../lib/native-chat-initial-view-mode.test.ts | 13 + .../src/lib/native-chat-initial-view-mode.ts | 10 +- .../src/lib/native-chat-supported-agent.ts | 1 + .../lib/worktree-creation-agent-seeds.test.ts | 18 ++ .../src/lib/worktree-creation-agent-seeds.ts | 3 +- .../worktree-draft-startup-view-mode.test.ts | 69 +++++ .../lib/worktree-draft-startup-view-mode.ts | 3 +- src/shared/native-chat-agent-support.test.ts | 21 +- src/shared/native-chat-agent-support.ts | 16 +- 20 files changed, 808 insertions(+), 16 deletions(-) create mode 100644 src/main/native-chat/transcript-line-decoders-omp.ts create mode 100644 src/main/native-chat/transcript-line-decoders.omp.test.ts create mode 100644 src/renderer/src/lib/worktree-draft-startup-view-mode.test.ts diff --git a/mobile/src/session/mobile-native-chat-eligibility.test.ts b/mobile/src/session/mobile-native-chat-eligibility.test.ts index 387736f065e..7daa4babea6 100644 --- a/mobile/src/session/mobile-native-chat-eligibility.test.ts +++ b/mobile/src/session/mobile-native-chat-eligibility.test.ts @@ -97,6 +97,24 @@ describe('resolveMobileNativeChat', () => { ).toBeNull() }) + // Why: omp's hook reports no transcript path either, so mobile can only show + // its chat when the serving host is the one holding the session file. + it('admits omp only when its transcript is readable by the serving host', () => { + const tab = { type: 'terminal', launchAgent: 'omp' } + expect(resolveMobileNativeChat(tab, isMobileNativeChatTranscriptReadable(null))).toMatchObject({ + agent: 'omp' + }) + expect( + resolveMobileNativeChat(tab, isMobileNativeChatTranscriptReadable('runtime-ssh-environment')) + ).toMatchObject({ agent: 'omp' }) + expect( + resolveMobileNativeChat(tab, isMobileNativeChatTranscriptReadable('model-a-ssh')) + ).toBeNull() + expect(canShowMobileNativeChat(tab, isMobileNativeChatTranscriptReadable('model-a-ssh'))).toBe( + false + ) + }) + it('returns null for a plain shell (no agent)', () => { expect(resolveMobileNativeChat({ type: 'terminal' })).toBeNull() }) diff --git a/mobile/src/session/mobile-native-chat-eligibility.ts b/mobile/src/session/mobile-native-chat-eligibility.ts index 2f997c2cdc5..abda64b04ab 100644 --- a/mobile/src/session/mobile-native-chat-eligibility.ts +++ b/mobile/src/session/mobile-native-chat-eligibility.ts @@ -1,10 +1,14 @@ import type { AgentStatusEntry } from '../../../src/shared/agent-status-types' import { isRuntimeOwnedSshTargetId } from '../../../src/shared/execution-host' -import { isNativeChatSupportedAgent } from '../../../src/shared/native-chat-agent-support' +import { + isNativeChatSupportedAgent, + nativeChatRequiresLocalTranscript +} from '../../../src/shared/native-chat-agent-support' // Why: native chat renders an agent's own JSONL transcript, and the host -// resolver knows these transcript layouts. Grok is additionally gated on host -// readability because Model-A SSH stores its transcript on the remote target. +// resolver knows these transcript layouts. Agents whose hook reports no +// transcript path (Grok, omp) are additionally gated on host readability, +// because Model-A SSH stores their transcript on the remote target. export function isMobileNativeChatTranscriptReadable( connectionId: string | null | undefined ): boolean { @@ -50,7 +54,7 @@ export function resolveMobileNativeChat( if (!agent || !isNativeChatSupportedAgent(agent)) { return null } - if (agent === 'grok' && !nativeChatTranscriptIsLocalReadable) { + if (nativeChatRequiresLocalTranscript(agent) && !nativeChatTranscriptIsLocalReadable) { return null } return { diff --git a/src/main/native-chat/session-file-resolver.test.ts b/src/main/native-chat/session-file-resolver.test.ts index 85cd653185b..98aa1f4abad 100644 --- a/src/main/native-chat/session-file-resolver.test.ts +++ b/src/main/native-chat/session-file-resolver.test.ts @@ -137,6 +137,58 @@ describe('resolveSessionFilePath', () => { expect(resolved).toBe(target) }) + it('matches omp transcripts by session id suffix inside the per-cwd directory', async () => { + const root = await makeRoot('orca-native-chat-resolve-omp-') + const ompSessionsDir = join(root, 'omp-sessions') + const cwdDir = join(ompSessionsDir, '-Users-ada-repo') + await mkdir(cwdDir, { recursive: true }) + const target = join(cwdDir, '2026-07-16T00-27-02-222Z_sess-omp-1.jsonl') + await writeFile(target, '{}\n') + + const resolved = await resolveSessionFilePath('omp', 'sess-omp-1', { ompSessionsDir }) + expect(resolved).toBe(target) + }) + + it('never descends into an omp session artifact dir', async () => { + // Why: a session's task-subagent transcripts sit in its same-named + // `_/` artifact dir, and a label-named child CAN end in + // `_`. Asserting the parent wins would only prove the prune on a + // filesystem that happens to enumerate the dir first, so give the id exactly + // one match — inside the artifact dir. Pruned resolves to null; descending + // finds the child, whatever order readdir returns. + const root = await makeRoot('orca-native-chat-resolve-omp-artifact-') + const ompSessionsDir = join(root, 'omp-sessions') + const cwdDir = join(ompSessionsDir, '-Users-ada-repo') + const stem = '2026-07-16T00-27-02-222Z_019fd8e2-fd56-7000-acfe-2e497adfa83c' + await mkdir(join(cwdDir, stem), { recursive: true }) + await writeFile(join(cwdDir, `${stem}.jsonl`), '{}\n') + await writeFile(join(cwdDir, stem, 'worker_sess-omp-child.jsonl'), '{}\n') + + await expect( + resolveSessionFilePath('omp', 'sess-omp-child', { ompSessionsDir }) + ).resolves.toBeNull() + // The parent transcript itself still resolves through the pruned walk. + await expect( + resolveSessionFilePath('omp', '019fd8e2-fd56-7000-acfe-2e497adfa83c', { ompSessionsDir }) + ).resolves.toBe(join(cwdDir, `${stem}.jsonl`)) + }) + + it('honors OMP_CODING_AGENT_DIR when resolving omp transcripts', async () => { + const root = await makeRoot('orca-native-chat-resolve-omp-env-') + const cwdDir = join(root, 'omp-sessions', '-Users-ada-repo') + await mkdir(cwdDir, { recursive: true }) + const target = join(cwdDir, '2026-07-16T00-27-02-222Z_sess-omp-env.jsonl') + await writeFile(target, '{}\n') + + const previous = process.env.OMP_CODING_AGENT_DIR + process.env.OMP_CODING_AGENT_DIR = join(root, 'omp-sessions') + try { + await expect(resolveSessionFilePath('omp', 'sess-omp-env')).resolves.toBe(target) + } finally { + restoreEnv('OMP_CODING_AGENT_DIR', previous) + } + }) + it('resolves a rollout from the orca-managed Codex home (ORCA_USER_DATA_PATH)', async () => { // Orca launches Codex with its own managed CODEX_HOME, so rollout files land // under /codex-runtime-home/home/sessions, NOT ~/.codex/sessions. diff --git a/src/main/native-chat/session-file-resolver.ts b/src/main/native-chat/session-file-resolver.ts index 33cf8c23823..136e3ea2291 100644 --- a/src/main/native-chat/session-file-resolver.ts +++ b/src/main/native-chat/session-file-resolver.ts @@ -4,6 +4,8 @@ import { basename, extname, join } from 'node:path' import type { AgentType } from '../../shared/native-chat-types' import { resolveNativeChatTranscriptAgent } from '../../shared/native-chat-agent-support' import { walkSessionFiles } from '../ai-vault/session-scanner-discovery' +import { OMP_SESSION_ARTIFACT_DIR_PATTERN } from '../ai-vault/session-scanner-omp-subagent-transcripts' +import { normalizeAgentSessionsDir } from '../ai-vault/session-scanner-values' import { getOrcaManagedCodexHomePath } from '../codex/codex-home-paths' import { findGrokChatHistoryBySessionId, @@ -37,6 +39,15 @@ function grokSessionsDir(): string { return resolveGrokSessionsDir(process.env, homedir()) } +/** Mirrors the AI Vault scanner so an OMP_CODING_AGENT_DIR override resolves the + * same root for both, rather than leaving native chat pointed at the default. */ +function ompSessionsDir(): string { + return normalizeAgentSessionsDir( + process.env.OMP_CODING_AGENT_DIR?.trim() || join(homedir(), '.omp', 'agent', 'sessions'), + '.omp' + ) +} + export type ResolveSessionFileOptions = { /** Override the Claude projects root (used by tests / isolated scans). */ claudeProjectsDir?: string @@ -45,6 +56,8 @@ export type ResolveSessionFileOptions = { codexSessionsDirs?: string[] /** Override the Grok sessions root (`~/.grok/sessions`). */ grokSessionsDir?: string + /** Override the omp sessions root (`~/.omp/agent/sessions`). */ + ompSessionsDir?: string /** Authoritative transcript path reported by the agent hook * (`providerSession.transcriptPath`). When set and the file exists, it is used * directly — recent Claude Code names the transcript with a UUID that differs @@ -94,6 +107,9 @@ export async function resolveSessionFilePath( if (transcriptAgent === 'grok') { return resolveGrokSessionFile(trimmedId, options.grokSessionsDir ?? grokSessionsDir()) } + if (transcriptAgent === 'omp') { + return resolveOmpSessionFile(trimmedId, options.ompSessionsDir ?? ompSessionsDir()) + } return null } @@ -143,3 +159,30 @@ async function resolveGrokSessionFile( const history = await findGrokChatHistoryBySessionId(sessionsDir, sessionId) return history } + +// omp keeps one directory per working directory (`-Documents-dog-app`) with the +// transcript inside it, named `_.jsonl` — so match the +// id as a base-name suffix, the way Codex rollout files are matched, and let the +// walk cover the per-cwd subdirectories. +async function resolveOmpSessionFile( + sessionId: string, + sessionsDir: string +): Promise { + const files = await walkSessionFiles(sessionsDir, 'omp', [], { + extensions: new Set(['.jsonl']), + // Why: a session's task-subagent transcripts live in its same-named + // `_/` artifact dir, and a label-named child can still end in + // `_` — so descending would let a subagent transcript win the + // suffix match over its own parent. Prune the subtree exactly as the AI + // Vault scanner does (session-scanner-source-discovery.ts): it keeps the + // walk at one readdir per workspace dir regardless of how much the session + // delegated. Depth 0 is the workspace dir, which is never an artifact dir. + directoryPredicate: (name, depth) => + depth === 0 || !OMP_SESSION_ARTIFACT_DIR_PATTERN.test(name), + filePredicate: (path) => { + const name = basename(path, extname(path)) + return name === sessionId || name.endsWith(`_${sessionId}`) + } + }) + return files[0] ?? null +} diff --git a/src/main/native-chat/transcript-line-decoders-omp.ts b/src/main/native-chat/transcript-line-decoders-omp.ts new file mode 100644 index 00000000000..60fcd736f35 --- /dev/null +++ b/src/main/native-chat/transcript-line-decoders-omp.ts @@ -0,0 +1,227 @@ +// omp (pi-agent) JSONL line → NativeChatMessage decoder. +// +// Conversation turns are `type: 'message'`; every other record type is session +// bookkeeping. Rendering follows file order, so `parentId` is not consulted: +// omp's file is really a tree and its own TUI renders pathTo(leaf), so a session +// rewound onto a new branch shows the abandoned turns here too. That matches the +// Claude decoder, which ignores `parentUuid` the same way; unwinding the branch +// needs a stateful decoder contract shared by every agent, not an omp-only fix. + +import { + NATIVE_CHAT_INTERRUPTED_STATUS_TEXT, + type NativeChatBlock, + type NativeChatMessage +} from '../../shared/native-chat-types' +import { + asRecord, + extractString, + parseJsonObject, + timestampMs +} from '../ai-vault/session-scanner-values' +import { toolResultOutput } from './transcript-record-blocks' + +/** + * omp session rows: `type: 'message'` turns carrying user/assistant/toolResult/ + * developer records with text, thinking, toolCall and image content blocks, the + * content-less bash/python execution cells, plus the `type: 'custom_message'` + * rows extensions inject into the conversation. + * Session bookkeeping rows (session_init, mode_change, compaction, custom) are + * skipped, as are records of an unrecognized type. + */ +export function decodeOmpTranscriptLine( + line: string, + fallbackId: string +): NativeChatMessage | null { + const record = parseJsonObject(line) + if (!record || (record.type !== 'message' && record.type !== 'custom_message')) { + return null + } + const id = extractString(record.id) ?? fallbackId + const timestamp = parseTimestamp(record.timestamp) + + if (record.type === 'custom_message') { + // Why: these extension-authored turns reach the model, and omp's own + // transcript renders them — but only when `display` is set; the rest are + // extension state it never shows (CustomMessageEntry, session-entries.d.ts). + const customBlocks = record.display === true ? ompContentBlocks(record.content) : [] + return customBlocks.length === 0 + ? null + : { id, role: 'system', blocks: customBlocks, timestamp, source: 'transcript' } + } + + const message = asRecord(record.message) + if (!message) { + return null + } + const role = extractString(message.role) + + if (role === 'toolResult') { + return { + id, + role: 'tool', + blocks: [ + { + type: 'tool-result', + output: toolResultOutput(message.content), + ...(message.isError === true ? { isError: true } : {}) + } + ], + timestamp, + source: 'transcript' + } + } + + if (role === 'bashExecution' || role === 'pythonExecution') { + // Why: omp persists a TUI `!command` / python run as a content-less message + // ({role, command|code, output, exitCode}) and renders it as a command cell. + // Surface it as a tool turn so the output keeps its collapsible affordance. + return { + id, + role: 'tool', + blocks: ompExecutionBlocks(role, message), + timestamp, + source: 'transcript' + } + } + + if (role === 'fileMention') { + // Why: an `@path` attachment is another content-less record omp's transcript + // renders. List the paths only — `files[].content` is an auto-read dump that + // would bury the conversation. + const paths = ompFileMentionPaths(message.files) + return paths.length === 0 + ? null + : { + id, + role: 'system', + blocks: [{ type: 'text', text: paths.map((path) => `@${path}`).join('\n') }], + timestamp, + source: 'transcript' + } + } + + // Why: sessions written before version 3 stored extension turns as + // `type: 'message'` with role custom/hookMessage and a message-level + // `display` flag (the v3 migration rewrites hookMessage -> custom). Honor the + // same gate the `custom_message` branch does, or a resumed legacy session + // renders state omp itself hides. + if ((role === 'custom' || role === 'hookMessage') && message.display !== true) { + return null + } + const blocks = ompContentBlocks(message.content) + if (blocks.length === 0) { + // Why: omp stamps an aborted turn onto the assistant message itself + // (`stopReason: 'aborted'`), and when nothing streamed before the abort the + // content is empty — so the turn would silently vanish. Surface it as the + // same interrupted row Claude and Codex emit for their own aborts. + return role === 'assistant' && message.stopReason === 'aborted' + ? { + id, + role: 'system', + blocks: [{ type: 'text', text: NATIVE_CHAT_INTERRUPTED_STATUS_TEXT }], + timestamp, + source: 'transcript' + } + : null + } + const messageRole = role === 'assistant' ? 'assistant' : role === 'user' ? 'user' : 'system' + return { id, role: messageRole, blocks, timestamp, source: 'transcript' } +} + +/** A bash/python execution cell: the invocation, then its captured output. */ +function ompExecutionBlocks( + role: 'bashExecution' | 'pythonExecution', + message: Record +): NativeChatBlock[] { + const isBash = role === 'bashExecution' + // Why: read these raw rather than via `extractString` — it trims, and leading + // or trailing whitespace is meaningful in captured command output. + const source = isBash ? message.command : message.code + const output = message.output + // Why: every cancel/timeout path returns `{exitCode: undefined, cancelled: true}`, + // and JSON.stringify drops the undefined key, so a cancelled run carries NO + // exitCode on disk. Without the `cancelled` arm it would render as a clean + // success next to partial output; omp's own cell shows a cancelled marker. + const failed = + message.cancelled === true || (typeof message.exitCode === 'number' && message.exitCode !== 0) + return [ + { + type: 'tool-call', + name: isBash ? 'bash' : 'python', + input: typeof source === 'string' ? source : '' + }, + { + type: 'tool-result', + output: typeof output === 'string' ? output : '', + ...(failed ? { isError: true } : {}) + } + ] +} + +/** The `path` of every entry in a fileMention's `files` array. */ +function ompFileMentionPaths(files: unknown): string[] { + if (!Array.isArray(files)) { + return [] + } + const paths: string[] = [] + for (const file of files) { + const path = extractString(asRecord(file)?.path) + if (path) { + paths.push(path) + } + } + return paths +} + +/** Build the blocks for one omp content array. */ +function ompContentBlocks(content: unknown): NativeChatBlock[] { + if (typeof content === 'string') { + return content.trim() ? [{ type: 'text', text: content }] : [] + } + if (!Array.isArray(content)) { + return [] + } + const blocks: NativeChatBlock[] = [] + for (const item of content) { + const block = ompContentBlock(asRecord(item)) + if (block) { + blocks.push(block) + } + } + return blocks +} + +/** Map one omp content entry; unknown block types yield null and are dropped. */ +function ompContentBlock(record: Record | null): NativeChatBlock | null { + if (!record) { + return null + } + switch (record.type) { + case 'text': { + const text = extractString(record.text) + return text ? { type: 'text', text } : null + } + case 'thinking': { + const text = extractString(record.thinking) ?? extractString(record.text) + return text ? { type: 'text', text } : null + } + case 'toolCall': { + const name = extractString(record.name) ?? 'tool' + return { type: 'tool-call', name, input: record.arguments } + } + case 'image': + // Why: omp stores images as content-addressed blob handles + // (`blob:sha256:…`) rather than a path or URL, so there is nothing the + // renderer can load. Dropping the block matches how the Claude mapper + // treats an image record with neither source. + return null + default: + return null + } +} + +/** `timestampMs` yields NaN for an unparsable value; the chat model wants null. */ +function parseTimestamp(value: unknown): number | null { + const parsed = timestampMs(value) + return Number.isFinite(parsed) ? parsed : null +} diff --git a/src/main/native-chat/transcript-line-decoders.omp.test.ts b/src/main/native-chat/transcript-line-decoders.omp.test.ts new file mode 100644 index 00000000000..04e28dfbc22 --- /dev/null +++ b/src/main/native-chat/transcript-line-decoders.omp.test.ts @@ -0,0 +1,280 @@ +import { describe, expect, it } from 'vitest' +import { decodeOmpTranscriptLine } from './transcript-line-decoders' + +const line = (record: unknown): string => JSON.stringify(record) + +const message = (role: string, content: unknown, extra: Record = {}): string => + line({ + type: 'message', + id: 'rec-1', + parentId: 'rec-0', + timestamp: '2026-07-16T00:27:02.222Z', + message: { role, content, ...extra } + }) + +describe('decodeOmpTranscriptLine', () => { + it('skips malformed lines and non-conversation records', () => { + expect(decodeOmpTranscriptLine('not json', 'f')).toBeNull() + expect(decodeOmpTranscriptLine(line({ type: 'session_init', id: 'a' }), 'f')).toBeNull() + expect(decodeOmpTranscriptLine(line({ type: 'mode_change', id: 'a' }), 'f')).toBeNull() + expect( + decodeOmpTranscriptLine(line({ type: 'custom', customType: 'tool_execution_start' }), 'f') + ).toBeNull() + expect(decodeOmpTranscriptLine(line({ type: 'compaction', shortSummary: 's' }), 'f')).toBeNull() + expect(decodeOmpTranscriptLine(line({ type: 'a-type-from-the-future' }), 'f')).toBeNull() + }) + + it('decodes a user turn', () => { + const decoded = decodeOmpTranscriptLine( + message('user', [{ type: 'text', text: 'resume' }]), + 'f' + ) + expect(decoded).toEqual({ + id: 'rec-1', + role: 'user', + blocks: [{ type: 'text', text: 'resume' }], + timestamp: Date.parse('2026-07-16T00:27:02.222Z'), + source: 'transcript' + }) + }) + + it('keeps thinking and tool calls together on a mixed assistant turn', () => { + const decoded = decodeOmpTranscriptLine( + message('assistant', [ + { type: 'thinking', thinking: 'Checking the goal' }, + { type: 'text', text: 'Reading it now.' }, + { type: 'toolCall', id: 'call-1', name: 'goal', arguments: { op: 'get' } } + ]), + 'f' + ) + expect(decoded?.role).toBe('assistant') + expect(decoded?.blocks).toEqual([ + { type: 'text', text: 'Checking the goal' }, + { type: 'text', text: 'Reading it now.' }, + { type: 'tool-call', name: 'goal', input: { op: 'get' } } + ]) + }) + + it('keeps a thinking-only assistant turn on the assistant role', () => { + const decoded = decodeOmpTranscriptLine( + message('assistant', [{ type: 'thinking', thinking: 'Weighing two options' }]), + 'f' + ) + expect(decoded?.role).toBe('assistant') + expect(decoded?.blocks).toEqual([{ type: 'text', text: 'Weighing two options' }]) + }) + + it('passes tool arguments through unchanged', () => { + const decoded = decodeOmpTranscriptLine( + message('assistant', [ + { type: 'toolCall', name: 'goal', arguments: { op: 'get', objective: null } } + ]), + 'f' + ) + expect(decoded?.blocks[0]).toEqual({ + type: 'tool-call', + name: 'goal', + input: { op: 'get', objective: null } + }) + }) + + it('decodes a tool result', () => { + const decoded = decodeOmpTranscriptLine( + message('toolResult', [{ type: 'text', text: 'ok' }], { + toolCallId: 'call-1', + toolName: 'goal', + isError: false + }), + 'f' + ) + expect(decoded?.role).toBe('tool') + expect(decoded?.blocks).toEqual([{ type: 'tool-result', output: 'ok' }]) + }) + + it('flags an errored tool result', () => { + const decoded = decodeOmpTranscriptLine( + message('toolResult', [{ type: 'text', text: 'boom' }], { + toolCallId: 'call-2', + isError: true + }), + 'f' + ) + expect(decoded?.blocks[0]).toEqual({ type: 'tool-result', output: 'boom', isError: true }) + }) + + it('surfaces a displayed custom_message, and hides a state-only one', () => { + const custom = (display: boolean): string => + line({ + type: 'custom_message', + id: 'rec-c', + customType: 'rewind-report', + display, + content: [{ type: 'text', text: 'Investigation summary' }], + timestamp: '2026-07-16T00:27:02.222Z' + }) + expect(decodeOmpTranscriptLine(custom(true), 'f')).toEqual({ + id: 'rec-c', + role: 'system', + blocks: [{ type: 'text', text: 'Investigation summary' }], + timestamp: Date.parse('2026-07-16T00:27:02.222Z'), + source: 'transcript' + }) + expect(decodeOmpTranscriptLine(custom(false), 'f')).toBeNull() + }) + + it('accepts string content on a custom_message', () => { + const decoded = decodeOmpTranscriptLine( + line({ type: 'custom_message', id: 'rec-s', display: true, content: 'peer said hi' }), + 'f' + ) + expect(decoded?.blocks).toEqual([{ type: 'text', text: 'peer said hi' }]) + }) + + it('renders a bash execution cell as a tool turn', () => { + const decoded = decodeOmpTranscriptLine( + line({ + type: 'message', + id: 'rec-b', + timestamp: '2026-07-16T00:27:02.222Z', + message: { role: 'bashExecution', command: 'ls -a', output: '.git\n', exitCode: 0 } + }), + 'f' + ) + expect(decoded?.role).toBe('tool') + expect(decoded?.blocks).toEqual([ + { type: 'tool-call', name: 'bash', input: 'ls -a' }, + { type: 'tool-result', output: '.git\n' } + ]) + }) + + it('flags a nonzero exit code and reads python cells from `code`', () => { + const decoded = decodeOmpTranscriptLine( + line({ + type: 'message', + id: 'rec-p', + message: { + role: 'pythonExecution', + code: 'raise SystemExit(2)', + output: 'boom', + exitCode: 2 + } + }), + 'f' + ) + expect(decoded?.blocks).toEqual([ + { type: 'tool-call', name: 'python', input: 'raise SystemExit(2)' }, + { type: 'tool-result', output: 'boom', isError: true } + ]) + }) + + // Why: omp's cancel and timeout paths both return `exitCode: undefined`, which + // JSON.stringify omits — so the record has no exitCode at all and only + // `cancelled` distinguishes it from a clean run. + it('marks a cancelled run, which carries no exitCode at all', () => { + const decoded = decodeOmpTranscriptLine( + line({ + type: 'message', + id: 'rec-c1', + message: { role: 'bashExecution', command: 'sleep 99', output: 'partial', cancelled: true } + }), + 'f' + ) + expect(decoded?.blocks[1]).toEqual({ type: 'tool-result', output: 'partial', isError: true }) + }) + + it('lists file mention paths without dumping their auto-read contents', () => { + const decoded = decodeOmpTranscriptLine( + line({ + type: 'message', + id: 'rec-f', + message: { + role: 'fileMention', + files: [ + { path: 'src/a.ts', content: 'SECRET FILE BODY' }, + { path: 'src/b.ts' }, + { image: true } + ] + } + }), + 'f' + ) + expect(decoded?.role).toBe('system') + expect(decoded?.blocks).toEqual([{ type: 'text', text: '@src/a.ts\n@src/b.ts' }]) + expect(JSON.stringify(decoded)).not.toContain('SECRET FILE BODY') + }) + + // Why: pre-v3 sessions stored extension turns as `type:'message'` with role + // custom/hookMessage; the display flag gates them exactly as custom_message. + it('honors the display gate on legacy custom / hookMessage message rows', () => { + const legacy = (role: string, display: boolean): string => + line({ + type: 'message', + id: 'rec-l', + message: { role, customType: 'irc:incoming', display, content: 'peer note' } + }) + expect(decodeOmpTranscriptLine(legacy('custom', false), 'f')).toBeNull() + expect(decodeOmpTranscriptLine(legacy('hookMessage', false), 'f')).toBeNull() + expect(decodeOmpTranscriptLine(legacy('custom', true), 'f')).toMatchObject({ + role: 'system', + blocks: [{ type: 'text', text: 'peer note' }] + }) + }) + + it('surfaces the developer channel as system', () => { + const decoded = decodeOmpTranscriptLine( + message('developer', [{ type: 'text', text: 'context note' }]), + 'f' + ) + expect(decoded?.role).toBe('system') + }) + + it('drops blob-handle images, which the renderer cannot load', () => { + expect( + decodeOmpTranscriptLine( + message('user', [{ type: 'image', data: 'blob:sha256:abc', mimeType: 'image/webp' }]), + 'f' + ) + ).toBeNull() + }) + + it('surfaces an aborted turn as the interrupted row, not silence', () => { + // Why: omp stamps `stopReason: 'aborted'` on the assistant message itself and + // leaves the content empty when nothing streamed, so the turn used to decode + // to null and vanish. Claude and Codex both emit this row for their aborts. + const decoded = decodeOmpTranscriptLine( + message('assistant', [{ type: 'text', text: '' }], { + stopReason: 'aborted', + errorMessage: 'Stopped before model call' + }), + 'f' + ) + expect(decoded?.role).toBe('system') + expect(decoded?.blocks).toEqual([{ type: 'text', text: 'Conversation interrupted' }]) + }) + + it('keeps partial content when a turn aborted mid-stream', () => { + // omp spreads the in-flight message and stamps the abort on it, so a partial + // answer is real conversation and must not be replaced by the status row. + const decoded = decodeOmpTranscriptLine( + message('assistant', [{ type: 'text', text: 'Partial answer' }], { stopReason: 'aborted' }), + 'f' + ) + expect(decoded?.role).toBe('assistant') + expect(decoded?.blocks).toEqual([{ type: 'text', text: 'Partial answer' }]) + }) + + it('still drops an empty non-aborted assistant turn', () => { + expect( + decodeOmpTranscriptLine(message('assistant', [{ type: 'text', text: '' }]), 'f') + ).toBeNull() + }) + + it('falls back to the supplied id when the record carries none', () => { + const decoded = decodeOmpTranscriptLine( + line({ type: 'message', message: { role: 'user', content: [{ type: 'text', text: 'hi' }] } }), + 'fallback-9' + ) + expect(decoded?.id).toBe('fallback-9') + expect(decoded?.timestamp).toBeNull() + }) +}) diff --git a/src/main/native-chat/transcript-line-decoders.ts b/src/main/native-chat/transcript-line-decoders.ts index 67b1aaf76aa..2d84d1a5586 100644 --- a/src/main/native-chat/transcript-line-decoders.ts +++ b/src/main/native-chat/transcript-line-decoders.ts @@ -12,3 +12,4 @@ export { decodeClaudeTranscriptLine } from './transcript-line-decoders-claude' export { decodeCodexTranscriptLine } from './transcript-line-decoders-codex' export { decodeGrokTranscriptLine } from './transcript-line-decoders-grok' +export { decodeOmpTranscriptLine } from './transcript-line-decoders-omp' diff --git a/src/main/native-chat/transcript-reader.ts b/src/main/native-chat/transcript-reader.ts index 86353604e20..8cb57fdb91d 100644 --- a/src/main/native-chat/transcript-reader.ts +++ b/src/main/native-chat/transcript-reader.ts @@ -10,7 +10,8 @@ import { resolveSessionFilePath, type ResolveSessionFileOptions } from './sessio import { decodeClaudeTranscriptLine, decodeCodexTranscriptLine, - decodeGrokTranscriptLine + decodeGrokTranscriptLine, + decodeOmpTranscriptLine } from './transcript-line-decoders' import { decodeTranscriptStream } from './transcript-stream-lines' @@ -55,6 +56,9 @@ export async function readNativeChatTranscript( if (transcriptAgent === 'grok') { return { messages: await readTranscript(filePath, decodeGrokTranscriptLine) } } + if (transcriptAgent === 'omp') { + return { messages: await readTranscript(filePath, decodeOmpTranscriptLine) } + } return { error: `Unsupported agent for Chat UI transcript: ${agent}` } } catch (err) { // Why: ENOENT after a successful resolve is the same first-flush/rotation diff --git a/src/main/native-chat/transcript-tail-reader.ts b/src/main/native-chat/transcript-tail-reader.ts index 9fb66705103..35bee906956 100644 --- a/src/main/native-chat/transcript-tail-reader.ts +++ b/src/main/native-chat/transcript-tail-reader.ts @@ -9,7 +9,8 @@ import { resolveSessionFilePath, type ResolveSessionFileOptions } from './sessio import { decodeClaudeTranscriptLine, decodeCodexTranscriptLine, - decodeGrokTranscriptLine + decodeGrokTranscriptLine, + decodeOmpTranscriptLine } from './transcript-line-decoders' import { transcriptFallbackId } from './transcript-fallback-id' import { @@ -33,6 +34,9 @@ export function nativeChatLineDecoderForAgent(agent: AgentType): NativeChatLineD if (transcriptAgent === 'grok') { return decodeGrokTranscriptLine } + if (transcriptAgent === 'omp') { + return decodeOmpTranscriptLine + } return null } diff --git a/src/renderer/src/components/native-chat/native-chat-availability.test.ts b/src/renderer/src/components/native-chat/native-chat-availability.test.ts index 50d19d8821d..9c77e05992a 100644 --- a/src/renderer/src/components/native-chat/native-chat-availability.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-availability.test.ts @@ -90,6 +90,21 @@ describe('canToggleNativeChat', () => { ).toBe(false) }) + // Why: omp discloses no hook transcript path either, so its session file is + // only reachable when this process can read the agent's disk. + it('rejects Model-A SSH omp but accepts it local and runtime-owned', () => { + const forConnection = (connectionId: string | null): boolean => + canToggleNativeChat({ + experimentalNativeChatEnabled: true, + contentType: 'terminal', + launchAgent: 'omp', + nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable(connectionId) + }) + expect(forConnection('ssh-target-1')).toBe(false) + expect(forConnection(null)).toBe(true) + expect(forConnection('runtime-ssh-env-1')).toBe(true) + }) + it('lets an existing Model-A SSH Grok chat toggle back to terminal', () => { expect( canToggleNativeChat({ diff --git a/src/renderer/src/components/native-chat/native-chat-availability.ts b/src/renderer/src/components/native-chat/native-chat-availability.ts index 3d2be38e376..11b9e5af7ad 100644 --- a/src/renderer/src/components/native-chat/native-chat-availability.ts +++ b/src/renderer/src/components/native-chat/native-chat-availability.ts @@ -1,6 +1,9 @@ import type { Tab, TuiAgent } from '../../../../shared/types' import type { AgentType } from '../../../../shared/agent-status-types' -import { isNativeChatSupportedAgent } from '@/lib/native-chat-supported-agent' +import { + isNativeChatSupportedAgent, + nativeChatRequiresLocalTranscript +} from '@/lib/native-chat-supported-agent' export { isNativeChatSupportedAgent } @@ -46,7 +49,10 @@ export function canToggleNativeChat(input: NativeChatAvailabilityInput): boolean return true } const agent = input.detectedAgent ?? input.launchAgent ?? input.resolvedAgent - if (agent === 'grok' && input.nativeChatTranscriptIsLocalReadable !== true) { + if ( + nativeChatRequiresLocalTranscript(agent) && + input.nativeChatTranscriptIsLocalReadable !== true + ) { return false } return isNativeChatSupportedAgent(agent) diff --git a/src/renderer/src/lib/native-chat-initial-view-mode.test.ts b/src/renderer/src/lib/native-chat-initial-view-mode.test.ts index 5744c7684f1..4ef61a5cb78 100644 --- a/src/renderer/src/lib/native-chat-initial-view-mode.test.ts +++ b/src/renderer/src/lib/native-chat-initial-view-mode.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from 'vitest' +import type { Tab } from '../../../shared/types' import { decideInitialAgentTabViewMode, initialAgentTabViewModeProps @@ -70,6 +71,18 @@ describe('decideInitialAgentTabViewMode', () => { ).toBe('chat') }) + it('keeps Model-A SSH omp in the terminal view but opens it locally', () => { + const forConnection = (connectionId: string | null): Tab['viewMode'] => + decideInitialAgentTabViewMode({ + experimentalNativeChat: true, + openAgentTabsInChatByDefault: true, + agent: 'omp', + nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable(connectionId) + }) + expect(forConnection('ssh-target-1')).toBeUndefined() + expect(forConnection(null)).toBe('chat') + }) + it('keeps Model-A SSH Grok in the terminal view', () => { expect( decideInitialAgentTabViewMode({ diff --git a/src/renderer/src/lib/native-chat-initial-view-mode.ts b/src/renderer/src/lib/native-chat-initial-view-mode.ts index 1d2e17cabbe..9b06a0a0d38 100644 --- a/src/renderer/src/lib/native-chat-initial-view-mode.ts +++ b/src/renderer/src/lib/native-chat-initial-view-mode.ts @@ -1,6 +1,9 @@ import type { GlobalSettings, Tab, TuiAgent } from '../../../shared/types' import { canMirrorLaunchDraftToNativeChat } from '@/lib/native-chat-launch-draft-mirrorability' -import { isNativeChatSupportedAgent } from '@/lib/native-chat-supported-agent' +import { + isNativeChatSupportedAgent, + nativeChatRequiresLocalTranscript +} from '@/lib/native-chat-supported-agent' export type NativeChatLaunchPromptDelivery = 'auto-submit' | 'draft' | 'submit-after-ready' @@ -28,7 +31,10 @@ export function decideInitialAgentTabViewMode(args: { if (!isNativeChatSupportedAgent(args.agent)) { return undefined } - if (args.agent === 'grok' && args.nativeChatTranscriptIsLocalReadable !== true) { + if ( + nativeChatRequiresLocalTranscript(args.agent) && + args.nativeChatTranscriptIsLocalReadable !== true + ) { return undefined } if ( diff --git a/src/renderer/src/lib/native-chat-supported-agent.ts b/src/renderer/src/lib/native-chat-supported-agent.ts index 84135bba8f4..25bac3427ad 100644 --- a/src/renderer/src/lib/native-chat-supported-agent.ts +++ b/src/renderer/src/lib/native-chat-supported-agent.ts @@ -1,4 +1,5 @@ export { isNativeChatSupportedAgent, + nativeChatRequiresLocalTranscript, NATIVE_CHAT_SUPPORTED_AGENTS } from '../../../shared/native-chat-agent-support' diff --git a/src/renderer/src/lib/worktree-creation-agent-seeds.test.ts b/src/renderer/src/lib/worktree-creation-agent-seeds.test.ts index 43c48eef25e..9ac7e28ed25 100644 --- a/src/renderer/src/lib/worktree-creation-agent-seeds.test.ts +++ b/src/renderer/src/lib/worktree-creation-agent-seeds.test.ts @@ -157,6 +157,24 @@ describe('seedAgentTabStateAfterWorktreeCreate', () => { expect(tabViewMode('agent-tab')).toBe('chat') }) + it('still opens a local omp draft in chat, despite the local-transcript gate', () => { + // Why: omp discloses no hook transcript path, so it joins Grok in requiring a + // locally readable sessions root. This call site must therefore SUPPLY that + // readability flag for omp too — gating on Grok alone left it undefined and + // parked every omp draft in the terminal view, local workspace or not. + setTabs([{ id: 'agent-tab', launchAgent: 'omp', viewMode: 'terminal' }]) + + seedAgentTabStateAfterWorktreeCreate({ + request: { ...request, agent: 'omp' as const }, + worktreeId: 'wt-1', + primaryTabId: 'agent-tab', + startupTerminalTabId: 'agent-tab', + backendSpawned: true + }) + + expect(tabViewMode('agent-tab')).toBe('chat') + }) + it('keys a raw backend tab id and updates the host before its tab mirror lands', async () => { setTabs([], 'runtime-1') diff --git a/src/renderer/src/lib/worktree-creation-agent-seeds.ts b/src/renderer/src/lib/worktree-creation-agent-seeds.ts index 565ea421d71..295b5e5c06c 100644 --- a/src/renderer/src/lib/worktree-creation-agent-seeds.ts +++ b/src/renderer/src/lib/worktree-creation-agent-seeds.ts @@ -5,6 +5,7 @@ import { queueHookCommandsForFirstWorktreeTab } from '@/lib/hook-command-delayed import { decideInitialAgentTabViewMode } from '@/lib/native-chat-initial-view-mode' import { getConnectionIdFromState } from '@/lib/connection-context' import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' +import { nativeChatRequiresLocalTranscript } from '@/lib/native-chat-supported-agent' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { toWebTerminalSurfaceTabId } from '@/runtime/web-terminal-surface-id' import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation' @@ -63,7 +64,7 @@ function applyBackendSpawnedDraftViewMode(args: { agent, promptDelivery: 'draft', launchDraftText: request.launchDraftPrompt, - ...(agent === 'grok' + ...(nativeChatRequiresLocalTranscript(agent) ? { nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable( getConnectionIdFromState(state, worktreeId) diff --git a/src/renderer/src/lib/worktree-draft-startup-view-mode.test.ts b/src/renderer/src/lib/worktree-draft-startup-view-mode.test.ts new file mode 100644 index 00000000000..360e414ff13 --- /dev/null +++ b/src/renderer/src/lib/worktree-draft-startup-view-mode.test.ts @@ -0,0 +1,69 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { useAppStore } from '@/store' +import { resolveBackendDraftStartup } from './worktree-draft-startup-view-mode' + +type AppState = ReturnType + +const initialSettings = useAppStore.getState().settings! +const initialRepos = useAppStore.getState().repos + +const request = { + repoId: 'repo-1', + startup: { launchCommand: 'omp' }, + launchDraftPrompt: 'https://github.com/o/r/issues/12' +} as never + +function setRepoConnection(connectionId: string | null): void { + useAppStore.setState({ + repos: [{ id: 'repo-1', path: '/repo', connectionId }] + } as unknown as Partial) +} + +function viewModeFor(agent: string): string | undefined { + const startup = resolveBackendDraftStartup({ ...(request as object), agent } as never) as + | { viewMode?: string } + | undefined + return startup?.viewMode +} + +beforeEach(() => { + useAppStore.setState({ + settings: { + ...initialSettings, + experimentalNativeChat: true, + openAgentTabsInChatByDefault: true + } + }) +}) + +afterEach(() => { + useAppStore.setState({ settings: initialSettings, repos: initialRepos } as Partial) +}) + +describe('resolveBackendDraftStartup', () => { + // Why: omp discloses no hook transcript path, so it joins Grok in requiring a + // locally readable sessions root. This call site must SUPPLY that flag for omp + // too — gating on Grok alone left it undefined and parked every omp draft in + // the terminal view, local workspace or not. + it('opens a local omp draft in chat', () => { + setRepoConnection(null) + expect(viewModeFor('omp')).toBe('chat') + }) + + it('keeps a Model-A SSH omp draft in the terminal view', () => { + setRepoConnection('ssh-target-1') + expect(viewModeFor('omp')).toBe('terminal') + }) + + it('opens a runtime-owned SSH omp draft in chat, which reads the transcript locally', () => { + setRepoConnection('runtime-ssh-env-1') + expect(viewModeFor('omp')).toBe('chat') + }) + + it('preserves the same split for Grok', () => { + setRepoConnection(null) + expect(viewModeFor('grok')).toBe('chat') + setRepoConnection('ssh-target-1') + expect(viewModeFor('grok')).toBe('terminal') + }) +}) diff --git a/src/renderer/src/lib/worktree-draft-startup-view-mode.ts b/src/renderer/src/lib/worktree-draft-startup-view-mode.ts index b4ca4000f62..0c11ea02c80 100644 --- a/src/renderer/src/lib/worktree-draft-startup-view-mode.ts +++ b/src/renderer/src/lib/worktree-draft-startup-view-mode.ts @@ -1,6 +1,7 @@ import { useAppStore } from '@/store' import { decideInitialAgentTabViewMode } from '@/lib/native-chat-initial-view-mode' import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' +import { nativeChatRequiresLocalTranscript } from '@/lib/native-chat-supported-agent' import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation' export function resolveBackendDraftStartup( @@ -19,7 +20,7 @@ export function resolveBackendDraftStartup( agent: request.agent, promptDelivery: 'draft', launchDraftText: request.launchDraftPrompt, - ...(request.agent === 'grok' + ...(nativeChatRequiresLocalTranscript(request.agent) ? { nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable(connectionId) } diff --git a/src/shared/native-chat-agent-support.test.ts b/src/shared/native-chat-agent-support.test.ts index d32764d3f53..7608f50c98c 100644 --- a/src/shared/native-chat-agent-support.test.ts +++ b/src/shared/native-chat-agent-support.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { isNativeChatSupportedAgent, + nativeChatRequiresLocalTranscript, resolveNativeChatTranscriptAgent, shouldStepNativeChatAskAnswer } from './native-chat-agent-support' @@ -11,9 +12,10 @@ describe('resolveNativeChatTranscriptAgent', () => { expect(resolveNativeChatTranscriptAgent('claude')).toBe('claude') }) - it('passes codex and grok through and rejects everything else', () => { + it('passes codex, grok and omp through and rejects everything else', () => { expect(resolveNativeChatTranscriptAgent('codex')).toBe('codex') expect(resolveNativeChatTranscriptAgent('grok')).toBe('grok') + expect(resolveNativeChatTranscriptAgent('omp')).toBe('omp') expect(resolveNativeChatTranscriptAgent('cursor')).toBeNull() expect(resolveNativeChatTranscriptAgent(null)).toBeNull() expect(resolveNativeChatTranscriptAgent(undefined)).toBeNull() @@ -24,12 +26,28 @@ describe('isNativeChatSupportedAgent', () => { it('recognizes the parseable agents and rejects unknown / nullish input', () => { expect(isNativeChatSupportedAgent('claude')).toBe(true) expect(isNativeChatSupportedAgent('openclaude')).toBe(true) + expect(isNativeChatSupportedAgent('omp')).toBe(true) expect(isNativeChatSupportedAgent('cursor')).toBe(false) expect(isNativeChatSupportedAgent(null)).toBe(false) expect(isNativeChatSupportedAgent(undefined)).toBe(false) }) }) +describe('nativeChatRequiresLocalTranscript', () => { + it('covers the agents whose hook discloses no transcript path', () => { + // Claude/Codex report `transcript_path`; Grok and omp report only an id, so + // native chat has to find their file on a disk this process can read. + expect(nativeChatRequiresLocalTranscript('grok')).toBe(true) + expect(nativeChatRequiresLocalTranscript('omp')).toBe(true) + expect(nativeChatRequiresLocalTranscript('claude')).toBe(false) + expect(nativeChatRequiresLocalTranscript('openclaude')).toBe(false) + expect(nativeChatRequiresLocalTranscript('codex')).toBe(false) + expect(nativeChatRequiresLocalTranscript('cursor')).toBe(false) + expect(nativeChatRequiresLocalTranscript(null)).toBe(false) + expect(nativeChatRequiresLocalTranscript(undefined)).toBe(false) + }) +}) + describe('shouldStepNativeChatAskAnswer', () => { it('steps the digit-commit selector agents (Claude, OpenClaude, Codex)', () => { expect(shouldStepNativeChatAskAnswer('claude')).toBe(true) @@ -41,6 +59,7 @@ describe('shouldStepNativeChatAskAnswer', () => { it('does not step other or unknown agents', () => { expect(shouldStepNativeChatAskAnswer('grok')).toBe(false) + expect(shouldStepNativeChatAskAnswer('omp')).toBe(false) expect(shouldStepNativeChatAskAnswer('cursor')).toBe(false) expect(shouldStepNativeChatAskAnswer(null)).toBe(false) expect(shouldStepNativeChatAskAnswer(undefined)).toBe(false) diff --git a/src/shared/native-chat-agent-support.ts b/src/shared/native-chat-agent-support.ts index 2e063d6aa73..8210e907e0a 100644 --- a/src/shared/native-chat-agent-support.ts +++ b/src/shared/native-chat-agent-support.ts @@ -1,17 +1,27 @@ -export type NativeChatTranscriptAgent = 'claude' | 'codex' | 'grok' +export type NativeChatTranscriptAgent = 'claude' | 'codex' | 'grok' | 'omp' /** Agents whose transcripts the native chat view can parse and render. */ export const NATIVE_CHAT_SUPPORTED_AGENTS: ReadonlySet = new Set([ 'claude', 'openclaude', 'codex', - 'grok' + 'grok', + 'omp' ]) export function isNativeChatSupportedAgent(agent: string | null | undefined): boolean { return agent != null && NATIVE_CHAT_SUPPORTED_AGENTS.has(agent) } +/** Agents whose hook discloses no transcript path (`extractAgentProviderSession`), + * so native chat can only reach the session file by scanning a sessions root on + * a disk THIS process can read. Under Model-A SSH that disk is the wrong host, + * so the chat view must stay closed instead of loading forever. */ +export function nativeChatRequiresLocalTranscript(agent: string | null | undefined): boolean { + const transcriptAgent = resolveNativeChatTranscriptAgent(agent) + return transcriptAgent === 'grok' || transcriptAgent === 'omp' +} + /** True when the agent renders a digit-commit question selector that ignores * typed label text (pasting "Blue" + Enter commits the highlighted FIRST * option — STA-1860): Claude's AskUserQuestion and Codex 0.145's @@ -30,7 +40,7 @@ export function resolveNativeChatTranscriptAgent( if (agent === 'claude' || agent === 'openclaude') { return 'claude' } - if (agent === 'codex' || agent === 'grok') { + if (agent === 'codex' || agent === 'grok' || agent === 'omp') { return agent } return null From ddf58d6d6a60e8fb4e34f96f4414dfd171dc1388 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 7 Aug 2026 01:25:18 -0700 Subject: [PATCH 009/289] fix(terminal): restore preserved remote PTYs after host relaunch (#12990) * fix(terminal): foreground preserved daemon PTYs * fix(terminal): keep snapshot sequence domains distinct * test(terminal): use active reconnect control * test(terminal): await reconnect control activation * test(terminal): validate reconnect with fresh control * test(terminal): tighten host restart evidence * fix(terminal): retry preserved PTY attach after inventory * fix(terminal): retry attach after overlapping inventory --------- Co-authored-by: Jinwoo-H --- config/reliability-gates.jsonc | 84 +++- src/main/ipc/pty.test.ts | 47 ++ src/main/ipc/pty.ts | 2 +- src/main/runtime/orca-runtime.ts | 33 +- src/main/runtime/rpc/methods/terminal.ts | 9 +- .../runtime/rpc/terminal-multiplex.test.ts | 52 ++ .../rpc/terminal-subscribe-buffer.test.ts | 12 +- ...al-subscriber-driven-daemon-attach.test.ts | 65 +++ ...minal-host-restart-background-sync.spec.ts | 447 ++++++++++++++++++ 9 files changed, 728 insertions(+), 23 deletions(-) create mode 100644 tests/e2e/paired-remote-terminal-host-restart-background-sync.spec.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 33945cfe8c2..f7935dbfc1f 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -4196,23 +4196,28 @@ "snapshot-probe sequence gap recovery", "hidden remote worktree retention", "remote terminal reveal and input", + "paired host relaunch with preserved daemon PTYs", "manual server disconnect" ], "platforms": ["macos", "linux", "windows"], "providers": ["paired-runtime", "ssh"], "coveredPlatforms": ["macos"], "coveredProviders": ["paired-runtime"], - "coverageNotes": "Deterministic headed macOS runs launch an isolated Orca desktop server and a separate paired web client. A byte-identical headless run uses an isolated `orca serve` host. Both create six real paired host PTYs with bounded high-output scrollback, prove sustained output while all six client xterms are warm-mounted but hidden causes zero renderer scheduler work, ordinary-park five xterms without enabling the lossy retention budget, assert bounded cells/heap/timer lag, then restore the bounded authoritative tail exactly once on the original PTY including output produced while parked and continued input/output. The headed oracle additionally keeps every remote target workspace and terminal unmounted in the host renderer before and after client recovery. Separate headed/headless ACK-starvation tests recover one stalled stream without replacing its PTY. A headed output-drop oracle proves a successful snapshot probe cannot certify a stale live stream when the authoritative PTY sequence advanced beyond the client high-water. Unit coverage requires an unsequenced client to establish a first-probe baseline, remain attached at the same sequence, and recover only after a later probe advances. A capability-disabled run proves legacy hosts retain the prior lossy limit/TTL fallback. Unit tests cover exact-owner capability routing, raw-stream release, singleton side-effect facts with timed handoff cleanup, 64 simultaneous synthetic-title sources with zero decorative client events after convergence, per-client late-subscribe recovery, semantic bell/title transitions, local animation-frame preservation, legacy no-output-pause delivery gating with exact snapshot restore, provider-authoritative snapshots, 128 active streams plus retry after a 129th-stream capacity rejection, capacity-pressure backoff, full split-leaf remint reconciliation, truncation, and manual-disconnect queue fencing. Linux, Windows, live SSH, and production-scale paired hosts remain gaps.", + "coverageNotes": "Deterministic headed macOS runs launch an isolated Orca desktop server and a separate paired web client. A byte-identical headless run uses an isolated `orca serve` host. Both create six real paired host PTYs with bounded high-output scrollback, prove sustained output while all six client xterms are warm-mounted but hidden causes zero renderer scheduler work, ordinary-park five xterms without enabling the lossy retention budget, assert bounded cells/heap/timer lag, then restore the bounded authoritative tail exactly once on the original PTY including output produced while parked and continued input/output. The headed oracle additionally keeps every remote target workspace and terminal unmounted in the host renderer before and after client recovery. A separate headed desktop-client journey relaunches the host main process while retaining the daemon and a daemon-backgrounded PTY, then requires first-observation foreground sync and continued input/output on the same PTY. Unit coverage keeps layout versions out of snapshot output high-waters when a restored snapshot has no output sequence and retries an existing subscriber only after authoritative provider inventory proves the retained PTY exists. Separate headed/headless ACK-starvation tests recover one stalled stream without replacing its PTY. A headed output-drop oracle proves a successful snapshot probe cannot certify a stale live stream when the authoritative PTY sequence advanced beyond the client high-water. Unit coverage requires an unsequenced client to establish a first-probe baseline, remain attached at the same sequence, and recover only after a later probe advances. A capability-disabled run proves legacy hosts retain the prior lossy limit/TTL fallback. Unit tests cover exact-owner capability routing, raw-stream release, singleton side-effect facts with timed handoff cleanup, 64 simultaneous synthetic-title sources with zero decorative client events after convergence, per-client late-subscribe recovery, semantic bell/title transitions, local animation-frame preservation, legacy no-output-pause delivery gating with exact snapshot restore, provider-authoritative snapshots, 128 active streams plus retry after a 129th-stream capacity rejection, capacity-pressure backoff, full split-leaf remint reconciliation, truncation, and manual-disconnect queue fencing. Linux, Windows, live SSH, and production-scale paired hosts remain gaps.", "motivatingLinks": [ "https://github.com/stablyai/orca/issues/8652", "https://github.com/stablyai/orca/pull/10625" ], - "invariant": "A host advertising terminal.paired-parking.v1 keeps the PTY and bounded authoritative history alive while an ordinary hidden-view park destroys the client xterm and releases its raw per-PTY stream. Reveal must restore up to the requested 5,000 rows, parked-time side effects/output, the same PTY identity, and continued input/output. Hosts without the capability must gate hidden raw output before xterm scheduling and repaint from the authoritative snapshot on reveal while retaining the existing limit/TTL force-parking fallback. After a paired client's first semantic title state, decorative spinner frequency must add zero encrypted client-event frames; local title animation and semantic title, status, bell, completion, and query facts remain intact. A paired terminal stream whose delivery credits stop progressing must replace only that stream; command silence first probes authoritative state and replaces the stream if the probe times out or proves the PTY advanced beyond the client's delivered output sequence. When no comparable delivery high-water exists, the first sequenced probe establishes a baseline and only later advancement proves staleness. A same-sequence snapshot remains valid proof of a responsive silent command. A successful status probe may replace a pre-ready shared-control socket without rejecting or duplicating calls already waiting for that transport. Manual disconnect must retain pairing while preventing queued or passive calls and subscriptions from recreating transport until explicit Connect.", + "invariant": "A host advertising terminal.paired-parking.v1 keeps the PTY and bounded authoritative history alive while an ordinary hidden-view park destroys the client xterm and releases its raw per-PTY stream. Reveal must restore up to the requested 5,000 rows, parked-time side effects/output, the same PTY identity, and continued input/output. After a host main-process relaunch, unknown local delivery-sync state must not be treated as proof that a surviving daemon PTY is already foregrounded. If subscription precedes provider readiness, authoritative inventory must reconsider that existing subscriber without spawning, resizing, or changing reconnect state. A snapshot without an output high-water must keep it unknown rather than borrowing a layout version that can suppress newer live output. Hosts without the capability must gate hidden raw output before xterm scheduling and repaint from the authoritative snapshot on reveal while retaining the existing limit/TTL force-parking fallback. After a paired client's first semantic title state, decorative spinner frequency must add zero encrypted client-event frames; local title animation and semantic title, status, bell, completion, and query facts remain intact. A paired terminal stream whose delivery credits stop progressing must replace only that stream; command silence first probes authoritative state and replaces the stream if the probe times out or proves the PTY advanced beyond the client's delivered output sequence. When no comparable delivery high-water exists, the first sequenced probe establishes a baseline and only later advancement proves staleness. A same-sequence snapshot remains valid proof of a responsive silent command. A successful status probe may replace a pre-ready shared-control socket without rejecting or duplicating calls already waiting for that transport. Manual disconnect must retain pairing while preventing queued or passive calls and subscriptions from recreating transport until explicit Connect.", "oracle": "Run one byte-identical six-terminal oracle against an isolated headed desktop host and an isolated headless `orca serve` host. Stage at least 1,000,000 xterm cells, enable ordinary parking with the lossy retention budget disabled, require exactly one mounted manager and five parked tabs, at most 45% retained cells, no more than 16 MiB heap growth, and under 500 ms timer drift. In headed mode, require the host renderer to remain on its original workspace with zero target terminal managers mounted throughout client park, reveal, and live I/O. While a tab is parked, require authoritative terminal.read to observe new PTY output; reveal it and require the original PTY, a marker within the requested 5,000-row history, the parked marker, and post-reveal input/output. Drive 64 host PTYs through ten 80 ms synthetic title frames, require zero paired client events after the first frame per PTY while all 640 local frames remain observable, attach a late client and require one current frame per PTY, then require semantic bell and idle transitions on both clients. Reconstruct a legacy subscribed stream with no outputPause capability, hide a chatty pane, require no hidden xterm writes, reveal it, and require one authoritative snapshot plus continued live output. Drop and acknowledge output only for one original paired-client stream, prove the fixture process consumed input while the host model advanced and the client stayed stale, then require the command snapshot probe to replace that stream, repaint exact fixture output, preserve authoritative PTY identity and target-tab cardinality, and resume live I/O. For a client without a delivered sequence, require the first numeric probe to establish a baseline without replacement, a same-sequence probe to remain attached, and a later advanced probe to replace only that stream. Withhold the first encrypted shared-control ready frame, start one RPC, trigger a status-probe refresh, and require two connections, one host delivery, successful response, and zero retained request bytes. Admit 128 active streams, reject the 129th as retryable, release one stream, then require the retry to attach and publish a snapshot without multiplying retained subscribers. Disable terminal.paired-parking.v1 and require the same oracle to fail before parking, while the legacy limit-one fallback separately passes. Also preserve truncated first paint, ACK-starved same-PTY recovery, responsive silent-command snapshot probes, dead-stream probe timeout recovery, and queued manual-disconnect fencing.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/terminal-multiplex.test.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts src/renderer/src/components/terminal-pane/terminal-parked-watcher-reconciliation.test.ts src/renderer/src/components/terminal-pane/terminal-parked-watcher-partial-reconciliation.test.ts src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts src/renderer/src/runtime/remote-runtime-terminal-stall-recovery.test.ts src/renderer/src/runtime/runtime-client-events.test.ts src/renderer/src/web/web-preload-api.test.ts src/main/ipc/runtime-environments.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/shared/remote-runtime-shared-control-connection.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/runtime/remote-runtime-terminal-parse-backpressure.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty.test.ts", + "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-host-restart-background-sync.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "ORCA_E2E_FORWARD_APP_LOGS=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-host-restart-background-sync.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --repeat-each=10 --max-failures=1", "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-truncated-tail-first-paint.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_DISABLE_PAIRED_TERMINAL_PARKING=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-truncated-tail-first-paint.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-stall-recovery.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", @@ -4228,6 +4233,8 @@ "src/main/ipc/runtime-environments.test.ts", "src/main/runtime/rpc/terminal-multiplex.test.ts", "src/main/runtime/rpc/terminal-subscribe-buffer.test.ts", + "src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts", + "src/main/ipc/pty.test.ts", "src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts", "src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts", "src/renderer/src/components/terminal-pane/pty-connection.test.ts", @@ -4248,9 +4255,38 @@ "tests/e2e/headless-paired-remote-terminal-stall-recovery.spec.ts", "tests/e2e/paired-remote-terminal-retention-memory.spec.ts", "tests/e2e/headless-paired-remote-terminal-retention-memory.spec.ts", - "tests/e2e/terminal-parked-memory.spec.ts" + "tests/e2e/terminal-parked-memory.spec.ts", + "tests/e2e/paired-remote-terminal-host-restart-background-sync.spec.ts" ], "assertionRefs": [ + { + "file": "src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts", + "assertions": ["retries an existing subscriber when provider inventory becomes ready"] + }, + { + "file": "src/main/ipc/pty.test.ts", + "assertions": [ + "foregrounds a preserved daemon PTY after handler recreation loses sync memory exactly once" + ] + }, + { + "file": "src/main/runtime/rpc/terminal-multiplex.test.ts", + "assertions": ["keeps layout versions out of the output sequence domain"] + }, + { + "file": "src/main/runtime/rpc/terminal-subscribe-buffer.test.ts", + "assertions": [ + "omits an unknown snapshot output sequence instead of publishing the layout version" + ] + }, + { + "file": "tests/e2e/paired-remote-terminal-host-restart-background-sync.spec.ts", + "assertions": [ + "preserves daemon PID and target PTY identity across the paired host relaunch", + "foregrounds the daemon-backgrounded target on its first post-relaunch remote observation", + "delivers target and post-relaunch control terminal input and live output" + ] + }, { "file": "tests/e2e/paired-remote-terminal-probe-gap-recovery.spec.ts", "assertions": ["replaces a stale paired stream when the PTY snapshot advanced"] @@ -4379,6 +4415,42 @@ } ], "evidenceRuns": [ + { + "date": "2026-08-07", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts", + "result": "passed", + "durationSeconds": 4.29, + "summary": "All 10 subscriber-driven attach contracts passed. With two authoritative inventories overlapping one refused in-flight attach, removing only the post-settlement retry made the byte-identical focused assertion fail with one attach instead of two; restoring it retained one bounded waiter, retried once, and resumed live output on the existing stream." + }, + { + "date": "2026-08-07", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty.test.ts", + "result": "passed", + "durationSeconds": 5.84, + "summary": "The full PTY IPC file passed 449 tests, including one first-observation foreground sync after handler recreation and deduplication of the repeated presence signal." + }, + { + "date": "2026-08-07", + "runner": "local", + "platform": "macos", + "command": "ORCA_E2E_FORWARD_APP_LOGS=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-host-restart-background-sync.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --repeat-each=10 --max-failures=1", + "result": "passed", + "durationSeconds": 126, + "summary": "Ten consecutive isolated host-relaunch journeys preserved the daemon and PTY identities and resumed terminal input plus live output after the paired transport recovered." + }, + { + "date": "2026-08-07", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-host-restart-background-sync.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "result": "passed", + "durationSeconds": 12.5, + "summary": "With the parking override captured by both host launches, the isolated headed host and separate paired desktop client retained the daemon PID and PTY ID across host relaunch, emitted one target foreground transition, and delivered target plus post-relaunch control input/output. The byte-identical run with only the unknown-state fix reverted failed at the missing target foreground transition." + }, { "date": "2026-08-02", "runner": "local", @@ -4503,15 +4575,15 @@ }, "flakeHistory": { "status": "unknown", - "evidence": "The paired headed oracle is deterministic locally but has no CI or soak history yet." + "evidence": "The host-relaunch oracle passed a fresh 10-run local series. One earlier exploratory repeat timed out in transport recovery before terminal input/output assertions; it did not recur with forwarded app logs. A later Linux CI run reproduced missing live output when inventory overlapped the refused in-flight attach, which now has a deterministic unit oracle and is pending cross-platform rerun. Broader headed/headless parking coverage has no aggregated CI or soak history yet." }, "redGreenEvidence": { "status": "complete", - "evidence": "The six-PTY ordinary-parking oracle was red in two independently observed stages: the parked paired remount first used detached attach and restored only rows 5,963–5,999, then the requested snapshot still lost to the current-screen replay until park reveals explicitly entered the capability-gated reattach coordinator. The candidate is green on headed and headless hosts, while disabling terminal.paired-parking.v1 makes the byte-identical oracle fail at its capability precondition and leaves the separately tested legacy lossy fallback green. The 64-PTY decorative-title control delivered 576 client events after convergence on unmodified main; the candidate delivers zero while preserving all 640 local frames, one current frame to a late client, and bell/idle transitions to both clients. The legacy no-output-pause control failed to request a reveal snapshot and kept hidden bytes on the xterm path; the candidate gates those bytes locally and restores one authoritative snapshot before live output. With stalled-stream recovery disabled, the host cursor advanced but the client remained frozen; enabling it repainted the marker with the same PTY and tab. The sequence-gap unit and headed paired oracle are red on current main and with only the new probe decision reverted: the authoritative host advances while the client stays stale after a successful probe. The candidate replaces the stream, repaints the marker, preserves PTY identity, and resumes I/O. Reverting the active-stream limit to 64 makes the exact 128-stream contract fail at 64; restoring 128 admits every intended stream, rejects 129, and reattaches it after one slot releases." + "evidence": "The six-PTY ordinary-parking oracle was red in two independently observed stages: the parked paired remount first used detached attach and restored only rows 5,963–5,999, then the requested snapshot still lost to the current-screen replay until park reveals explicitly entered the capability-gated reattach coordinator. The candidate is green on headed and headless hosts, while disabling terminal.paired-parking.v1 makes the byte-identical oracle fail at its capability precondition and leaves the separately tested legacy lossy fallback green. The 64-PTY decorative-title control delivered 576 client events after convergence on unmodified main; the candidate delivers zero while preserving all 640 local frames, one current frame to a late client, and bell/idle transitions to both clients. The legacy no-output-pause control failed to request a reveal snapshot and kept hidden bytes on the xterm path; the candidate gates those bytes locally and restores one authoritative snapshot before live output. With stalled-stream recovery disabled, the host cursor advanced but the client remained frozen; enabling it repainted the marker with the same PTY and tab. The sequence-gap unit and headed paired oracle are red on current main and with only the new probe decision reverted: the authoritative host advances while the client stays stale after a successful probe. The candidate replaces the stream, repaints the marker, preserves PTY identity, and resumes I/O. Reverting the active-stream limit to 64 makes the exact 128-stream contract fail at 64; restoring 128 admits every intended stream, rejects 129, and reattaches it after one slot releases. The host-relaunch oracle fails when unknown delivery state is coerced to foregrounded or a layout version becomes an output high-water; the candidate foregrounds the preserved daemon PTY once and resumes same-PTY input/output. When authoritative inventory overlaps a refused in-flight attach, removing only the post-settlement retry leaves the existing subscriber without daemon output; the candidate retains one waiter across repeated inventory publications, retries exactly once, and restores live output." }, "performanceBudget": { "required": true, - "evidence": "Before ordinary parking, six warm-mounted hidden paired xterms receive zero raw frames and schedule zero renderer output drains while host models continue ingesting sustained output. Ordinary parking then retains the host PTY and bounded 5,000-row provider model while destroying five client xterms. Decorative title ticks are collapsed per paired subscriber before JSON serialization and encryption; semantic facts remain on the singleton client-event stream. Headed and headless oracles require exact reduction from six managers to one, at least 55% staged xterm-cell release, no more than 16 MiB heap growth, and under 500 ms timer drift. Legacy hosts retain the prior 12-worktree/45-minute lossy force-parking policy but now gate hidden raw output before xterm scheduling and restore from a bounded snapshot on reveal. Stream recovery adds no polling and is scoped to one stream." + "evidence": "Before ordinary parking, six warm-mounted hidden paired xterms receive zero raw frames and schedule zero renderer output drains while host models continue ingesting sustained output. Ordinary parking then retains the host PTY and bounded 5,000-row provider model while destroying five client xterms. Decorative title ticks are collapsed per paired subscriber before JSON serialization and encryption; semantic facts remain on the singleton client-event stream. Headed and headless oracles require exact reduction from six managers to one, at least 55% staged xterm-cell release, no more than 16 MiB heap growth, and under 500 ms timer drift. Legacy hosts retain the prior 12-worktree/45-minute lossy force-parking policy but now gate hidden raw output before xterm scheduling and restore from a bounded snapshot on reveal. Stream recovery adds no polling and is scoped to one stream. Provider-ready reconciliation runs only for PTYs returned by a successful controller inventory, requires an existing remote subscriber, and retains at most one post-settlement waiter per PTY while attach deduplication remains active." }, "promotionCriteria": [ "Collect 100 consecutive CI passes or 14 days of soak history for the headed and headless parking commands.", diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 877d7d8862c..678cc877465 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -637,6 +637,7 @@ describe('registerPtyHandlers', () => { const write = vi.fn() const pauseProducer = vi.fn() const resumeProducer = vi.fn() + const setPtyBackgrounded = vi.fn() const shutdown = vi.fn() let dataHandler: ((payload: { id: string; data: string }) => void) | null = null let exitHandler: ((payload: { id: string; code: number }) => void) | null = null @@ -650,6 +651,7 @@ describe('registerPtyHandlers', () => { resize: vi.fn(), pauseProducer, resumeProducer, + setPtyBackgrounded, kill: vi.fn(), shutdown, sendSignal: vi.fn(), @@ -688,6 +690,7 @@ describe('registerPtyHandlers', () => { write, pauseProducer, resumeProducer, + setPtyBackgrounded, shutdown, getBufferSnapshot, emitData: (id: string, data: string) => dataHandler?.({ id, data }), @@ -15608,6 +15611,50 @@ describe('registerPtyHandlers', () => { }) describe('hidden renderer delivery gate', () => { + it('foregrounds a preserved daemon PTY after handler recreation loses sync memory', async () => { + const daemon = installObservableDaemonTestProvider() + const firstRuntime = { + setPtyController: vi.fn(), + hasRawTerminalViewSubscriber: vi.fn(() => false), + createPreAllocatedTerminalHandle: vi.fn(() => null), + registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn() + } + registerPtyHandlers(mainWindow as never, firstRuntime as never) + const result = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + sessionId: 'daemon-session' + })) as { id: string } + + getPtySetRendererPtyVisibleListener()(null, { id: result.id, visible: false }) + expect(daemon.setPtyBackgrounded).toHaveBeenLastCalledWith(result.id, true) + + daemon.setPtyBackgrounded.mockClear() + handlers.clear() + let rawSubscriberPresent = false + const nextRuntime = { + setPtyController: vi.fn(), + hasRawTerminalViewSubscriber: vi.fn(() => rawSubscriberPresent), + onRemoteTerminalViewPresenceChanged: null as ((id: string) => void) | null, + registerRawTerminalViewSubscriber(id: string): void { + rawSubscriberPresent = true + this.onRemoteTerminalViewPresenceChanged?.(id) + } + } + registerPtyHandlers(mainWindow as never, nextRuntime as never) + + nextRuntime.registerRawTerminalViewSubscriber(result.id) + // Repeated presence signals must remain deduplicated. + nextRuntime.onRemoteTerminalViewPresenceChanged?.(result.id) + + expect(daemon.setPtyBackgrounded).toHaveBeenCalledOnce() + expect(daemon.setPtyBackgrounded).toHaveBeenCalledWith(result.id, false) + }) + it('drops hidden PTY data after model ingestion and emits one out-of-band restore marker', async () => { vi.useFakeTimers() const runtime = { diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 5d858cac7f3..46498fdf0a3 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -2516,7 +2516,7 @@ export function registerPtyHandlers( function syncPtyBackgroundedDelivery(id: string, caller: string): void { const background = rendererPtyIsKnownHidden(id) && !(runtime?.hasRawTerminalViewSubscriber?.(id) ?? false) - if ((backgroundedDeliverySyncByPty.get(id) ?? false) === background) { + if (backgroundedDeliverySyncByPty.get(id) === background) { return } const provider = tryGetProviderForPty(id) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 7983ebacb40..72bd2b0c12f 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -3111,6 +3111,7 @@ export class OrcaRuntimeService { // concurrent first-subscribes and keeps later subscribes no-ops; it is // cleared per lifecycle generation (exit/respawn) and on failed attempts. private subscriberDrivenProviderAttachesByPtyId = new Map>() + private subscriberDrivenProviderAttachInventoryWaiters = new Set() // Why: a spawn through this app already attaches its provider stream, so // subscriber-driven attach and the never-attached read fallback must target // only inventory-discovered sessions no local spawn published this @@ -10597,6 +10598,7 @@ export class OrcaRuntimeService { this.legacyWorkerRecoveredPtys.delete(ptyId) // Why: a respawn under the same session id needs its own subscriber-driven attach. this.subscriberDrivenProviderAttachesByPtyId.delete(ptyId) + this.subscriberDrivenProviderAttachInventoryWaiters.delete(ptyId) this.spawnPublishedPtys.delete(ptyId) // Why: a provider response belongs to the process generation that issued // it; a respawn must neither reuse its frame nor join its in-flight call. @@ -10806,10 +10808,7 @@ export class OrcaRuntimeService { // A spawn published (or admission pending) this generation already // attaches the provider stream; a replacement under a reused id must not // read as the discovered never-attached session it replaced. - if ( - this.spawnPublishedPtys.has(ptyId) || - this.pendingPtyRegistrationIncarnations.has(ptyId) - ) { + if (this.spawnPublishedPtys.has(ptyId) || this.pendingPtyRegistrationIncarnations.has(ptyId)) { return false } // SSH panes have their own lease/reattach machinery. @@ -10846,6 +10845,31 @@ export class OrcaRuntimeService { }) } + private reconcileSubscriberDrivenProviderAttach(ptyId: string): void { + if (!this.hasRemoteTerminalViewSubscriber(ptyId)) { + return + } + const pending = this.subscriberDrivenProviderAttachesByPtyId.get(ptyId) + if (!pending) { + this.ensureSubscriberDrivenProviderAttach(ptyId) + return + } + if (this.subscriberDrivenProviderAttachInventoryWaiters.has(ptyId)) { + return + } + this.subscriberDrivenProviderAttachInventoryWaiters.add(ptyId) + void pending.then((attached) => { + this.subscriberDrivenProviderAttachInventoryWaiters.delete(ptyId) + if (attached || !this.hasRemoteTerminalViewSubscriber(ptyId)) { + return + } + if (this.subscriberDrivenProviderAttachesByPtyId.get(ptyId) === pending) { + this.subscriberDrivenProviderAttachesByPtyId.delete(ptyId) + } + this.ensureSubscriberDrivenProviderAttach(ptyId) + }) + } + /** Mark a raw-output viewer without transferring terminal query authority. */ registerRawTerminalViewSubscriber(ptyId: string): () => void { this.rawTerminalViewSubscriberCounts.set( @@ -29268,6 +29292,7 @@ export class OrcaRuntimeService { this.restoredOrchestrationAuthorityByPtyId.delete(session.id) } pty.controllerTitle = session.title?.trim() || null + this.reconcileSubscriberDrivenProviderAttach(session.id) } // Why: fire-and-forget so this listing hot path doesn't serialize a relay round-trip per session and a throw can't abort the sweep below. this.refreshPtyForegroundAgent(session.id) diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 05f94f2e631..1cdec021e55 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -2623,7 +2623,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ const size = runtime.getTerminalSize(ptyId) const displayMode = runtime.getMobileDisplayMode(ptyId) const layoutSeq = runtime.getLayout(ptyId)?.seq - const snapshotFrameSeq = serialized?.seq ?? layoutSeq + // Why: layout versions and output offsets are different sequence domains. const snapshotOutputSeq = serialized?.seq emit({ type: 'subscribed', @@ -2664,7 +2664,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ cols: serialized?.cols ?? size?.cols ?? 80, rows: serialized?.rows ?? size?.rows ?? 24, displayMode, - seq: snapshotFrameSeq, + seq: snapshotOutputSeq, cwd: serialized?.cwd, truncated: initialOutputOverflowed, truncatedByByteBudget: serialized?.truncatedByByteBudget, @@ -3424,8 +3424,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ const displayMode = runtime.getMobileDisplayMode(ptyId) // Why: layout seq is the mobile stale-event filter's high-water mark (undefined pre-transition is fail-open). See docs/mobile-terminal-layout-state-machine.md. const layoutSeq = runtime.getLayout(ptyId)?.seq - const snapshotFrameSeq = serialized?.seq ?? layoutSeq - // Why: track the seq that actually covered the buffered chunks (recovery snapshots advance it) or an absorbed query gets zero replies. + // Why: only an output offset can cover buffered chunks; layout versions are a separate sequence domain. let snapshotOutputSeq = serialized?.seq emit({ type: 'subscribed', @@ -3445,7 +3444,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ cols: serialized?.cols ?? size?.cols ?? params.viewport?.cols ?? 80, rows: serialized?.rows ?? size?.rows ?? params.viewport?.rows ?? 24, displayMode, - seq: snapshotFrameSeq, + seq: snapshotOutputSeq, cwd: serialized?.cwd, truncated: initialOutputOverflowed, truncatedByByteBudget: serialized?.truncatedByByteBudget, diff --git a/src/main/runtime/rpc/terminal-multiplex.test.ts b/src/main/runtime/rpc/terminal-multiplex.test.ts index dd46b36650e..95ad62ec18c 100644 --- a/src/main/runtime/rpc/terminal-multiplex.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex.test.ts @@ -444,6 +444,58 @@ function startSourceRangeOverflowHarness(options: { } describe('terminal multiplex RPC', () => { + it('keeps layout versions out of the output sequence domain', async () => { + let dataListener: ((data: string, meta?: RuntimeTerminalDataMeta) => void) | undefined + const harness = startDesktopMultiplexSubscribe({ + getLayout: vi.fn().mockReturnValue({ seq: 675 }), + serializeTerminalBuffer: vi.fn().mockResolvedValue({ + data: 'restored terminal', + cols: 120, + rows: 40, + source: 'renderer' + }), + subscribeToTerminalData: vi.fn((_ptyId, listener) => { + dataListener = listener + return vi.fn() + }) + }) + await vi.waitFor(() => expect(harness.handlers.has(0)).toBe(true)) + sendDesktopMultiplexSubscribe(harness.handlers) + await vi.waitFor(() => + expect( + harness.messages.some((message) => JSON.parse(message).result?.type === 'subscribed') + ).toBe(true) + ) + + const subscribed = harness.messages + .map((message) => JSON.parse(message).result) + .find((event) => event?.type === 'subscribed') + const snapshotStart = harness.binaryFrames + .map(decodeTerminalStreamFrame) + .find((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotStart) + expect(subscribed.seq).toBe(675) + if (!snapshotStart) { + throw new Error('Missing multiplex snapshot start frame') + } + const snapshotPayload = decodeTerminalStreamJson(snapshotStart.payload) + expect(snapshotPayload).toMatchObject({ kind: 'scrollback' }) + expect(snapshotPayload).not.toHaveProperty('seq') + + harness.binaryFrames.splice(0) + dataListener?.('live', { seq: 4, rawLength: 4 }) + await vi.waitFor(() => + expect( + harness.binaryFrames.some((bytes) => { + const frame = decodeTerminalStreamFrame(bytes) + return frame?.opcode === TerminalStreamOpcode.Output && frame.seq === 4 + }) + ).toBe(true) + ) + + harness.cleanups.get('terminal-multiplex:conn-desktop-first-paint')?.() + await harness.dispatchPromise + }) + it.each(['headless', 'renderer'] as const)( 'commits a source-range replacement only after the %s snapshot publishes', async (source) => { diff --git a/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts index 837e49eff5a..8265e713fb3 100644 --- a/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts +++ b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts @@ -833,15 +833,13 @@ describe('terminal subscribe buffering', () => { const decodedFrames = binaryFrames .map((frame) => decodeTerminalStreamFrame(frame)) .filter((frame): frame is NonNullable => frame !== null) - const snapshotStarts = decodedFrames.filter( + const snapshotStart = decodedFrames.find( (frame) => frame.opcode === TerminalStreamOpcode.SnapshotStart ) - // Why seq 1 (layout seq): a no-output-seq snapshot falls back to the - // layout seq on the wire; the recovered data still ships as the first - // and only scrollback snapshot. - expect(snapshotStarts.map((frame) => decodeTerminalStreamJson(frame.payload))).toEqual([ - expect.objectContaining({ kind: 'scrollback', seq: 1 }) - ]) + // Why: layout versions and output offsets are different sequence domains. + const snapshotInfo = decodeTerminalStreamJson(snapshotStart!.payload) + expect(snapshotInfo).toMatchObject({ kind: 'scrollback' }) + expect(snapshotInfo).not.toHaveProperty('seq') const snapshotText = decodedFrames .filter((frame) => frame.opcode === TerminalStreamOpcode.SnapshotChunk) .map((frame) => decodeTerminalStreamText(frame.payload)) diff --git a/src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts b/src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts index 7fd6c42b5fb..09c39f20137 100644 --- a/src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts +++ b/src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts @@ -37,6 +37,12 @@ type RuntimeInternals = { ) => unknown issuePtyHandle: (pty: unknown) => string headlessTerminals: Map + subscriberDrivenProviderAttachesByPtyId: Map> + subscriberDrivenProviderAttachInventoryWaiters: Set + refreshPtyWorktreeRecordsWithControllerInventory: ( + worktrees: [], + targetWorktreeId: string | null + ) => Promise } function internals(runtime: OrcaRuntimeService): RuntimeInternals { @@ -56,11 +62,21 @@ function createDaemonProviderModel(opts: { snapshotCapable: boolean }) { const sessions = new Map() const attachCalls: string[] = [] const resizeCalls: [string, number, number][] = [] + let nextAttachBarrier: { promise: Promise; result: boolean } | null = null let runtime: OrcaRuntimeService | null = null const controller = { write: () => true, kill: () => true, getForegroundProcess: async () => null, + listProcesses: async () => + [...sessions.entries()].map(([id, session]) => ({ + id, + cwd: '/tmp/wt', + worktreeId: WORKTREE_ID, + title: '', + cols: session.cols, + rows: session.rows + })), hasRendererSerializer: () => false, getSize: (ptyId: string) => { const session = sessions.get(ptyId) @@ -72,6 +88,14 @@ function createDaemonProviderModel(opts: { snapshotCapable: boolean }) { }, attach: async (ptyId: string) => { attachCalls.push(ptyId) + const barrier = nextAttachBarrier + nextAttachBarrier = null + if (barrier) { + await barrier.promise + if (!barrier.result) { + return false + } + } const session = sessions.get(ptyId) // Attach-only: an absent session is refused, never created. if (!session) { @@ -100,6 +124,14 @@ function createDaemonProviderModel(opts: { snapshotCapable: boolean }) { sessions, attachCalls, resizeCalls, + deferNextAttach(result: boolean): () => void { + let release!: () => void + const promise = new Promise((resolve) => { + release = resolve + }) + nextAttachBarrier = { promise, result } + return release + }, bind(target: OrcaRuntimeService) { runtime = target }, @@ -376,6 +408,39 @@ describe('subscriber-driven daemon attach (never-activated tab)', () => { await vi.waitFor(() => expect(outputText(harness)).toContain('late daemon hello')) }) + it('retries an existing subscriber when provider inventory becomes ready', async () => { + const { runtime, model, handle } = setupNeverAttachedDaemonSession({ + snapshotCapable: false + }) + model.sessions.delete(PTY_ID) + const refuseFirstAttach = model.deferNextAttach(false) + const harness = startMultiplex(runtime) + await vi.waitFor(() => expect(harness.handlers.has(0)).toBe(true)) + + sendSubscribe(harness, 1, handle) + await waitForSubscribed(harness, 1) + await vi.waitFor(() => expect(model.attachCalls).toEqual([PTY_ID])) + expect(internals(runtime).subscriberDrivenProviderAttachesByPtyId.has(PTY_ID)).toBe(true) + expect(runtime.hasRemoteTerminalViewSubscriber(PTY_ID)).toBe(true) + expect(model.emitData(PTY_ID, 'before inventory\r\n')).toBe(false) + + model.sessions.set(PTY_ID, { cols: 100, rows: 30, attached: false, screen: '' }) + await internals(runtime).refreshPtyWorktreeRecordsWithControllerInventory([], null) + await internals(runtime).refreshPtyWorktreeRecordsWithControllerInventory([], null) + expect(model.attachCalls).toEqual([PTY_ID]) + expect([...internals(runtime).subscriberDrivenProviderAttachInventoryWaiters]).toEqual([PTY_ID]) + refuseFirstAttach() + + await vi.waitFor(() => expect(model.attachCalls).toEqual([PTY_ID, PTY_ID])) + await vi.waitFor(() => + expect(internals(runtime).subscriberDrivenProviderAttachInventoryWaiters.has(PTY_ID)).toBe( + false + ) + ) + expect(model.emitData(PTY_ID, 'after inventory\r\n')).toBe(true) + await vi.waitFor(() => expect(outputText(harness)).toContain('after inventory')) + }) + it('does not subscriber-attach or provider-read a session this app already spawned', async () => { const { runtime, model, handle } = setupNeverAttachedDaemonSession({ snapshotCapable: true, diff --git a/tests/e2e/paired-remote-terminal-host-restart-background-sync.spec.ts b/tests/e2e/paired-remote-terminal-host-restart-background-sync.spec.ts new file mode 100644 index 00000000000..2d0eb5a9acd --- /dev/null +++ b/tests/e2e/paired-remote-terminal-host-restart-background-sync.spec.ts @@ -0,0 +1,447 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import { PROTOCOL_VERSION } from '../../src/main/daemon/types' +import { + HOST_TERMINAL_SURFACE_SEPARATOR, + toWebTerminalSurfaceTabId +} from '../../src/shared/terminal-surface-id' +import { expect, test } from './helpers/orca-app' +import { TEST_REPO_PATH_FILE } from './global-setup' +import { + createRuntimeDesktopPairingOffer, + launchPairedElectronClient, + type PairedElectronClient +} from './helpers/paired-electron-client' +import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart' +import { waitForTabParked } from './helpers/terminal-hidden-parking' + +const PARK_DELAY_MS = 2_000 +const scratch = mkdtempSync(path.join(os.tmpdir(), 'orca-paired-host-restart-background-')) +const fixturePath = path.join(scratch, 'paired-host-restart-terminal.mjs') +const backlogPath = path.join(scratch, 'daemon-stream-backlog.jsonl') + +writeFileSync( + fixturePath, + [ + "import { appendFileSync } from 'node:fs'", + 'const sink = process.argv[2]', + "process.stdout.write('READY\\r\\n')", + "process.stdin.setEncoding('utf8')", + "let pending = ''", + "process.stdin.on('data', (data) => {", + ' pending += data', + ' const lines = pending.split(/\\r\\n|\\r|\\n/)', + " pending = lines.pop() ?? ''", + ' for (const line of lines) {', + ' appendFileSync(sink, `${line}\\n`)', + ' process.stdout.write(`LIVE:${line}\\r\\n`)', + ' }', + '})', + 'process.stdin.resume()' + ].join('\n') +) + +test.afterAll(() => { + rmSync(scratch, { recursive: true, force: true }) +}) + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", `'\\''`)}'` +} + +function fixtureCommand(sinkPath: string): string { + const command = [process.execPath, fixturePath, sinkPath] + return process.platform === 'win32' + ? command.map((value) => `"${value.replaceAll('"', '""')}"`).join(' ') + : command.map(shellQuote).join(' ') +} + +function seededRepoPathOrSkip(): string { + const repoPath = existsSync(TEST_REPO_PATH_FILE) + ? readFileSync(TEST_REPO_PATH_FILE, 'utf8').trim() + : '' + test.skip(!repoPath || !existsSync(repoPath), 'Global setup did not produce a seeded test repo') + return repoPath +} + +function readDaemonPid(userDataDir: string): number { + const value = JSON.parse( + readFileSync(path.join(userDataDir, 'daemon', `daemon-v${PROTOCOL_VERSION}.pid`), 'utf8') + ) as { pid?: unknown } + if (typeof value.pid !== 'number') { + throw new Error('Daemon pid file did not contain a numeric pid') + } + return value.pid +} + +type BacklogEntry = { + atMs?: number + background?: boolean + backgroundedSessionIdSuffixes?: string[] + event?: string + sessionIdSuffix?: string +} + +function readBacklogEntries(): BacklogEntry[] { + try { + return readFileSync(backlogPath, 'utf8') + .split(/\r?\n/) + .filter(Boolean) + .flatMap((line) => { + try { + return [JSON.parse(line) as BacklogEntry] + } catch { + return [] + } + }) + } catch { + return [] + } +} + +function readText(filePath: string): string { + try { + return readFileSync(filePath, 'utf8') + } catch { + return '' + } +} + +async function callRuntime( + page: Page, + environmentId: string, + method: string, + params: unknown +): Promise { + return page.evaluate( + async ({ environmentId, method, params }) => { + const response = await window.api.runtimeEnvironments.call({ + selector: environmentId, + method, + params + }) + if (!response.ok) { + throw new Error(`${response.error.code}: ${response.error.message}`) + } + return response.result + }, + { environmentId, method, params } + ) as Promise +} + +type HostTerminal = { + handle: string + parentTabId: string + ptyId: string + sinkPath: string + webTabId: string +} + +async function createHostTerminal( + client: PairedElectronClient, + worktreeId: string, + name: string +): Promise { + const sinkPath = path.join(scratch, `${name}.log`) + const created = await callRuntime<{ + tab: { id: string; parentTabId: string; terminal: string | null } + }>(client.page, client.environmentId, 'session.tabs.createTerminal', { + worktree: `id:${worktreeId}`, + command: fixtureCommand(sinkPath), + activate: false, + select: false, + navigation: 'caller' + }) + if (!created.tab.terminal) { + throw new Error('Host did not publish the fixture terminal') + } + const shown = await callRuntime<{ terminal: { ptyId: string | null } }>( + client.page, + client.environmentId, + 'terminal.show', + { terminal: created.tab.terminal } + ) + if (!shown.terminal.ptyId) { + throw new Error('Host fixture terminal has no PTY') + } + const parentTabId = + created.tab.parentTabId || created.tab.id.split(HOST_TERMINAL_SURFACE_SEPARATOR)[0] + return { + handle: created.tab.terminal, + parentTabId, + ptyId: shown.terminal.ptyId, + sinkPath, + webTabId: toWebTerminalSurfaceTabId(parentTabId) + } +} + +async function findTerminalHandle( + client: PairedElectronClient, + worktreeId: string, + parentTabId: string +): Promise { + let terminal: string | null = null + await expect + .poll( + async () => { + const snapshot = await callRuntime<{ + tabs: { type: string; parentTabId?: string; terminal?: string | null }[] + }>(client.page, client.environmentId, 'session.tabs.list', { + worktree: `id:${worktreeId}` + }) + terminal = + snapshot.tabs.find((tab) => tab.type === 'terminal' && tab.parentTabId === parentTabId) + ?.terminal ?? null + return terminal + }, + { timeout: 30_000, message: `Host did not republish terminal tab ${parentTabId}` } + ) + .not.toBeNull() + if (!terminal) { + throw new Error(`Host did not republish terminal tab ${parentTabId}`) + } + return terminal +} + +async function openClientTab(page: Page, worktreeId: string, webTabId: string): Promise { + await expect + .poll( + () => + page.evaluate( + ({ worktreeId, webTabId }) => + (window.__store?.getState().tabsByWorktree[worktreeId] ?? []).some( + (tab) => tab.id === webTabId + ), + { worktreeId, webTabId } + ), + { timeout: 60_000, message: `Client never mirrored host tab ${webTabId}` } + ) + .toBe(true) + await page.evaluate( + ({ worktreeId, webTabId }) => { + const state = window.__store?.getState() + state?.setActiveView('terminal') + state?.setActiveWorktree(worktreeId) + state?.setActiveTab(webTabId) + state?.setActiveTabType('terminal') + }, + { worktreeId, webTabId } + ) + await expect + .poll(() => page.evaluate((id) => window.__paneManagers?.has(id) ?? false, webTabId), { + timeout: 60_000, + message: `Client pane for ${webTabId} did not mount` + }) + .toBe(true) +} + +async function readPaneContent(page: Page, webTabId: string): Promise { + return page.evaluate((id) => { + const manager = window.__paneManagers?.get(id) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + return pane?.serializeAddon?.serialize?.() ?? '' + }, webTabId) +} + +async function waitForPaneConnected(page: Page, webTabId: string): Promise { + await expect + .poll( + () => + page.evaluate((id) => { + const manager = window.__paneManagers?.get(id) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + return pane?.container.dataset.ptyRecoveryState ?? null + }, webTabId), + { timeout: 30_000, message: `Pane ${webTabId} never completed transport recovery` } + ) + .toBe('connected') +} + +async function expectTerminalInteractive( + client: PairedElectronClient, + target: HostTerminal, + marker: string +): Promise { + await client.page.evaluate((id) => { + const manager = window.__paneManagers?.get(id) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + if (!pane) { + throw new Error(`No pane mounted for ${id}`) + } + pane.terminal.focus() + const textarea = pane.container.querySelector('.xterm-helper-textarea') as HTMLTextAreaElement + textarea.focus() + }, target.webTabId) + await client.page.keyboard.type(marker) + await client.page.keyboard.press('Enter') + await expect.poll(() => readText(target.sinkPath), { timeout: 15_000 }).toContain(marker) + await expect + .poll(() => readPaneContent(client.page, target.webTabId), { timeout: 15_000 }) + .toContain(`LIVE:${marker}`) +} + +async function moveHostAwayFromWorktree(page: Page, targetWorktreeId: string): Promise { + const alternateWorktreeId = await page.evaluate((targetId) => { + const state = window.__store?.getState() + const alternate = state?.allWorktrees().find((worktree) => worktree.id !== targetId) + if (!state || !alternate) { + return null + } + state.setActiveView('editor') + state.setActiveWorktree(alternate.id) + return alternate.id + }, targetWorktreeId) + if (!alternateWorktreeId) { + throw new Error('Host fixture needs a second worktree for inactive-workspace restart coverage') + } + await expect + .poll(() => page.evaluate(() => window.__store?.getState().activeWorktreeId ?? null)) + .toBe(alternateWorktreeId) + return alternateWorktreeId +} + +test('foregrounds a preserved daemon PTY after the paired host relaunches', async (// oxlint-disable-next-line no-empty-pattern -- This lifecycle test owns both host launches. +{}, testInfo) => { + test.setTimeout(360_000) + const repoPath = seededRepoPathOrSkip() + writeFileSync(backlogPath, '') + const previousParkDelay = process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS + process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS = String(PARK_DELAY_MS) + const session = createRestartSession(testInfo, { + ORCA_DAEMON_STREAM_BACKLOG_FILE: backlogPath + }) + let firstHost: ElectronApplication | null = null + let secondHost: ElectronApplication | null = null + let client: PairedElectronClient | null = null + const terminals: HostTerminal[] = [] + try { + const first = await session.launch() + firstHost = first.app + const worktreeId = await attachRepoAndOpenTerminal(first.page, repoPath) + const daemonPid = readDaemonPid(session.userDataDir) + client = await launchPairedElectronClient( + await createRuntimeDesktopPairingOffer(first.page), + testInfo, + 'host-restart-background-sync' + ) + await expect + .poll( + () => + client!.page.evaluate( + (id) => + window.__store + ?.getState() + .allWorktrees() + .some((worktree) => worktree.id === id) ?? false, + worktreeId + ), + { timeout: 60_000, message: 'Paired client never saw the host worktree' } + ) + .toBe(true) + + const target = await createHostTerminal(client, worktreeId, 'target') + const firstDecoy = await createHostTerminal(client, worktreeId, 'first-decoy') + const parkingDecoy = await createHostTerminal(client, worktreeId, 'parking-decoy') + terminals.push(target, firstDecoy, parkingDecoy) + await openClientTab(client.page, worktreeId, target.webTabId) + await expect + .poll(() => readPaneContent(client!.page, target.webTabId), { timeout: 30_000 }) + .toContain('READY') + await openClientTab(client.page, worktreeId, firstDecoy.webTabId) + await openClientTab(client.page, worktreeId, parkingDecoy.webTabId) + await waitForTabParked(client.page, target.webTabId, { parkDelayMs: PARK_DELAY_MS }) + + const targetSuffix = target.ptyId.slice(-10) + await expect + .poll( + () => + readBacklogEntries() + .findLast((entry) => entry.backgroundedSessionIdSuffixes) + ?.backgroundedSessionIdSuffixes?.includes(targetSuffix) ?? false, + { timeout: 15_000, message: 'Daemon never retained the target background hint' } + ) + .toBe(true) + + const alternateHostWorktreeId = await moveHostAwayFromWorktree(first.page, worktreeId) + await session.close(firstHost) + firstHost = null + const foregroundEventStartMs = Date.now() + const second = await session.launch() + secondHost = second.app + await second.page.waitForFunction( + (expectedId) => + window.__store?.getState().workspaceSessionReady === true && + window.__store?.getState().activeWorktreeId === expectedId, + alternateHostWorktreeId, + { timeout: 30_000 } + ) + expect(readDaemonPid(session.userDataDir), 'daemon must survive the host relaunch').toBe( + daemonPid + ) + await expect + .poll( + () => + client!.page.evaluate(async (selector) => { + const response = await window.api.runtimeEnvironments.connect({ selector }) + return response.ok + }, client!.environmentId), + { timeout: 60_000, message: 'Paired client never reconnected to the relaunched host' } + ) + .toBe(true) + + target.handle = await findTerminalHandle(client, worktreeId, target.parentTabId) + const shown = await callRuntime<{ terminal: { ptyId: string | null } }>( + client.page, + client.environmentId, + 'terminal.show', + { terminal: target.handle } + ) + expect(shown.terminal.ptyId).toBe(target.ptyId) + await openClientTab(client.page, worktreeId, target.webTabId) + await waitForPaneConnected(client.page, target.webTabId) + await expect + .poll( + () => + readBacklogEntries().some( + (entry) => + (entry.atMs ?? 0) >= foregroundEventStartMs && + entry.event === 'setSessionBackground' && + entry.sessionIdSuffix === targetSuffix && + entry.background === false + ), + { timeout: 15_000, message: 'Relaunched host never foregrounded the preserved PTY' } + ) + .toBe(true) + await expectTerminalInteractive(client, target, 'x') + + const reconnectControl = await createHostTerminal(client, worktreeId, 'reconnect-control') + terminals.push(reconnectControl) + expect(reconnectControl.ptyId).not.toBe(target.ptyId) + await openClientTab(client.page, worktreeId, reconnectControl.webTabId) + await waitForPaneConnected(client.page, reconnectControl.webTabId) + await expectTerminalInteractive(client, reconnectControl, 'y') + } finally { + if (client) { + for (const terminal of terminals) { + await callRuntime(client.page, client.environmentId, 'terminal.closeTab', { + terminal: terminal.handle + }).catch(() => undefined) + } + await client.dispose() + } + if (secondHost) { + await session.close(secondHost) + } + if (firstHost) { + await session.close(firstHost) + } + await session.dispose() + if (previousParkDelay === undefined) { + delete process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS + } else { + process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS = previousParkDelay + } + } +}) From 419151dddd54891e863206b1a01ea35ed6f8017b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 7 Aug 2026 01:29:37 -0700 Subject: [PATCH 010/289] fix(browser): present Firefox UA on Google auth hosts to end ~1h sign-in staleness (#12884) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Google binds a signed-in session to the browser identity that created it, and rejected Orca's Electron/Chrome-shaped UA on accounts.google.com — so sessions died at ~1h and re-copying cookies only reset the same losing clock. Present a Firefox identity scoped to the Google/YouTube sign-in hosts at every layer Google probes: the request headers (with sec-ch-ua* stripped, since real Firefox sends none), the per-WebContents UA that auth JS reads, and the CDP emulation override a viewport preset installs. All three must agree — a disagreement between them is a sharper bot signal than any single wrong UA. Native-UA profiles opt out at every layer, including popup child windows and after a browser-native cookie import. Validated live against a real account: freshness cookies issued and rotating on a ~10-min cadence, session still signed in and self-renewing past 10 hours, against a pre-fix baseline of death at ~1h. Closes #11518. --- src/main/browser/browser-cookie-import.ts | 5 +- .../browser/browser-google-auth-ua.test.ts | 65 ++ src/main/browser/browser-google-auth-ua.ts | 51 ++ src/main/browser/browser-manager.test.ts | 812 +++++++++++++++++- src/main/browser/browser-manager.ts | 231 ++++- ...owser-session-registry.persistence.test.ts | 17 +- .../browser/browser-session-registry.test.ts | 80 +- src/main/browser/browser-session-registry.ts | 14 +- src/main/browser/browser-session-ua.ts | 66 +- .../browser-session-user-agent-mode.ts | 22 + .../browser-viewport-user-agent.test.ts | 66 ++ .../browser/browser-viewport-user-agent.ts | 73 ++ 12 files changed, 1423 insertions(+), 79 deletions(-) create mode 100644 src/main/browser/browser-google-auth-ua.test.ts create mode 100644 src/main/browser/browser-google-auth-ua.ts create mode 100644 src/main/browser/browser-session-user-agent-mode.ts create mode 100644 src/main/browser/browser-viewport-user-agent.test.ts create mode 100644 src/main/browser/browser-viewport-user-agent.ts diff --git a/src/main/browser/browser-cookie-import.ts b/src/main/browser/browser-cookie-import.ts index 131adcca66b..e67c5470be8 100644 --- a/src/main/browser/browser-cookie-import.ts +++ b/src/main/browser/browser-cookie-import.ts @@ -74,6 +74,7 @@ import type { BrowserSessionProfileSource } from '../../shared/types' import { browserSessionRegistry } from './browser-session-registry' +import { getBrowserSessionUserAgentMode } from './browser-session-user-agent-mode' import { setupClientHintsOverride } from './browser-session-ua' import { isGoogleSourceBoundCookie, @@ -1876,7 +1877,9 @@ export async function importCookiesFromBrowser( const ua = getUserAgentForBrowser(browser.family) if (ua) { targetSession.setUserAgent(ua) - setupClientHintsOverride(targetSession, ua) + setupClientHintsOverride(targetSession, ua, { + googleAuthOverride: getBrowserSessionUserAgentMode(targetSession) !== 'native' + }) browserSessionRegistry.persistUserAgent(targetPartition, ua) diag(` set UA for partition: ${ua.substring(0, 80)}...`) } diff --git a/src/main/browser/browser-google-auth-ua.test.ts b/src/main/browser/browser-google-auth-ua.test.ts new file mode 100644 index 00000000000..a1ed1370418 --- /dev/null +++ b/src/main/browser/browser-google-auth-ua.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest' + +import { + googleAuthUserAgent, + isGoogleAuthUrl, + setUserAgentHeader, + stripClientHints +} from './browser-google-auth-ua' + +describe('isGoogleAuthUrl', () => { + it('matches the Google/YouTube sign-in hosts exactly', () => { + expect(isGoogleAuthUrl('https://accounts.google.com/')).toBe(true) + expect(isGoogleAuthUrl('https://accounts.google.com/v3/signin/identifier')).toBe(true) + expect(isGoogleAuthUrl('https://accounts.youtube.com/signin')).toBe(true) + expect(isGoogleAuthUrl('https://ACCOUNTS.GOOGLE.COM/')).toBe(true) + }) + + it('does not match post-auth app subdomains or lookalikes', () => { + expect(isGoogleAuthUrl('https://myaccount.google.com/')).toBe(false) + expect(isGoogleAuthUrl('https://mail.google.com/')).toBe(false) + expect(isGoogleAuthUrl('https://accounts.google.com.evil.test/')).toBe(false) + expect(isGoogleAuthUrl('https://google.com/')).toBe(false) + expect(isGoogleAuthUrl('not a url')).toBe(false) + }) +}) + +describe('googleAuthUserAgent', () => { + it('produces an internally consistent Firefox UA for the host platform', () => { + const ua = googleAuthUserAgent() + expect(ua).toMatch(/^Mozilla\/5\.0 \(.+; rv:\d+\.0\) Gecko\/20100101 Firefox\/\d+\.0$/) + expect(ua).not.toContain('Chrome') + expect(ua).not.toContain('Electron') + }) +}) + +describe('stripClientHints', () => { + it('removes every sec-ch-ua* header regardless of case, keeps others', () => { + const headers: Record = { + 'sec-ch-ua': 'a', + 'Sec-CH-UA-Platform': 'b', + 'sec-ch-ua-full-version-list': 'c', + 'User-Agent': 'ua', + Accept: 'text/html' + } + stripClientHints(headers) + expect(Object.keys(headers).some((k) => k.toLowerCase().startsWith('sec-ch-ua'))).toBe(false) + expect(headers['User-Agent']).toBe('ua') + expect(headers.Accept).toBe('text/html') + }) +}) + +describe('setUserAgentHeader', () => { + it('overwrites an existing user-agent header in place, preserving its casing', () => { + const headers: Record = { 'user-agent': 'old' } + setUserAgentHeader(headers, 'new') + expect(headers['user-agent']).toBe('new') + expect(Object.keys(headers)).toEqual(['user-agent']) + }) + + it('adds a canonical header when none exists', () => { + const headers: Record = {} + setUserAgentHeader(headers, 'new') + expect(headers['User-Agent']).toBe('new') + }) +}) diff --git a/src/main/browser/browser-google-auth-ua.ts b/src/main/browser/browser-google-auth-ua.ts new file mode 100644 index 00000000000..4392e6351c2 --- /dev/null +++ b/src/main/browser/browser-google-auth-ua.ts @@ -0,0 +1,51 @@ +// Why: Google binds a signed-in session to the browser identity that created it. +// Cookies copied in from another browser (or sent under an Electron/Chrome-shaped +// UA that doesn't match a real first-party browser) get flagged by anti-fraud on +// accounts.google.com and expire within ~1h. Presenting a Firefox identity scoped +// to Google's auth hosts lets the user sign in *inside* the embedded browser, so +// Google issues cookies bound to THIS browser that self-refresh — instead of us +// transplanting cookies that go stale. Scope is deliberately the auth hosts only: +// post-auth app surfaces (mail.google.com, myaccount.google.com, drive, etc.) keep +// the profile's real Chrome-shaped identity so nothing else about the session shifts. + +// Why: exact hostname match — subdomains such as myaccount.google.com are post-auth +// app surfaces, not the sign-in flow, and must retain the profile's real identity. +const GOOGLE_AUTH_HOSTS = new Set(['accounts.google.com', 'accounts.youtube.com']) + +export function isGoogleAuthUrl(rawUrl: string): boolean { + try { + return GOOGLE_AUTH_HOSTS.has(new URL(rawUrl).hostname.toLowerCase()) + } catch { + return false + } +} + +// Why: rv:/Gecko/Firefox tokens must line up with a real released build and the +// platform token must match the host OS, or the UA is internally inconsistent and +// itself a bot tell. +export function googleAuthUserAgent(): string { + const platform = + process.platform === 'darwin' + ? 'Macintosh; Intel Mac OS X 10.15' + : process.platform === 'win32' + ? 'Windows NT 10.0; Win64; x64' + : 'X11; Linux x86_64' + return `Mozilla/5.0 (${platform}; rv:140.0) Gecko/20100101 Firefox/140.0` +} + +// Why: real Firefox emits no sec-ch-ua* client hints; leaving Chromium's hints on a +// Firefox UA is a sharper mismatch than either signal alone. +export function stripClientHints(headers: Record): void { + for (const key of Object.keys(headers)) { + if (key.toLowerCase().startsWith('sec-ch-ua')) { + delete headers[key] + } + } +} + +// Why: the user-agent request header may already carry a base identity under a +// different case; overwrite the existing key rather than adding a duplicate. +export function setUserAgentHeader(headers: Record, value: string): void { + const existing = Object.keys(headers).find((key) => key.toLowerCase() === 'user-agent') + headers[existing ?? 'User-Agent'] = value +} diff --git a/src/main/browser/browser-manager.test.ts b/src/main/browser/browser-manager.test.ts index fcda3846045..2119c1fe11c 100644 --- a/src/main/browser/browser-manager.test.ts +++ b/src/main/browser/browser-manager.test.ts @@ -55,9 +55,18 @@ vi.mock('./popup-origin-bar-window', () => ({ })) import { browserManager } from './browser-manager' +import { googleAuthUserAgent } from './browser-google-auth-ua' +import { setBrowserSessionUserAgentMode } from './browser-session-user-agent-mode' describe('browserManager', () => { const rendererWebContentsId = 5001 + // Base (non-Firefox) UA a guest reports off the Google auth hosts. + const guestBaseUserAgent = 'Mozilla/5.0 (Test) Chrome/140.0.0.0' + const guestUaMethods = () => ({ + getUserAgent: vi.fn(() => guestBaseUserAgent), + setUserAgent: vi.fn(), + session: { getUserAgent: vi.fn(() => guestBaseUserAgent) } + }) type DownloadItemHandlerState = 'progressing' | 'interrupted' | 'completed' | 'cancelled' type DownloadItemHandler = (event: Electron.Event, state: DownloadItemHandlerState) => void @@ -1610,7 +1619,8 @@ describe('browserManager', () => { on: guestOnMock, off: guestOffMock, openDevTools: guestOpenDevToolsMock, - getURL: vi.fn(() => 'http://localhost:3000/') + getURL: vi.fn(() => 'http://localhost:3000/'), + ...guestUaMethods() } webContentsFromIdMock.mockImplementation((id: number) => { @@ -1686,7 +1696,8 @@ describe('browserManager', () => { on: guestOnMock, off: guestOffMock, openDevTools: guestOpenDevToolsMock, - getURL: vi.fn(() => 'https://example.com/') + getURL: vi.fn(() => 'https://example.com/'), + ...guestUaMethods() } webContentsFromIdMock.mockImplementation((id: number) => id === guest.id @@ -1738,7 +1749,8 @@ describe('browserManager', () => { off: guestOffMock, openDevTools: guestOpenDevToolsMock, send: vi.fn(), - getURL: vi.fn(() => 'chrome-error://chromewebdata/') + getURL: vi.fn(() => 'chrome-error://chromewebdata/'), + ...guestUaMethods() } webContentsFromIdMock.mockReturnValue(guest) @@ -1780,7 +1792,8 @@ describe('browserManager', () => { off: guestOffMock, openDevTools: guestOpenDevToolsMock, send: vi.fn(), - getURL: vi.fn(() => 'chrome-error://chromewebdata/') + getURL: vi.fn(() => 'chrome-error://chromewebdata/'), + ...guestUaMethods() } webContentsFromIdMock.mockReturnValue(guest) @@ -1819,6 +1832,172 @@ describe('browserManager', () => { expect(browserManager.getBrowserPageLoadError('browser-retry-abort-page')).toBeNull() }) + it('presents the Firefox UA on Google auth hosts and restores the base UA off them', () => { + let currentUa = guestBaseUserAgent + const setUserAgent = vi.fn((ua: string) => { + currentUa = ua + }) + const guest = { + id: 408, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + setBackgroundThrottling: guestSetBackgroundThrottlingMock, + setWindowOpenHandler: guestSetWindowOpenHandlerMock, + on: guestOnMock, + off: guestOffMock, + openDevTools: guestOpenDevToolsMock, + getURL: vi.fn(() => 'https://accounts.google.com/'), + getUserAgent: vi.fn(() => currentUa), + setUserAgent, + session: { getUserAgent: vi.fn(() => guestBaseUserAgent) } + } + webContentsFromIdMock.mockReturnValue(guest) + + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: 'browser-auth-ua', + webContentsId: guest.id, + rendererWebContentsId + }) + const didStartNavigation = guestOnMock.mock.calls.find( + ([event]) => event === 'did-start-navigation' + )?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void + setUserAgent.mockClear() + + didStartNavigation(null, 'https://accounts.google.com/v3/signin/identifier', false, true) + expect(setUserAgent).toHaveBeenLastCalledWith(googleAuthUserAgent()) + + // Off the auth host, the guest's base identity is restored. + didStartNavigation(null, 'https://myaccount.google.com/', false, true) + expect(setUserAgent).toHaveBeenLastCalledWith(guestBaseUserAgent) + + // A navigation that doesn't change the required UA must not thrash setUserAgent. + setUserAgent.mockClear() + didStartNavigation(null, 'https://example.com/', false, true) + expect(setUserAgent).not.toHaveBeenCalled() + }) + + it('leaves the UA untouched on Google auth hosts for native-UA profiles', () => { + const setUserAgent = vi.fn() + const guest = { + id: 409, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + setBackgroundThrottling: guestSetBackgroundThrottlingMock, + setWindowOpenHandler: guestSetWindowOpenHandlerMock, + on: guestOnMock, + off: guestOffMock, + openDevTools: guestOpenDevToolsMock, + getURL: vi.fn(() => 'https://accounts.google.com/'), + getUserAgent: vi.fn(() => guestBaseUserAgent), + setUserAgent, + session: { getUserAgent: vi.fn(() => guestBaseUserAgent) } + } + webContentsFromIdMock.mockReturnValue(guest) + + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: 'browser-native-ua', + webContentsId: guest.id, + rendererWebContentsId, + userAgentMode: 'native' + }) + const didStartNavigation = guestOnMock.mock.calls.find( + ([event]) => event === 'did-start-navigation' + )?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void + setUserAgent.mockClear() + + didStartNavigation(null, 'https://accounts.google.com/v3/signin/identifier', false, true) + expect(setUserAgent).not.toHaveBeenCalled() + }) + + it('honors native session mode before the guest registration IPC arrives', () => { + const nativeSession = { getUserAgent: vi.fn(() => guestBaseUserAgent) } + setBrowserSessionUserAgentMode(nativeSession as never, 'native') + const setUserAgent = vi.fn() + const guest = { + id: 417, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + setBackgroundThrottling: guestSetBackgroundThrottlingMock, + setWindowOpenHandler: guestSetWindowOpenHandlerMock, + on: guestOnMock, + off: guestOffMock, + openDevTools: guestOpenDevToolsMock, + getURL: vi.fn(() => 'https://accounts.google.com/'), + getUserAgent: vi.fn(() => guestBaseUserAgent), + setUserAgent, + session: nativeSession + } + + browserManager.attachGuestPolicies(guest as never) + const didStartNavigation = guestOnMock.mock.calls.find( + ([event]) => event === 'did-start-navigation' + )?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void + + didStartNavigation(null, 'https://accounts.google.com/v3/signin/identifier', false, true) + expect(setUserAgent).not.toHaveBeenCalled() + }) + + // Why: popup child windows get attachGuestPolicies but are never entered into tabIdByWebContentsId, + // so a direct lookup of the UA mode misses the native opt-out. That is worse than doing nothing — + // native sessions skip setupClientHintsOverride, so the popup would send the raw Electron UA on the + // wire while navigator.userAgent claimed Firefox. Google sign-in popups are a first-class surface. + it('leaves the UA untouched on auth hosts for a popup owned by a native-UA profile', () => { + const ownerGuest = { + id: 415, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + setBackgroundThrottling: guestSetBackgroundThrottlingMock, + setWindowOpenHandler: guestSetWindowOpenHandlerMock, + on: guestOnMock, + off: guestOffMock, + openDevTools: guestOpenDevToolsMock, + getURL: vi.fn(() => 'https://accounts.google.com/'), + getUserAgent: vi.fn(() => guestBaseUserAgent), + setUserAgent: vi.fn(), + session: { getUserAgent: vi.fn(() => guestBaseUserAgent) } + } + webContentsFromIdMock.mockReturnValue(ownerGuest) + browserManager.attachGuestPolicies(ownerGuest as never) + browserManager.registerGuest({ + browserPageId: 'browser-native-popup-owner', + webContentsId: ownerGuest.id, + rendererWebContentsId, + userAgentMode: 'native' + }) + + // The popup carries its own listeners so its handler is unambiguous. + const popupOn = vi.fn() + const popupSetUserAgent = vi.fn() + const popupGuest = { + id: 416, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'window'), + setBackgroundThrottling: guestSetBackgroundThrottlingMock, + setWindowOpenHandler: guestSetWindowOpenHandlerMock, + on: popupOn, + off: guestOffMock, + openDevTools: guestOpenDevToolsMock, + getURL: vi.fn(() => 'https://accounts.google.com/'), + getUserAgent: vi.fn(() => guestBaseUserAgent), + setUserAgent: popupSetUserAgent, + session: { getUserAgent: vi.fn(() => guestBaseUserAgent) } + } + browserManager.attachGuestPolicies(popupGuest as never, { + browserTabId: 'browser-native-popup-owner', + rootGuestWebContentsId: ownerGuest.id + }) + + const popupDidStartNavigation = popupOn.mock.calls.find( + ([event]) => event === 'did-start-navigation' + )?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void + expect(popupDidStartNavigation).toBeDefined() + + popupDidStartNavigation(null, 'https://accounts.google.com/v3/signin/identifier', false, true) + expect(popupSetUserAgent).not.toHaveBeenCalled() + }) + it('queues permission denials and download requests until the guest registers', () => { const rendererSendMock = vi.fn() const guest = { @@ -2806,23 +2985,46 @@ describe('browserManager', () => { }) describe('setViewportOverride', () => { - function makeGuest(id: number): { + const GUEST_ELECTRON_UA = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) orca/1.0.0 Chrome/134.0.0.0 Electron/30.0.0 Safari/537.36' + const GUEST_CLEAN_UA = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36' + + // Why: viewport UA writes are queued on the per-tab chain, so draining it takes more than one + // microtask hop; loop until the chain is empty rather than guessing a tick count. + async function flushViewportOps(): Promise { + for (let i = 0; i < 20; i++) { + await Promise.resolve() + } + } + + function makeGuest( + id: number, + url = 'https://example.com/' + ): { guest: Record debuggerSendCommand: ReturnType debuggerIsAttached: ReturnType debuggerAttach: ReturnType + setGuestUserAgent: (ua: string) => void + commitNavigationTo: (nextUrl: string) => void } { const debuggerSendCommand = vi.fn().mockResolvedValue(undefined) const debuggerIsAttached = vi.fn(() => true) const debuggerAttach = vi.fn() + let currentUa = GUEST_ELECTRON_UA + // Why: getURL() reports the last COMMITTED url — it does not move at did-start-navigation. + let committedUrl = url const guest = { id, isDestroyed: vi.fn(() => false), getType: vi.fn(() => 'webview'), - getUserAgent: vi.fn( - () => - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) orca/1.0.0 Chrome/134.0.0.0 Electron/30.0.0 Safari/537.36' - ), + getURL: vi.fn(() => committedUrl), + getUserAgent: vi.fn(() => currentUa), + setUserAgent: vi.fn((ua: string) => { + currentUa = ua + }), + session: { getUserAgent: vi.fn(() => GUEST_ELECTRON_UA) }, setBackgroundThrottling: guestSetBackgroundThrottlingMock, setWindowOpenHandler: guestSetWindowOpenHandlerMock, on: guestOnMock, @@ -2835,7 +3037,18 @@ describe('browserManager', () => { sendCommand: debuggerSendCommand } } - return { guest, debuggerSendCommand, debuggerIsAttached, debuggerAttach } + return { + guest, + debuggerSendCommand, + debuggerIsAttached, + debuggerAttach, + setGuestUserAgent: (ua: string) => { + currentUa = ua + }, + commitNavigationTo: (nextUrl: string) => { + committedUrl = nextUrl + } + } } it('returns false when the tab is not registered', async () => { @@ -2921,6 +3134,585 @@ describe('browserManager', () => { } ) + // Why: the CDP override outranks setUserAgent for navigator.userAgent, so a preset applied on an + // auth host must carry the same Firefox identity the header hook sends (verified against real + // Electron 43: Emulation.setUserAgentOverride wins over WebContents.setUserAgent and stands + // across every later navigation until explicitly cleared). + it.each([false, true])( + 'presents the Firefox UA for a preset applied on a Google auth host (mobile=%s)', + async (mobile) => { + const { guest, debuggerSendCommand } = makeGuest( + mobile ? 4246 : 4245, + 'https://accounts.google.com/v3/signin/identifier' + ) + webContentsFromIdMock.mockReturnValue(guest) + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: `tab-auth-${mobile}`, + webContentsId: guest.id as number, + rendererWebContentsId + }) + + await browserManager.setViewportOverride(`tab-auth-${mobile}`, { + width: mobile ? 375 : 1024, + height: mobile ? 667 : 768, + deviceScaleFactor: mobile ? 2 : 1, + mobile + }) + + expect(debuggerSendCommand).toHaveBeenCalledWith('Emulation.setUserAgentOverride', { + userAgent: googleAuthUserAgent() + }) + } + ) + + it('re-issues the standing UA override when navigating onto and back off an auth host', async () => { + const { guest, debuggerSendCommand } = makeGuest(4247) + webContentsFromIdMock.mockReturnValue(guest) + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: 'tab-auth-nav', + webContentsId: guest.id as number, + rendererWebContentsId + }) + const didStartNavigation = guestOnMock.mock.calls.find( + ([event]) => event === 'did-start-navigation' + )?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void + + // Case A: the desktop preset lands first, while the tab is still off the auth host. + await browserManager.setViewportOverride('tab-auth-nav', { + width: 1024, + height: 768, + deviceScaleFactor: 1, + mobile: false + }) + expect(debuggerSendCommand).toHaveBeenLastCalledWith('Emulation.setUserAgentOverride', { + userAgent: GUEST_CLEAN_UA + }) + + // Navigating to the auth host must move the standing override to the Firefox identity. + debuggerSendCommand.mockClear() + didStartNavigation(null, 'https://accounts.google.com/v3/signin/identifier', false, true) + await flushViewportOps() + expect(debuggerSendCommand).toHaveBeenCalledWith('Emulation.setUserAgentOverride', { + userAgent: googleAuthUserAgent() + }) + + // Leaving the auth host restores the clean Chrome-shaped preset UA. + debuggerSendCommand.mockClear() + didStartNavigation(null, 'https://example.com/', false, true) + await flushViewportOps() + expect(debuggerSendCommand).toHaveBeenCalledWith('Emulation.setUserAgentOverride', { + userAgent: GUEST_CLEAN_UA + }) + }) + + // Why: not an ordering race — debugger.sendCommand dispatches in call order over one channel, so + // the later-issued write always wins. The defect is post-await staleness: verified against real + // Electron 43.1.0, did-start-navigation fires while an awaited sendCommand is still pending, and + // getURL() keeps reporting the OUTGOING page until commit. A preset resuming mid-navigation + // therefore resolves the wrong host and wins with the wrong value. Both writers must resolve the + // host from the in-flight navigation target instead. + function lastUserAgentOverride( + debuggerSendCommand: ReturnType + ): Record | undefined { + const calls = debuggerSendCommand.mock.calls.filter( + (call) => call[0] === 'Emulation.setUserAgentOverride' + ) + return calls.at(-1)?.[1] as Record | undefined + } + + // Why mobile: on the desktop branch the break is masked by coincidence — applyGoogleAuthUserAgent + // has already switched the WebContents UA to Firefox, and cleanElectronUserAgent passes a Firefox + // UA through untouched, so the stale-URL desktop path happens to emit Firefox anyway. The mobile + // branch derives a Chrome-shaped iPhone UA from that same base and exposes the real defect. + it('does not leave the Chrome preset UA standing when a mobile preset lands mid-navigation onto an auth host', async () => { + const { guest, debuggerSendCommand } = makeGuest(4251, 'https://example.com/') + // Hold the preset's first CDP command open so the navigation lands inside its await window. + let releaseMetrics = (): void => {} + const metricsGate = new Promise((resolve) => { + releaseMetrics = () => resolve() + }) + debuggerSendCommand.mockImplementation((method: string) => + method === 'Emulation.setDeviceMetricsOverride' ? metricsGate : Promise.resolve(undefined) + ) + webContentsFromIdMock.mockReturnValue(guest) + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: 'tab-race-onto-auth', + webContentsId: guest.id as number, + rendererWebContentsId + }) + const didStartNavigation = guestOnMock.mock.calls.find( + ([event]) => event === 'did-start-navigation' + )?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void + + const presetDone = browserManager.setViewportOverride('tab-race-onto-auth', { + width: 375, + height: 667, + deviceScaleFactor: 2, + mobile: true + }) + await flushViewportOps() + + // The tab navigates to the auth host while the preset is still awaiting its first command. + didStartNavigation(null, 'https://accounts.google.com/v3/signin/identifier', false, true) + releaseMetrics() + await presetDone + await flushViewportOps() + + // Without a shared chain + shared URL source, the preset resumes, reads getURL() as the + // pre-navigation host, and pins the tab to the Chrome-shaped UA while it is on the auth host. + expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ + userAgent: googleAuthUserAgent() + }) + }) + + it('does not leave the Firefox UA standing when a preset lands mid-navigation off an auth host', async () => { + const { guest, debuggerSendCommand } = makeGuest(4252, 'https://accounts.google.com/') + webContentsFromIdMock.mockReturnValue(guest) + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: 'tab-race-off-auth', + webContentsId: guest.id as number, + rendererWebContentsId + }) + const didStartNavigation = guestOnMock.mock.calls.find( + ([event]) => event === 'did-start-navigation' + )?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void + + // Establish a standing preset while the tab really is on the auth host. + await browserManager.setViewportOverride('tab-race-off-auth', { + width: 1024, + height: 768, + deviceScaleFactor: 1, + mobile: false + }) + await flushViewportOps() + expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ + userAgent: googleAuthUserAgent() + }) + + // A second preset change is now in flight when the tab leaves the auth host. + let releaseMetrics = (): void => {} + const metricsGate = new Promise((resolve) => { + releaseMetrics = () => resolve() + }) + debuggerSendCommand.mockImplementation((method: string) => + method === 'Emulation.setDeviceMetricsOverride' ? metricsGate : Promise.resolve(undefined) + ) + const presetDone = browserManager.setViewportOverride('tab-race-off-auth', { + width: 1440, + height: 900, + deviceScaleFactor: 2, + mobile: false + }) + await flushViewportOps() + + didStartNavigation(null, 'https://example.com/', false, true) + releaseMetrics() + await presetDone + await flushViewportOps() + + // Without the fix the resuming preset re-reads getURL() as the auth host and clobbers the + // navigation's correct write, stranding the Firefox UA on a non-auth page. + expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ userAgent: GUEST_CLEAN_UA }) + }) + + it('falls back to the committed URL once a navigation commits or fails', async () => { + const { guest, debuggerSendCommand, commitNavigationTo } = makeGuest( + 4253, + 'https://example.com/' + ) + webContentsFromIdMock.mockReturnValue(guest) + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: 'tab-pending-cleared', + webContentsId: guest.id as number, + rendererWebContentsId + }) + const didStartNavigation = guestOnMock.mock.calls.find( + ([event]) => event === 'did-start-navigation' + )?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void + const didFailLoad = guestOnMock.mock.calls.find( + ([event]) => event === 'did-fail-load' + )?.[1] as ( + event: unknown, + errorCode: number, + errorDescription: string, + validatedURL: string, + isMainFrame: boolean + ) => void + const didNavigate = guestOnMock.mock.calls.find( + ([event]) => event === 'did-navigate' + )?.[1] as (event: unknown, url: string) => void + + await browserManager.setViewportOverride('tab-pending-cleared', { + width: 1024, + height: 768, + deviceScaleFactor: 1, + mobile: false + }) + await flushViewportOps() + + // An aborted navigation to the auth host must not leave that host standing as the tab's + // identity: the tab never went there, so a later preset must resolve the committed URL. + didStartNavigation(null, 'https://accounts.google.com/', false, true) + didFailLoad(null, -3, 'Aborted', 'https://accounts.google.com/', true) + await flushViewportOps() + + expect(guest.setUserAgent).toHaveBeenLastCalledWith(GUEST_ELECTRON_UA) + expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ userAgent: GUEST_CLEAN_UA }) + + // A later preset must also resolve the committed, non-auth URL. + debuggerSendCommand.mockClear() + await browserManager.setViewportOverride('tab-pending-cleared', { + width: 375, + height: 667, + deviceScaleFactor: 2, + mobile: true + }) + await flushViewportOps() + expect(lastUserAgentOverride(debuggerSendCommand)?.userAgent).toContain('iPhone') + + // Same after a successful commit: the committed URL takes over from the pending target. + didStartNavigation(null, 'https://accounts.google.com/signin', false, true) + commitNavigationTo('https://accounts.google.com/signin') + didNavigate(null, 'https://accounts.google.com/signin') + await flushViewportOps() + + debuggerSendCommand.mockClear() + await browserManager.setViewportOverride('tab-pending-cleared', { + width: 1024, + height: 768, + deviceScaleFactor: 1, + mobile: false + }) + await flushViewportOps() + expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ + userAgent: googleAuthUserAgent() + }) + }) + + it('does not let a superseded navigation failure revert a newer target', async () => { + const { guest, debuggerSendCommand } = makeGuest(4255, 'https://example.com/') + webContentsFromIdMock.mockReturnValue(guest) + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: 'tab-overlapping-navs', + webContentsId: guest.id as number, + rendererWebContentsId + }) + const didStartNavigation = guestOnMock.mock.calls.find( + ([event]) => event === 'did-start-navigation' + )?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void + const willRedirect = guestOnMock.mock.calls.find( + ([event]) => event === 'will-redirect' + )?.[1] as ( + event: { preventDefault: () => void }, + url: string, + isInPlace: boolean, + isMainFrame: boolean + ) => void + const didFailLoad = guestOnMock.mock.calls.find( + ([event]) => event === 'did-fail-load' + )?.[1] as ( + event: unknown, + errorCode: number, + errorDescription: string, + validatedURL: string, + isMainFrame: boolean + ) => void + + await browserManager.setViewportOverride('tab-overlapping-navs', { + width: 1024, + height: 768, + deviceScaleFactor: 1, + mobile: false + }) + didStartNavigation(null, 'https://example.com/start', false, true) + willRedirect({ preventDefault: vi.fn() }, 'https://accounts.google.com/same', false, true) + didStartNavigation(null, 'https://accounts.google.com/same', false, true) + await flushViewportOps() + + debuggerSendCommand.mockClear() + didFailLoad(null, -3, 'Aborted', 'https://accounts.google.com/same', true) + await flushViewportOps() + + expect(guest.setUserAgent).toHaveBeenLastCalledWith(googleAuthUserAgent()) + expect(debuggerSendCommand).not.toHaveBeenCalledWith( + 'Emulation.setUserAgentOverride', + expect.objectContaining({ userAgent: GUEST_CLEAN_UA }) + ) + }) + + it('switches identity for a server redirect and restores it if the redirect fails', async () => { + const { guest, debuggerSendCommand } = makeGuest(4256, 'https://example.com/') + webContentsFromIdMock.mockReturnValue(guest) + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: 'tab-auth-redirect', + webContentsId: guest.id as number, + rendererWebContentsId + }) + const didStartNavigation = guestOnMock.mock.calls.find( + ([event]) => event === 'did-start-navigation' + )?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void + const willRedirect = guestOnMock.mock.calls.find( + ([event]) => event === 'will-redirect' + )?.[1] as ( + event: { preventDefault: () => void }, + url: string, + isInPlace: boolean, + isMainFrame: boolean + ) => void + const didFailLoad = guestOnMock.mock.calls.find( + ([event]) => event === 'did-fail-load' + )?.[1] as ( + event: unknown, + errorCode: number, + errorDescription: string, + validatedURL: string, + isMainFrame: boolean + ) => void + + await browserManager.setViewportOverride('tab-auth-redirect', { + width: 1024, + height: 768, + deviceScaleFactor: 1, + mobile: false + }) + didStartNavigation(null, 'https://example.com/start', false, true) + willRedirect( + { preventDefault: vi.fn() }, + 'https://accounts.google.com/redirected', + false, + true + ) + await flushViewportOps() + + expect(guest.setUserAgent).toHaveBeenLastCalledWith(googleAuthUserAgent()) + expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ + userAgent: googleAuthUserAgent() + }) + + didFailLoad(null, -3, 'Aborted', 'https://accounts.google.com/redirected', true) + await flushViewportOps() + expect(guest.setUserAgent).toHaveBeenLastCalledWith(GUEST_ELECTRON_UA) + expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ userAgent: GUEST_CLEAN_UA }) + }) + + it('reapplies a preset when navigation starts during its final UA write', async () => { + const { guest, debuggerSendCommand } = makeGuest(4257, 'https://example.com/') + let releaseFirstUa = (): void => {} + const firstUaGate = new Promise((resolve) => { + releaseFirstUa = resolve + }) + let uaWrites = 0 + debuggerSendCommand.mockImplementation((method: string) => { + if (method === 'Emulation.setUserAgentOverride' && uaWrites++ === 0) { + return firstUaGate + } + return Promise.resolve(undefined) + }) + webContentsFromIdMock.mockReturnValue(guest) + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: 'tab-final-apply-race', + webContentsId: guest.id as number, + rendererWebContentsId + }) + const didStartNavigation = guestOnMock.mock.calls.find( + ([event]) => event === 'did-start-navigation' + )?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void + + const presetDone = browserManager.setViewportOverride('tab-final-apply-race', { + width: 1024, + height: 768, + deviceScaleFactor: 1, + mobile: false + }) + await flushViewportOps() + didStartNavigation(null, 'https://accounts.google.com/', false, true) + await flushViewportOps() + + expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ + userAgent: googleAuthUserAgent() + }) + releaseFirstUa() + await presetDone + }) + + it('does not reinstall a preset while its final UA clear is in flight', async () => { + const { guest, debuggerSendCommand } = makeGuest(4258, 'https://example.com/') + webContentsFromIdMock.mockReturnValue(guest) + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: 'tab-final-clear-race', + webContentsId: guest.id as number, + rendererWebContentsId + }) + const didStartNavigation = guestOnMock.mock.calls.find( + ([event]) => event === 'did-start-navigation' + )?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void + + await browserManager.setViewportOverride('tab-final-clear-race', { + width: 1024, + height: 768, + deviceScaleFactor: 1, + mobile: false + }) + let releaseClearUa = (): void => {} + const clearUaGate = new Promise((resolve) => { + releaseClearUa = resolve + }) + debuggerSendCommand.mockImplementation( + (method: string, params: { userAgent?: string } | undefined) => + method === 'Emulation.setUserAgentOverride' && params?.userAgent === '' + ? clearUaGate + : Promise.resolve(undefined) + ) + const clearDone = browserManager.setViewportOverride('tab-final-clear-race', null) + await flushViewportOps() + + debuggerSendCommand.mockClear() + didStartNavigation(null, 'https://accounts.google.com/', false, true) + await flushViewportOps() + expect(debuggerSendCommand).not.toHaveBeenCalledWith('Emulation.setUserAgentOverride', { + userAgent: googleAuthUserAgent() + }) + + releaseClearUa() + await clearDone + }) + + // Why: a failed clear leaves the CDP override standing on the target. Dropping the tracking entry + // first makes it untracked, so the navigation path can never correct it again and the tab carries + // a Chrome-shaped navigator.userAgent onto the auth hosts — the exact failure this PR prevents. + it('keeps tracking the standing override when the CDP clear fails', async () => { + const { guest, debuggerSendCommand } = makeGuest(4254, 'https://example.com/') + webContentsFromIdMock.mockReturnValue(guest) + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: 'tab-failed-clear', + webContentsId: guest.id as number, + rendererWebContentsId + }) + const didStartNavigation = guestOnMock.mock.calls.find( + ([event]) => event === 'did-start-navigation' + )?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void + + await browserManager.setViewportOverride('tab-failed-clear', { + width: 1024, + height: 768, + deviceScaleFactor: 1, + mobile: false + }) + await flushViewportOps() + + // The final UA clear fails after tracking was optimistically removed. + debuggerSendCommand.mockImplementation( + (method: string, params: { userAgent?: string } | undefined) => + method === 'Emulation.setUserAgentOverride' && params?.userAgent === '' + ? Promise.reject(new Error('debugger detached')) + : Promise.resolve(undefined) + ) + await expect(browserManager.setViewportOverride('tab-failed-clear', null)).resolves.toBe( + false + ) + await flushViewportOps() + + // The override is still standing on the target, so navigation must still be able to correct it. + debuggerSendCommand.mockImplementation(() => Promise.resolve(undefined)) + debuggerSendCommand.mockClear() + didStartNavigation(null, 'https://accounts.google.com/v3/signin/identifier', false, true) + await flushViewportOps() + expect(debuggerSendCommand).toHaveBeenCalledWith('Emulation.setUserAgentOverride', { + userAgent: googleAuthUserAgent() + }) + }) + + it('does not touch the UA override on navigation when no preset is standing', async () => { + const { guest, debuggerSendCommand } = makeGuest(4248) + webContentsFromIdMock.mockReturnValue(guest) + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: 'tab-no-preset', + webContentsId: guest.id as number, + rendererWebContentsId + }) + const didStartNavigation = guestOnMock.mock.calls.find( + ([event]) => event === 'did-start-navigation' + )?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void + + didStartNavigation(null, 'https://accounts.google.com/', false, true) + await flushViewportOps() + expect(debuggerSendCommand).not.toHaveBeenCalledWith( + 'Emulation.setUserAgentOverride', + expect.anything() + ) + }) + + it('stops re-issuing the UA override once the preset is cleared', async () => { + const { guest, debuggerSendCommand } = makeGuest(4249) + webContentsFromIdMock.mockReturnValue(guest) + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: 'tab-cleared-preset', + webContentsId: guest.id as number, + rendererWebContentsId + }) + const didStartNavigation = guestOnMock.mock.calls.find( + ([event]) => event === 'did-start-navigation' + )?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void + + await browserManager.setViewportOverride('tab-cleared-preset', { + width: 1024, + height: 768, + deviceScaleFactor: 1, + mobile: false + }) + await browserManager.setViewportOverride('tab-cleared-preset', null) + + debuggerSendCommand.mockClear() + didStartNavigation(null, 'https://accounts.google.com/', false, true) + await flushViewportOps() + expect(debuggerSendCommand).not.toHaveBeenCalledWith( + 'Emulation.setUserAgentOverride', + expect.anything() + ) + }) + + it('leaves the UA override alone on navigation for native-UA profiles', async () => { + const { guest, debuggerSendCommand } = makeGuest(4250) + webContentsFromIdMock.mockReturnValue(guest) + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: 'tab-native-nav', + sessionProfileId: 'native-profile', + userAgentMode: 'native', + webContentsId: guest.id as number, + rendererWebContentsId + }) + const didStartNavigation = guestOnMock.mock.calls.find( + ([event]) => event === 'did-start-navigation' + )?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void + + await browserManager.setViewportOverride('tab-native-nav', { + width: 1024, + height: 768, + deviceScaleFactor: 1, + mobile: false + }) + debuggerSendCommand.mockClear() + didStartNavigation(null, 'https://accounts.google.com/', false, true) + await flushViewportOps() + expect(debuggerSendCommand).not.toHaveBeenCalledWith( + 'Emulation.setUserAgentOverride', + expect.anything() + ) + }) + it('clears device metrics and disables touch for override=null', async () => { const { guest, debuggerSendCommand } = makeGuest(4343) webContentsFromIdMock.mockReturnValue(guest) diff --git a/src/main/browser/browser-manager.ts b/src/main/browser/browser-manager.ts index d4547abada8..fbcac378412 100644 --- a/src/main/browser/browser-manager.ts +++ b/src/main/browser/browser-manager.ts @@ -43,6 +43,9 @@ import { buildBrowserIframeClickedLinkRoutingScript } from './browser-clicked-link-routing' import { cleanElectronUserAgent } from './browser-session-ua' +import { getBrowserSessionUserAgentMode } from './browser-session-user-agent-mode' +import { googleAuthUserAgent, isGoogleAuthUrl } from './browser-google-auth-ua' +import { buildViewportUserAgentOverride } from './browser-viewport-user-agent' import type { BrowserViewportOverride, BrowserCertificateFailure, @@ -130,16 +133,6 @@ function isAutomationVisibilityToken(token: unknown): token is string { return typeof token === 'string' && token.length > 0 } -// Why: responsive sites UA-sniff; this is Chrome DevTools' default iPhone UA template with the real Chrome major spliced in to keep sec-ch-ua consistent (see setupClientHintsOverride). -function buildMobileUserAgent(chromeMajor: string): string { - return `Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/${chromeMajor}.0.0.0 Mobile/15E148 Safari/604.1` -} - -function extractChromeMajor(ua: string): string { - const match = ua.match(/Chrome\/(\d+)/) - return match ? match[1] : '134' -} - export type BrowserGuestRegistration = { browserPageId?: string browserTabId?: string @@ -158,6 +151,10 @@ type PopupOwnerContext = { browserTabId: string rootGuestWebContentsId: number } +type PendingMainFrameNavigation = { + currentUrl: string + supersededUrls: string[] +} const SAFE_POPUP_WINDOW_OPTIONS = { alwaysOnTop: false, closable: true, @@ -231,6 +228,12 @@ export class BrowserManager { private readonly rendererWebContentsIdByTabId = new Map() // Why: serialize per-tab setViewportOverride so rapid toggles don't interleave CDP commands and leave emulation in a wrong state. private readonly viewportOpsByTabId = new Map>() + // Why: presence means the preset requires a CDP UA override (installed or in flight), so navigation + // can re-issue it against the target URL's identity. + private readonly viewportUaOverrideMobileByTabId = new Map() + // Why: the in-flight main-frame navigation target, held only until commit or failure — getURL() + // still reports the outgoing page until then. See resolveTabNavigationUrl. + private readonly pendingNavigationByGuestId = new Map() private readonly contextMenuCleanupByTabId = new Map void>() private readonly grabShortcutCleanupByTabId = new Map void>() private readonly shortcutForwardingCleanupByTabId = new Map void>() @@ -781,20 +784,35 @@ export class BrowserManager { return { action: 'deny' } }) - const navigationGuard = (event: Electron.Event, url: string): void => { + const navigationGuard = (event: Electron.Event, url: string): boolean => { // Why: Turnstile loads challenge resources via blob:; blocking them trips error 600010. Allow only http(s) blobs, not opaque ones. if (url.startsWith('blob:https://') || url.startsWith('blob:http://')) { - return + return true } // Why: initial file:// attach is allowed for user-opened previews, but block later file:// redirects so remote pages can't probe the FS. if (url.startsWith('file:')) { event.preventDefault() - return + return false } if (!normalizeBrowserNavigationUrl(url)) { // Why: will-attach-webview only validates the initial src; keep enforcing the allowlist on later navs. event.preventDefault() + return false } + return true + } + + const willRedirectHandler = ( + event: Electron.Event, + url: string, + _isInPlace: boolean, + isMainFrame: boolean + ): void => { + if (!navigationGuard(event, url) || !isMainFrame || isChromiumInternalErrorUrl(url)) { + return + } + this.updatePendingNavigationForRedirect(guest.id, url) + this.applyGoogleAuthUserAgent(guest, url) } const didFailLoadHandler = ( @@ -807,6 +825,12 @@ export class BrowserManager { if (!isMainFrame) { return } + // Why: a nav that never committed must not leave its target standing as the tab's host. + const failedNavigationWasCurrent = this.failPendingNavigation(guest.id, validatedURL) + if (failedNavigationWasCurrent) { + // The attempted host never committed, so restore every UA layer to the document that remains. + this.applyGoogleAuthUserAgent(guest, guest.getURL()) + } const browserPageId = this.tabIdByWebContentsId.get(guest.id) const certificateFailure = browserPageId ? this.certificateTrustController?.getFailure(browserPageId) @@ -850,6 +874,10 @@ export class BrowserManager { if (!isMainFrame || isChromiumInternalErrorUrl(url)) { return } + // Why: getURL() still reports the previous committed URL until this navigation commits, so + // every UA writer must read the in-flight target or they disagree about the tab's host. + this.startPendingNavigation(guest.id, url) + this.applyGoogleAuthUserAgent(guest, url) this.certificateTrustController?.onMainFrameNavigationStarted(guest.id) // Why: a pre-registration failure belongs only to its own nav; a replacement nav must not replay it. this.pendingLoadFailuresByGuestId.delete(guest.id) @@ -865,13 +893,15 @@ export class BrowserManager { } const didNavigateHandler = (_event: Electron.Event, url: string): void => { + // Why: once committed, getURL() reports this url, so the pending target is redundant. + this.pendingNavigationByGuestId.delete(guest.id) // Why: a committed nav makes the did-start-navigation stash obsolete; drop it so a later ERR_ABORTED can't restore an error over it. this.clearedLoadErrorsByGuestId.delete(guest.id) this.certificateTrustController?.onMainFrameNavigationCommitted(guest.id, url) } guest.on('will-navigate', navigationGuard) - guest.on('will-redirect', navigationGuard) + guest.on('will-redirect', willRedirectHandler) guest.on('did-start-navigation', didStartNavigationHandler) guest.on('did-navigate', didNavigateHandler) guest.on('did-fail-load', didFailLoadHandler) @@ -905,7 +935,7 @@ export class BrowserManager { } if (!guest.isDestroyed()) { guest.off('will-navigate', navigationGuard) - guest.off('will-redirect', navigationGuard) + guest.off('will-redirect', willRedirectHandler) guest.off('did-start-navigation', didStartNavigationHandler) guest.off('did-navigate', didNavigateHandler) guest.off('did-fail-load', didFailLoadHandler) @@ -913,6 +943,124 @@ export class BrowserManager { }) } + // Why: navigator.userAgent (read by Google's auth JS) reflects the WebContents UA, + // not the request header, so the header-level Firefox switch in setupClientHintsOverride + // must be matched here per navigation or the two layers disagree — itself a bot tell. + // Restores the session's base identity off the auth hosts. Native-UA profiles opt out + // of the whole clean-UA path, so they keep their untouched identity everywhere. + private applyGoogleAuthUserAgent(guest: Electron.WebContents, url: string): void { + const browserPageId = this.tabIdByWebContentsId.get(guest.id) + // Why: popup child windows get these policies but are never in tabIdByWebContentsId, so a direct + // lookup misses the native-UA opt-out and would hand a native profile's popup the Firefox UA. + // That is worse than doing nothing: native sessions skip setupClientHintsOverride entirely, so + // the popup would send the raw Electron UA on the wire while navigator.userAgent claims Firefox. + const ownerTabId = this.resolveBrowserTabIdForGuestWebContentsId(guest.id) + // Session state is authoritative before renderer registration and after a native profile imports a source UA. + const mode = + getBrowserSessionUserAgentMode(guest.session) ?? + (ownerTabId ? this.userAgentModeByPageId.get(ownerTabId) : undefined) + if (mode === 'native') { + return + } + const firefoxUa = googleAuthUserAgent() + const currentUa = guest.getUserAgent() + if (isGoogleAuthUrl(url)) { + if (currentUa !== firefoxUa) { + guest.setUserAgent(firefoxUa) + } + } else if (currentUa === firefoxUa) { + // Only restore when the auth-host override is actually in place, so normal + // navigation never touches the session UA. + guest.setUserAgent(guest.session.getUserAgent()) + } + // Why: gate on the DIRECT page id, not ownerTabId — a popup has no device-metrics override of + // its own, so inheriting the owner tab's preset UA would pair a mobile UA with a desktop viewport. + if (browserPageId) { + this.reapplyViewportUserAgentOverride(guest, browserPageId, url) + } + } + + private startPendingNavigation(guestId: number, url: string): void { + const pending = this.pendingNavigationByGuestId.get(guestId) + this.pendingNavigationByGuestId.set(guestId, { + currentUrl: url, + supersededUrls: pending ? [...pending.supersededUrls, pending.currentUrl] : [] + }) + } + + private updatePendingNavigationForRedirect(guestId: number, url: string): void { + const pending = this.pendingNavigationByGuestId.get(guestId) + if (!pending) { + this.pendingNavigationByGuestId.set(guestId, { + currentUrl: url, + supersededUrls: [] + }) + return + } + pending.currentUrl = url + } + + private failPendingNavigation(guestId: number, failedUrl: string): boolean { + const pending = this.pendingNavigationByGuestId.get(guestId) + if (!pending) { + return false + } + const supersededIndex = pending.supersededUrls.indexOf(failedUrl) + if (supersededIndex !== -1) { + pending.supersededUrls.splice(supersededIndex, 1) + return false + } + if (pending.currentUrl !== failedUrl) { + return false + } + this.pendingNavigationByGuestId.delete(guestId) + return true + } + + // Why: webContents.getURL() reports the last COMMITTED url, so mid-navigation it names the host + // the tab is leaving, not the one it is entering. Every UA writer must resolve the host through + // here or two writers racing the same navigation will pick opposite identities. + private resolveTabNavigationUrl(guest: Electron.WebContents): string { + return this.pendingNavigationByGuestId.get(guest.id)?.currentUrl ?? guest.getURL() + } + + // Why: Emulation.setUserAgentOverride is set once and stands across every later navigation, + // outranking setUserAgent for navigator.userAgent. A viewport preset applied before reaching an + // auth host would otherwise pin navigator.userAgent to the Chrome-shaped preset UA while the + // request header says Firefox — the two-layer disagreement this scope exists to remove. + private reapplyViewportUserAgentOverride( + guest: Electron.WebContents, + browserTabId: string, + url: string + ): void { + const mobile = this.viewportUaOverrideMobileByTabId.get(browserTabId) + if (mobile === undefined) { + return + } + // Why: no queue needed — debugger.sendCommand dispatches in call order over one channel, so the + // later-issued write wins. What matters is that both writers resolve the SAME host, which they + // now do via the navigation target rather than the stale committed URL. + void this.sendViewportUserAgentOverride(guest, mobile, url).catch(() => {}) + } + + private async sendViewportUserAgentOverride( + guest: Electron.WebContents, + mobile: boolean, + url?: string + ): Promise { + if (guest.isDestroyed() || !guest.debugger.isAttached()) { + return + } + await guest.debugger.sendCommand( + 'Emulation.setUserAgentOverride', + buildViewportUserAgentOverride({ + url: url ?? this.resolveTabNavigationUrl(guest), + mobile, + baseUserAgent: cleanElectronUserAgent(guest.getUserAgent()) + }) + ) + } + private createPopupChildWindowWithOriginBar( openerGuest: Electron.WebContents, targetUrl: string, @@ -961,6 +1109,7 @@ export class BrowserManager { this.clickedLinkFrameNameByGuestId.delete(guestWebContentsId) this.offscreenGuestIds.delete(guestWebContentsId) this.popupOwnerContextByGuestId.delete(guestWebContentsId) + this.pendingNavigationByGuestId.delete(guestWebContentsId) // Why: a popup must stop inheriting authorization the moment its owner retires, before Chromium destroys the child. if (isPrimaryGuest) { for (const [popupGuestId, owner] of this.popupOwnerContextByGuestId) { @@ -1094,6 +1243,10 @@ export class BrowserManager { this.worktreeIdByTabId.delete(browserTabId) // Why: drop the viewport-op chain so the Map doesn't retain a promise keyed to a destroyed guest. this.viewportOpsByTabId.delete(browserTabId) + this.viewportUaOverrideMobileByTabId.delete(browserTabId) + if (wcId !== undefined) { + this.pendingNavigationByGuestId.delete(wcId) + } this.annotationViewportBridgeOpsByTabId.delete(browserTabId) } @@ -1159,6 +1312,8 @@ export class BrowserManager { this.worktreeIdByTabId.clear() this.sessionProfileIdByPageId.clear() this.userAgentModeByPageId.clear() + this.viewportUaOverrideMobileByTabId.clear() + this.pendingNavigationByGuestId.clear() this.pendingLoadFailuresByGuestId.clear() this.loadErrorsByGuestId.clear() this.clearedLoadErrorsByGuestId.clear() @@ -1552,36 +1707,10 @@ export class BrowserManager { }) // Why: viewport sizing must not override a profile's explicit native-UA identity. if (this.userAgentModeByPageId.get(browserTabId) !== 'native') { - if (override.mobile) { - const chromeMajor = extractChromeMajor(cleanElectronUserAgent(guest.getUserAgent())) - // Why: userAgentMetadata must accompany the mobile UA so client hints match, or bot-detection flags the desktop-hint leak. - await dbg.sendCommand('Emulation.setUserAgentOverride', { - userAgent: buildMobileUserAgent(chromeMajor), - userAgentMetadata: { - brands: [ - { brand: 'Google Chrome', version: chromeMajor }, - { brand: 'Chromium', version: chromeMajor }, - { brand: 'Not/A)Brand', version: '24' } - ], - fullVersionList: [ - { brand: 'Google Chrome', version: `${chromeMajor}.0.0.0` }, - { brand: 'Chromium', version: `${chromeMajor}.0.0.0` }, - { brand: 'Not/A)Brand', version: '24.0.0.0' } - ], - fullVersion: `${chromeMajor}.0.0.0`, - platform: 'iOS', - platformVersion: '17.0', - architecture: '', - model: 'iPhone', - mobile: true - } - }) - } else { - // Why: desktop presets still need the clean (non-Electron) UA so Cloudflare/Turnstile don't flag the session. - await dbg.sendCommand('Emulation.setUserAgentOverride', { - userAgent: cleanElectronUserAgent(guest.getUserAgent()) - }) - } + // Navigation must see the preset intent while the final CDP command is in flight. + this.viewportUaOverrideMobileByTabId.set(browserTabId, override.mobile) + // Why: same sender as the navigation path, so both resolve the tab's host identically. + await this.sendViewportUserAgentOverride(guest, override.mobile) } } else { await dbg.sendCommand('Emulation.clearDeviceMetricsOverride', {}) @@ -1589,8 +1718,18 @@ export class BrowserManager { enabled: false, maxTouchPoints: 0 }) + const trackedMobile = this.viewportUaOverrideMobileByTabId.get(browserTabId) + // A navigation after this point must not re-install the override behind the clear. + this.viewportUaOverrideMobileByTabId.delete(browserTabId) // Why: passing an empty string restores the session default UA. - await dbg.sendCommand('Emulation.setUserAgentOverride', { userAgent: '' }) + try { + await dbg.sendCommand('Emulation.setUserAgentOverride', { userAgent: '' }) + } catch (error) { + if (trackedMobile !== undefined) { + this.viewportUaOverrideMobileByTabId.set(browserTabId, trackedMobile) + } + throw error + } } return true } catch { diff --git a/src/main/browser/browser-session-registry.persistence.test.ts b/src/main/browser/browser-session-registry.persistence.test.ts index 76ddaf4fee8..f7792903bac 100644 --- a/src/main/browser/browser-session-registry.persistence.test.ts +++ b/src/main/browser/browser-session-registry.persistence.test.ts @@ -230,8 +230,10 @@ describe('BrowserSessionRegistry persistence', () => { browserSessionRegistry.createProfile('isolated', 'Google', { userAgentMode: 'native' }) const profileSession = sessionFromPartitionMock.mock.results.at(-1)?.value + const { getBrowserSessionUserAgentMode } = await import('./browser-session-user-agent-mode') expect(profileSession.setUserAgent).not.toHaveBeenCalled() expect(setupClientHintsOverrideMock).not.toHaveBeenCalled() + expect(getBrowserSessionUserAgentMode(profileSession as never)).toBe('native') }) it('merges partition-keyed pending entries without clobbering unrelated entries', async () => { @@ -391,7 +393,14 @@ describe('BrowserSessionRegistry persistence', () => { setupClientHintsOverrideMock.mock.calls.some( (c: unknown[]) => (c[0] as { partition?: string } | undefined)?.partition === importedPartition && - c[1] === importedUa + c[1] === importedUa && + (c[2] as { googleAuthOverride?: boolean } | undefined)?.googleAuthOverride === false + ) + ).toBe(true) + const { getBrowserSessionUserAgentMode } = await import('./browser-session-user-agent-mode') + expect( + importedSessions.every( + (session) => getBrowserSessionUserAgentMode(session as never) === 'native' ) ).toBe(true) }) @@ -432,6 +441,12 @@ describe('BrowserSessionRegistry persistence', () => { ([sess]) => (sess as { partition?: string }).partition === importedPartition ) ).toBe(false) + const { getBrowserSessionUserAgentMode } = await import('./browser-session-user-agent-mode') + expect( + importedSessions.every( + (session) => getBrowserSessionUserAgentMode(session as never) === 'native' + ) + ).toBe(true) }) it('sets up default-partition policies on restore', async () => { diff --git a/src/main/browser/browser-session-registry.test.ts b/src/main/browser/browser-session-registry.test.ts index 31260f4cf4c..44e1b6b8e92 100644 --- a/src/main/browser/browser-session-registry.test.ts +++ b/src/main/browser/browser-session-registry.test.ts @@ -393,13 +393,89 @@ describe('BrowserSessionRegistry', () => { expect(modified['sec-ch-ua']).not.toContain('Microsoft Edge') }) - it('does not register handler for non-Chrome UA', () => { + it('registers handler even for non-Chrome UA but leaves sec-ch-ua untouched off auth hosts', () => { const onBeforeSendHeaders = vi.fn() const mockSess = { webRequest: { onBeforeSendHeaders } } as never + // Why: the Google-auth Firefox switch must install regardless of the base UA. setupClientHintsOverride(mockSess, 'Mozilla/5.0 (compatible; MSIE 10.0)') - expect(onBeforeSendHeaders).not.toHaveBeenCalled() + expect(onBeforeSendHeaders).toHaveBeenCalledWith( + { urls: ['https://*/*'] }, + expect.any(Function) + ) + const callback = vi.fn() + const listener = onBeforeSendHeaders.mock.calls[0][1] + listener({ url: 'https://example.com/', requestHeaders: { 'sec-ch-ua': 'old' } }, callback) + expect(callback.mock.calls[0][0].requestHeaders['sec-ch-ua']).toBe('old') + }) + + it('presents a Firefox UA and strips client hints on Google auth hosts', () => { + const onBeforeSendHeaders = vi.fn() + const mockSess = { webRequest: { onBeforeSendHeaders } } as never + setupClientHintsOverride( + mockSess, + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.6890.3 Safari/537.36' + ) + + const callback = vi.fn() + const listener = onBeforeSendHeaders.mock.calls[0][1] + listener( + { + url: 'https://accounts.google.com/v3/signin/identifier', + requestHeaders: { + 'User-Agent': 'Chrome/147', + 'sec-ch-ua': 'old', + 'sec-ch-ua-full-version-list': 'old', + 'sec-ch-ua-platform': '"macOS"' + } + }, + callback + ) + const modified = callback.mock.calls[0][0].requestHeaders + expect(modified['User-Agent']).toMatch(/Firefox\/\d/) + expect(modified['User-Agent']).not.toContain('Chrome') + expect(modified['sec-ch-ua']).toBeUndefined() + expect(modified['sec-ch-ua-full-version-list']).toBeUndefined() + expect(modified['sec-ch-ua-platform']).toBeUndefined() + }) + + it('keeps Chrome client hints on Google app subdomains (not auth hosts)', () => { + const onBeforeSendHeaders = vi.fn() + const mockSess = { webRequest: { onBeforeSendHeaders } } as never + setupClientHintsOverride( + mockSess, + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.6890.3 Safari/537.36' + ) + + const callback = vi.fn() + const listener = onBeforeSendHeaders.mock.calls[0][1] + listener( + { url: 'https://myaccount.google.com/', requestHeaders: { 'sec-ch-ua': 'old' } }, + callback + ) + expect(callback.mock.calls[0][0].requestHeaders['sec-ch-ua']).toContain('Google Chrome') + }) + + it('keeps an imported native UA on auth hosts while aligning its Chrome hints', () => { + const onBeforeSendHeaders = vi.fn() + const mockSess = { webRequest: { onBeforeSendHeaders } } as never + const importedUa = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.6890.3 Safari/537.36' + setupClientHintsOverride(mockSess, importedUa, { googleAuthOverride: false }) + + const callback = vi.fn() + const listener = onBeforeSendHeaders.mock.calls[0][1] + listener( + { + url: 'https://accounts.google.com/v3/signin/identifier', + requestHeaders: { 'User-Agent': importedUa, 'sec-ch-ua': 'old' } + }, + callback + ) + const modified = callback.mock.calls[0][0].requestHeaders + expect(modified['User-Agent']).toBe(importedUa) + expect(modified['sec-ch-ua']).toContain('Google Chrome') }) it('leaves non-Client-Hints headers unchanged', () => { diff --git a/src/main/browser/browser-session-registry.ts b/src/main/browser/browser-session-registry.ts index e30c3a0230d..d6188bf88b7 100644 --- a/src/main/browser/browser-session-registry.ts +++ b/src/main/browser/browser-session-registry.ts @@ -26,6 +26,10 @@ import type { import { browserManager } from './browser-manager' import { hasSystemMediaAccess, requestSystemMediaAccess } from './browser-media-access' import { cleanElectronUserAgent, setupClientHintsOverride } from './browser-session-ua' +import { + clearBrowserSessionUserAgentMode, + setBrowserSessionUserAgentMode +} from './browser-session-user-agent-mode' import { resolveChromiumCookiesPath } from './chromium-cookie-path' import { isAutoGrantedBrowserSessionPermission } from './browser-session-permission-policy' import { @@ -190,10 +194,14 @@ class BrowserSessionRegistry { const partition = profile.partition try { const sess = session.fromPartition(partition) + const userAgentMode = profile.userAgentMode ?? 'clean' + setBrowserSessionUserAgentMode(sess, userAgentMode) const persistedUa = meta.userAgentByPartition[partition] if (persistedUa) { sess.setUserAgent(persistedUa) - setupClientHintsOverride(sess, persistedUa) + setupClientHintsOverride(sess, persistedUa, { + googleAuthOverride: userAgentMode !== 'native' + }) continue } @@ -435,6 +443,7 @@ class BrowserSessionRegistry { // Why: clear the partition's storage so deleting a profile doesn't leave orphaned cookies/cache behind. try { const sess = session.fromPartition(profile.partition) + clearBrowserSessionUserAgentMode(sess) this.clearSessionPolicies(profile.partition, sess) await sess.clearStorageData() await sess.clearCache() @@ -523,11 +532,12 @@ class BrowserSessionRegistry { private setupSessionPolicies(profile: BrowserSessionProfile): void { const { partition } = profile + const sess = session.fromPartition(partition) + setBrowserSessionUserAgentMode(sess, profile.userAgentMode ?? 'clean') if (this.configuredPartitions.has(partition)) { return } - const sess = session.fromPartition(partition) browserManager.installCertificateRequestGuard(sess) if (profile.userAgentMode !== 'native' && typeof sess.getUserAgent === 'function') { const cleanUA = cleanElectronUserAgent(sess.getUserAgent()) diff --git a/src/main/browser/browser-session-ua.ts b/src/main/browser/browser-session-ua.ts index f30ce96fc0f..081e1a5deba 100644 --- a/src/main/browser/browser-session-ua.ts +++ b/src/main/browser/browser-session-ua.ts @@ -1,5 +1,12 @@ import type { Session } from 'electron' +import { + googleAuthUserAgent, + isGoogleAuthUrl, + setUserAgentHeader, + stripClientHints +} from './browser-google-auth-ua' + // Why: Electron's default UA includes "Electron/X.X.X" and the app name // (e.g. "orca/1.2.3"), which Cloudflare Turnstile and other bot detectors // flag as non-human traffic. Strip those tokens so the webview's UA and @@ -19,10 +26,46 @@ export function cleanElectronUserAgent(ua: string): string { // reveal the real version, creating a mismatch that Google's anti-fraud // detection flags as CookieMismatch on accounts.google.com. Override Client // Hints on outgoing requests to match the source browser's UA. -export function setupClientHintsOverride(sess: Session, ua: string): void { +export function setupClientHintsOverride( + sess: Session, + ua: string, + options: { googleAuthOverride?: boolean } = {} +): void { + // Why: only Chrome-shaped base UAs carry sec-ch-ua hints to rewrite, but the + // Google-auth Firefox switch below must install regardless, so keep the hints + // optional rather than bailing out of the whole handler. + const chromeHints = buildChromeClientHints(ua) + const firefoxUa = googleAuthUserAgent() + + sess.webRequest.onBeforeSendHeaders({ urls: ['https://*/*'] }, (details, callback) => { + const headers = details.requestHeaders + if (options.googleAuthOverride !== false && isGoogleAuthUrl(details.url)) { + // Why: present a Firefox identity on Google's sign-in hosts so the user logs + // in inside the app and Google issues self-refreshing bound cookies. Strip + // sec-ch-ua* because real Firefox sends none. + setUserAgentHeader(headers, firefoxUa) + stripClientHints(headers) + callback({ requestHeaders: headers }) + return + } + if (chromeHints) { + for (const key of Object.keys(headers)) { + const lower = key.toLowerCase() + if (lower === 'sec-ch-ua') { + headers[key] = chromeHints.secChUa + } else if (lower === 'sec-ch-ua-full-version-list') { + headers[key] = chromeHints.secChUaFull + } + } + } + callback({ requestHeaders: headers }) + }) +} + +function buildChromeClientHints(ua: string): { secChUa: string; secChUaFull: string } | null { const chromeMatch = ua.match(/Chrome\/([\d.]+)/) if (!chromeMatch) { - return + return null } const fullChromeVersion = chromeMatch[1] const majorVersion = fullChromeVersion.split('.')[0] @@ -37,19 +80,8 @@ export function setupClientHintsOverride(sess: Session, ua: string): void { } const brandMajor = brandFullVersion.split('.')[0] - const secChUa = `"${brand}";v="${brandMajor}", "Chromium";v="${majorVersion}", "Not/A)Brand";v="24"` - const secChUaFull = `"${brand}";v="${brandFullVersion}", "Chromium";v="${fullChromeVersion}", "Not/A)Brand";v="24.0.0.0"` - - sess.webRequest.onBeforeSendHeaders({ urls: ['https://*/*'] }, (details, callback) => { - const headers = details.requestHeaders - for (const key of Object.keys(headers)) { - const lower = key.toLowerCase() - if (lower === 'sec-ch-ua') { - headers[key] = secChUa - } else if (lower === 'sec-ch-ua-full-version-list') { - headers[key] = secChUaFull - } - } - callback({ requestHeaders: headers }) - }) + return { + secChUa: `"${brand}";v="${brandMajor}", "Chromium";v="${majorVersion}", "Not/A)Brand";v="24"`, + secChUaFull: `"${brand}";v="${brandFullVersion}", "Chromium";v="${fullChromeVersion}", "Not/A)Brand";v="24.0.0.0"` + } } diff --git a/src/main/browser/browser-session-user-agent-mode.ts b/src/main/browser/browser-session-user-agent-mode.ts new file mode 100644 index 00000000000..8fa8f71d1a9 --- /dev/null +++ b/src/main/browser/browser-session-user-agent-mode.ts @@ -0,0 +1,22 @@ +import type { Session } from 'electron' + +import type { BrowserSessionUserAgentMode } from '../../shared/types' + +const userAgentModeBySession = new WeakMap() + +export function setBrowserSessionUserAgentMode( + session: Session, + mode: BrowserSessionUserAgentMode +): void { + userAgentModeBySession.set(session, mode) +} + +export function getBrowserSessionUserAgentMode( + session: Session +): BrowserSessionUserAgentMode | undefined { + return userAgentModeBySession.get(session) +} + +export function clearBrowserSessionUserAgentMode(session: Session): void { + userAgentModeBySession.delete(session) +} diff --git a/src/main/browser/browser-viewport-user-agent.test.ts b/src/main/browser/browser-viewport-user-agent.test.ts new file mode 100644 index 00000000000..1d069a209ad --- /dev/null +++ b/src/main/browser/browser-viewport-user-agent.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' + +import { googleAuthUserAgent } from './browser-google-auth-ua' +import { buildViewportUserAgentOverride } from './browser-viewport-user-agent' + +const CHROME_UA = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36' + +describe('buildViewportUserAgentOverride', () => { + it('presents the Firefox UA on Google auth hosts regardless of the preset', () => { + for (const mobile of [false, true]) { + const override = buildViewportUserAgentOverride({ + url: 'https://accounts.google.com/v3/signin/identifier', + mobile, + baseUserAgent: CHROME_UA + }) + expect(override.userAgent).toBe(googleAuthUserAgent()) + // Real Firefox emits no client hints, so Chrome brands would contradict the stripped headers. + expect(override.userAgentMetadata).toBeUndefined() + } + }) + + it('keeps the clean desktop UA off the auth hosts', () => { + const override = buildViewportUserAgentOverride({ + url: 'https://myaccount.google.com/', + mobile: false, + baseUserAgent: CHROME_UA + }) + expect(override.userAgent).toBe(CHROME_UA) + expect(override.userAgentMetadata).toBeUndefined() + }) + + it('splices the real Chrome major into the mobile UA and its client hints', () => { + const override = buildViewportUserAgentOverride({ + url: 'https://example.com/', + mobile: true, + baseUserAgent: CHROME_UA + }) + expect(override.userAgent).toContain('iPhone') + expect(override.userAgent).toContain('CriOS/134.0.0.0') + expect(override.userAgentMetadata?.mobile).toBe(true) + expect(override.userAgentMetadata?.platform).toBe('iOS') + expect(override.userAgentMetadata?.brands).toContainEqual({ + brand: 'Google Chrome', + version: '134' + }) + }) + + it('falls back to a known Chrome major when the base UA carries none', () => { + const override = buildViewportUserAgentOverride({ + url: 'https://example.com/', + mobile: true, + baseUserAgent: googleAuthUserAgent() + }) + expect(override.userAgent).toContain('CriOS/134.0.0.0') + }) + + it('treats an unparseable URL as a non-auth host', () => { + const override = buildViewportUserAgentOverride({ + url: 'not a url', + mobile: false, + baseUserAgent: CHROME_UA + }) + expect(override.userAgent).toBe(CHROME_UA) + }) +}) diff --git a/src/main/browser/browser-viewport-user-agent.ts b/src/main/browser/browser-viewport-user-agent.ts new file mode 100644 index 00000000000..7dedf8d8a6e --- /dev/null +++ b/src/main/browser/browser-viewport-user-agent.ts @@ -0,0 +1,73 @@ +// Why: a CDP Emulation.setUserAgentOverride outranks WebContents.setUserAgent for both +// navigator.userAgent and the outgoing request header, and it stands across every later +// navigation until explicitly cleared. So the viewport preset's UA is a third identity layer +// that must agree with the auth-host Firefox switch, or applying a preset silently reintroduces +// the exact UA mismatch this scope exists to remove. + +import { googleAuthUserAgent, isGoogleAuthUrl } from './browser-google-auth-ua' + +type UserAgentBrand = { brand: string; version: string } + +export type ViewportUserAgentOverride = { + userAgent: string + userAgentMetadata?: { + brands: UserAgentBrand[] + fullVersionList: UserAgentBrand[] + fullVersion: string + platform: string + platformVersion: string + architecture: string + model: string + mobile: boolean + } +} + +// Why: responsive sites UA-sniff; this is Chrome DevTools' default iPhone UA template with the real +// Chrome major spliced in to keep sec-ch-ua consistent (see setupClientHintsOverride). +function buildMobileUserAgent(chromeMajor: string): string { + return `Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/${chromeMajor}.0.0.0 Mobile/15E148 Safari/604.1` +} + +function extractChromeMajor(ua: string): string { + const match = ua.match(/Chrome\/(\d+)/) + return match ? match[1] : '134' +} + +export function buildViewportUserAgentOverride(args: { + url: string + mobile: boolean + baseUserAgent: string +}): ViewportUserAgentOverride { + if (isGoogleAuthUrl(args.url)) { + // Why: match the header-level Firefox switch exactly, and send no userAgentMetadata — real + // Firefox emits no client hints, so Chrome brands here would contradict the stripped headers. + return { userAgent: googleAuthUserAgent() } + } + if (!args.mobile) { + // Why: desktop presets still need the clean (non-Electron) UA so Cloudflare/Turnstile don't flag the session. + return { userAgent: args.baseUserAgent } + } + const chromeMajor = extractChromeMajor(args.baseUserAgent) + // Why: userAgentMetadata must accompany the mobile UA so client hints match, or bot-detection flags the desktop-hint leak. + return { + userAgent: buildMobileUserAgent(chromeMajor), + userAgentMetadata: { + brands: [ + { brand: 'Google Chrome', version: chromeMajor }, + { brand: 'Chromium', version: chromeMajor }, + { brand: 'Not/A)Brand', version: '24' } + ], + fullVersionList: [ + { brand: 'Google Chrome', version: `${chromeMajor}.0.0.0` }, + { brand: 'Chromium', version: `${chromeMajor}.0.0.0` }, + { brand: 'Not/A)Brand', version: '24.0.0.0' } + ], + fullVersion: `${chromeMajor}.0.0.0`, + platform: 'iOS', + platformVersion: '17.0', + architecture: '', + model: 'iPhone', + mobile: true + } + } +} From 02a1251c2d721a29e3b69848bc793f1e3aa21ef0 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 7 Aug 2026 03:13:14 -0700 Subject: [PATCH 011/289] fix(native-chat): classify diff lines whose content begins with -- or ++ (#12459) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(native-chat): stop diff colouring from misreading -- / ++ content lines as file headers diffFromText skipped every line starting with --- / +++ as a file header, so a deleted SQL/Lua '-- comment' (git emits '---') or an added '++flag' fell through to gray context with its marker still attached — and when it was the only change, the two-marker gate dropped the coloured diff entirely. Detect real headers structurally instead: an adjacent '--- ' / '+++ ' pair outside any hunk. A hunk header or 'diff --git' line now also proves the text is a diff, so a genuine single-line change renders while prose keeps the guard. Co-authored-by: Orca * test(native-chat): adopt #12335 diff-collision vectors and add mobile parity Pulls in @YuriNachos's test vectors from #12335 (header-less --- deletion, an adjacent --x/++y content pair, mobile re-export parity) and adds the spaced -- / ++ pair inside a hunk, which the pair-only rule in that PR misreads. Co-authored-by: Orca * fix(native-chat): keep bare --- / +++ rules out of the diff marker count Dropping the `---`/`+++` prefix exclusions made a bare `---` — a Markdown thematic break or YAML document separator — classify as a deletion. Tool results routinely carry those, so `---\na: 1\n---\nb: 2` went from correctly rejected to rendering as a red diff. A bare rule is never a file header (those need a path after the marker) and is only content inside a hunk, so treat it as meta when outside one. Fold the separate `isStructuredDiff` scan into the same pre-pass and skip non-marker lines early, so the added guard costs no extra traversal: 5.1 -> 4.3 us per 120-line prose result, diff path unchanged. --------- Co-authored-by: Orca --- .../session/mobile-native-chat-diff.test.ts | 12 ++ .../native-chat/native-chat-diff.test.ts | 132 ++++++++++++++++++ src/shared/native-chat-diff.ts | 71 +++++++++- 3 files changed, 210 insertions(+), 5 deletions(-) diff --git a/mobile/src/session/mobile-native-chat-diff.test.ts b/mobile/src/session/mobile-native-chat-diff.test.ts index 3171b818050..69277dd09ae 100644 --- a/mobile/src/session/mobile-native-chat-diff.test.ts +++ b/mobile/src/session/mobile-native-chat-diff.test.ts @@ -36,6 +36,18 @@ describe('diffFromText', () => { expect(diffFromText('Here is a list:\n- one bullet\nthat is all')).toBeNull() }) + // Parity guard: the mobile module re-exports the shared parser, so the -- / ++ + // content collision must stay fixed on both platforms. + it('counts a removed -- comment line despite the --- prefix collision', () => { + const lines = diffFromText('@@ -1,2 +1,2 @@\n ctx\n---sql comment\n+new') + expect(lines).toEqual([ + { kind: 'meta', text: '@@ -1,2 +1,2 @@' }, + { kind: 'context', text: ' ctx' }, + { kind: 'del', text: '--sql comment' }, + { kind: 'add', text: 'new' } + ]) + }) + it('caps large diffs before creating render rows', () => { const text = Array.from( { length: 1_000 }, diff --git a/src/renderer/src/components/native-chat/native-chat-diff.test.ts b/src/renderer/src/components/native-chat/native-chat-diff.test.ts index 598b6b97d4a..229edc1e4bd 100644 --- a/src/renderer/src/components/native-chat/native-chat-diff.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-diff.test.ts @@ -60,4 +60,136 @@ describe('diffFromText', () => { expect(diff?.filter((l) => l.kind === 'add').map((l) => l.text)).toEqual(['new']) expect(diff?.filter((l) => l.kind === 'del').map((l) => l.text)).toEqual(['old']) }) + + it('treats a removed line whose content starts with -- as a deletion', () => { + const diff = diffFromText( + '@@ -1,4 +1,1 @@\n SELECT 1;\n--- legacy comment\n-SELECT 2;\n-SELECT 3;' + ) + expect(diff).toEqual([ + { kind: 'meta', text: '@@ -1,4 +1,1 @@' }, + { kind: 'context', text: ' SELECT 1;' }, + { kind: 'del', text: '-- legacy comment' }, + { kind: 'del', text: 'SELECT 2;' }, + { kind: 'del', text: 'SELECT 3;' } + ]) + }) + + it('treats an added line whose content starts with ++ as an addition', () => { + const diff = diffFromText('@@ -1,1 +1,3 @@\n ctx\n+++flag\n+normal') + expect(diff?.filter((l) => l.kind === 'add').map((l) => l.text)).toEqual(['++flag', 'normal']) + }) + + it('classifies a C-style --i deletion inside a hunk', () => { + const diff = diffFromText('@@ -1,3 +1,2 @@\n int i;\n---i;\n-return i;') + expect(diff?.filter((l) => l.kind === 'del').map((l) => l.text)).toEqual(['--i;', 'return i;']) + }) + + it('keeps real file headers as meta in full git diff output', () => { + const diff = diffFromText( + [ + 'diff --git a/a.sql b/a.sql', + 'index 9eff25c..33791bf 100644', + '--- a/a.sql', + '+++ b/a.sql', + '@@ -1,3 +1,2 @@', + ' SELECT 1;', + '--- legacy comment', + ' SELECT 2;' + ].join('\n') + ) + expect(diff?.filter((l) => l.kind === 'meta').map((l) => l.text)).toEqual([ + 'diff --git a/a.sql b/a.sql', + 'index 9eff25c..33791bf 100644', + '--- a/a.sql', + '+++ b/a.sql', + '@@ -1,3 +1,2 @@' + ]) + expect(diff?.filter((l) => l.kind === 'del').map((l) => l.text)).toEqual(['-- legacy comment']) + }) + + it('detects the second file headers in a multi-file diff', () => { + const diff = diffFromText( + [ + 'diff --git a/a.sql b/a.sql', + '--- a/a.sql', + '+++ b/a.sql', + '@@ -1,2 +1,1 @@', + '--- legacy comment', + ' SELECT 2;', + 'diff --git a/b.lua b/b.lua', + '--- a/b.lua', + '+++ b/b.lua', + '@@ -1,2 +1,1 @@', + '--- lua comment', + ' print(1)' + ].join('\n') + ) + expect(diff?.filter((l) => l.kind === 'del').map((l) => l.text)).toEqual([ + '-- legacy comment', + '-- lua comment' + ]) + }) + + it('renders a single-line change once hunk headers prove it is a diff', () => { + expect(diffFromText('@@ -1,2 +1,1 @@\n SELECT 1;\n--- legacy comment')).not.toBeNull() + // Without diff structure the two-marker prose guard still applies. + expect(diffFromText('-- legacy comment')).toBeNull() + }) + + it('classifies a --- content deletion in header-less agent-tool output', () => { + const diff = diffFromText('---sql comment\n+new\n-more context') + expect(diff).toEqual([ + { kind: 'del', text: '--sql comment' }, + { kind: 'add', text: 'new' }, + { kind: 'del', text: 'more context' } + ]) + }) + + it('does not read an adjacent --x / ++y content pair as a file header', () => { + const diff = diffFromText('@@ -1,2 +1,2 @@\n ctx\n---note\n+++flag') + expect(diff).toEqual([ + { kind: 'meta', text: '@@ -1,2 +1,2 @@' }, + { kind: 'context', text: ' ctx' }, + { kind: 'del', text: '--note' }, + { kind: 'add', text: '++flag' } + ]) + }) + + it('does not read a spaced -- / ++ content pair inside a hunk as a file header', () => { + const diff = diffFromText('@@ -1,2 +1,2 @@\n ctx\n--- note\n+++ flag') + expect(diff).toEqual([ + { kind: 'meta', text: '@@ -1,2 +1,2 @@' }, + { kind: 'context', text: ' ctx' }, + { kind: 'del', text: '-- note' }, + { kind: 'add', text: '++ flag' } + ]) + }) + + it('handles /dev/null headers for an added file', () => { + const diff = diffFromText('--- /dev/null\n+++ b/x.sql\n+-- new comment\n+SELECT 1;') + expect(diff?.filter((l) => l.kind === 'add').map((l) => l.text)).toEqual([ + '-- new comment', + 'SELECT 1;' + ]) + expect(diff?.filter((l) => l.kind === 'del')).toEqual([]) + }) + + it('leaves a YAML document separator out of the marker count', () => { + expect(diffFromText('---\na: 1\n---\nb: 2')).toBeNull() + }) + + it('keeps a Markdown thematic break as meta rather than a deletion', () => { + const diff = diffFromText('Intro\n-----\n- alpha\n- beta') + expect(diff).toEqual([ + { kind: 'context', text: 'Intro' }, + { kind: 'meta', text: '-----' }, + { kind: 'del', text: ' alpha' }, + { kind: 'del', text: ' beta' } + ]) + }) + + it('still reads a bare --- inside a hunk as a deletion', () => { + const diff = diffFromText('@@ -1,3 +1,1 @@\n ctx\n---\n-SELECT 1;') + expect(diff?.filter((l) => l.kind === 'del').map((l) => l.text)).toEqual(['--', 'SELECT 1;']) + }) }) diff --git a/src/shared/native-chat-diff.ts b/src/shared/native-chat-diff.ts index 1e49d1e8969..0e9e242af6e 100644 --- a/src/shared/native-chat-diff.ts +++ b/src/shared/native-chat-diff.ts @@ -13,6 +13,59 @@ const DIFF_TRUNCATED_LINE: NativeChatDiffLine = { text: '… diff truncated …' } +const HUNK_HEADER = /^@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@/ +// Lines that open a new file section, so any hunk before them has ended. +const FILE_SECTION_START = + /^(?:diff |index |old mode |new mode |new file mode |deleted file mode |similarity index |dissimilarity index |rename |copy |Binary files )/ +// Markdown thematic break or YAML document separator, not a marker. +const BARE_RULE = /^(?:-{3,}|\+{3,})$/ + +type DiffStructure = { + /** Lines that are structure rather than content, so they never count as add/del. */ + metaIndices: Set + /** The text carries a hunk header or `diff --git`, so it is provably a diff. */ + isStructuredDiff: boolean +} + +/** + * Locates the `--- ` / `+++ ` file headers. A bare `---`/`+++` prefix + * is not enough to spot one: a removed line whose content began with `--` + * (SQL/Lua `-- comment`, C `--i`) is emitted as `---`. Real headers + * always come as an adjacent pair and never appear inside a hunk. + */ +function scanDiffStructure(lines: string[]): DiffStructure { + const metaIndices = new Set() + let isStructuredDiff = false + let inHunk = false + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index] ?? '' + if (line.startsWith('@@')) { + inHunk = true + isStructuredDiff ||= HUNK_HEADER.test(line) + continue + } + if (FILE_SECTION_START.test(line)) { + inHunk = false + isStructuredDiff ||= line.startsWith('diff --git ') + continue + } + // Only marker lines can be a header or a rule; skip context and prose early. + if (inHunk || !(line.startsWith('-') || line.startsWith('+'))) { + continue + } + if (BARE_RULE.test(line)) { + metaIndices.add(index) + continue + } + if (line.startsWith('--- ') && (lines[index + 1] ?? '').startsWith('+++ ')) { + metaIndices.add(index) + metaIndices.add(index + 1) + index += 1 + } + } + return { metaIndices, isStructuredDiff } +} + function toLines(value: unknown, maxLines: number): { lines: string[]; truncated: boolean } { if (typeof value !== 'string') { return { lines: [], truncated: false } @@ -62,23 +115,31 @@ export function diffFromText( return null } const bounded = toLines(text, maxLines) + const { metaIndices, isStructuredDiff } = scanDiffStructure(bounded.lines) let added = 0 let removed = 0 - const lines = bounded.lines.map((line): NativeChatDiffLine => { - if (line.startsWith('@@') || line.startsWith('diff ') || line.startsWith('index ')) { + const lines = bounded.lines.map((line, index): NativeChatDiffLine => { + if ( + metaIndices.has(index) || + line.startsWith('@@') || + line.startsWith('diff ') || + line.startsWith('index ') + ) { return { kind: 'meta', text: line } } - if (line.startsWith('+') && !line.startsWith('+++')) { + if (line.startsWith('+')) { added += 1 return { kind: 'add', text: line.slice(1) } } - if (line.startsWith('-') && !line.startsWith('---')) { + if (line.startsWith('-')) { removed += 1 return { kind: 'del', text: line.slice(1) } } return { kind: 'context', text: line } }) - if (added + removed < 2) { + // Proven diff text renders a single-line change; without that proof, two + // markers guard against colouring prose that merely opens a line with `-`. + if (added + removed < (isStructuredDiff ? 1 : 2)) { return null } return bounded.truncated ? [...lines.slice(0, maxLines - 1), DIFF_TRUNCATED_LINE] : lines From 46b9d3b13a29d28cf2d6ad070d7aeefc07bbee85 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 7 Aug 2026 09:55:54 -0700 Subject: [PATCH 012/289] Break out test and generated lines in branch line total (#13057) * rm comments * reduce comment --- ...us-branch-line-total-exec-contract.test.ts | 34 ++- .../status-branch-line-total-real-git.test.ts | 111 +++++++++- ...tus-branch-line-total-relay-parity.test.ts | 8 +- .../git-status-branch-line-total.test.ts | 22 +- .../SourceControl.branch-line-total.test.tsx | 35 +++- ...source-control-branch-context-row.test.tsx | 170 ++++++++++++++- .../source-control-branch-line-total-chip.tsx | 197 +++++++++++++++++- ...e-control-header-toolbar-identity.test.tsx | 2 +- src/renderer/src/i18n/locales/en.json | 15 +- .../slices/editor-branch-line-total.test.ts | 86 +++++++- src/renderer/src/store/slices/editor.ts | 20 +- src/shared/generated-code-path.test.ts | 72 +++++++ src/shared/generated-code-path.ts | 50 +++++ src/shared/git-branch-line-total.test.ts | 93 ++++++++- src/shared/git-branch-line-total.ts | 40 +++- src/shared/git-status-types.ts | 4 + src/shared/test-code-path.test.ts | 61 ++++++ src/shared/test-code-path.ts | 43 ++++ 18 files changed, 995 insertions(+), 68 deletions(-) create mode 100644 src/shared/generated-code-path.test.ts create mode 100644 src/shared/generated-code-path.ts create mode 100644 src/shared/test-code-path.test.ts create mode 100644 src/shared/test-code-path.ts diff --git a/src/main/git/status-branch-line-total-exec-contract.test.ts b/src/main/git/status-branch-line-total-exec-contract.test.ts index 13ece36daec..7dff12b5491 100644 --- a/src/main/git/status-branch-line-total-exec-contract.test.ts +++ b/src/main/git/status-branch-line-total-exec-contract.test.ts @@ -67,6 +67,8 @@ import { } from './status' const BOGUS_MERGE_BASE = 'deadbeef'.repeat(5) +// No fixture path here looks like test or generated code, so it is all source. +const NO_LINES = { added: 0, removed: 0 } const tempRoots: string[] = [] function git(repo: string, args: string[]): string { @@ -257,7 +259,13 @@ describe('branch line total exec budget', () => { getStatus(repo, { branchLineTotalMergeBase: mergeBase }) ]) - expect(first.branchLineTotal).toEqual({ added: 1, removed: 0, mergeBase }) + expect(first.branchLineTotal).toEqual({ + added: 1, + removed: 0, + mergeBase, + test: NO_LINES, + generated: NO_LINES + }) expect(second.branchLineTotal).toEqual(first.branchLineTotal) expect(rangedDiffCalls()).toHaveLength(1) }) @@ -280,7 +288,13 @@ describe('branch line total exec budget', () => { getStatus(repo, { branchLineTotalMergeBase: mergeBase, limit: 4096 }) ]) - expect(first.branchLineTotal).toEqual({ added: 1, removed: 0, mergeBase }) + expect(first.branchLineTotal).toEqual({ + added: 1, + removed: 0, + mergeBase, + test: NO_LINES, + generated: NO_LINES + }) expect(second.branchLineTotal).toEqual(first.branchLineTotal) // Proves the saving came from the branch-total coalescer: both passes really // ran their own `git status`, so the status lease merged nothing. @@ -306,7 +320,13 @@ describe('branch line total exec budget', () => { }) expect(second.branchLineTotal).toEqual(first.branchLineTotal) - expect(second.branchLineTotal).toEqual({ added: 1, removed: 0, mergeBase }) + expect(second.branchLineTotal).toEqual({ + added: 1, + removed: 0, + mergeBase, + test: NO_LINES, + generated: NO_LINES + }) expect(numstatCalls()).toEqual([]) }) @@ -324,7 +344,13 @@ describe('branch line total exec budget', () => { reuseLineStats: true }) - expect(result.branchLineTotal).toEqual({ added: 0, removed: 0, mergeBase: laterMergeBase }) + expect(result.branchLineTotal).toEqual({ + added: 0, + removed: 0, + mergeBase: laterMergeBase, + test: NO_LINES, + generated: NO_LINES + }) expect(rangedDiffCalls()).toHaveLength(1) }) }) diff --git a/src/main/git/status-branch-line-total-real-git.test.ts b/src/main/git/status-branch-line-total-real-git.test.ts index 80426c76e65..1748efb2907 100644 --- a/src/main/git/status-branch-line-total-real-git.test.ts +++ b/src/main/git/status-branch-line-total-real-git.test.ts @@ -40,6 +40,9 @@ function commitAll(repo: string, message: string): string { return git(repo, ['rev-parse', 'HEAD']) } +// No fixture path here looks like test or generated code, so it is all source. +const NO_LINES = { added: 0, removed: 0 } + /** What a plain `git diff ` reports, i.e. the number the chip must not disagree with. */ function rangedDiffTotal(repo: string, mergeBase: string): { added: number; removed: number } { let added = 0 @@ -80,7 +83,13 @@ describe('branch line total against a real repository', () => { const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) expect(rangedDiffTotal(repo, mergeBase)).toEqual({ added: 1, removed: 0 }) - expect(result.branchLineTotal).toEqual({ added: 1, removed: 0, mergeBase }) + expect(result.branchLineTotal).toEqual({ + added: 1, + removed: 0, + mergeBase, + test: NO_LINES, + generated: NO_LINES + }) expect(result.entries.map((entry) => [entry.area, entry.added, entry.removed])).toEqual([ ['staged', 1, 0], ['unstaged', 1, 1] @@ -97,7 +106,13 @@ describe('branch line total against a real repository', () => { const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) - expect(result.branchLineTotal).toEqual({ added: 0, removed: 0, mergeBase }) + expect(result.branchLineTotal).toEqual({ + added: 0, + removed: 0, + mergeBase, + test: NO_LINES, + generated: NO_LINES + }) }) it('keeps the total across a commit and updates it on the next status call after an edit', async () => { @@ -107,15 +122,33 @@ describe('branch line total against a real repository', () => { await write(repo, 'f.txt', 'a\nb\nc\n') const beforeCommit = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) - expect(beforeCommit.branchLineTotal).toEqual({ added: 2, removed: 0, mergeBase }) + expect(beforeCommit.branchLineTotal).toEqual({ + added: 2, + removed: 0, + mergeBase, + test: NO_LINES, + generated: NO_LINES + }) commitAll(repo, 'commit the edit') const afterCommit = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) - expect(afterCommit.branchLineTotal).toEqual({ added: 2, removed: 0, mergeBase }) + expect(afterCommit.branchLineTotal).toEqual({ + added: 2, + removed: 0, + mergeBase, + test: NO_LINES, + generated: NO_LINES + }) await write(repo, 'f.txt', 'a\nb\nc\nd\n') const afterSecondEdit = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) - expect(afterSecondEdit.branchLineTotal).toEqual({ added: 3, removed: 0, mergeBase }) + expect(afterSecondEdit.branchLineTotal).toEqual({ + added: 3, + removed: 0, + mergeBase, + test: NO_LINES, + generated: NO_LINES + }) }) it('counts a rename across the commit boundary once, using the post-rename path', async () => { @@ -129,7 +162,13 @@ describe('branch line total against a real repository', () => { const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) expect(rangedDiffTotal(repo, mergeBase)).toEqual({ added: 1, removed: 0 }) - expect(result.branchLineTotal).toEqual({ added: 1, removed: 0, mergeBase }) + expect(result.branchLineTotal).toEqual({ + added: 1, + removed: 0, + mergeBase, + test: NO_LINES, + generated: NO_LINES + }) }) it('counts an untracked-only branch from the untracked file contents', async () => { @@ -141,7 +180,13 @@ describe('branch line total against a real repository', () => { const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) expect(result.entries.map((entry) => entry.area)).toEqual(['untracked']) - expect(result.branchLineTotal).toEqual({ added: 3, removed: 0, mergeBase }) + expect(result.branchLineTotal).toEqual({ + added: 3, + removed: 0, + mergeBase, + test: NO_LINES, + generated: NO_LINES + }) }) it('excludes a binary-only change, matching numstat reporting it as "-"', async () => { @@ -153,7 +198,13 @@ describe('branch line total against a real repository', () => { const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) expect(git(repo, ['diff', '--numstat', mergeBase, '--'])).toMatch(/^-\t-\t/) - expect(result.branchLineTotal).toEqual({ added: 0, removed: 0, mergeBase }) + expect(result.branchLineTotal).toEqual({ + added: 0, + removed: 0, + mergeBase, + test: NO_LINES, + generated: NO_LINES + }) }) it('contributes nothing for a pure rename', async () => { @@ -164,7 +215,33 @@ describe('branch line total against a real repository', () => { const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) - expect(result.branchLineTotal).toEqual({ added: 0, removed: 0, mergeBase }) + expect(result.branchLineTotal).toEqual({ + added: 0, + removed: 0, + mergeBase, + test: NO_LINES, + generated: NO_LINES + }) + }) + + it('splits test-path and generated files out of the published total', async () => { + const repo = await createFixtureRepo() + await write(repo, 'src/a.ts', 'a\n') + const mergeBase = commitAll(repo, 'base') + await write(repo, 'src/a.ts', 'a\nb\n') + await write(repo, 'src/a.test.ts', 't1\nt2\nt3\n') + await write(repo, 'pnpm-lock.yaml', 'l1\nl2\nl3\nl4\nl5\n') + + const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) + + expect(rangedDiffTotal(repo, mergeBase)).toEqual({ added: 1, removed: 0 }) + expect(result.branchLineTotal).toEqual({ + added: 9, + removed: 0, + mergeBase, + test: { added: 3, removed: 0 }, + generated: { added: 5, removed: 0 } + }) }) it('includes untracked additions alongside tracked range changes', async () => { @@ -178,7 +255,13 @@ describe('branch line total against a real repository', () => { const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) expect(rangedDiffTotal(repo, mergeBase)).toEqual({ added: 1, removed: 0 }) - expect(result.branchLineTotal).toEqual({ added: 5, removed: 0, mergeBase }) + expect(result.branchLineTotal).toEqual({ + added: 5, + removed: 0, + mergeBase, + test: NO_LINES, + generated: NO_LINES + }) }) // Documented deviation from a pure `git diff`: the untracked half is added on @@ -198,6 +281,12 @@ describe('branch line total against a real repository', () => { ['untracked', 'gone.txt'] ]) expect(rangedDiffTotal(repo, mergeBase)).toEqual({ added: 0, removed: 3 }) - expect(result.branchLineTotal).toEqual({ added: 2, removed: 3, mergeBase }) + expect(result.branchLineTotal).toEqual({ + added: 2, + removed: 3, + mergeBase, + test: NO_LINES, + generated: NO_LINES + }) }) }) diff --git a/src/main/git/status-branch-line-total-relay-parity.test.ts b/src/main/git/status-branch-line-total-relay-parity.test.ts index e9f5240728c..c62d419a25f 100644 --- a/src/main/git/status-branch-line-total-relay-parity.test.ts +++ b/src/main/git/status-branch-line-total-relay-parity.test.ts @@ -26,7 +26,13 @@ const execFileAsync = promisify(execFile) // flip.txt 0 (added in the branch commit, removed again in the worktree) // moved.txt 0 (pure rename) // fresh.txt +3 (untracked) -const EXPECTED_TOTAL = { added: 6, removed: 0 } +// No fixture path here looks like test or generated code, so it is all source. +const EXPECTED_TOTAL = { + added: 6, + removed: 0, + test: { added: 0, removed: 0 }, + generated: { added: 0, removed: 0 } +} // Summing the per-area status rows instead would give this — the wrong answer // the shared module exists to avoid. const AREA_ROW_SUM = { added: 5, removed: 2 } diff --git a/src/relay/git-status-branch-line-total.test.ts b/src/relay/git-status-branch-line-total.test.ts index ceb04351e07..ed20750d217 100644 --- a/src/relay/git-status-branch-line-total.test.ts +++ b/src/relay/git-status-branch-line-total.test.ts @@ -24,6 +24,8 @@ const OTHER_MERGE_BASE = 'fedcba9876543210fedcba9876543210fedcba98' // Untracked additions ride on top of the ranged diff, so the fixture needs a real file. const UNTRACKED_FILE = 'notes.md' const UNTRACKED_LINES = 4 +// No fixture path here looks like test or generated code, so it is all source. +const NO_LINES = { added: 0, removed: 0 } const STATUS_OUTPUT = [ '# branch.oid 1111111111111111111111111111111111111111', '# branch.head (detached)', @@ -166,7 +168,9 @@ describe('getStatusOp branch line total', () => { expect(result.branchLineTotal).toEqual({ added: 3 + UNTRACKED_LINES, removed: 2, - mergeBase + mergeBase, + test: NO_LINES, + generated: NO_LINES }) expect(rangedDiffCalls(git.mock.calls)).toEqual([ ['-c', 'core.quotePath=false', 'diff', '-z', '--numstat', '-M', mergeBase, '--'] @@ -352,7 +356,9 @@ describe('getStatusOp branch line total', () => { expect(first.branchLineTotal).toEqual({ added: 12 + UNTRACKED_LINES, removed: 5, - mergeBase: MERGE_BASE + mergeBase: MERGE_BASE, + test: NO_LINES, + generated: NO_LINES }) expect(reused.branchLineTotal).toEqual(first.branchLineTotal) expect(rangedDiffCalls(git.mock.calls)).toHaveLength(1) @@ -391,7 +397,9 @@ describe('getStatusOp branch line total', () => { expect(first.branchLineTotal).toEqual({ added: 12 + UNTRACKED_LINES, removed: 5, - mergeBase: MERGE_BASE + mergeBase: MERGE_BASE, + test: NO_LINES, + generated: NO_LINES }) }) @@ -411,7 +419,9 @@ describe('getStatusOp branch line total', () => { expect(moved.branchLineTotal).toEqual({ added: 12 + UNTRACKED_LINES, removed: 5, - mergeBase: OTHER_MERGE_BASE + mergeBase: OTHER_MERGE_BASE, + test: NO_LINES, + generated: NO_LINES }) expect(rangedDiffCalls(git.mock.calls)).toHaveLength(2) }) @@ -466,7 +476,9 @@ describe('getStatusOp branch line total', () => { expect(result.branchLineTotal).toEqual({ added: UNTRACKED_LINES, removed: 0, - mergeBase: MERGE_BASE + mergeBase: MERGE_BASE, + test: NO_LINES, + generated: NO_LINES }) expect(Number.isFinite(result.branchLineTotal?.added)).toBe(true) expect(Number.isFinite(result.branchLineTotal?.removed)).toBe(true) diff --git a/src/renderer/src/components/right-sidebar/SourceControl.branch-line-total.test.tsx b/src/renderer/src/components/right-sidebar/SourceControl.branch-line-total.test.tsx index c9a7f24beb2..9f2529973dc 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.branch-line-total.test.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.branch-line-total.test.tsx @@ -207,6 +207,12 @@ function chip(): HTMLElement | null { return container.querySelector('[data-testid="source-control-branch-line-total"]') } +function loadingChip(): HTMLElement | null { + return container.querySelector( + '[data-testid="source-control-branch-line-total-loading"]' + ) +} + describe('SourceControl branch line total request gate', () => { it('asks for a total when the panel is visible and compare is ready', () => { renderSourceControl() @@ -277,14 +283,14 @@ describe('SourceControl branch line total chip', () => { }) renderSourceControl() - expect(chip()?.getAttribute('aria-label')).toBe('8259 additions, 670 deletions') + expect(chip()?.getAttribute('aria-label')).toBe('8259 lines added, 670 lines deleted') // Grouping is pinned to the app locale (`en`), not the runner's host locale. expect(chip()?.textContent).toBe('+8,259-670') }) it('drops a total whose fork point has since moved', () => { // Why: status and branch compare refresh on different cadences, so a total - // can outlive the merge base it measured. Hidden beats a stale number. + // can outlive the merge base it measured. Stale digits must not render. resetState({ gitBranchLineTotalByWorktree: { [mocks.activeWorktree.id]: { added: 8259, removed: 670, mergeBase: 'stale-merge-base' } @@ -293,6 +299,7 @@ describe('SourceControl branch line total chip', () => { renderSourceControl() expect(chip()).toBeNull() + expect(loadingChip()).toBeNull() }) it('drops a published total while branch compare has no ready summary', () => { @@ -307,12 +314,27 @@ describe('SourceControl branch line total chip', () => { renderSourceControl() expect(chip()).toBeNull() + expect(loadingChip()).toBeNull() }) - it('renders nothing when no total was published', () => { - renderSourceControl() + // Why: a pending total and one this host will never send look identical from + // here, so a pulsing placeholder would keep pulsing forever on an old host, + // after a hard failure, or during the ranged-diff cooldown. + it('shows no placeholder while the total is still pending', () => { + vi.useFakeTimers() + try { + renderSourceControl() - expect(chip()).toBeNull() + expect(chip()).toBeNull() + act(() => { + vi.advanceTimersByTime(20_000) + }) + expect(chip()).toBeNull() + expect(loadingChip()).toBeNull() + expect(container.innerHTML).not.toContain('animate-pulse') + } finally { + vi.useRealTimers() + } }) it('renders nothing for an exact zero total', () => { @@ -324,6 +346,7 @@ describe('SourceControl branch line total chip', () => { renderSourceControl() expect(chip()).toBeNull() + expect(loadingChip()).toBeNull() }) it('omits the zero half of a one-sided total', () => { @@ -335,6 +358,6 @@ describe('SourceControl branch line total chip', () => { renderSourceControl() expect(chip()?.textContent).toBe('+42') - expect(chip()?.getAttribute('aria-label')).toBe('42 additions') + expect(chip()?.getAttribute('aria-label')).toBe('42 lines added') }) }) diff --git a/src/renderer/src/components/right-sidebar/source-control-branch-context-row.test.tsx b/src/renderer/src/components/right-sidebar/source-control-branch-context-row.test.tsx index 963e4e00a21..9ccad4f3acc 100644 --- a/src/renderer/src/components/right-sidebar/source-control-branch-context-row.test.tsx +++ b/src/renderer/src/components/right-sidebar/source-control-branch-context-row.test.tsx @@ -11,6 +11,12 @@ vi.mock('@/components/ui/tooltip', () => ({ TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children} })) +vi.mock('@/components/ui/hover-card', () => ({ + HoverCard: ({ children }: { children: ReactNode }) => <>{children}, + HoverCardContent: ({ children }: { children: ReactNode }) => <>{children}, + HoverCardTrigger: ({ children }: { children: ReactNode }) => <>{children} +})) + const readySummary: GitBranchCompareSummary = { baseRef: 'refs/remotes/origin/FRONT-192-ZisVoucherStrip', baseOid: 'base', @@ -241,7 +247,7 @@ describe('SourceControlBranchContextRow branch line total', () => { expect(markup).toContain('+8,259') expect(markup).toContain('-670') // Label reads raw digits; the grouped spans are decoration. - expect(markup).toContain('aria-label="8259 additions, 670 deletions"') + expect(markup).toContain('aria-label="8259 lines added, 670 lines deleted"') expect(markup).toContain('tabular-nums') expect(markup).toContain('text-[color:var(--git-decoration-added)]') expect(markup).toContain('text-[color:var(--git-decoration-deleted)]') @@ -286,12 +292,12 @@ describe('SourceControlBranchContextRow branch line total', () => { const addedOnly = renderWithLineTotal({ added: 42, removed: 0, mergeBase: 'base' }) expect(addedOnly).toContain('>+42<') expect(addedOnly).not.toContain('>-0<') - expect(addedOnly).toContain('aria-label="42 additions"') + expect(addedOnly).toContain('aria-label="42 lines added"') const removedOnly = renderWithLineTotal({ added: 0, removed: 7, mergeBase: 'base' }) expect(removedOnly).toContain('>-7<') expect(removedOnly).not.toContain('>+0<') - expect(removedOnly).toContain('aria-label="7 deletions"') + expect(removedOnly).toContain('aria-label="7 lines deleted"') }) it('hides the chip when both counts are zero', () => { @@ -302,10 +308,13 @@ describe('SourceControlBranchContextRow branch line total', () => { expect(markup).not.toContain('>-0<') }) - it('hides the chip when the total is absent', () => { + it('renders nothing at all when the total is absent', () => { + // Why: a null total is equally "still computing" and "this host will never + // send one", so there is no placeholder to show — the slot stays empty. for (const total of [null, undefined]) { const markup = renderWithLineTotal(total) expect(markup).not.toContain('data-testid="source-control-branch-line-total"') + expect(markup).not.toContain('animate-pulse') expect(markup).not.toContain('NaN') } }) @@ -382,3 +391,156 @@ describe('SourceControlBranchContextRow branch line total', () => { } }) }) + +// The hover-card mock at the top renders content inline, so these assert the +// breakdown copy without driving a real hover. +describe('SourceControlBranchContextRow line total test split', () => { + it('breaks the panel down into test and non-test halves', () => { + const markup = renderWithLineTotal({ + added: 243, + removed: 149, + mergeBase: 'base', + test: { added: 120, removed: 40 } + }) + + expect(markup).toContain('Code breakdown') + expect(markup).toContain('Lines of code') + expect(markup).toContain('data-testid="source-control-branch-line-total-breakdown"') + expect(markup).toContain('Non-test') + expect(markup).toContain('>+123<') + expect(markup).toContain('>-109<') + expect(markup).toContain('Tests') + expect(markup).toContain('>+120<') + expect(markup).toContain('>-40<') + // Non-test first so the primary share leads the panel. + expect(markup.indexOf('Non-test')).toBeLessThan(markup.indexOf('Tests')) + }) + + it('spells the split into the label so the summary is announced closed', () => { + const markup = renderWithLineTotal({ + added: 243, + removed: 149, + mergeBase: 'base', + test: { added: 120, removed: 40 } + }) + + expect(markup).toContain( + 'aria-label="243 lines added, 149 lines deleted — test code: 120 lines added, 40 lines deleted"' + ) + }) + + it('keeps a zero test share visible rather than hiding the row', () => { + const markup = renderWithLineTotal({ + added: 243, + removed: 149, + mergeBase: 'base', + test: { added: 0, removed: 0 } + }) + + expect(markup).toContain('>+0<') + expect(markup).toContain('>-0<') + expect(markup).toContain('>+243<') + expect(markup).toContain('>-149<') + // Panel keeps the zero row; the spoken label stays the main totals only. + expect(markup).toContain('aria-label="243 lines added, 149 lines deleted"') + expect(markup).not.toContain('test code:') + }) + + // A host predating the split omits the field; inventing a 0% test share there + // would be a confidently wrong claim. + it('renders exactly as before when the host reported no split', () => { + const withoutSplit = renderWithLineTotal({ added: 243, removed: 149, mergeBase: 'base' }) + + expect(withoutSplit).not.toContain('Non-test') + expect(withoutSplit).not.toContain('Code breakdown') + expect(withoutSplit).toContain('aria-label="243 lines added, 149 lines deleted"') + }) + + it('labels the remainder Source once generated is known, and shows that row', () => { + const markup = renderWithLineTotal({ + added: 243, + removed: 149, + mergeBase: 'base', + test: { added: 20, removed: 10 }, + generated: { added: 100, removed: 50 } + }) + + expect(markup).toContain('Source') + expect(markup).not.toContain('Non-test') + expect(markup).toContain('Generated') + expect(markup).toContain('>+123<') // 243-20-100 + expect(markup).toContain('>-89<') // 149-10-50 + expect(markup).toContain('>+100<') + expect(markup).toContain('>-50<') + expect(markup).toContain( + 'aria-label="243 lines added, 149 lines deleted — test code: 20 lines added, 10 lines deleted — generated: 100 lines added, 50 lines deleted"' + ) + }) + + // The remainder here still contains tests, so calling it "Source" would claim + // a split the host never sent. + it('labels the remainder Non-generated when the host omitted the test field', () => { + const markup = renderWithLineTotal({ + added: 243, + removed: 149, + mergeBase: 'base', + generated: { added: 100, removed: 50 } + }) + + expect(markup).toContain('Non-generated') + expect(markup).toContain('>Generated<') + expect(markup).not.toContain('>Source<') + expect(markup).not.toContain('Tests') + expect(markup).toContain('>+143<') // 243-100 + expect(markup).toContain('>-99<') // 149-50 + }) + + // "No tests in this branch" is worth stating; "nothing was generated" is the + // normal case, so that row is dropped rather than shown as +0 -0. + it('drops the generated row and its announcement when nothing was generated', () => { + const markup = renderWithLineTotal({ + added: 243, + removed: 149, + mergeBase: 'base', + test: { added: 120, removed: 40 }, + generated: { added: 0, removed: 0 } + }) + + expect(markup).not.toContain('Generated') + expect(markup).not.toContain('generated:') + // The host did classify and found nothing, so the remainder is still Source. + expect(markup).toContain('Source') + expect(markup).toContain('Tests') + expect(markup).toContain('>+123<') + }) + + it('orders the rows source, tests, generated', () => { + const markup = renderWithLineTotal({ + added: 1243, + removed: 149, + mergeBase: 'base', + test: { added: 120, removed: 40 }, + generated: { added: 1000, removed: 0 } + }) + + expect(markup.indexOf('Source')).toBeLessThan(markup.indexOf('Tests')) + expect(markup.indexOf('Tests')).toBeLessThan(markup.indexOf('Generated')) + expect(markup).toContain('>+1,000<') + }) + + // The three rows must account for every line, or the panel contradicts the chip. + it('keeps the three rows summing back to the chip total', () => { + const markup = renderWithLineTotal({ + added: 500, + removed: 200, + mergeBase: 'base', + test: { added: 150, removed: 60 }, + generated: { added: 300, removed: 100 } + }) + + expect(markup).toContain('>+50<') // 500-150-300 + expect(markup).toContain('>-40<') // 200-60-100 + expect(markup).toContain('>+150<') + expect(markup).toContain('>+300<') + }) +}) diff --git a/src/renderer/src/components/right-sidebar/source-control-branch-line-total-chip.tsx b/src/renderer/src/components/right-sidebar/source-control-branch-line-total-chip.tsx index d7256ac0e8b..6db2d9aacec 100644 --- a/src/renderer/src/components/right-sidebar/source-control-branch-line-total-chip.tsx +++ b/src/renderer/src/components/right-sidebar/source-control-branch-line-total-chip.tsx @@ -1,5 +1,6 @@ import React, { useMemo } from 'react' import type { GitBranchLineTotal } from '../../../../shared/git-status-types' +import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card' import { getIntlLocale, translate } from '@/i18n/i18n' // Why: raw digits, not the grouped display string — screen readers announce @@ -8,26 +9,106 @@ function buildAccessibleLabel(added: number, removed: number): string { if (added > 0 && removed > 0) { return translate( 'auto.components.right.sidebar.source.control.branch.line.total.chip.daa8e8e59b', - '{{value0}} additions, {{value1}} deletions', + '{{value0}} lines added, {{value1}} lines deleted', { value0: added, value1: removed } ) } if (added > 0) { return translate( 'auto.components.right.sidebar.source.control.branch.line.total.chip.8a9b97b666', - '{{value0}} additions', + '{{value0}} lines added', { value0: added } ) } return translate( 'auto.components.right.sidebar.source.control.branch.line.total.chip.52c366d88d', - '{{value0}} deletions', + '{{value0}} lines deleted', { value0: removed } ) } -// Absent, incomplete and genuinely-empty all render as nothing: no `+0 -0`, no -// spinner, no reserved width. Not clickable — `openBranchAllDiffs` is narrower. +// The chip takes no focus; the split still reaches assistive tech through the +// label rather than the hover-only panel. +function appendTestSplitToLabel(label: string, testAdded: number, testRemoved: number): string { + return translate( + 'auto.components.right.sidebar.source.control.branch.line.total.chip.4c1f70ba92', + '{{value0}} — test code: {{value1}} lines added, {{value2}} lines deleted', + { value0: label, value1: testAdded, value2: testRemoved } + ) +} + +function appendGeneratedSplitToLabel(label: string, added: number, removed: number): string { + return translate( + 'auto.components.right.sidebar.source.control.branch.line.total.chip.7f3e1a9c24', + '{{value0}} — generated: {{value1}} lines added, {{value2}} lines deleted', + { value0: label, value1: added, value2: removed } + ) +} + +type LineTotalSplitRow = { key: string; label: string; added: number; removed: number } + +function LineCountPair({ + added, + removed, + locale +}: { + added: number + removed: number + locale: string +}): React.JSX.Element { + return ( + + + +{added.toLocaleString(locale)} + + + -{removed.toLocaleString(locale)} + + + ) +} + +function CodeBreakdownPanel({ + rows, + locale +}: { + rows: LineTotalSplitRow[] + locale: string +}): React.JSX.Element { + return ( +
+
+
+ {translate( + 'auto.components.right.sidebar.source.control.branch.line.total.chip.a1b2c3d4e5', + 'Code breakdown' + )} +
+
+ {translate( + 'auto.components.right.sidebar.source.control.branch.line.total.chip.b2c3d4e5f6', + 'Lines of code' + )} +
+
+
+ {rows.map((row) => ( +
+ {row.label} + +
+ ))} +
+
+ ) +} + +// Genuinely-empty (`+0 -0`), not-yet-published and unknown-after-timeout all +// render as nothing: no reserved width, no placeholder. There is no loading +// state because a null total is indistinguishable from permanent absence (old +// host, failed diff, cooldown), and the store now keeps the last published +// total across a soft miss so the slot rarely empties once filled. +// Not clickable — `openBranchAllDiffs` is narrower. export const SourceControlBranchLineTotalChip = React.memo( function SourceControlBranchLineTotalChip({ branchLineTotal @@ -38,12 +119,93 @@ export const SourceControlBranchLineTotalChip = React.memo( const removed = branchLineTotal?.removed ?? 0 const hasAdded = added > 0 const hasRemoved = removed > 0 + // Why: hosts predating either split omit the field; inventing a zero share + // there would be confidently wrong, so the breakdown is dropped per field. + const testTotal = branchLineTotal?.test + const testAdded = testTotal?.added + const testRemoved = testTotal?.removed + const generatedTotal = branchLineTotal?.generated + const generatedAdded = generatedTotal?.added + const generatedRemoved = generatedTotal?.removed // Full precision and app-locale-aware; a status tick that leaves the counts // alone must not rebuild these strings. const locale = getIntlLocale() const addedText = useMemo(() => added.toLocaleString(locale), [added, locale]) const removedText = useMemo(() => removed.toLocaleString(locale), [removed, locale]) - const accessibleLabel = useMemo(() => buildAccessibleLabel(added, removed), [added, removed]) + const accessibleLabel = useMemo(() => { + let label = buildAccessibleLabel(added, removed) + // Zero test share still shows in the hover panel; don't announce noise. + if (testAdded != null && testRemoved != null && (testAdded > 0 || testRemoved > 0)) { + label = appendTestSplitToLabel(label, testAdded, testRemoved) + } + if ( + generatedAdded != null && + generatedRemoved != null && + (generatedAdded > 0 || generatedRemoved > 0) + ) { + label = appendGeneratedSplitToLabel(label, generatedAdded, generatedRemoved) + } + return label + }, [added, removed, testAdded, testRemoved, generatedAdded, generatedRemoved]) + const splitRows = useMemo(() => { + // Why: the fields ship together today but the type is independent so a + // host can gain one before the other; either alone is enough to render. + const hasTest = testAdded != null && testRemoved != null + const hasGenerated = generatedAdded != null && generatedRemoved != null + if (!hasTest && !hasGenerated) { + return [] + } + + const rows: LineTotalSplitRow[] = [ + { + key: 'source', + // Only "Source" once both splits are known — with one missing the + // remainder still contains the other bucket, so name what was taken out. + label: + hasTest && hasGenerated + ? translate( + 'auto.components.right.sidebar.source.control.branch.line.total.chip.c8d5b21e07', + 'Source' + ) + : hasTest + ? translate( + 'auto.components.right.sidebar.source.control.branch.line.total.chip.9e4a3c5081', + 'Non-test' + ) + : translate( + 'auto.components.right.sidebar.source.control.branch.line.total.chip.3d7e9b1042', + 'Non-generated' + ), + added: added - (testAdded ?? 0) - (generatedAdded ?? 0), + removed: removed - (testRemoved ?? 0) - (generatedRemoved ?? 0) + } + ] + if (hasTest) { + rows.push({ + key: 'test', + label: translate( + 'auto.components.right.sidebar.source.control.branch.line.total.chip.6b2d0f14a7', + 'Tests' + ), + added: testAdded, + removed: testRemoved + }) + } + // Why: "no tests in this branch" is worth showing as +0 -0; "nothing was + // generated" is the normal case, so that row is dropped instead. + if (hasGenerated && (generatedAdded > 0 || generatedRemoved > 0)) { + rows.push({ + key: 'generated', + label: translate( + 'auto.components.right.sidebar.source.control.branch.line.total.chip.7a04c6f8b3', + 'Generated' + ), + added: generatedAdded, + removed: generatedRemoved + }) + } + return rows + }, [added, removed, testAdded, testRemoved, generatedAdded, generatedRemoved]) if (!hasAdded && !hasRemoved) { return null @@ -51,12 +213,18 @@ export const SourceControlBranchLineTotalChip = React.memo( // Why: no fixed `ch` width — that clips at 5+ digits; `tabular-nums` alone // keeps digits from jittering between refreshes. - return ( + // `cursor-help` signals hover detail without implying a click target. + const hasBreakdown = splitRows.length > 0 + const chip = ( {hasAdded ? ( ) + + if (!hasBreakdown) { + return chip + } + + return ( + + {chip} + + + + + ) } ) diff --git a/src/renderer/src/components/right-sidebar/source-control-header-toolbar-identity.test.tsx b/src/renderer/src/components/right-sidebar/source-control-header-toolbar-identity.test.tsx index 4147569b7de..fac0f0b6ded 100644 --- a/src/renderer/src/components/right-sidebar/source-control-header-toolbar-identity.test.tsx +++ b/src/renderer/src/components/right-sidebar/source-control-header-toolbar-identity.test.tsx @@ -132,7 +132,7 @@ describe('SourceControlHeaderToolbar branch identity', () => { it('threads the branch line total down to the base-ref line', () => { const markup = renderToolbar({ branchLineTotal: { added: 24, removed: 3, mergeBase: 'base' } }) - expect(markup).toContain('aria-label="24 additions, 3 deletions"') + expect(markup).toContain('aria-label="24 lines added, 3 lines deleted"') expect(markup).toContain('+24') expect(markup).toContain('-3') }) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 962bf2dbec1..568e139ebed 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -11359,9 +11359,18 @@ "line": { "total": { "chip": { - "daa8e8e59b": "{{value0}} additions, {{value1}} deletions", - "8a9b97b666": "{{value0}} additions", - "52c366d88d": "{{value0}} deletions" + "daa8e8e59b": "{{value0}} lines added, {{value1}} lines deleted", + "8a9b97b666": "{{value0}} lines added", + "52c366d88d": "{{value0}} lines deleted", + "4c1f70ba92": "{{value0}} — test code: {{value1}} lines added, {{value2}} lines deleted", + "6b2d0f14a7": "Tests", + "9e4a3c5081": "Non-test", + "3d7e9b1042": "Non-generated", + "a1b2c3d4e5": "Code breakdown", + "b2c3d4e5f6": "Lines of code", + "7f3e1a9c24": "{{value0}} — generated: {{value1}} lines added, {{value2}} lines deleted", + "c8d5b21e07": "Source", + "7a04c6f8b3": "Generated" } } } diff --git a/src/renderer/src/store/slices/editor-branch-line-total.test.ts b/src/renderer/src/store/slices/editor-branch-line-total.test.ts index c4f88540951..2b7132085da 100644 --- a/src/renderer/src/store/slices/editor-branch-line-total.test.ts +++ b/src/renderer/src/store/slices/editor-branch-line-total.test.ts @@ -69,7 +69,7 @@ describe('createEditorSlice branch line total', () => { }) }) - it('clears a stored total when a later status omits the field', () => { + it('keeps the published total when a later status omits the field', () => { const store = createEditorStore() store .getState() @@ -77,12 +77,25 @@ describe('createEditorSlice branch line total', () => { 'wt-1', status({ branchLineTotal: { added: 24, removed: 3, mergeBase: MERGE_BASE } }) ) + const published = store.getState().gitBranchLineTotalByWorktree['wt-1'] - // Why: an omitted field means "not known exact" — an old server, a failed - // numstat, or a timeout. Keeping the old number renders a confident lie. + // Why: an omitted field is "not computed on this pass" — a soft-deadline + // miss or a cooldown — and the host keeps backfilling its cache. Clearing + // it blanked the chip mid-poll and then flashed it back. store.getState().setGitStatus('wt-1', status()) - expect(store.getState().gitBranchLineTotalByWorktree['wt-1']).toBeUndefined() + expect(store.getState().gitBranchLineTotalByWorktree['wt-1']).toBe(published) + }) + + it('does not produce a new state object when a status merely omits the total', () => { + const store = createEditorStore() + const tick = status({ branchLineTotal: { added: 24, removed: 3, mergeBase: MERGE_BASE } }) + store.getState().setGitStatus('wt-1', tick) + const before = store.getState() + + store.getState().setGitStatus('wt-1', { ...tick, branchLineTotal: undefined }) + + expect(store.getState()).toBe(before) }) it('clears a stored total when the listing hits the entry cap', () => { @@ -132,7 +145,7 @@ describe('createEditorSlice branch line total', () => { status({ branchLineTotal: { added: 1, removed: 1, mergeBase: 'other' } }) ) - store.getState().setGitStatus('wt-1', status()) + store.getState().setGitStatus('wt-1', status({ didHitLimit: true, statusLength: 2 })) expect(store.getState().gitBranchLineTotalByWorktree['wt-1']).toBeUndefined() expect(store.getState().gitBranchLineTotalByWorktree['wt-2']).toEqual({ @@ -203,6 +216,69 @@ describe('createEditorSlice branch line total', () => { }) }) + // Moving a line from a source file into a test file leaves the totals + // identical; without the split in the equality check the hover would keep + // showing the old share. + it('produces a new state object when only the test split changed', () => { + const store = createEditorStore() + const tick = status({ + branchLineTotal: { + added: 24, + removed: 3, + mergeBase: MERGE_BASE, + test: { added: 4, removed: 0 } + } + }) + store.getState().setGitStatus('wt-1', tick) + const before = store.getState() + + store.getState().setGitStatus('wt-1', { + ...tick, + branchLineTotal: { + added: 24, + removed: 3, + mergeBase: MERGE_BASE, + test: { added: 5, removed: 1 } + } + }) + + expect(store.getState()).not.toBe(before) + expect(store.getState().gitBranchLineTotalByWorktree['wt-1']?.test).toEqual({ + added: 5, + removed: 1 + }) + }) + + it('produces a new state object when only the generated split changed', () => { + const store = createEditorStore() + const tick = status({ + branchLineTotal: { + added: 24, + removed: 3, + mergeBase: MERGE_BASE, + generated: { added: 8, removed: 0 } + } + }) + store.getState().setGitStatus('wt-1', tick) + const before = store.getState() + + store.getState().setGitStatus('wt-1', { + ...tick, + branchLineTotal: { + added: 24, + removed: 3, + mergeBase: MERGE_BASE, + generated: { added: 12, removed: 1 } + } + }) + + expect(store.getState()).not.toBe(before) + expect(store.getState().gitBranchLineTotalByWorktree['wt-1']?.generated).toEqual({ + added: 12, + removed: 1 + }) + }) + it('leaves other worktree entries referentially stable when one total changes', () => { const store = createEditorStore() store diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts index e3123db9cd1..971ab20cba6 100644 --- a/src/renderer/src/store/slices/editor.ts +++ b/src/renderer/src/store/slices/editor.ts @@ -4193,15 +4193,29 @@ export const createEditorSlice: StateCreator = (s const hugeUnchanged = (prevHuge?.limit ?? null) === (nextHuge?.limit ?? null) const prevBranchLineTotal = s.gitBranchLineTotalByWorktree[worktreeId] ?? null - // Why: an omitted field means "not known exact"; keeping the old total would render a confidently wrong chip. - const nextBranchLineTotal = status.branchLineTotal ?? null + // Why: an omitted field means "not computed on this pass" — soft-deadline + // miss, cooldown, old host — not "zero", so dropping it blanks a published + // chip between polls. Staleness is handled where it can be: the host + // carries its cache forward, and SourceControl hides any total whose + // mergeBase no longer matches the fork point. A capped listing skips the + // ranged diff outright, so nothing will refresh it — clear it there. + const nextBranchLineTotal = status.didHitLimit + ? null + : (status.branchLineTotal ?? prevBranchLineTotal) const branchLineTotalUnchanged = prevBranchLineTotal === nextBranchLineTotal || (prevBranchLineTotal !== null && nextBranchLineTotal !== null && prevBranchLineTotal.added === nextBranchLineTotal.added && prevBranchLineTotal.removed === nextBranchLineTotal.removed && - prevBranchLineTotal.mergeBase === nextBranchLineTotal.mergeBase) + prevBranchLineTotal.mergeBase === nextBranchLineTotal.mergeBase && + (prevBranchLineTotal.test?.added ?? null) === (nextBranchLineTotal.test?.added ?? null) && + (prevBranchLineTotal.test?.removed ?? null) === + (nextBranchLineTotal.test?.removed ?? null) && + (prevBranchLineTotal.generated?.added ?? null) === + (nextBranchLineTotal.generated?.added ?? null) && + (prevBranchLineTotal.generated?.removed ?? null) === + (nextBranchLineTotal.generated?.removed ?? null)) const prevStatusHead = s.gitStatusHeadByWorktree[worktreeId] const nextStatusHead = getKnownGitHead(status.head) diff --git a/src/shared/generated-code-path.test.ts b/src/shared/generated-code-path.test.ts new file mode 100644 index 00000000000..6af7617cf40 --- /dev/null +++ b/src/shared/generated-code-path.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest' +import { isGeneratedCodePath } from './generated-code-path' + +describe('isGeneratedCodePath', () => { + it('recognizes dependency lockfiles across ecosystems', () => { + for (const filePath of [ + 'package-lock.json', + 'pnpm-lock.yaml', + 'yarn.lock', + 'bun.lockb', + 'Cargo.lock', + 'poetry.lock', + 'uv.lock', + 'Gemfile.lock', + 'composer.lock', + 'go.sum', + 'flake.lock', + 'mobile/pubspec.lock', + 'services/api/Pipfile.lock' + ]) { + expect(isGeneratedCodePath(filePath), filePath).toBe(true) + } + }) + + it('recognizes tool-stamped filename suffixes', () => { + for (const filePath of [ + 'api/service.pb.go', + // grpc-gateway / protoc-gen-validate stack extra segments onto `.pb.` + 'api/service.pb.gw.go', + 'api/service.pb.validate.go', + 'api/service_pb2.py', + 'api/service_pb2_grpc.py', + 'src/schema.gen.ts', + 'internal/bindings_generated.go', + 'src/Api.generated.ts', + 'Forms/Main.Designer.cs', + 'lib/model.g.dart', + 'lib/model.freezed.dart', + 'public/app.min.js', + 'public/app.js.map', + 'src/components/__snapshots__/Chip.tsx.snap' + ]) { + expect(isGeneratedCodePath(filePath), filePath).toBe(true) + } + }) + + it('recognizes generated directories on either separator', () => { + expect(isGeneratedCodePath('dist/renderer/index.js')).toBe(true) + expect(isGeneratedCodePath('src/__generated__/schema.ts')).toBe(true) + expect(isGeneratedCodePath('vendor/github.com/pkg/errors/errors.go')).toBe(true) + expect(isGeneratedCodePath('src\\__pycache__\\mod.pyc')).toBe(true) + }) + + it('leaves hand-written paths alone, including the ambiguous directory names', () => { + for (const filePath of [ + 'src/shared/git-branch-line-total.ts', + // Excluded on purpose: all common as authored source directories. + 'build/scripts/release.ts', + 'target/tracker.rs', + 'src/out/renderer.ts', + 'cmd/bin/main.go', + // Substrings, not whole segments or suffixes. + 'src/distributed/queue.ts', + 'src/vendors/stripe.ts', + 'src/generator/emit.ts', + 'docs/generated-code-policy.md', + 'src/shared/generated-code-path.ts' + ]) { + expect(isGeneratedCodePath(filePath), filePath).toBe(false) + } + }) +}) diff --git a/src/shared/generated-code-path.ts b/src/shared/generated-code-path.ts new file mode 100644 index 00000000000..b15dd020443 --- /dev/null +++ b/src/shared/generated-code-path.ts @@ -0,0 +1,50 @@ +/** + * Path-only heuristic for "was this file written by a tool?", used to carve + * machine-authored lines out of the branch total. A regenerated lockfile or + * protobuf stub can dwarf every hand-written line in a branch, which is what + * makes a bare `+8,259` misleading. + * + * Conservative on purpose: a false positive understates the real work, so only + * names that are unambiguous across ecosystems are listed. Matched against the + * raw path with anchored regexes, for the reasons in `test-code-path.ts`. + */ + +// Deliberately excludes `build`, `target`, `out` and `bin` — all common as +// hand-written source directories. +const GENERATED_DIRECTORY = + /(?:^|[/\\])(?:__generated__|__pycache__|\.next|\.nuxt|coverage|dist|generated|node_modules|vendor)[/\\]/i + +// Every dependency lockfile is regenerated wholesale, so its diff is churn +// rather than authored change. Kept separate from the suffix alternation below +// rather than merged into it: measured over 12.5k paths, two narrow anchored +// regexes reject a non-match faster than one wide alternation. +const LOCKFILE = + /(?:^|[/\\])(?:bun\.lockb?|cargo\.lock|composer\.lock|flake\.lock|gemfile\.lock|go\.sum|gradle\.lockfile|mix\.lock|npm-shrinkwrap\.json|package-lock\.json|packages\.lock\.json|pipfile\.lock|pnpm-lock\.yaml|poetry\.lock|pubspec\.lock|uv\.lock|yarn\.lock)$/i + +// The suffixes tools stamp onto their own output. +const GENERATED_BASENAME = new RegExp( + `(?:${[ + /\.(?:generated|designer)\.[^./\\]+/, // Foo.generated.ts, Form.Designer.cs + // Trailing segments are optional so plugin output keeps matching: + // service.pb.go, service_pb2.py, service.pb.gw.go, service.pb.validate.go. + /[._](?:pb|pb2|pb2_grpc)\.(?:[^./\\]+\.)*[^./\\]+/, + /[._]gen\.[^./\\]+/, // schema.gen.ts, mock_gen.go + /_generated\.[^./\\]+/, // bindings_generated.go + /\.(?:g|freezed)\.dart/, // model.g.dart + /\.min\.(?:js|css|mjs)/, + /\.(?:js|css|mjs)\.map/, // source maps + /\.snap/ // vitest/jest snapshots, rewritten by `-u` + ] + .map((pattern) => pattern.source) + .join('|')})$`, + 'i' +) + +/** Accepts POSIX and Windows separators — git reports `/`, callers may not. */ +export function isGeneratedCodePath(filePath: string): boolean { + return ( + GENERATED_BASENAME.test(filePath) || + LOCKFILE.test(filePath) || + GENERATED_DIRECTORY.test(filePath) + ) +} diff --git a/src/shared/git-branch-line-total.test.ts b/src/shared/git-branch-line-total.test.ts index 054c4fc6897..1716da811d6 100644 --- a/src/shared/git-branch-line-total.test.ts +++ b/src/shared/git-branch-line-total.test.ts @@ -15,6 +15,7 @@ import { import type { GitLineStats } from './git-uncommitted-line-stats' const MERGE_BASE = 'a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4' +const NO_LINES = { added: 0, removed: 0 } const OTHER_MERGE_BASE = '0f1e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c' function statsMap(entries: Record): ReadonlyMap { @@ -58,7 +59,7 @@ describe('sumGitBranchLineTotal', () => { }), untracked: statsMap({}) }) - ).toEqual({ added: 4, removed: 1, mergeBase: MERGE_BASE }) + ).toEqual({ added: 4, removed: 1, mergeBase: MERGE_BASE, test: NO_LINES, generated: NO_LINES }) }) it('counts a half-binary entry on the side git did report', () => { @@ -68,7 +69,7 @@ describe('sumGitBranchLineTotal', () => { tracked: statsMap({ 'odd.txt': { added: 3 } }), untracked: statsMap({ 'new.bin': {} }) }) - ).toEqual({ added: 3, removed: 0, mergeBase: MERGE_BASE }) + ).toEqual({ added: 3, removed: 0, mergeBase: MERGE_BASE, test: NO_LINES, generated: NO_LINES }) }) it('adds untracked additions on top of the tracked range and echoes the merge base', () => { @@ -78,7 +79,7 @@ describe('sumGitBranchLineTotal', () => { tracked: statsMap({ 'src/a.ts': { added: 10, removed: 2 } }), untracked: statsMap({ 'src/new.ts': { added: 7, removed: 0 }, 'src/also.ts': { added: 1 } }) }) - ).toEqual({ added: 18, removed: 2, mergeBase: MERGE_BASE }) + ).toEqual({ added: 18, removed: 2, mergeBase: MERGE_BASE, test: NO_LINES, generated: NO_LINES }) }) it('returns an all-zero total for a pure rename rather than omitting it', () => { @@ -88,7 +89,71 @@ describe('sumGitBranchLineTotal', () => { tracked: statsMap({ 'g.txt': { added: 0, removed: 0 } }), untracked: statsMap({}) }) - ).toEqual({ added: 0, removed: 0, mergeBase: MERGE_BASE }) + ).toEqual({ added: 0, removed: 0, mergeBase: MERGE_BASE, test: NO_LINES, generated: NO_LINES }) + }) + + it('splits out the test-file share of both halves, untracked files included', () => { + expect( + sumGitBranchLineTotal({ + mergeBase: MERGE_BASE, + tracked: statsMap({ + 'src/a.ts': { added: 10, removed: 2 }, + 'src/a.test.ts': { added: 30, removed: 5 }, + 'e2e/login.ts': { added: 4, removed: 1 } + }), + untracked: statsMap({ 'src/b.spec.ts': { added: 6 }, 'src/b.ts': { added: 2 } }) + }) + ).toEqual({ + added: 52, + removed: 8, + mergeBase: MERGE_BASE, + test: { added: 40, removed: 6 }, + generated: NO_LINES + }) + }) + + it('splits generated lines out of both the total and the test share', () => { + expect( + sumGitBranchLineTotal({ + mergeBase: MERGE_BASE, + tracked: statsMap({ + 'src/a.ts': { added: 10, removed: 2 }, + 'src/a.test.ts': { added: 30, removed: 5 }, + 'pnpm-lock.yaml': { added: 900, removed: 400 }, + 'api/service.pb.go': { added: 120, removed: 0 }, + // Generated wins the overlap: a snapshot is both, and counts as churn. + 'src/__snapshots__/a.tsx.snap': { added: 50, removed: 20 } + }), + untracked: statsMap({ 'dist/bundle.js': { added: 5000 } }) + }) + ).toEqual({ + added: 6110, + removed: 427, + mergeBase: MERGE_BASE, + test: { added: 30, removed: 5 }, + generated: { added: 6070, removed: 420 } + }) + }) + + it('keeps the three buckets disjoint so they sum back to the total', () => { + const total = sumGitBranchLineTotal({ + mergeBase: MERGE_BASE, + tracked: statsMap({ + 'src/a.ts': { added: 10, removed: 2 }, + 'src/a.test.ts': { added: 30, removed: 5 }, + 'go.sum': { added: 7, removed: 3 }, + 'tests/fixtures/repo.json': { added: 4, removed: 1 } + }), + untracked: statsMap({ 'src/b.ts': { added: 2 } }) + }) + + const source = { + added: total.added - total.test!.added - total.generated!.added, + removed: total.removed - total.test!.removed - total.generated!.removed + } + expect(source).toEqual({ added: 12, removed: 2 }) + expect(total.test).toEqual({ added: 34, removed: 6 }) + expect(total.generated).toEqual({ added: 7, removed: 3 }) }) }) @@ -178,7 +243,13 @@ describe('computeGitBranchLineTotal', () => { untrackedPaths: ['new.txt'], runDiffNumstat: async () => '4\t1\tsrc/a.ts\0' }) - ).resolves.toEqual({ added: 7, removed: 1, mergeBase: MERGE_BASE }) + ).resolves.toEqual({ + added: 7, + removed: 1, + mergeBase: MERGE_BASE, + test: NO_LINES, + generated: NO_LINES + }) }) it('omits the total when the ranged numstat fails instead of publishing untracked-only zeros', async () => { @@ -218,8 +289,8 @@ describe('computeGitBranchLineTotal', () => { release() expect(await both).toEqual([ - { added: 4, removed: 1, mergeBase: MERGE_BASE }, - { added: 4, removed: 1, mergeBase: MERGE_BASE } + { added: 4, removed: 1, mergeBase: MERGE_BASE, test: NO_LINES, generated: NO_LINES }, + { added: 4, removed: 1, mergeBase: MERGE_BASE, test: NO_LINES, generated: NO_LINES } ]) expect(runDiffNumstat).toHaveBeenCalledTimes(1) }) @@ -365,7 +436,13 @@ describe('computeGitBranchLineTotal ranged-diff cooldown', () => { } } - const TOTAL = { added: 10, removed: 2, mergeBase: MERGE_BASE } + const TOTAL = { + added: 10, + removed: 2, + mergeBase: MERGE_BASE, + test: NO_LINES, + generated: NO_LINES + } beforeEach(() => { nowMs = 0 diff --git a/src/shared/git-branch-line-total.ts b/src/shared/git-branch-line-total.ts index 270b2870488..cfd841a46b6 100644 --- a/src/shared/git-branch-line-total.ts +++ b/src/shared/git-branch-line-total.ts @@ -5,6 +5,8 @@ import { parseNumstat, type GitLineStats } from './git-uncommitted-line-stats' +import { isGeneratedCodePath } from './generated-code-path' +import { isTestCodePath } from './test-code-path' export type { GitBranchLineTotal } @@ -87,17 +89,37 @@ export function sumGitBranchLineTotal(input: { }): GitBranchLineTotal { let added = 0 let removed = 0 - for (const stats of input.tracked.values()) { - // Binary files parse to undefined in numstat and contribute nothing, matching - // the per-file rows. - added += stats.added ?? 0 - removed += stats.removed ?? 0 + let testAdded = 0 + let testRemoved = 0 + let generatedAdded = 0 + let generatedRemoved = 0 + for (const source of [input.tracked, input.untracked]) { + for (const [filePath, stats] of source) { + // Binary files parse to undefined in numstat and contribute nothing, matching + // the per-file rows. + const fileAdded = stats.added ?? 0 + const fileRemoved = stats.removed ?? 0 + added += fileAdded + removed += fileRemoved + // Generated wins the overlap (a snapshot is both): the point of the bucket + // is to separate authored lines from churn, and a regenerated file is churn + // wherever it lives. + if (isGeneratedCodePath(filePath)) { + generatedAdded += fileAdded + generatedRemoved += fileRemoved + } else if (isTestCodePath(filePath)) { + testAdded += fileAdded + testRemoved += fileRemoved + } + } } - for (const stats of input.untracked.values()) { - added += stats.added ?? 0 - removed += stats.removed ?? 0 + return { + added, + removed, + mergeBase: input.mergeBase, + test: { added: testAdded, removed: testRemoved }, + generated: { added: generatedAdded, removed: generatedRemoved } } - return { added, removed, mergeBase: input.mergeBase } } // Why: one shared exec per (host, worktree, mergeBase). The renderer's own diff --git a/src/shared/git-status-types.ts b/src/shared/git-status-types.ts index 7b6c6e12c81..23b57aebbc1 100644 --- a/src/shared/git-status-types.ts +++ b/src/shared/git-status-types.ts @@ -59,6 +59,10 @@ export type GitBranchLineTotal = { added: number removed: number mergeBase: string + // Path-heuristic buckets of added/removed; generated wins on overlap. Optional + // for pre-split hosts (absent = unknown, not zero). + test?: { added: number; removed: number } + generated?: { added: number; removed: number } } export type GitStatusResult = { diff --git a/src/shared/test-code-path.test.ts b/src/shared/test-code-path.test.ts new file mode 100644 index 00000000000..778158c47a7 --- /dev/null +++ b/src/shared/test-code-path.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest' +import { isTestCodePath } from './test-code-path' + +describe('isTestCodePath', () => { + it('recognizes the per-ecosystem filename conventions', () => { + for (const filePath of [ + 'src/renderer/src/components/Chip.test.tsx', + 'src/shared/git-status.spec.js', + 'internal/server/handler_test.go', + 'app/models/user_spec.rb', + 'tools/test_migration.py', + 'src/main/java/com/orca/GitTest.java', + 'src/Orca.Core/GitStatusTests.cs', + 'src/UserTest.kt', + 'AppTests.swift', + 'FooSpec.rb', + 'python/conftest.py', + 'src/components/__snapshots__/Chip.tsx.snap' + ]) { + expect(isTestCodePath(filePath), filePath).toBe(true) + } + }) + + it('recognizes test directories anywhere in the path, on either separator', () => { + expect(isTestCodePath('src/__tests__/git-status.ts')).toBe(true) + expect(isTestCodePath('tests/fixtures/repo.json')).toBe(true) + expect(isTestCodePath('e2e/login.ts')).toBe(true) + expect(isTestCodePath('src\\__mocks__\\fs.ts')).toBe(true) + }) + + it('matches whole segments, so production paths that merely contain the word do not count', () => { + for (const filePath of [ + 'src/latest/index.ts', + 'src/contest/leaderboard.ts', + 'src/testing-library-setup.ts', + 'docs/protest.md', + 'src/shared/git-status-types.ts', + // A `specs/` folder is usually specifications, not tests. + 'src/cli/specs/account.ts', + // Case-insensitive "…test.ext" suffixes hit ordinary type names. + 'src/Contest.java', + 'src/Latest.kt', + 'src/MyContest.php', + 'packages/spec.rb', + // The `test_` prefix is pytest's; outside Python it hits fixtures and pages. + 'src/pages/test_page.tsx', + 'fixtures/test_data.json' + ]) { + expect(isTestCodePath(filePath), filePath).toBe(false) + } + // ...but a real test inside one still matches on its basename. + expect(isTestCodePath('src/cli/specs/account.test.ts')).toBe(true) + expect(isTestCodePath('spec/models/user_spec.rb')).toBe(true) + }) + + it('handles a bare filename and an empty path without throwing', () => { + expect(isTestCodePath('chip.test.ts')).toBe(true) + expect(isTestCodePath('chip.ts')).toBe(false) + expect(isTestCodePath('')).toBe(false) + }) +}) diff --git a/src/shared/test-code-path.ts b/src/shared/test-code-path.ts new file mode 100644 index 00000000000..2b9a6e1b7b8 --- /dev/null +++ b/src/shared/test-code-path.ts @@ -0,0 +1,43 @@ +// Path-only test heuristic for branch line-total buckets. Conservative and +// anchored on the raw path (no lowercasing/splitting per file). + +// Whole path segments only, so `src/latest/x.ts` and `contest/` don't count. +// The trailing separator is what keeps a *file* named `test.ts` out of here. +// `spec/` is RSpec's; plural `specs/` is left out because it far more often +// holds specifications (this repo's `src/cli/specs/` is production code), and a +// real test inside one still matches on its `.spec.`/`_spec.` basename. +const TEST_DIRECTORY = + /(?:^|[/\\])(?:__mocks__|__snapshots__|__tests__|cypress|e2e|spec|tests?|testdata)[/\\]/i + +// One alternative per ecosystem convention; `[^./\\]` keeps each anchored match +// inside the basename. Joined into a single regex so V8 walks the path once. +const TEST_BASENAME = new RegExp( + `(?:${[ + /\.(?:test|spec)\.[^./\\]+/, // Chip.test.tsx, git-status.spec.js + /[-_](?:test|spec)s?\.[^./\\]+/, // handler_test.go, user_spec.rb + // `.py` only: the bare `test_` prefix is pytest's convention, and widening it + // to any extension swept in fixtures and pages (test_data.json, test_page.tsx). + // A production helper that happens to be named `test_client.py` is + // indistinguishable from a real module here — pytest collects it too. + /(?:^|[/\\])test_[^./\\]*\.py/, + /(?:^|[/\\])conftest\.py/, + /\.snap/ + ] + .map((pattern) => pattern.source) + .join('|')})$`, + 'i' +) + +// Case-sensitive on purpose: a case-insensitive `test`/`tests`/`spec` suffix +// falsely matches ordinary type names like Contest.java, Latest.kt, MyContest.php. +// JVM/C#/Swift/PHP/Ruby test types use a capital T/S in the conventional suffix. +const TEST_TYPE_NAME_SUFFIX = /(?:Test|Tests|Spec)\.(?:java|kt|kts|scala|groovy|cs|swift|php|rb)$/ + +/** Accepts POSIX and Windows separators — git reports `/`, callers may not. */ +export function isTestCodePath(filePath: string): boolean { + return ( + TEST_DIRECTORY.test(filePath) || + TEST_BASENAME.test(filePath) || + TEST_TYPE_NAME_SUFFIX.test(filePath) + ) +} From 6c9215a12750b85bee442e60d3d7dc388b77130f Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 7 Aug 2026 10:10:53 -0700 Subject: [PATCH 013/289] fix(worktrees): keep local base refresh failure toast sticky and named (#13059) * fix(worktrees): keep local base refresh failure toast sticky and named Create-time local-base refresh failures are easy to miss when the toast auto-dismisses. Stick the warning until dismiss, name the new workspace, surface the dirty owner path, and localize full detail sentences so ja/ko copy is not mid-clause English. * fix(i18n): use native commit terminology in local-base refresh toasts Address CodeRabbit feedback: replace mixed English "commits" with locale-native wording in ja/zh/ko failure detail strings, and shorten the sticky-toast comment. --- src/renderer/src/i18n/locales/en.json | 8 +- src/renderer/src/i18n/locales/es.json | 8 +- src/renderer/src/i18n/locales/ja.json | 8 +- src/renderer/src/i18n/locales/ko.json | 8 +- src/renderer/src/i18n/locales/zh.json | 8 +- .../src/store/slices/worktrees.test.ts | 87 ++++++++++++++++- src/renderer/src/store/slices/worktrees.ts | 95 ++++++++++++++----- 7 files changed, 189 insertions(+), 33 deletions(-) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 568e139ebed..3b986240bcf 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -294,8 +294,14 @@ "34a03a6565": "Keep {{value0}} up to date", "fa9299a66f": "Your new worktree is current, but local {{value0}} is {{value1}} {{value2}} behind. AI diffs may miss recent commits.", "14bc053a47": "Local {{value0}} was not refreshed", + "localBaseRefRefreshFailedForWorktree": "Local {{value0}} was not refreshed for \"{{value1}}\"", "4a18052018": "Local {{value0}} is behind {{value1}}", - "903b51c2ed": "Workspace created from {{value0}}, but Orca could not fast-forward local {{value1}} because {{value2}}", + "903b51c2ed": "Workspace created from {{value0}}, but Orca could not fast-forward local {{value1}}. {{value2}}", + "localBaseRefRefreshFailedDescriptionNamed": "Workspace \"{{value0}}\" was created from {{value1}}, but Orca could not fast-forward local {{value2}}. {{value3}}", + "localBaseRefRefreshFailedDetailDirtyNamed": "The worktree at {{value0}} (where local {{value1}} is checked out) has uncommitted changes. Commit, stash, or discard those changes, then update local {{value1}} manually.", + "localBaseRefRefreshFailedDetailDirty": "The worktree where local {{value0}} is checked out has uncommitted changes. Commit, stash, or discard those changes, then update local {{value0}} manually.", + "localBaseRefRefreshFailedDetailNotFastForward": "Local {{value0}} does not exist or cannot be fast-forwarded cleanly from the remote base. Check for local-only commits before updating it manually.", + "localBaseRefRefreshFailedDetailError": "Git returned an error while updating local {{value0}}. Check the repo for locked refs or unusual worktree state, then update local {{value0}} manually.", "0216895fb5": "Failed to delete branch", "c6cf133786": "This workspace is no longer available.", "19db0085fb": "Local branch deleted", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index e60f92ba08d..f0ff1fab3dd 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -239,8 +239,14 @@ "34a03a6565": "Mantener {{value0}} actualizado", "fa9299a66f": "Tu nuevo worktree está actualizado, pero el {{value0}} local está {{value1}} {{value2}} por detrás. Los diffs de IA pueden omitir commits recientes.", "14bc053a47": "No se actualizó el {{value0}} local", + "localBaseRefRefreshFailedForWorktree": "No se actualizó el {{value0}} local para \"{{value1}}\"", "4a18052018": "El {{value0}} local está {{value1}} por detrás", - "903b51c2ed": "Espacio de trabajo creado desde {{value0}}, pero Orca no pudo hacer fast-forward del {{value1}} local porque {{value2}}", + "903b51c2ed": "Espacio de trabajo creado desde {{value0}}, pero Orca no pudo hacer fast-forward del {{value1}} local. {{value2}}", + "localBaseRefRefreshFailedDescriptionNamed": "El espacio de trabajo \"{{value0}}\" se creó desde {{value1}}, pero Orca no pudo hacer fast-forward del {{value2}} local. {{value3}}", + "localBaseRefRefreshFailedDetailDirtyNamed": "El worktree en {{value0}} (donde está checked out el {{value1}} local) tiene cambios sin confirmar. Haz commit, stash o descarta esos cambios y luego actualiza el {{value1}} local manualmente.", + "localBaseRefRefreshFailedDetailDirty": "El worktree donde está checked out el {{value0}} local tiene cambios sin confirmar. Haz commit, stash o descarta esos cambios y luego actualiza el {{value0}} local manualmente.", + "localBaseRefRefreshFailedDetailNotFastForward": "El {{value0}} local no existe o no se puede hacer fast-forward de forma limpia desde la base remota. Revisa si hay commits solo locales antes de actualizarlo manualmente.", + "localBaseRefRefreshFailedDetailError": "Git devolvió un error al actualizar el {{value0}} local. Revisa el repo por refs bloqueados o un estado de worktree inusual y luego actualiza el {{value0}} local manualmente.", "0216895fb5": "No se pudo eliminar la rama", "19db0085fb": "Rama local eliminada", "2b0afc7f14": "No se pudo mantener actualizado el {{value0}} local", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index f008abfa70e..aaa9248e68a 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -239,8 +239,14 @@ "34a03a6565": "{{value0}} を最新の状態に保つ", "fa9299a66f": "新規ワークツリーは最新ですが、ローカル {{value0}} は {{value1}} {{value2}} 遅れています。 AI の差分では最近の commits が失われる可能性があります。", "14bc053a47": "ローカル {{value0}} は更新されませんでした", + "localBaseRefRefreshFailedForWorktree": "「{{value1}}」のローカル {{value0}} は更新されませんでした", "4a18052018": "ローカル {{value0}} は {{value1}} の後ろにあります", - "903b51c2ed": "ワークスペースは {{value0}} から作成されましたが、{{value2}} のため Orca はローカル {{value1}} を早送りできませんでした", + "903b51c2ed": "ワークスペースは {{value0}} から作成されましたが、Orca はローカル {{value1}} を早送りできませんでした。{{value2}}", + "localBaseRefRefreshFailedDescriptionNamed": "ワークスペース「{{value0}}」は {{value1}} から作成されましたが、Orca はローカル {{value2}} を早送りできませんでした。{{value3}}", + "localBaseRefRefreshFailedDetailDirtyNamed": "{{value0}} のワークツリー(ローカル {{value1}} がチェックアウトされている場所)に未コミットの変更があります。コミット、stash、または破棄してから、ローカル {{value1}} を手動で更新してください。", + "localBaseRefRefreshFailedDetailDirty": "ローカル {{value0}} がチェックアウトされているワークツリーに未コミットの変更があります。コミット、stash、または破棄してから、ローカル {{value0}} を手動で更新してください。", + "localBaseRefRefreshFailedDetailNotFastForward": "ローカル {{value0}} が存在しないか、リモートのベースからクリーンに早送りできません。手動で更新する前に、ローカルにのみ存在するコミットがないか確認してください。", + "localBaseRefRefreshFailedDetailError": "ローカル {{value0}} の更新中に Git がエラーを返しました。ロックされた refs や異常なワークツリー状態がないか確認し、ローカル {{value0}} を手動で更新してください。", "0216895fb5": "ブランチの削除に失敗しました", "19db0085fb": "ローカルブランチが削除されました", "2b0afc7f14": "ローカル {{value0}} を最新の状態に維持できませんでした", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 1b4c5c4fba2..f462a445800 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -239,8 +239,14 @@ "34a03a6565": "{{value0}}을(를) 최신 상태로 유지하세요", "fa9299a66f": "새 워크트리는 최신이지만 로컬 {{value0}}은(는) {{value1}} {{value2}} 뒤처져 있습니다. AI diff가 최근 commits을 놓칠 수 있습니다.", "14bc053a47": "로컬 {{value0}}이(가) 새로 고쳐지지 않았습니다.", + "localBaseRefRefreshFailedForWorktree": "\"{{value1}}\"의 로컬 {{value0}}이(가) 새로 고쳐지지 않았습니다.", "4a18052018": "로컬 {{value0}}이(가) {{value1}} 뒤에 있습니다.", - "903b51c2ed": "{{value0}}에서 워크스페이스를 만들었지만 {{value2}} 때문에 Orca가 로컬 {{value1}}을(를) fast-forward할 수 없습니다.", + "903b51c2ed": "{{value0}}에서 워크스페이스를 만들었지만 Orca가 로컬 {{value1}}을(를) fast-forward할 수 없습니다. {{value2}}", + "localBaseRefRefreshFailedDescriptionNamed": "워크스페이스 \"{{value0}}\"을(를) {{value1}}에서 만들었지만 Orca가 로컬 {{value2}}을(를) fast-forward할 수 없습니다. {{value3}}", + "localBaseRefRefreshFailedDetailDirtyNamed": "{{value0}} 워크트리(로컬 {{value1}}이(가) 체크아웃된 위치)에 커밋되지 않은 변경이 있습니다. 커밋, stash 또는 삭제한 다음 로컬 {{value1}}을(를) 수동으로 업데이트하세요.", + "localBaseRefRefreshFailedDetailDirty": "로컬 {{value0}}이(가) 체크아웃된 워크트리에 커밋되지 않은 변경이 있습니다. 커밋, stash 또는 삭제한 다음 로컬 {{value0}}을(를) 수동으로 업데이트하세요.", + "localBaseRefRefreshFailedDetailNotFastForward": "로컬 {{value0}}이(가) 없거나 원격 베이스에서 깨끗하게 fast-forward할 수 없습니다. 수동으로 업데이트하기 전에 로컬 전용 커밋이 있는지 확인하세요.", + "localBaseRefRefreshFailedDetailError": "로컬 {{value0}}을(를) 업데이트하는 중 Git이 오류를 반환했습니다. 잠긴 refs나 비정상적인 워크트리 상태가 있는지 확인한 다음 로컬 {{value0}}을(를) 수동으로 업데이트하세요.", "0216895fb5": "브랜치를 삭제하지 못했습니다.", "19db0085fb": "로컬 브랜치가 삭제되었습니다.", "2b0afc7f14": "로컬 {{value0}}을(를) 최신 상태로 유지할 수 없습니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 90628fb006f..50700ac525e 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -239,8 +239,14 @@ "34a03a6565": "保持 {{value0}} 为最新状态", "fa9299a66f": "您的新工作树是最新的,但本地 {{value0}} 落后于 {{value1}} {{value2}} 。 AI 差异可能会错过最近的 commits。", "14bc053a47": "本地 {{value0}} 未刷新", + "localBaseRefRefreshFailedForWorktree": "“{{value1}}”的本地 {{value0}} 未刷新", "4a18052018": "本地 {{value0}} 落后于 {{value1}}", - "903b51c2ed": "从 {{value0}} 创建工作区,但 Orca 无法快进本地 {{value1}},因为 {{value2}}", + "903b51c2ed": "从 {{value0}} 创建工作区,但 Orca 无法快进本地 {{value1}}。{{value2}}", + "localBaseRefRefreshFailedDescriptionNamed": "工作区“{{value0}}”从 {{value1}} 创建,但 Orca 无法快进本地 {{value2}}。{{value3}}", + "localBaseRefRefreshFailedDetailDirtyNamed": "{{value0}} 处的工作树(本地 {{value1}} 的检出位置)有未提交的更改。请提交、暂存或丢弃这些更改,然后手动更新本地 {{value1}}。", + "localBaseRefRefreshFailedDetailDirty": "检出本地 {{value0}} 的工作树有未提交的更改。请提交、暂存或丢弃这些更改,然后手动更新本地 {{value0}}。", + "localBaseRefRefreshFailedDetailNotFastForward": "本地 {{value0}} 不存在,或无法从远程基线干净快进。手动更新前请检查是否有仅存在于本地的提交。", + "localBaseRefRefreshFailedDetailError": "更新本地 {{value0}} 时 Git 返回错误。请检查仓库是否有锁定的 refs 或异常工作树状态,然后手动更新本地 {{value0}}。", "0216895fb5": "删除分支失败", "19db0085fb": "本地分支已删除", "2b0afc7f14": "无法使本地 {{value0}} 保持最新", diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index 785ada2fd09..b13a197b597 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -4725,7 +4725,8 @@ describe('createWorktree base status merge', () => { const wt = makeWorktree({ id: 'repo1::/path/wt1', repoId: 'repo1', - path: '/path/wt1' + path: '/path/wt1', + displayName: 'feature-wt' }) mockApi.worktrees.create.mockResolvedValue({ worktree: wt, @@ -4739,12 +4740,78 @@ describe('createWorktree base status merge', () => { await store.getState().createWorktree('repo1', 'feature', 'origin/main') - expect(toast.warning).toHaveBeenCalledWith('Local main was not refreshed', { - description: expect.stringContaining(expectedReason) + expect(toast.warning).toHaveBeenCalledWith('Local main was not refreshed for "feature-wt"', { + id: 'local-base-ref-refresh-failed:repo1::/path/wt1:main', + description: expect.stringContaining(expectedReason), + duration: Infinity, + dismissible: true }) const description = vi.mocked(toast.warning).mock.calls.at(-1)?.[1]?.description + expect(description).toContain('feature-wt') + // Create already succeeded — guidance must push manual recovery, not "try again". + expect(description).toMatch(/manually/i) + expect(description).not.toContain('try again') expect(description).not.toContain('AI tools') expect(description).not.toContain('git diff') + // Owner path is only meaningful for the dirty-owner skip; do not leak it into other reasons. + if (status === 'skipped_dirty_worktree') { + expect(description).toContain('/repo') + } else { + expect(description).not.toContain('/repo') + } + }) + + it('falls back to the generic dirty detail when ownerWorktreePath is missing', async () => { + const store = createTestStore() + const wt = makeWorktree({ + id: 'repo1::/path/wt1', + repoId: 'repo1', + path: '/path/wt1', + displayName: 'feature-wt' + }) + mockApi.worktrees.create.mockResolvedValue({ + worktree: wt, + localBaseRefRefresh: { + status: 'skipped_dirty_worktree', + baseRef: 'origin/main', + localBranch: 'main' + } + }) + + await store.getState().createWorktree('repo1', 'feature', 'origin/main') + + const description = vi.mocked(toast.warning).mock.calls.at(-1)?.[1]?.description + expect(description).toContain('uncommitted changes') + expect(description).toContain('where local main is checked out') + expect(description).not.toContain('The worktree at') + }) + + it('names the toast from branch when displayName is blank', async () => { + const store = createTestStore() + const wt = makeWorktree({ + id: 'repo1::/path/wt1', + repoId: 'repo1', + path: '/path/wt1', + branch: 'refs/heads/feature', + displayName: ' ' + }) + mockApi.worktrees.create.mockResolvedValue({ + worktree: wt, + localBaseRefRefresh: { + status: 'skipped_error', + baseRef: 'origin/main', + localBranch: 'main' + } + }) + + await store.getState().createWorktree('repo1', 'feature', 'origin/main') + + expect(toast.warning).toHaveBeenCalledWith( + 'Local main was not refreshed for "feature"', + expect.objectContaining({ + id: 'local-base-ref-refresh-failed:repo1::/path/wt1:main' + }) + ) }) it('does not warn when the local base ref refresh succeeds', async () => { @@ -4768,6 +4835,20 @@ describe('createWorktree base status merge', () => { expect(toast.warning).not.toHaveBeenCalled() }) + it('does not warn when local base ref refresh is omitted from the create result', async () => { + const store = createTestStore() + const wt = makeWorktree({ + id: 'repo1::/path/wt1', + repoId: 'repo1', + path: '/path/wt1' + }) + mockApi.worktrees.create.mockResolvedValue({ worktree: wt }) + + await store.getState().createWorktree('repo1', 'feature', 'origin/main') + + expect(toast.warning).not.toHaveBeenCalled() + }) + it('suggests turning on local main freshness when the create result reports a stale local base', async () => { const store = createTestStore() const wt = makeWorktree({ diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 9b639b02979..f2efca80185 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -77,6 +77,7 @@ import { import { showLocalBaseRefUpdateSuggestionToast } from '@/components/sidebar/local-base-ref-suggestion-toast' import { showPreservedBranchToast } from '@/components/sidebar/preserved-branch-toast' import { requestWorktreeBaseFallbackNotice } from '@/components/worktree-base-fallback-notice' +import { resolveWorktreeDisplayName } from '@/lib/worktree-default-display-name' import { translate } from '@/i18n/i18n' import { getRepoExecutionHostId, @@ -232,37 +233,81 @@ function shouldDeferActivationTerminalPrep(): boolean { return typeof window !== 'undefined' && import.meta.env.MODE !== 'test' } -function showLocalBaseRefRefreshToast(result: LocalBaseRefRefreshResult | undefined): void { +function localBaseRefRefreshFailureDetail(result: LocalBaseRefRefreshResult): string { + const ownerWorktreePath = result.ownerWorktreePath?.trim() + switch (result.status) { + case 'skipped_dirty_worktree': + // Create already succeeded — guide cleanup + manual base update, not "try again". + return ownerWorktreePath + ? translate( + 'auto.store.slices.worktrees.localBaseRefRefreshFailedDetailDirtyNamed', + 'The worktree at {{value0}} (where local {{value1}} is checked out) has uncommitted changes. Commit, stash, or discard those changes, then update local {{value1}} manually.', + { value0: ownerWorktreePath, value1: result.localBranch } + ) + : translate( + 'auto.store.slices.worktrees.localBaseRefRefreshFailedDetailDirty', + 'The worktree where local {{value0}} is checked out has uncommitted changes. Commit, stash, or discard those changes, then update local {{value0}} manually.', + { value0: result.localBranch } + ) + case 'skipped_not_fast_forward': + return translate( + 'auto.store.slices.worktrees.localBaseRefRefreshFailedDetailNotFastForward', + 'Local {{value0}} does not exist or cannot be fast-forwarded cleanly from the remote base. Check for local-only commits before updating it manually.', + { value0: result.localBranch } + ) + case 'skipped_error': + return translate( + 'auto.store.slices.worktrees.localBaseRefRefreshFailedDetailError', + 'Git returned an error while updating local {{value0}}. Check the repo for locked refs or unusual worktree state, then update local {{value0}} manually.', + { value0: result.localBranch } + ) + case 'updated': + return '' + } +} + +function showLocalBaseRefRefreshToast( + result: LocalBaseRefRefreshResult | undefined, + createdWorktree?: Pick +): void { if (!result || result.status === 'updated') { return } - let reason: string - switch (result.status) { - case 'skipped_dirty_worktree': - reason = - 'the worktree where it is checked out has uncommitted changes. Commit, stash, or discard those changes, then try again.' - break - case 'skipped_not_fast_forward': - reason = - 'the local branch does not exist or cannot be fast-forwarded cleanly from the remote base. Check for local-only commits before updating it manually.' - break - case 'skipped_error': - reason = - 'Git returned an error while updating the local ref. Check the repo for locked refs or unusual worktree state, then try again.' - break - } + const worktreeName = createdWorktree ? resolveWorktreeDisplayName(createdWorktree).trim() : '' + const detail = localBaseRefRefreshFailureDetail(result) + // Why: Infinity so create-time failures aren't buried; id is per worktree so each create stays attributable. toast.warning( - translate('auto.store.slices.worktrees.14bc053a47', 'Local {{value0}} was not refreshed', { - value0: result.localBranch - }), + worktreeName + ? translate( + 'auto.store.slices.worktrees.localBaseRefRefreshFailedForWorktree', + 'Local {{value0}} was not refreshed for "{{value1}}"', + { value0: result.localBranch, value1: worktreeName } + ) + : translate('auto.store.slices.worktrees.14bc053a47', 'Local {{value0}} was not refreshed', { + value0: result.localBranch + }), { - description: translate( - 'auto.store.slices.worktrees.903b51c2ed', - 'Workspace created from {{value0}}, but Orca could not fast-forward local {{value1}} because {{value2}}', - { value0: result.baseRef, value1: result.localBranch, value2: reason } - ) + id: `local-base-ref-refresh-failed:${createdWorktree?.id ?? 'unknown'}:${result.localBranch}`, + description: worktreeName + ? translate( + 'auto.store.slices.worktrees.localBaseRefRefreshFailedDescriptionNamed', + 'Workspace "{{value0}}" was created from {{value1}}, but Orca could not fast-forward local {{value2}}. {{value3}}', + { + value0: worktreeName, + value1: result.baseRef, + value2: result.localBranch, + value3: detail + } + ) + : translate( + 'auto.store.slices.worktrees.903b51c2ed', + 'Workspace created from {{value0}}, but Orca could not fast-forward local {{value1}}. {{value2}}', + { value0: result.baseRef, value1: result.localBranch, value2: detail } + ), + duration: Infinity, + dismissible: true } ) } @@ -4109,7 +4154,7 @@ export const createWorktreeSlice: StateCreator sortEpoch: s.sortEpoch + 1 } }) - showLocalBaseRefRefreshToast(result.localBaseRefRefresh) + showLocalBaseRefRefreshToast(result.localBaseRefRefresh, result.worktree) if (result.baseFallback) { requestWorktreeBaseFallbackNotice(result.baseFallback) } From bc1e049b3f1725adc2aacb9eba6611cfb76e637f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 7 Aug 2026 10:14:37 -0700 Subject: [PATCH 014/289] fix(terminal): defer metric option writes to unmeasurable panes (#12944) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(terminal): defer metric option writes to unmeasurable panes Writing fontSize/fontFamily/fontWeight/lineHeight makes xterm re-measure cell size against the pane's current box. A hidden or mid-layout pane can measure a wrong-but-nonzero size, which latches (hasValidSize) and mis-keys the shared WebGL glyph atlas until a manual resize — the stuck variant of the P0 bold/blurry-font reports. Metric writes now land only on measurable panes; otherwise the latest values park per-pane and flush on the next safe fit or reveal (with a refit on the light tab-resume path, which otherwise skips fitting). Measurability helpers move to pane-fit-measurability.ts to stay under the pane-fit.ts line cap. * fix(terminal): key metric deferral by terminal, not pane view getPanes() returns a fresh toPublicPane() wrapper per call, so a WeakMap keyed on ManagedPane never matched across call sites: deferred metric options were dropped, not deferred. Key on pane.terminal, which is carried by reference and dies with the pane. Also from review: - flushDeferredPaneMetricOptionsIfMeasurable checks the pending WeakMap before the measurability probe, so the common no-deferral case costs zero forced style/layout on every reveal. - applyTerminalAppearance skips the apply (and the probe) when all five values are already live and nothing is parked; any settings write re-runs the pass over every mounted pane, and arming a no-op deferral would trigger a refit on the next reveal. - fitRevealedPane flushes first: its pixel/grid checks can both no-op and return without fitting, stranding parked options. - Font zoom folds its direct fontSize write into any pending deferral so the flush inside safeFit cannot clobber the user's zoom. Corrects comments that asserted a cell-size re-measure mechanism xterm does not have: CharSizeService measures via OffscreenCanvas TextMetrics, independent of the pane box, and only fontSize/fontFamily re-measure. Test fixtures now allocate a fresh pane view per getPanes() call, which is what production does and what hid the keying bug. * fix(terminal): re-check the fit floor after a metric flush performSafeFit evaluated the min cols/rows gate with the pre-flush cell size, then flushed and fit unconditionally. A large font jump on a narrow pane passes the gate at the old size and lands under it at the new one, so fit() pinned the PTY to the tiny grid the floor exists to reject. Re-check after a flush that actually landed. The parked values still apply, so the pane is never stuck on stale metrics; only the fit is skipped. * fix(terminal): route a reveal metric flush through the stable fit fitRevealedPane's new flush branch called safeFit directly, which is exactly what the function's contract forbids on reveal: resumeRendering has just re-attached WebGL, whose cell metrics transiently differ from the DOM renderer's, so a raw fit can propose a one-column-off grid and reflow — and xterm's wrap/unwrap is not a perfect inverse, leaving a diff-painting inline TUI corrupted. A landed flush leaves pixels unchanged with a diverged grid, the same shape as a snapshot resize, so it takes the same steady-grid repair. A real resize still fits synchronously, after the flush. Reachable via window wake, which calls fitAllRevealedPanes with no pre-flush loop. * fix(terminal): gate metric writes on the pixel box, not the fit floor canApplyPaneMetricOptions reused canMeasurePaneForFit, whose >=8 cols / >=4 rows floor exists to stop a fit pinning the PTY to a sliver. But the divider clamp is 50px, which clears the 48px pixel floor and proposes ~5 cols — so a pane dragged to the clamp deferred every font change and never flushed: it never hides, and its box never changes, so no reveal and no ResizeObserver entry ever arrives. It rendered a stale font until widened, where pre-PR the write was unconditional. Gate metric writes on display plus the pixel box only. Hidden panes and the transient worktree-switch overlay are near-zero, so they still defer — the deferral's purpose is unchanged. The cols/rows floor stays on the fit, including the post-flush re-check in performSafeFit. Apply and flush share the same predicate, so no "applies but never flushes" state can open up. * fix(terminal): flush heavy reveal metrics after WebGL resume --- .oxlintrc.json | 7 +- .../scripts/relay-watcher-fault-harness.mjs | 22 ++- .../GitHubMarkdownComposerEditorPane.tsx | 5 +- .../terminal-pane/terminal-appearance.test.ts | 91 +++++++++- .../terminal-pane/terminal-appearance.ts | 25 ++- .../terminal-visibility-resume.test.ts | 29 +++ .../terminal-visibility-resume.ts | 14 ++ .../terminal-pane/useTerminalFontZoom.ts | 5 + .../pane-manager/pane-fit-measurability.ts | 63 +++++++ .../src/lib/pane-manager/pane-fit.test.ts | 40 +++++ src/renderer/src/lib/pane-manager/pane-fit.ts | 46 ++--- .../pane-metric-options-deferral.test.ts | 167 ++++++++++++++++++ .../pane-metric-options-deferral.ts | 113 ++++++++++++ .../lib/pane-manager/pane-reveal-fit.test.ts | 44 ++++- .../src/lib/pane-manager/pane-reveal-fit.ts | 9 +- ...script-prompt-unreadable-orca-yaml.spec.ts | 5 +- 16 files changed, 623 insertions(+), 62 deletions(-) create mode 100644 src/renderer/src/lib/pane-manager/pane-fit-measurability.ts create mode 100644 src/renderer/src/lib/pane-manager/pane-metric-options-deferral.test.ts create mode 100644 src/renderer/src/lib/pane-manager/pane-metric-options-deferral.ts diff --git a/.oxlintrc.json b/.oxlintrc.json index 4cdb4f2c0e2..0f679f17d52 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -150,10 +150,5 @@ } } ], - "ignorePatterns": [ - "**/node_modules", - "**/dist", - "**/out", - "tests/e2e/.cross-version-checkouts" - ] + "ignorePatterns": ["**/node_modules", "**/dist", "**/out", "tests/e2e/.cross-version-checkouts"] } diff --git a/config/scripts/relay-watcher-fault-harness.mjs b/config/scripts/relay-watcher-fault-harness.mjs index 12e36f72490..b27a1f945d9 100644 --- a/config/scripts/relay-watcher-fault-harness.mjs +++ b/config/scripts/relay-watcher-fault-harness.mjs @@ -109,9 +109,7 @@ function waitForStdoutSentinel(proc, protocol, stderr) { settled = true proc.stdout.off('data', onData) rejectPromise( - new Error( - `process exited before sentinel (code=${code}, signal=${signal})\n${stderr()}` - ) + new Error(`process exited before sentinel (code=${code}, signal=${signal})\n${stderr()}`) ) } proc.stdout.on('data', onData) @@ -162,11 +160,7 @@ function createRelayClient(entryPath, args, env, protocol) { }) const waitForMessage = (startIndex, predicate, label) => - pollUntil( - () => messages.slice(startIndex).find(predicate), - label, - streams.stderr - ) + pollUntil(() => messages.slice(startIndex).find(predicate), label, streams.stderr) const request = async (method, params = {}) => { const id = nextSequence++ @@ -317,8 +311,10 @@ async function main() { ) await relay.request('fs.watch', { rootPath: watchRoot }) - const firstWatcherPid = await waitForWatcherPid(pidFile, undefined, () => - `${daemonStreams.stderr()}\n${relay.stderr()}` + const firstWatcherPid = await waitForWatcherPid( + pidFile, + undefined, + () => `${daemonStreams.stderr()}\n${relay.stderr()}` ) const beforePath = join(watchRoot, 'before.txt') startIndex = relay.messageCount() @@ -330,8 +326,10 @@ async function main() { const faultSignal = process.platform === 'win32' ? 'SIGTERM' : 'SIGSEGV' startIndex = relay.messageCount() process.kill(firstWatcherPid, faultSignal) - const replacementWatcherPid = await waitForWatcherPid(pidFile, firstWatcherPid, () => - `${daemonStreams.stderr()}\n${relay.stderr()}` + const replacementWatcherPid = await waitForWatcherPid( + pidFile, + firstWatcherPid, + () => `${daemonStreams.stderr()}\n${relay.stderr()}` ) await relay.waitForNotification(startIndex, 'fs.changed', (params) => Array.isArray(params.events) diff --git a/src/renderer/src/components/github/GitHubMarkdownComposerEditorPane.tsx b/src/renderer/src/components/github/GitHubMarkdownComposerEditorPane.tsx index 6914f9b3aba..792799376ea 100644 --- a/src/renderer/src/components/github/GitHubMarkdownComposerEditorPane.tsx +++ b/src/renderer/src/components/github/GitHubMarkdownComposerEditorPane.tsx @@ -13,7 +13,10 @@ export function GitHubMarkdownComposerEditorPane({ const scrollContainerRef = useRef(null) return ( -
+
{ }) describe('applyTerminalAppearance theme assignment', () => { // xterm rebuilds the palette on any new theme-object identity (wiping OSC color mutations), so the assignment must be value-gated. - function makePane(id: number): ManagedPane { - return { id, terminal: { options: {}, cols: 80, rows: 24 } } as unknown as ManagedPane + // Measurable by default: metric options (fontSize/fontFamily/…) only land on + // panes that can measure; unmeasurable panes defer them until fit/reveal. + function makePane(id: number, overrides?: { measurable?: boolean }): ManagedPane { + const measurable = overrides?.measurable ?? true + return { + id, + terminal: { options: {}, cols: 80, rows: 24 }, + container: { + dataset: {}, + getBoundingClientRect: () => ({ width: measurable ? 800 : 0, height: measurable ? 600 : 0 }) + }, + fitAddon: { + proposeDimensions: () => (measurable ? { cols: 80, rows: 24 } : undefined) + } + } as unknown as ManagedPane } function makeManager(panes: ManagedPane[]): PaneManager { return { - getPanes: () => panes, + // Mirrors the real getPanes(), which allocates a fresh toPublicPane() + // wrapper per call over a shared terminal — per-pane state must survive that. + getPanes: () => panes.map((pane) => ({ ...pane })), setPaneLigaturesEnabled: vi.fn(), setPaneStyleOptions: vi.fn() } as unknown as PaneManager @@ -265,6 +281,75 @@ describe('applyTerminalAppearance theme assignment', () => { // The value-gate must not rewrite an unchanged ratio — each write clears xterm's contrast cache. expect(writes).toBe(writesAfterFirst) }) + + it('defers metric options on an unmeasurable pane and lands them on the next fit', () => { + // A metric write makes xterm clear, resize and full-refresh; on a pane with + // no usable box that repaint is wasted and the cols/rows re-fit that must + // follow it cannot run. The write waits for a measurable pane. + let measurable = false + const pane = { + id: 1, + terminal: { options: {}, cols: 80, rows: 24 }, + container: { + dataset: {}, + getBoundingClientRect: () => ({ width: measurable ? 800 : 0, height: measurable ? 600 : 0 }) + }, + fitAddon: { + fit: vi.fn(), + proposeDimensions: () => (measurable ? { cols: 80, rows: 24 } : undefined) + } + } as unknown as ManagedPane + const settings = getDefaultSettings('/tmp') + + apply(pane, { ...settings, terminalFontSize: 19 }) + + expect(pane.terminal.options.fontSize).toBeUndefined() + expect(pane.terminal.options.fontFamily).toBeUndefined() + // Non-metric options are safe while hidden and must not be deferred with them. + expect(pane.terminal.options.cursorStyle).toBeDefined() + + measurable = true + safeFit(pane) + + expect(pane.terminal.options.fontSize).toBe(19) + expect(pane.terminal.options.fontFamily).toContain('monospace') + }) + + it('applies only the latest deferred metric options after repeated hidden changes', () => { + let measurable = false + const writes: number[] = [] + const options: Record = {} + Object.defineProperty(options, 'fontSize', { + configurable: true, + enumerable: true, + get: () => writes.at(-1), + set: (value: number) => { + writes.push(value) + } + }) + const pane = { + id: 1, + terminal: { options, cols: 80, rows: 24 }, + container: { + dataset: {}, + getBoundingClientRect: () => ({ width: measurable ? 800 : 0, height: measurable ? 600 : 0 }) + }, + fitAddon: { + fit: vi.fn(), + proposeDimensions: () => (measurable ? { cols: 80, rows: 24 } : undefined) + } + } as unknown as ManagedPane + const settings = getDefaultSettings('/tmp') + + apply(pane, { ...settings, terminalFontSize: 15 }) + apply(pane, { ...settings, terminalFontSize: 21 }) + + measurable = true + safeFit(pane) + + // Latest wins, exactly one write: intermediate hidden values never touch xterm. + expect(writes).toEqual([21]) + }) }) describe('publishTerminalViewAttributesAtAppStart', () => { diff --git a/src/renderer/src/components/terminal-pane/terminal-appearance.ts b/src/renderer/src/components/terminal-pane/terminal-appearance.ts index c1ff2c24f1a..f9dbd9a1a0e 100644 --- a/src/renderer/src/components/terminal-pane/terminal-appearance.ts +++ b/src/renderer/src/components/terminal-pane/terminal-appearance.ts @@ -10,6 +10,11 @@ import { } from '@/lib/terminal-theme' import { buildFontFamily } from './layout-serialization' import { safeFit, safeFitAndThen } from '@/lib/pane-manager/pane-tree-ops' +import { canApplyPaneMetricOptions } from '@/lib/pane-manager/pane-fit' +import { + applyOrDeferPaneMetricOptions, + paneMetricOptionsAlreadySettled +} from '@/lib/pane-manager/pane-metric-options-deferral' import { normalizeTerminalFastScrollSensitivity, normalizeTerminalScrollSensitivity, @@ -174,10 +179,21 @@ export function applyTerminalAppearance( pane.terminal.options.cursorInactiveStyle = resolveTerminalCursorInactiveStyle(cursorStyle) pane.terminal.options.cursorBlink = settings.terminalCursorBlink const paneSize = paneFontSizes.get(pane.id) - pane.terminal.options.fontSize = paneSize ?? settings.terminalFontSize - pane.terminal.options.fontFamily = buildFontFamily(settings.terminalFontFamily) - pane.terminal.options.fontWeight = terminalFontWeights.fontWeight - pane.terminal.options.fontWeightBold = terminalFontWeights.fontWeightBold + const metricOptions = { + fontSize: paneSize ?? settings.terminalFontSize, + fontFamily: buildFontFamily(settings.terminalFontFamily), + fontWeight: terminalFontWeights.fontWeight, + fontWeightBold: terminalFontWeights.fontWeightBold, + lineHeight: normalizeTerminalLineHeight(settings.terminalLineHeight) + } + // Why value-gated: any settings write re-runs this over every mounted pane, and + // canApplyPaneMetricOptions forces style+layout; an unchanged no-op deferral + // would also arm a pointless refit on the next reveal. + // Why deferred: a metric write makes xterm clear/resize/full-refresh, which is + // wasted on a pane with no usable box and whose follow-up cols/rows fit can't run. + if (!paneMetricOptionsAlreadySettled(pane, metricOptions)) { + applyOrDeferPaneMetricOptions(pane, metricOptions, canApplyPaneMetricOptions(pane)) + } pane.terminal.options.scrollSensitivity = normalizeTerminalScrollSensitivity( settings.terminalScrollSensitivity ) @@ -186,7 +202,6 @@ export function applyTerminalAppearance( ) // Why only 'true': 'left'/'right' are handled in the keydown policy, which needs Option composable at the xterm level. pane.terminal.options.macOptionIsMeta = effectiveMacOptionAsAlt === 'true' - pane.terminal.options.lineHeight = normalizeTerminalLineHeight(settings.terminalLineHeight) // Why unconditional: the helper no-ops when addon state already matches, so this keeps new panes and live toggles in sync. manager.setPaneLigaturesEnabled(pane.id, ligaturesEnabled) const transport = paneTransports.get(pane.id) diff --git a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts index e2d4aac2929..c6b82f2470c 100644 --- a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts @@ -27,6 +27,11 @@ vi.mock('./terminal-webgl-atlas-recovery', () => ({ // the terminal-output debounce (which a background stream could otherwise defer). scheduleTabRevealWebglAtlasRecovery: () => scheduleTabRevealWebglAtlasRecovery() })) +const flushDeferredPaneMetricOptionsIfMeasurable = vi.fn((_pane: unknown) => false) +vi.mock('@/lib/pane-manager/pane-fit', () => ({ + flushDeferredPaneMetricOptionsIfMeasurable: (pane: unknown) => + flushDeferredPaneMetricOptionsIfMeasurable(pane) +})) const resetTerminalLinkifierHoverState = vi.fn() const isTerminalLinkifierHoverActive = vi.fn((_terminal: unknown) => false) vi.mock('@/lib/pane-manager/terminal-linkifier-hover-reset', () => ({ @@ -131,6 +136,17 @@ describe('resumeTerminalVisibility reveal repaint', () => { expect(manager.fitAllPanes).not.toHaveBeenCalled() }) + it('leaves heavy metric flushing to the reveal fit after rendering resumes', () => { + const manager = createManager() + manager.getPanes.mockReturnValue([{ terminal: {} }]) + + resumeTerminalVisibility(resumeArgs(manager, false)) + + expect(manager.resumeRendering).toHaveBeenCalledTimes(1) + expect(manager.fitAllRevealedPanes).toHaveBeenCalledTimes(1) + expect(flushDeferredPaneMetricOptionsIfMeasurable).not.toHaveBeenCalled() + }) + it('does not fit on a light tab reveal', () => { const manager = createManager() resumeTerminalVisibility(resumeArgs(manager, true)) @@ -139,6 +155,19 @@ describe('resumeTerminalVisibility reveal repaint', () => { expect(manager.fitAllPanes).not.toHaveBeenCalled() }) + it('flushes hidden-era metric options on reveal and refits the light path', () => { + // A font change while hidden must land and refit on reveal, or cols/rows + // stay pinned to the old metrics. + const manager = createManager() + manager.getPanes.mockReturnValue([{ terminal: {} }]) + flushDeferredPaneMetricOptionsIfMeasurable.mockReturnValueOnce(true) + + resumeTerminalVisibility(resumeArgs(manager, true)) + + expect(flushDeferredPaneMetricOptionsIfMeasurable).toHaveBeenCalledTimes(1) + expect(manager.fitAllRevealedPanes).toHaveBeenCalledTimes(1) + }) + it('fits window wake recovery through the stable path, not the sync fit', () => { const manager = createManager() recoverVisibleTerminalWindowWake({ diff --git a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts index c9cf2a8926d..01d3627d2c1 100644 --- a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts +++ b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts @@ -15,6 +15,7 @@ import { } from '@/lib/pane-manager/terminal-linkifier-hover-reset' import { focusActivePane } from './pane-helpers' import { scheduleTabRevealWebglAtlasRecovery } from './terminal-webgl-atlas-recovery' +import { flushDeferredPaneMetricOptionsIfMeasurable } from '@/lib/pane-manager/pane-fit' const VISIBLE_RESUME_FLUSH_CHARS = 256 * 1024 const WINDOW_WAKE_FLUSH_CHARS = 64 * 1024 @@ -72,6 +73,12 @@ export function resumeTerminalVisibility({ captureViewportPositions(!wasVisible) withSuppressedScrollTracking(() => { if (shouldUseLightTabResume) { + let flushedDeferredMetrics = false + for (const pane of manager.getPanes()) { + if (flushDeferredPaneMetricOptionsIfMeasurable(pane)) { + flushedDeferredMetrics = true + } + } // Why: intra-worktree tab switches only toggle the overlay. Keeping // synchronous drain and atlas rebuilds off this path avoids racing the // overlay's delayed geometry fit. Still request hidden-output recovery: @@ -81,10 +88,17 @@ export function resumeTerminalVisibility({ // — a background agent streaming in another pane must not defer this tab's // atlas rebuild. scheduleTabRevealWebglAtlasRecovery() + if (flushedDeferredMetrics) { + // Why: the light path normally skips fitting, but flushed metrics changed + // cell size — refit so cols/rows match before the overlay settles. + manager.fitAllRevealedPanes() + } if (isActive) { focusActivePane(manager) } } else { + // fitAllRevealedPanes flushes after WebGL reattaches, avoiding a redundant + // full refresh in the suspended DOM renderer while preserving first paint. resumeTerminalVisibilityHeavy(manager, isActive) } enforceTerminalViewportIntents(manager) diff --git a/src/renderer/src/components/terminal-pane/useTerminalFontZoom.ts b/src/renderer/src/components/terminal-pane/useTerminalFontZoom.ts index 1d2bd6c22a0..d53cda92612 100644 --- a/src/renderer/src/components/terminal-pane/useTerminalFontZoom.ts +++ b/src/renderer/src/components/terminal-pane/useTerminalFontZoom.ts @@ -2,6 +2,7 @@ import { useEffect } from 'react' import type { PaneManager } from '@/lib/pane-manager/pane-manager' import { dispatchZoomLevelChanged } from '@/lib/zoom-events' import { safeFit } from '@/lib/pane-manager/pane-tree-ops' +import { overridePendingPaneMetricOptions } from '@/lib/pane-manager/pane-metric-options-deferral' import { getPaneOwnedActiveHelperTextarea } from './regular-terminal-focus-ownership' type FontZoomDeps = { @@ -57,6 +58,10 @@ export function useTerminalFontZoom({ } pane.terminal.options.fontSize = nextSize + // Why: safeFit flushes parked metric options, which would otherwise + // overwrite this zoom with the font size captured while the pane was + // unmeasurable. Fold the new size in; other parked keys still apply. + overridePendingPaneMetricOptions(pane, { fontSize: nextSize }) safeFit(pane) const percent = Math.round((nextSize / globalSize) * 100) diff --git a/src/renderer/src/lib/pane-manager/pane-fit-measurability.ts b/src/renderer/src/lib/pane-manager/pane-fit-measurability.ts new file mode 100644 index 00000000000..57186374409 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-fit-measurability.ts @@ -0,0 +1,63 @@ +import type { ManagedPane } from './pane-manager-types' +import { isManagedPaneDisplayNone } from './pane-display-visibility' +import { + flushDeferredPaneMetricOptions, + hasDeferredPaneMetricOptions +} from './pane-metric-options-deferral' + +const MIN_PANE_FIT_WIDTH_PX = 48 +const MIN_PANE_FIT_HEIGHT_PX = 24 +const MIN_PANE_FIT_COLS = 8 +const MIN_PANE_FIT_ROWS = 4 + +export function getProposedPaneDimensions( + pane: ManagedPane +): { cols: number; rows: number } | null { + try { + return pane.fitAddon.proposeDimensions() ?? null + } catch { + return null + } +} + +function hasPaneFitPixelBox(pane: ManagedPane): boolean { + const measure = pane.container?.getBoundingClientRect + if (typeof measure !== 'function') { + return true + } + const rect = measure.call(pane.container) + return rect.width >= MIN_PANE_FIT_WIDTH_PX && rect.height >= MIN_PANE_FIT_HEIGHT_PX +} + +export function canMeasurePaneForFit(pane: ManagedPane): boolean { + if (!hasPaneFitPixelBox(pane)) { + return false + } + const dims = getProposedPaneDimensions(pane) + if (!dims) { + return false + } + // Why: worktree switches can briefly measure a near-zero overlay before + // fallback positioning lands. Fitting there pins the PTY at ~2 cols. + return dims.cols >= MIN_PANE_FIT_COLS && dims.rows >= MIN_PANE_FIT_ROWS +} + +/** Why only the pixel box, not the fit floor: a pane held at the 50px divider + * clamp proposes ~5 cols, so the fit floor would reject it forever — it never + * hides and its box never changes, so nothing would flush and it would render + * a stale font until widened. A hidden pane or the transient worktree-switch + * overlay is near-zero, so the pixel floor still defers those. The cols/rows + * floor stays where it belongs: on the fit. */ +export function canApplyPaneMetricOptions(pane: ManagedPane): boolean { + return !isManagedPaneDisplayNone(pane) && hasPaneFitPixelBox(pane) +} + +/** Why the pending check comes first: it is an O(1) WeakMap lookup, while the + * measurability probe forces style+layout. This runs per pane on every reveal, + * so the common no-deferral case must cost zero DOM reads. */ +export function flushDeferredPaneMetricOptionsIfMeasurable(pane: ManagedPane): boolean { + if (!hasDeferredPaneMetricOptions(pane) || !canApplyPaneMetricOptions(pane)) { + return false + } + return flushDeferredPaneMetricOptions(pane) +} diff --git a/src/renderer/src/lib/pane-manager/pane-fit.test.ts b/src/renderer/src/lib/pane-manager/pane-fit.test.ts index e7975eec142..1e0a9cf92bb 100644 --- a/src/renderer/src/lib/pane-manager/pane-fit.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-fit.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { recordRendererCrashBreadcrumb } from '@/lib/crash-breadcrumb-recorder' import type { ManagedPane, ManagedPaneInternal, ScrollState } from './pane-manager-types' import { safeFit, safeFitAndThen } from './pane-fit' +import { applyOrDeferPaneMetricOptions } from './pane-metric-options-deferral' import { paneFitClientSizeChanged } from './pane-reveal-fit' vi.mock('@/lib/crash-breadcrumb-recorder', () => ({ @@ -321,3 +322,42 @@ describe('paneFitClientSizeChanged (reveal fit gate)', () => { expect(paneFitClientSizeChanged(pane)).toBe(true) }) }) + +describe('deferred metric flush inside safeFit', () => { + function createMetricPane(): ManagedPane & { fitAddon: { fit: ReturnType } } { + const terminal = { cols: 80, rows: 24, options: {} as Record } + // Grid shrinks once the parked large font lands — the case the min-dimension + // gate exists to reject, but which it can only see after the flush. + const proposeDimensions = (): { cols: number; rows: number } => + Number(terminal.options.fontSize ?? 10) >= 24 ? { cols: 5, rows: 2 } : { cols: 40, rows: 20 } + return { + id: 11, + terminal, + container: { + dataset: {}, + getBoundingClientRect: () => ({ width: 340, height: 240 }) + }, + fitAddon: { fit: vi.fn(), proposeDimensions: vi.fn(proposeDimensions) } + } as unknown as ManagedPane & { fitAddon: { fit: ReturnType } } + } + + it('does not fit when the flushed font drops the pane under the minimum grid', () => { + const pane = createMetricPane() + applyOrDeferPaneMetricOptions(pane, { fontSize: 24 }, false) + + expect(safeFit(pane)).toBe(false) + // The parked value still lands so the pane is not stuck on stale metrics. + expect(pane.terminal.options.fontSize).toBe(24) + // But the PTY must not be pinned to the 5x2 grid the floor rejects. + expect(pane.fitAddon.fit).not.toHaveBeenCalled() + }) + + it('still fits when the flushed font keeps the pane above the minimum grid', () => { + const pane = createMetricPane() + applyOrDeferPaneMetricOptions(pane, { fontSize: 12 }, false) + + expect(safeFit(pane)).toBe(true) + expect(pane.terminal.options.fontSize).toBe(12) + expect(pane.fitAddon.fit).toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/lib/pane-manager/pane-fit.ts b/src/renderer/src/lib/pane-manager/pane-fit.ts index dcb638c0a1a..0b7d3bd8e92 100644 --- a/src/renderer/src/lib/pane-manager/pane-fit.ts +++ b/src/renderer/src/lib/pane-manager/pane-fit.ts @@ -21,13 +21,16 @@ import { deferTerminalGeometryMutationDuringRebuild, isTerminalScrollIntentRebuildInFlight } from './terminal-scroll-intent-rebuild' +import { flushDeferredPaneMetricOptions } from './pane-metric-options-deferral' +import { canMeasurePaneForFit, getProposedPaneDimensions } from './pane-fit-measurability' import { notifyPaneFitSucceeded } from './pane-fit-webgl-attach-signal' import { recordPaneFitClientSize } from './pane-fit-client-size' -const MIN_PANE_FIT_WIDTH_PX = 48 -const MIN_PANE_FIT_HEIGHT_PX = 24 -const MIN_PANE_FIT_COLS = 8 -const MIN_PANE_FIT_ROWS = 4 +export { + canApplyPaneMetricOptions, + canMeasurePaneForFit, + flushDeferredPaneMetricOptionsIfMeasurable +} from './pane-fit-measurability' export type SafeFitContinuationHandle = { completion: Promise @@ -45,33 +48,8 @@ const pendingSafeFitContinuations = new WeakMap< Map >() -function getProposedDimensions(pane: ManagedPane): { cols: number; rows: number } | null { - try { - return pane.fitAddon.proposeDimensions() ?? null - } catch { - return null - } -} - export { readFitClientSize } from './pane-fit-client-size' -export function canMeasurePaneForFit(pane: ManagedPane): boolean { - const measure = pane.container?.getBoundingClientRect - if (typeof measure === 'function') { - const rect = measure.call(pane.container) - if (rect.width < MIN_PANE_FIT_WIDTH_PX || rect.height < MIN_PANE_FIT_HEIGHT_PX) { - return false - } - } - const dims = getProposedDimensions(pane) - if (!dims) { - return false - } - // Why: worktree switches can briefly measure a near-zero overlay before - // fallback positioning lands. Fitting there pins the PTY at ~2 cols. - return dims.cols >= MIN_PANE_FIT_COLS && dims.rows >= MIN_PANE_FIT_ROWS -} - function canPreserveScrollIntentForFit(pane: ManagedPane): boolean { // Why: split reparent has its own delayed restore; restoring here can fight that timer. return !( @@ -86,6 +64,14 @@ function performSafeFit(pane: ManagedPane): boolean { if (!canMeasurePaneForFit(pane)) { return false } + // Why here: metric options deferred while the pane was unmeasurable must land + // before this fit reads dimensions, or the fit pins cols/rows to stale metrics. + // Why re-check: the gate above measured with the old cell size — a large font + // jump on a narrow pane can drop it under the floor, and fit() would then pin + // the PTY at the tiny grid that floor exists to prevent. + if (flushDeferredPaneMetricOptions(pane) && !canMeasurePaneForFit(pane)) { + return false + } let scrollIntent = null as ReturnType let pinnedScrollState: ScrollState | null = null let shouldRestoreScroll = false @@ -113,7 +99,7 @@ function performSafeFit(pane: ManagedPane): boolean { return true } - const dims = getProposedDimensions(pane) + const dims = getProposedPaneDimensions(pane) if (dims && dims.cols === pane.terminal.cols && dims.rows === pane.terminal.rows) { // Why: divider drags often stay within one cell; avoid needless clear/refresh churn. resumePendingFitScrollRestoreAfterFit(pane.terminal) diff --git a/src/renderer/src/lib/pane-manager/pane-metric-options-deferral.test.ts b/src/renderer/src/lib/pane-manager/pane-metric-options-deferral.test.ts new file mode 100644 index 00000000000..e07ae670d18 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-metric-options-deferral.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it } from 'vitest' +import type { ManagedPane, ManagedPaneInternal } from './pane-manager-types' +import { toPublicPane } from './pane-public-view' +import { canApplyPaneMetricOptions, canMeasurePaneForFit } from './pane-fit-measurability' +import { + applyOrDeferPaneMetricOptions, + flushDeferredPaneMetricOptions, + hasDeferredPaneMetricOptions, + overridePendingPaneMetricOptions, + paneMetricOptionsAlreadySettled +} from './pane-metric-options-deferral' + +function makePane(): ManagedPane { + return { id: 1, terminal: { options: {} } } as unknown as ManagedPane +} + +// Mirrors PaneManager.getPanes(), which returns a fresh toPublicPane() wrapper +// per call — so deferral state must not be keyed on the pane object identity. +function makeInternalPane(): ManagedPaneInternal { + return { id: 1, terminal: { options: {} } } as unknown as ManagedPaneInternal +} + +describe('pane-metric-options-deferral', () => { + it('writes metric options directly when the pane is measurable', () => { + const pane = makePane() + + const result = applyOrDeferPaneMetricOptions(pane, { fontSize: 16, fontFamily: 'X' }, true) + + expect(result).toBe('applied') + expect(pane.terminal.options.fontSize).toBe(16) + expect(pane.terminal.options.fontFamily).toBe('X') + expect(hasDeferredPaneMetricOptions(pane)).toBe(false) + }) + + it('defers writes on an unmeasurable pane until flushed', () => { + const pane = makePane() + + const result = applyOrDeferPaneMetricOptions(pane, { fontSize: 16 }, false) + + expect(result).toBe('deferred') + expect(pane.terminal.options.fontSize).toBeUndefined() + expect(hasDeferredPaneMetricOptions(pane)).toBe(true) + + expect(flushDeferredPaneMetricOptions(pane)).toBe(true) + expect(pane.terminal.options.fontSize).toBe(16) + expect(hasDeferredPaneMetricOptions(pane)).toBe(false) + }) + + it('keeps only the latest deferral and clears it when a measurable apply lands', () => { + const pane = makePane() + + applyOrDeferPaneMetricOptions(pane, { fontSize: 15 }, false) + applyOrDeferPaneMetricOptions(pane, { fontSize: 21 }, false) + // A later measurable apply supersedes the pending deferral entirely: flushing + // afterwards must not resurrect the hidden-era value. + applyOrDeferPaneMetricOptions(pane, { fontSize: 18 }, true) + + expect(pane.terminal.options.fontSize).toBe(18) + expect(flushDeferredPaneMetricOptions(pane)).toBe(false) + expect(pane.terminal.options.fontSize).toBe(18) + }) + + it('writes only the provided keys', () => { + const pane = makePane() + pane.terminal.options.lineHeight = 1.4 + + applyOrDeferPaneMetricOptions(pane, { fontSize: 12 }, true) + + expect(pane.terminal.options.lineHeight).toBe(1.4) + expect(pane.terminal.options.fontWeight).toBeUndefined() + }) + + it('flush is a no-op without a pending deferral', () => { + const pane = makePane() + expect(flushDeferredPaneMetricOptions(pane)).toBe(false) + }) + + it('flushes through a different pane view than the one that deferred', () => { + const internal = makeInternalPane() + const deferView = toPublicPane(internal) + const flushView = toPublicPane(internal) + // Every getPanes() call allocates a new wrapper; the two views are the same + // pane but never the same object. + expect(deferView).not.toBe(flushView) + + applyOrDeferPaneMetricOptions(deferView, { fontSize: 16 }, false) + + expect(hasDeferredPaneMetricOptions(flushView)).toBe(true) + expect(flushDeferredPaneMetricOptions(flushView)).toBe(true) + expect(internal.terminal.options.fontSize).toBe(16) + }) + + it('flushes through the internal pane when a public view deferred', () => { + // fitAllPanesInternal / fitAllRevealedPanes iterate the internal panes. + const internal = makeInternalPane() + + applyOrDeferPaneMetricOptions(toPublicPane(internal), { fontSize: 17 }, false) + + expect(flushDeferredPaneMetricOptions(internal)).toBe(true) + expect(internal.terminal.options.fontSize).toBe(17) + }) + + it('reports settled only when every value is live and nothing is parked', () => { + const pane = makePane() + applyOrDeferPaneMetricOptions(pane, { fontSize: 14, lineHeight: 1.2 }, true) + + expect(paneMetricOptionsAlreadySettled(pane, { fontSize: 14, lineHeight: 1.2 })).toBe(true) + expect(paneMetricOptionsAlreadySettled(pane, { fontSize: 15, lineHeight: 1.2 })).toBe(false) + }) + + it('is never settled while a deferral is parked, even if values match', () => { + // Otherwise an equal-valued apply would skip and strand the stale deferral. + const pane = makePane() + applyOrDeferPaneMetricOptions(pane, { fontSize: 20 }, false) + pane.terminal.options.fontSize = 14 + + expect(paneMetricOptionsAlreadySettled(pane, { fontSize: 14 })).toBe(false) + }) + + it('folds a direct write into a pending deferral so the flush cannot clobber it', () => { + const pane = makePane() + applyOrDeferPaneMetricOptions(pane, { fontSize: 12, lineHeight: 1.5 }, false) + + // Font zoom writes fontSize directly, then fits — which flushes. + pane.terminal.options.fontSize = 22 + overridePendingPaneMetricOptions(pane, { fontSize: 22 }) + flushDeferredPaneMetricOptions(pane) + + expect(pane.terminal.options.fontSize).toBe(22) + // The other parked key still lands. + expect(pane.terminal.options.lineHeight).toBe(1.5) + }) + + it('override is a no-op when nothing is parked', () => { + const pane = makePane() + overridePendingPaneMetricOptions(pane, { fontSize: 22 }) + + expect(hasDeferredPaneMetricOptions(pane)).toBe(false) + expect(pane.terminal.options.fontSize).toBeUndefined() + }) +}) + +describe('canApplyPaneMetricOptions gating', () => { + function makeSizedPane(rect: { width: number; height: number }, cols: number): ManagedPane { + return { + id: 2, + terminal: { options: {} }, + container: { getBoundingClientRect: () => rect }, + fitAddon: { proposeDimensions: () => ({ cols, rows: 20 }) } + } as unknown as ManagedPane + } + + it('applies to a pane clamped narrow by a divider drag', () => { + // 50px is the divider clamp: over the pixel floor but ~5 cols, under the + // fit floor. Gating on the fit floor would strand it on a stale font, + // because it never hides and its box never changes. + expect(canApplyPaneMetricOptions(makeSizedPane({ width: 50, height: 600 }, 5))).toBe(true) + }) + + it('still defers on a near-zero box (hidden pane / worktree-switch overlay)', () => { + expect(canApplyPaneMetricOptions(makeSizedPane({ width: 0, height: 0 }, 0))).toBe(false) + }) + + it('leaves the cols/rows floor on the fit itself', () => { + expect(canMeasurePaneForFit(makeSizedPane({ width: 50, height: 600 }, 5))).toBe(false) + }) +}) diff --git a/src/renderer/src/lib/pane-manager/pane-metric-options-deferral.ts b/src/renderer/src/lib/pane-manager/pane-metric-options-deferral.ts new file mode 100644 index 00000000000..fc4b32312db --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-metric-options-deferral.ts @@ -0,0 +1,113 @@ +import type { ManagedPane } from './pane-manager-types' +import { recordTerminalWebglDiagnostic } from '../../../../shared/terminal-webgl-diagnostics' + +/** The xterm options whose writes make the renderer clear, resize and full-refresh. */ +export type PaneMetricOptions = { + fontSize?: number + fontFamily?: string + fontWeight?: string | number + fontWeightBold?: string | number + lineHeight?: number +} + +type PaneTerminal = ManagedPane['terminal'] + +// Why keyed on the terminal, not the pane: getPanes() hands out a fresh +// toPublicPane() wrapper per call, so a pane-keyed entry could never be found +// again. `terminal` is carried by reference and dies with the pane. +const deferredMetricOptions = new WeakMap() + +/** + * Why deferred: writing one of these makes xterm clear the renderer, re-resize + * to the current grid and full-refresh. On a pane with no usable box that + * repaint is wasted — it lands in the DOM-renderer fallback while WebGL is + * suspended — and the cols/rows re-fit that has to follow the write cannot run. + * So the values park here and land once the pane can actually be fitted. + */ +export function applyOrDeferPaneMetricOptions( + pane: ManagedPane, + options: PaneMetricOptions, + measurable: boolean +): 'applied' | 'deferred' { + if (!measurable) { + // Latest wins: a newer settings change while hidden supersedes the pending one. + deferredMetricOptions.set(pane.terminal, options) + return 'deferred' + } + deferredMetricOptions.delete(pane.terminal) + writePaneMetricOptions(pane, options) + return 'applied' +} + +/** Applies a pending deferral. Callers must ensure the pane is measurable. */ +export function flushDeferredPaneMetricOptions(pane: ManagedPane): boolean { + const pending = deferredMetricOptions.get(pane.terminal) + if (!pending) { + return false + } + deferredMetricOptions.delete(pane.terminal) + writePaneMetricOptions(pane, pending) + recordTerminalWebglDiagnostic('metric-options-deferred-flush', { paneId: pane.id }) + return true +} + +export function hasDeferredPaneMetricOptions(pane: ManagedPane): boolean { + return deferredMetricOptions.has(pane.terminal) +} + +/** + * True when every value is already live and nothing is parked, so the caller can + * skip the apply. Why it matters: any settings write re-runs the appearance pass + * over every mounted pane, and arming a no-op deferral would make the next + * reveal refit for nothing. + */ +export function paneMetricOptionsAlreadySettled( + pane: ManagedPane, + options: PaneMetricOptions +): boolean { + if (deferredMetricOptions.has(pane.terminal)) { + return false + } + const target = pane.terminal.options + return ( + (options.fontSize === undefined || target.fontSize === options.fontSize) && + (options.fontFamily === undefined || target.fontFamily === options.fontFamily) && + (options.fontWeight === undefined || target.fontWeight === options.fontWeight) && + (options.fontWeightBold === undefined || target.fontWeightBold === options.fontWeightBold) && + (options.lineHeight === undefined || target.lineHeight === options.lineHeight) + ) +} + +/** + * Folds a directly-applied metric write into any pending deferral so a later + * flush cannot clobber it with the parked value. No-op when nothing is parked. + */ +export function overridePendingPaneMetricOptions( + pane: ManagedPane, + options: PaneMetricOptions +): void { + const pending = deferredMetricOptions.get(pane.terminal) + if (!pending) { + return + } + deferredMetricOptions.set(pane.terminal, { ...pending, ...options }) +} + +function writePaneMetricOptions(pane: ManagedPane, options: PaneMetricOptions): void { + const target = pane.terminal.options + if (options.fontSize !== undefined) { + target.fontSize = options.fontSize + } + if (options.fontFamily !== undefined) { + target.fontFamily = options.fontFamily + } + if (options.fontWeight !== undefined) { + target.fontWeight = options.fontWeight as typeof target.fontWeight + } + if (options.fontWeightBold !== undefined) { + target.fontWeightBold = options.fontWeightBold as typeof target.fontWeightBold + } + if (options.lineHeight !== undefined) { + target.lineHeight = options.lineHeight + } +} diff --git a/src/renderer/src/lib/pane-manager/pane-reveal-fit.test.ts b/src/renderer/src/lib/pane-manager/pane-reveal-fit.test.ts index caf10deb62b..c3ac10f44b4 100644 --- a/src/renderer/src/lib/pane-manager/pane-reveal-fit.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-reveal-fit.test.ts @@ -9,14 +9,16 @@ const mocks = vi.hoisted(() => ({ readFitClientSize: vi.fn<(pane: ManagedPane) => { width: number; height: number } | null>(), requestStablePaneFit: vi.fn(), clearPaneFitContinuationRetry: vi.fn(), - resumePendingFitScrollRestoreAfterFit: vi.fn() + resumePendingFitScrollRestoreAfterFit: vi.fn(), + flushDeferredPaneMetricOptionsIfMeasurable: vi.fn(() => false) })) vi.mock('./pane-fit', () => ({ safeFit: mocks.safeFit, canMeasurePaneForFit: mocks.canMeasurePaneForFit, flushPendingSafeFitContinuations: mocks.flushPendingSafeFitContinuations, - readFitClientSize: mocks.readFitClientSize + readFitClientSize: mocks.readFitClientSize, + flushDeferredPaneMetricOptionsIfMeasurable: mocks.flushDeferredPaneMetricOptionsIfMeasurable })) vi.mock('./pane-fit-resize-observer', () => ({ requestStablePaneFit: mocks.requestStablePaneFit @@ -50,6 +52,44 @@ describe('fitRevealedPane routing', () => { beforeEach(() => { vi.clearAllMocks() mocks.canMeasurePaneForFit.mockReturnValue(true) + mocks.flushDeferredPaneMetricOptionsIfMeasurable.mockReturnValue(false) + }) + + it('repairs on a steady grid when a deferred metric flush lands on a pane the size checks would skip', () => { + // Without the flush branch the checks below both say "nothing to do" and the + // parked font change never reaches the grid. It must route through the stable + // path, not a raw fit: pixels are unchanged and the grid diverged, so a + // synchronous fit would reflow on the WebGL/DOM metric wobble and corrupt a + // diff-painting inline TUI. + mocks.flushDeferredPaneMetricOptionsIfMeasurable.mockReturnValue(true) + const pane = createPane({ + lastFitClientSize: { width: 800, height: 600 }, + currentSize: { width: 800, height: 600 }, + terminal: { cols: 80, rows: 24 }, + proposed: { cols: 80, rows: 24 } + }) + + fitRevealedPane(pane) + + expect(mocks.flushDeferredPaneMetricOptionsIfMeasurable).toHaveBeenCalledWith(pane) + expect(mocks.requestStablePaneFit).toHaveBeenCalledWith(pane) + expect(mocks.safeFit).not.toHaveBeenCalled() + }) + + it('still flushes parked metric options when the pane also resized while hidden', () => { + mocks.flushDeferredPaneMetricOptionsIfMeasurable.mockReturnValue(true) + const pane = createPane({ + lastFitClientSize: { width: 800, height: 600 }, + currentSize: { width: 640, height: 480 }, + terminal: { cols: 80, rows: 24 }, + proposed: { cols: 64, rows: 20 } + }) + + fitRevealedPane(pane) + + expect(mocks.flushDeferredPaneMetricOptionsIfMeasurable).toHaveBeenCalledWith(pane) + // A real resize still takes the synchronous path, and the flush already landed. + expect(mocks.safeFit).toHaveBeenCalledWith(pane) }) it('fits synchronously when the fit element resized while hidden', () => { diff --git a/src/renderer/src/lib/pane-manager/pane-reveal-fit.ts b/src/renderer/src/lib/pane-manager/pane-reveal-fit.ts index a397eee933d..f0125433764 100644 --- a/src/renderer/src/lib/pane-manager/pane-reveal-fit.ts +++ b/src/renderer/src/lib/pane-manager/pane-reveal-fit.ts @@ -1,6 +1,7 @@ import type { ManagedPane, ManagedPaneInternal } from './pane-manager-types' import { canMeasurePaneForFit, + flushDeferredPaneMetricOptionsIfMeasurable, flushPendingSafeFitContinuations, readFitClientSize, safeFit @@ -61,11 +62,17 @@ function releaseMeasurableFitContinuations(pane: ManagedPane): void { // mismatch refits but a transient metric wobble does not reflow; // - grid already correct → leave it alone. export function fitRevealedPane(pane: ManagedPane): void { + // Why first: the checks below can both say "nothing to do" and return without + // fitting, stranding metric options parked while the pane was unmeasurable. + const flushed = flushDeferredPaneMetricOptionsIfMeasurable(pane) if (paneFitClientSizeChanged(pane)) { safeFit(pane) return } - if (!proposedGridMatchesTerminal(pane)) { + // Why the stable path for a flush: it leaves pixels unchanged but the grid + // diverged — the same shape as a snapshot resize, and a raw fit here would + // reflow on the transient WebGL/DOM metric wobble this function exists to avoid. + if (flushed || !proposedGridMatchesTerminal(pane)) { requestStablePaneFit(pane) return } diff --git a/tests/e2e/setup-script-prompt-unreadable-orca-yaml.spec.ts b/tests/e2e/setup-script-prompt-unreadable-orca-yaml.spec.ts index 5cc07ef0e5b..151a4b02bbc 100644 --- a/tests/e2e/setup-script-prompt-unreadable-orca-yaml.spec.ts +++ b/tests/e2e/setup-script-prompt-unreadable-orca-yaml.spec.ts @@ -65,8 +65,9 @@ async function installUnreadableOrcaYamlFault(electronApp: ElectronApplication): /** orca.yaml becomes readable again — every later check runs the production handler. */ async function healOrcaYamlRead(electronApp: ElectronApplication): Promise { await electronApp.evaluate(() => { - ;(globalThis as typeof globalThis & { __orcaE2eOrcaYamlUnreadable?: boolean }) - .__orcaE2eOrcaYamlUnreadable = false + ;( + globalThis as typeof globalThis & { __orcaE2eOrcaYamlUnreadable?: boolean } + ).__orcaE2eOrcaYamlUnreadable = false }) } From 2a2b517a8549e7b83fe28ba45f945896fa820656 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:41:50 -0700 Subject: [PATCH 015/289] Prevent empty agent-launch fallback on automation dispatch (#13068) The automation session owns the prompt-bearing agent tab. Passing undefined for createdWithAgent prevents spurious empty agent tabs from opening when dispatching automations. --- .../hooks/useAutomationDispatchEvents.test.ts | 13 ++++ .../src/hooks/useAutomationDispatchEvents.ts | 72 +++++++++---------- 2 files changed, 49 insertions(+), 36 deletions(-) diff --git a/src/renderer/src/hooks/useAutomationDispatchEvents.test.ts b/src/renderer/src/hooks/useAutomationDispatchEvents.test.ts index d49778b3a05..46585b0091c 100644 --- a/src/renderer/src/hooks/useAutomationDispatchEvents.test.ts +++ b/src/renderer/src/hooks/useAutomationDispatchEvents.test.ts @@ -293,6 +293,19 @@ describe('useAutomationDispatchEvents setup launch', () => { expect(mockLaunchAgentBackgroundSession).toHaveBeenCalled() }) + it('does not stamp the created workspace with an empty agent-launch fallback', async () => { + await registerAndDispatch() + + expect(mockCreateWorktree.mock.calls[0][10]).toBeUndefined() + expect(mockLaunchAgentBackgroundSession).toHaveBeenCalledWith( + expect.objectContaining({ + agent: 'claude', + prompt: 'run this', + worktreeId: 'wt-created' + }) + ) + }) + it('keeps launching the agent when background setup terminal launch fails', async () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) mockLaunchWorktreeBackgroundTerminals.mockRejectedValue(new Error('tab launch failed')) diff --git a/src/renderer/src/hooks/useAutomationDispatchEvents.ts b/src/renderer/src/hooks/useAutomationDispatchEvents.ts index 09009d40011..f4ae2067688 100644 --- a/src/renderer/src/hooks/useAutomationDispatchEvents.ts +++ b/src/renderer/src/hooks/useAutomationDispatchEvents.ts @@ -220,43 +220,43 @@ export function useAutomationDispatchEvents(): void { const automationWorkspaceCreateRequestId = createBrowserUuid() const createResult = automation.workspaceMode === 'new_per_run' - ? await useAppStore - .getState() - .createWorktree( - runRepoId, - buildAutomationWorkspaceName(run.title, run.scheduledFor), - automation.baseBranch ?? undefined, - automation.setupDecision ?? 'skip', - undefined, - 'unknown', - run.title, - undefined, - undefined, - undefined, - automation.agentId, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - { - automationProvenanceRequest: { - automationId: automation.id, - automationRunId: run.id, - dispatchToken, - createRequestId: automationWorkspaceCreateRequestId - } + ? await useAppStore.getState().createWorktree( + runRepoId, + buildAutomationWorkspaceName(run.title, run.scheduledFor), + automation.baseBranch ?? undefined, + automation.setupDecision ?? 'skip', + undefined, + 'unknown', + run.title, + undefined, + undefined, + undefined, + // Why: the automation session below owns the prompt-bearing + // agent tab; createdWithAgent would reopen an empty fallback. + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + { + automationProvenanceRequest: { + automationId: automation.id, + automationRunId: run.id, + dispatchToken, + createRequestId: automationWorkspaceCreateRequestId } - ) + } + ) : null const worktree = createResult ? createResult.worktree From 58531e4caa7a8ed48c1dede4e00e87369ac8a95f Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:42:40 -0700 Subject: [PATCH 016/289] Fix grok stale pane width (#13060) * fix(terminal): restore canonical terminal rows from unified sessions During session hydration, non-canonical terminal rows (closed/stale tabs) were being restored alongside canonical ones. Filter to only canonical terminal rows from unified sessions, treating legacy rows as terminal backing data only. * fix(terminal): preserve independent legacy PTY rows --- ...terminals-hydration-canonical-rows.test.ts | 130 ++++++++++++++++++ src/renderer/src/store/slices/terminals.ts | 30 +++- 2 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 src/renderer/src/store/slices/terminals-hydration-canonical-rows.test.ts diff --git a/src/renderer/src/store/slices/terminals-hydration-canonical-rows.test.ts b/src/renderer/src/store/slices/terminals-hydration-canonical-rows.test.ts new file mode 100644 index 00000000000..87563472922 --- /dev/null +++ b/src/renderer/src/store/slices/terminals-hydration-canonical-rows.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it, vi } from 'vitest' +import type { WorkspaceSessionState } from '../../../../shared/types' +import { getDefaultWorkspaceSession } from '../../../../shared/constants' +import { buildWorkspaceSessionPayload } from '@/lib/workspace-session' +import { createTestStore, makeLayout, makeTab, makeWorktree, seedStore } from './store-test-helpers' + +vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) +vi.mock('@/runtime/sync-runtime-graph', () => ({ + scheduleRuntimeGraphSync: vi.fn() +})) +vi.mock('@/components/terminal-pane/pty-transport', () => ({ + registerEagerPtyBuffer: vi.fn(), + ensurePtyDispatcher: vi.fn() +})) + +const apiProxy = (): unknown => + new Proxy(() => undefined, { + get: (_target, prop) => (prop === 'then' ? undefined : apiProxy()), + apply: () => Promise.resolve(null) + }) + +// @ts-expect-error -- mocked browser preload API +globalThis.window = { api: apiProxy() } + +describe('hydrateWorkspaceSession canonical terminal rows', () => { + it('drops only legacy rows that duplicate canonical PTY ownership', () => { + const store = createTestStore() + const worktreeId = 'repo1::/wt-1' + const sharedPtyId = 'daemon-session-1' + const recoveryPtyId = 'daemon-session-2' + seedStore(store, { + worktreesByRepo: { + repo1: [makeWorktree({ id: worktreeId, repoId: 'repo1', path: '/wt-1' })] + } + }) + + const session: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + activeRepoId: 'repo1', + activeWorktreeId: worktreeId, + activeTabId: 'stale-tab', + activeWorktreeIdsOnShutdown: [worktreeId], + activeTabIdByWorktree: { [worktreeId]: 'stale-tab' }, + tabsByWorktree: { + [worktreeId]: [ + makeTab({ id: 'canonical-tab', worktreeId, ptyId: sharedPtyId }), + makeTab({ id: 'stale-tab', worktreeId, ptyId: sharedPtyId }), + makeTab({ id: 'recovery-tab', worktreeId, ptyId: recoveryPtyId }) + ] + }, + terminalLayoutsByTabId: { + 'canonical-tab': { + ...makeLayout(), + ptyIdsByLeafId: { 'canonical-leaf': sharedPtyId } + }, + 'stale-tab': { + ...makeLayout(), + ptyIdsByLeafId: { 'stale-leaf': sharedPtyId } + }, + 'recovery-tab': { + ...makeLayout(), + ptyIdsByLeafId: { 'recovery-leaf': recoveryPtyId } + } + }, + remoteSessionIdsByTabId: { + 'canonical-tab': sharedPtyId, + 'stale-tab': sharedPtyId, + 'recovery-tab': recoveryPtyId + }, + unifiedTabs: { + [worktreeId]: [ + { + id: 'canonical-unified-tab', + entityId: 'canonical-tab', + groupId: 'group-1', + worktreeId, + contentType: 'terminal', + label: 'Grok', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + tabGroups: { + [worktreeId]: [ + { + id: 'group-1', + worktreeId, + activeTabId: 'canonical-unified-tab', + tabOrder: ['canonical-unified-tab'] + } + ] + } + } + + store.getState().hydrateWorkspaceSession(session) + store.getState().hydrateTabsSession(session) + const reconciliation = store.getState().reconcileWorktreeTabModel(worktreeId) + const state = store.getState() + const persisted = buildWorkspaceSessionPayload(state) + + expect(reconciliation.renderableTabCount).toBe(2) + expect(state.unifiedTabsByWorktree[worktreeId]?.map((tab) => tab.entityId)).toEqual([ + 'canonical-tab', + 'recovery-tab' + ]) + expect(state.tabsByWorktree[worktreeId]?.map((tab) => tab.id)).toEqual([ + 'canonical-tab', + 'recovery-tab' + ]) + expect(state.terminalLayoutsByTabId['stale-tab']).toBeUndefined() + expect(persisted.tabsByWorktree[worktreeId]?.map((tab) => tab.id)).toEqual([ + 'canonical-tab', + 'recovery-tab' + ]) + expect(persisted.terminalLayoutsByTabId['stale-tab']).toBeUndefined() + expect(state.pendingReconnectTabByWorktree[worktreeId]).toEqual([ + 'canonical-tab', + 'recovery-tab' + ]) + expect(state.pendingReconnectPtyIdByTabId).toEqual({ + 'canonical-tab': sharedPtyId, + 'recovery-tab': recoveryPtyId + }) + expect(state.activeTabId).toBeNull() + expect(state.activeTabIdByWorktree).toEqual({}) + }) +}) diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 7167e1cfc4d..87729c71ddb 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -492,6 +492,17 @@ function uniquePtyIds(ptyIds: readonly (string | null | undefined)[]): string[] return [...new Set(ptyIds.filter((ptyId): ptyId is string => Boolean(ptyId)))] } +function collectPersistedTerminalPtyIds( + session: WorkspaceSessionState, + tab: TerminalTab +): string[] { + return uniquePtyIds([ + tab.ptyId, + session.remoteSessionIdsByTabId?.[tab.id], + ...Object.values(session.terminalLayoutsByTabId[tab.id]?.ptyIdsByLeafId ?? {}) + ]) +} + function resolvePrimaryLayoutPtyId(layout: TerminalLayoutSnapshot): string | null { const ptyIdsByLeafId = layout.ptyIdsByLeafId ?? {} const activePtyId = layout.activeLeafId ? ptyIdsByLeafId[layout.activeLeafId] : undefined @@ -3943,6 +3954,16 @@ export const createTerminalSlice: StateCreator Object.entries(session.tabsByWorktree) .filter(([worktreeId]) => validWorktreeIds.has(worktreeId)) .map(([worktreeId, tabs]) => { + const canonicalTerminalIds = new Set( + (session.unifiedTabs?.[worktreeId] ?? []).flatMap((tab) => + tab.contentType === 'terminal' ? [tab.entityId] : [] + ) + ) + const canonicalPtyIds = new Set( + tabs + .filter((tab) => canonicalTerminalIds.has(tab.id)) + .flatMap((tab) => collectPersistedTerminalPtyIds(session, tab)) + ) const quickCommandLabelByTerminalId = new Map( (session.unifiedTabs?.[worktreeId] ?? []) .filter((tab) => tab.contentType === 'terminal' && tab.quickCommandLabel?.trim()) @@ -3957,8 +3978,15 @@ export const createTerminalSlice: StateCreator worktreeId, [...tabs] .filter((tab) => { + // Why: canonical mounts win PTY ownership over stale legacy duplicates. // Why: old web-client mirrors could persist host surface ids with "::"; makePaneKey reserves ":" as its separator. - return isValidTerminalTabId(tab.id) + return ( + (canonicalTerminalIds.has(tab.id) || + !collectPersistedTerminalPtyIds(session, tab).some((ptyId) => + canonicalPtyIds.has(ptyId) + )) && + isValidTerminalTabId(tab.id) + ) }) .sort((a, b) => a.sortOrder - b.sortOrder || a.createdAt - b.createdAt) .map((tab, index) => { From a77002c42b6bf74d6a87bbebfefde21a9e2a6c37 Mon Sep 17 00:00:00 2001 From: 5hseok <110245258+5hseok@users.noreply.github.com> Date: Sat, 8 Aug 2026 03:47:26 +0900 Subject: [PATCH 017/289] feat(ai-vault): delete a provider session from the AI Vault list (#10249) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ai-vault): validate session-delete targets for single-file providers Add the pure judgement layer for deleting an Agent Session History entry. `validateAiVaultSessionDeleteTarget` decides whether a session may be removed: the agent must be one of the nine providers where a single file is the whole session (gemini, copilot, cursor, hermes, devin, openclaw, droid, pi, omp), the host must be local, and the renderer-supplied path must resolve inside that agent's own session roots and match its discovery predicate. To keep the delete roots from drifting from the scanner's own roots, the WSL-expansion helper moves to session-scanner-root-dirs.ts and the OpenClaw root derivation + session predicate become shared helpers that discoverOpenClawFiles itself consumes. The result is path-only and never touches the filesystem; a returned `allowed: true` still requires an lstat/realpath re-check in the executor (S-2) before removal, documented as a caller contract on the result type. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XDLggjSAjDnaWi3Y8U622i * feat(ai-vault): move a validated session transcript to the trash Add the filesystem executor behind session deletion. It calls the S-1 path validator, then performs the fs-side guards that validator documented it could not: lstat().isFile() rejects a directory or symlink, and realpath is re-fed through the validator so a regular file reached through a symlinked parent that escapes the agent's roots is rejected too. Only then is the file moved to the OS trash via shell.trashItem, with ENOENT treated as success so a delete racing an external removal stays idempotent. WSL UNC paths (no Recycle Bin) are delegated to tryDeleteWslUncPath before the Windows-local fs guards, mirroring fs:deletePath. Any non-ENOENT error is returned as a failure result rather than thrown, since IPC payloads are untyped at runtime. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XDLggjSAjDnaWi3Y8U622i * feat(ai-vault): delete-session IPC handler, preload bridge, cache invalidation Wire the S-2 delete executor to an IPC endpoint and expose it on the preload bridge. The renderer calls aiVault:deleteSession with { agent, filePath, executionHostId }; the handler fetches WSL homes, delegates to the executor (which re-validates and trashes), and on a real delete invalidates the caches that could otherwise keep serving the deleted session. Cache invalidation is generation-guarded: a scan already in flight when the delete lands carries an older generation and must not write its pre-delete result back into the cache. Without this, an in-flight scan resolving just after the delete would resurrect the deleted session for the 15s TTL — and force-refreshing the panel only masks it for the desktop, not for the paired mobile client or runtime RPC that share the same cache module. Both the shared local-scope cache and the desktop multi-host cache carry the guard, with regression tests for the in-flight race. The delete result type moves to shared/ai-vault-types.ts so the renderer can import the same contract the executor returns. To keep ai-vault.ts within the max-lines budget after adding the delete wiring, two cohesive pieces are extracted to their own files: the delete orchestration (ai-vault-delete.ts) and listAiVaultSubagentSessions (ai-vault-subagent-list.ts). The latter is the only handler with no dependency on this module's private cache state, so it is the one piece that moves verbatim without threading state through a seam. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XDLggjSAjDnaWi3Y8U622i * feat(ai-vault): renderer judgement for whether Delete is offered Add the renderer counterpart to the main-side delete validator: given a session, decide whether the row menu shows Delete enabled, or disabled with a reason a tooltip can render. It reuses the shared deletable-agent set and unsupported-reason map so the two sides can never disagree about which agents are deletable, and reuses the existing local-host / synthetic-path renderer helpers. This is intentionally not a security boundary — it validates neither the path root nor the file predicate. Those are the main process's untrusted-input defense; the renderer only picks the affordance, and the main side re-checks on delete regardless. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XDLggjSAjDnaWi3Y8U622i * docs(ai-vault): correct deletability parity claim; test multi-reason agent The renderer deletability check runs host -> synthetic -> agent, while the main validator runs agent -> host -> synthetic. The two layers agree only on deletable-or-not (renderer-false is a subset of main-false), not on the reason code a doubly-failing session carries. Document that explicitly instead of implying the orders match, and add the antigravity case (two reason codes) so the agentReasonCodes array shape is actually exercised. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XDLggjSAjDnaWi3Y8U622i * feat(ai-vault): add Delete to the session row menu with a confirmation dialog Wire the delete affordance into AI Vault. Both the dropdown and the context menu gain a destructive Delete item; a session that can't be completely deleted (remote host, synthetic OpenCode-SQLite path, or a directory/registry-backed agent) shows the item disabled with a reason surfaced both as a tooltip and as an aria-label so keyboard and screen-reader users learn why. Confirming opens a dialog that names the session and states it will no longer be resumable from the provider's own CLI, then calls the delete IPC and force-refreshes the list for immediate feedback (the main side has already invalidated its caches). The confirmation copy says the session "will be deleted" rather than "moved to the trash": on Windows a WSL session is deleted with rm inside the distro (no Recycle Bin), so promising recoverability would be a lie on that platform. Deletability is computed once per row and shared by both menus so they can never disagree. New pure logic — the reason-to-tooltip mapping (including the multi-reason join) and the delete action hook's deleted/rejected/failed branches — is covered by unit tests. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XDLggjSAjDnaWi3Y8U622i * fix(ai-vault): state that Delete is unavailable without naming the cause The disabled Delete item explained a provider's storage layout to the user ("Claude sessions can't be deleted here: stores sessions as a folder, not a single file"). That is Orca's problem, not the reader's — the tooltip now says which sessions are affected and stops there. The non-local-host string stays as it was: it states scope, not a cause, and tells the user what would work. The reason-code plumbing existed only to compose that tooltip, so AI_VAULT_UNSUPPORTED_DELETE_REASONS, AiVaultUnsupportedDeleteReasonCode, and the renderer result's agentReasonCodes field go with it. Why each agent is excluded moves into the comment above AI_VAULT_DELETABLE_AGENTS, where a reader looking up the deletable set will find it. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01KGuzChimmQ1dYX2raecrH7 * feat(ai-vault): delete claude, rovo, and grok sessions by their directory These three were excluded only because the delete unit was one file. Their sessions are directories — claude keeps Task subagent transcripts in a sibling `/subagents/`, rovo and grok keep everything under `/` — and nothing in them is shared with another session, so a directory-aware delete is still a complete delete. Supported goes from 9 agents to 12; the four that remain (antigravity, kimi, codex, opencode) are blocked by a registry or a SQLite row, which no delete unit fixes. Validation now returns an ordered removal plan instead of a single path. Each removal carries the kind it must be on disk and the roots its realpath must stay inside, so the executor's guard is the same shape for a file and for a directory. Companions come first and the transcript last: the transcript is what puts the row on screen, so a part-way failure leaves the row to retry from rather than dropping it and stranding the rest on disk. Claude's `session-env//` goes with the transcript — it holds that session's generated shell exports and nothing else. Its sibling `file-history//` deliberately does not: it is the rewind buffer holding earlier versions of the user's own files, and retiring a session is no reason to take away the only copy that can restore them. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01KGuzChimmQ1dYX2raecrH7 * fix(ai-vault): remove a claude session's own directory, not just its subagents Deleting a claude session trashed `/subagents/` and left `/` behind as an empty directory — one per deleted session, accumulating under every project. The directory is named after the transcript, so it belongs to that session as a whole; take it rather than the one subdirectory inside it. Still derived from the scanner's own subagents path, so the two cannot drift. Reaching the parent means a degenerate stem now matters: `..jsonl` passes the extension check and its stem is `.`, which would resolve the session directory to the project directory holding every session. Reject it instead. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01KGuzChimmQ1dYX2raecrH7 * fix(ai-vault): keep a session row collapsed when a menu action is chosen Radix portals the row's dropdown and context menus out of its DOM, but React still bubbles their clicks back through the component tree, so every menu selection also hit the row's own click handler and expanded it. The trigger button already stopped propagation, which is why opening the menu looked fine and only choosing an item misbehaved. It shows worst on Delete: the row expands behind the confirm dialog, so cancelling leaves the list rearranged under a dialog the user just backed out of. Toggle details only for clicks that land in the row's own subtree — that covers the context menu and any future portalled surface, not just this one. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01KGuzChimmQ1dYX2raecrH7 * fix(ai-vault): harden the delete-confirmation flow against IPC rejection and mid-delete dismissal Two robustness gaps flagged in review: - handleConfirmDelete only branched on result.outcome. The main handler resolves with a 'failed'/'rejected' outcome rather than throwing, but the IPC invoke itself can still reject on a transport/serialization error, and the caller fires it with `void`. That reject would surface as an unhandled rejection with no toast. Catch it and show the same generic failure toast. - handleDialogOpenChange cleared sessionPendingDelete on every open=false. The Cancel button is disabled mid-delete, but Radix still fires its Escape/outside-click/X close, which could dismiss an in-flight delete out from under itself. Ignore close requests while deletingSession is true. Both covered by regression tests (verified failing without the fix). * fix(ai-vault): route WSL UNC directory removals through the WSL rm branch Directory-shaped deletes (claude's subagents/session-env dirs, rovo/grok's session dir) gated the WSL branch on kind === 'file', so on Windows a session under a WSL distro home fell through to shell.trashItem — which can't trash a WSL-volume item (no Recycle Bin) and throws, or worse is silently stranded when the 9P filesystem's unreliable lstat false-reports ENOENT and the executor treats that as success. Single-file deletes predate the directory kinds, so the file-only gate was correct until directory removals were added. tryDeleteWslUncPath already supports recursive removal; pass recursive for directory removals so they take the same WSL rm path as files instead of shell.trashItem. Covered by two regression tests (file: non-recursive, directory: recursive), verified failing without the fix. Also drops the internal ledger-ID references (D-*, S-*) from comments in these two files; they pointed at a private design doc a reader can't see. * docs(ai-vault): drop internal design-ledger IDs from shipped comments Comments across the session-delete feature cited decision/slice IDs (D-1..D-7, S-1..S-5) from a private design document. Those references are meaningless to anyone reading the code without that doc, so remove the IDs while keeping the reasoning each comment carried. No behavior change. * test(ai-vault): e2e-cover the real on-disk session delete The unit tests mock lstat/realpath/trashItem, so nothing proved the whole IPC path actually removes files. This spec seeds sessions into the E2E harness's isolated HOME and deletes them through window.api.aiVault.deleteSession: - a single-file session (gemini): the transcript is gone from disk and drops out of the list. - a directory-shaped session (claude): the transcript, the / session directory (subagents included, no empty shell left), and the session-env companion are all gone, while the file-history rewind buffer is preserved. Verified failing when the executor's removal is stubbed out. Runs on Linux CI. * fix(ai-vault): address review findings on the session-delete flow Three points raised in review: - Disable Delete for a still-running session. resolveAiVaultSessionDeletability now gates on liveState (working/blocked/waiting) last — an otherwise-deletable session that is mid-run shows "wait for it to finish" instead of an enabled Delete, so trashing a live agent's transcript can't drop writes it is still appending. Unsupported/remote sessions keep their permanent reason. - Realpath the roots, not just the target, in the executor's escape check. The roots were only resolve()'d (text), so a session under a symlinked root (~/.claude -> /Volumes/…) was falsely rejected; realpath each root (falling back to its text form when it can't be resolved) before the membership check. - Invalidate the parse cache with the raw filePath, not resolve(filePath). The cache is keyed by the exact path the scanner discovered, so resolve() could normalise it away from the stored key and miss. Drops the now-unused import. Also moves AiVaultDeleteSessionArgs/Result out of ai-vault-types.ts (which the upstream merge pushed over the max-lines limit) into the ai-vault-session-deletion domain module they belong to, and updates importers. Regression tests added for the live gate, the symlinked-root accept, and the reason string; verified failing without each fix. * fix(ai-vault): type the deleteSession preload bridge as its real result The bridge declared Promise while AiVaultApi.deleteSession promises AiVaultDeleteSessionResult, so the preload object leaned on the api-types declaration to stay honest instead of being checked against it. Co-authored-by: Orca * refactor(ai-vault): tighten the session-delete code to house style Comments across the delete flow explained HOW alongside WHY and ran to a dozen lines; they now carry only the non-obvious reasoning. The excluded-agent rationale, the caller contract on the validator, and the file-history carve-out are kept — those are knowledge, not narration. Also removes three duplications the feature introduced: - AiVaultSessionDeleteExecutionResult was an alias for AiVaultDeleteSessionResult whose comment pointed at a module the type no longer lives in. - The synthetic-path predicate existed twice under near-identical names; the renderer now re-exports the shared one it already had a sibling import of. - The delete-failure toast was written out verbatim in both the rejected and the thrown branch. Co-authored-by: Orca * refactor(ai-vault): use a design-system dialog width and a stable row selector The confirm dialog pinned an arbitrary sm:max-w-[440px]; every other dialog in the right sidebar uses a scale token, and md (448px) covers the role. The row-expand test selected the row by [draggable="true"], which stopped naming the row when draggable moved to the title element upstream. It still passed by bubbling, so the comment was the only thing wrong — now it selects the title deliberately and says why the query is first-match (Radix's asChild trigger repeats the subtree, so screen.get* sees duplicates). Also types the e2e delete helper as AiVaultDeleteSessionResult instead of a hand-written { outcome: string }, now that the preload bridge returns it. Co-authored-by: Orca * refactor(ai-vault): consolidate agent sources and use system dialog Discovery and deletion now share the same agent source definitions, eliminating the risk of them drifting apart. A single `AI_VAULT_AGENT_SOURCES` table declares each agent's root directories, file extensions, and acceptance predicates. Replaced the custom delete confirmation dialog with the system dialog, simplifying the delete action hook and removing boilerplate state management. --------- Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Co-authored-by: Orca --- src/main/ai-vault/cached-session-list.test.ts | 74 +++ src/main/ai-vault/cached-session-list.ts | 27 +- .../ai-vault/session-delete-target.test.ts | 437 ++++++++++++++++++ src/main/ai-vault/session-delete-target.ts | 145 ++++++ src/main/ai-vault/session-delete.test.ts | 293 ++++++++++++ src/main/ai-vault/session-delete.ts | 104 +++++ .../ai-vault/session-scanner-agent-sources.ts | 254 ++++++++++ .../ai-vault/session-scanner-discovery.ts | 26 +- .../session-scanner-droid-kimi-sources.ts | 61 --- .../ai-vault/session-scanner-parse-cache.ts | 6 + .../session-scanner-source-discovery.ts | 311 ++----------- src/main/ipc/ai-vault-delete.ts | 57 +++ src/main/ipc/ai-vault-host-leg-cache.ts | 22 +- src/main/ipc/ai-vault-subagent-list.ts | 51 ++ src/main/ipc/ai-vault.test.ts | 148 +++++- src/main/ipc/ai-vault.ts | 60 +-- src/preload/api-types.ts | 6 + src/preload/index.ts | 6 + .../components/right-sidebar/AiVaultPanel.tsx | 4 + .../AiVaultSessionActionMenuItems.tsx | 44 +- .../right-sidebar/AiVaultSessionRow.test.tsx | 95 ++++ .../right-sidebar/AiVaultSessionRow.tsx | 21 +- .../AiVaultSessionVirtualList.tsx | 10 +- .../SessionRowTrailingActions.test.tsx | 2 + .../SessionRowTrailingActions.tsx | 9 +- .../ai-vault-session-deletability.test.ts | 100 ++++ .../ai-vault-session-deletability.ts | 60 +++ .../ai-vault-session-delete-action.ts | 68 +++ .../ai-vault-session-path-actions.ts | 15 +- src/renderer/src/i18n/locales/en.json | 16 +- src/renderer/src/i18n/locales/es.json | 16 +- src/renderer/src/i18n/locales/ja.json | 16 +- src/renderer/src/i18n/locales/ko.json | 16 +- src/renderer/src/i18n/locales/zh.json | 16 +- src/renderer/src/web/web-preload-api.ts | 10 + src/shared/ai-vault-session-deletion.ts | 105 +++++ tests/e2e/ai-vault-session-delete.spec.ts | 127 +++++ 37 files changed, 2408 insertions(+), 430 deletions(-) create mode 100644 src/main/ai-vault/cached-session-list.test.ts create mode 100644 src/main/ai-vault/session-delete-target.test.ts create mode 100644 src/main/ai-vault/session-delete-target.ts create mode 100644 src/main/ai-vault/session-delete.test.ts create mode 100644 src/main/ai-vault/session-delete.ts create mode 100644 src/main/ai-vault/session-scanner-agent-sources.ts delete mode 100644 src/main/ai-vault/session-scanner-droid-kimi-sources.ts create mode 100644 src/main/ipc/ai-vault-delete.ts create mode 100644 src/main/ipc/ai-vault-subagent-list.ts create mode 100644 src/renderer/src/components/right-sidebar/AiVaultSessionRow.test.tsx create mode 100644 src/renderer/src/components/right-sidebar/ai-vault-session-deletability.test.ts create mode 100644 src/renderer/src/components/right-sidebar/ai-vault-session-deletability.ts create mode 100644 src/renderer/src/components/right-sidebar/ai-vault-session-delete-action.ts create mode 100644 src/shared/ai-vault-session-deletion.ts create mode 100644 tests/e2e/ai-vault-session-delete.spec.ts diff --git a/src/main/ai-vault/cached-session-list.test.ts b/src/main/ai-vault/cached-session-list.test.ts new file mode 100644 index 00000000000..49577d3e519 --- /dev/null +++ b/src/main/ai-vault/cached-session-list.test.ts @@ -0,0 +1,74 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AiVaultListResult } from '../../shared/ai-vault-types' + +const { scanAiVaultSessions } = vi.hoisted(() => ({ scanAiVaultSessions: vi.fn() })) + +vi.mock('./session-scanner', () => ({ scanAiVaultSessions })) +vi.mock('../wsl', () => ({ + getWslHomeAsync: vi.fn(), + listWslDistrosAsync: vi.fn().mockResolvedValue([]) +})) + +import { + invalidateAiVaultSessionListCache, + listAiVaultSessions, + resetAiVaultSessionListCacheForTests +} from './cached-session-list' + +function scanResult(scannedAt: string): AiVaultListResult { + return { sessions: [], issues: [], scannedAt } +} + +// A scan whose resolution the test controls, so an invalidation can be injected +// mid-flight. +function deferredScan(): { resolve: (value: AiVaultListResult) => void } { + let resolveFn: (value: AiVaultListResult) => void = () => {} + scanAiVaultSessions.mockReturnValueOnce( + new Promise((resolve) => { + resolveFn = resolve + }) + ) + return { resolve: resolveFn } +} + +describe('invalidateAiVaultSessionListCache generation guard', () => { + beforeEach(() => { + resetAiVaultSessionListCacheForTests() + scanAiVaultSessions.mockReset() + }) + afterEach(() => { + resetAiVaultSessionListCacheForTests() + }) + + it('does not let a scan that started before an invalidation repopulate the cache', async () => { + // Scan A starts and is still in flight. + const scanA = deferredScan() + const inFlight = listAiVaultSessions() + + // A delete invalidates the cache while A is running. + invalidateAiVaultSessionListCache() + + // A now resolves with a pre-delete result. + scanA.resolve(scanResult('scan-A')) + await inFlight + + // A non-force list must re-scan (cache empty) rather than serve A's stale + // result — proof A's late .then() did not repopulate the cache. + scanAiVaultSessions.mockResolvedValueOnce(scanResult('scan-B')) + const next = await listAiVaultSessions() + + expect(next.scannedAt).toBe('scan-B') + expect(scanAiVaultSessions).toHaveBeenCalledTimes(2) + }) + + it('caches normally when no invalidation interrupts the scan', async () => { + scanAiVaultSessions.mockResolvedValueOnce(scanResult('scan-A')) + await listAiVaultSessions() + + // Second non-force call is a cache hit — no second scan. + const cached = await listAiVaultSessions() + + expect(cached.scannedAt).toBe('scan-A') + expect(scanAiVaultSessions).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/ai-vault/cached-session-list.ts b/src/main/ai-vault/cached-session-list.ts index 875ab37310c..6a92bedb844 100644 --- a/src/main/ai-vault/cached-session-list.ts +++ b/src/main/ai-vault/cached-session-list.ts @@ -34,6 +34,11 @@ type CachedAiVaultList = { let cachedList: CachedAiVaultList | null = null let scanCoordinator = new AiVaultScanCoordinator() let sources: AiVaultSessionSources = {} +// Bumped on every invalidation. A scan that started before an invalidation +// carries the old generation and must not write its (now stale) result back +// into the cache — otherwise a delete's invalidation is silently undone by an +// in-flight scan that resolves just after it. +let cacheGeneration = 0 export function configureAiVaultSessionSources(next: AiVaultSessionSources): void { sources = next @@ -58,6 +63,10 @@ export async function listAiVaultSessions( ) { return truncateAiVaultListResult(cachedList.result, depth, args?.scopePaths) } + // Captured here, not inside start(): the coordinator defers start() by a + // microtask, so an invalidation landing in that gap would otherwise be read + // as having happened before this scan and leave the stale result cacheable. + const startGeneration = cacheGeneration return scanCoordinator.run({ key: scanKey, force: args?.force, @@ -78,7 +87,10 @@ export async function listAiVaultSessions( // runtime id get the result restamped at the RPC edge, never rescanned. executionHostId: LOCAL_EXECUTION_HOST_ID }) - if (!scanSignal.aborted) { + // A delete (or other invalidation) landed while this scan was running: + // its result predates the delete, so caching it would resurrect the + // deleted session for the TTL. Return it to this caller but don't cache. + if (!scanSignal.aborted && startGeneration === cacheGeneration) { const current = cachedList if ( args?.force === true || @@ -111,9 +123,20 @@ export async function getAiVaultWslHomeDirs(): Promise { return homes.filter((homeDir): homeDir is string => Boolean(homeDir)) } +// Drops the scan-result cache after a session is deleted so a non-force +// list call within the TTL can't still serve the trashed session. Bumping the +// generation also disarms any scan already in flight, so a scan that started +// before this call cannot write its pre-delete result back into the cache. +// Scan discovery itself walks disk first (session-scanner.ts), so a deleted +// file is never rediscovered — this only guards the cached RESULT. +export function invalidateAiVaultSessionListCache(): void { + cacheGeneration++ + cachedList = null +} + // Why: tests reset module-level cache/source state between cases. export function resetAiVaultSessionListCacheForTests(): void { - cachedList = null + invalidateAiVaultSessionListCache() scanCoordinator = new AiVaultScanCoordinator() sources = {} } diff --git a/src/main/ai-vault/session-delete-target.test.ts b/src/main/ai-vault/session-delete-target.test.ts new file mode 100644 index 00000000000..39c95f4f5f6 --- /dev/null +++ b/src/main/ai-vault/session-delete-target.test.ts @@ -0,0 +1,437 @@ +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { validateAiVaultSessionDeleteTarget } from './session-delete-target' + +// All roots are supplied via rootOptions so these tests never touch the real +// home directory or filesystem — validation is pure string-path judgement. +const HOME = join('/tmp', 'orca-ai-vault-delete-fixture-home') +const GEMINI_ROOT = join(HOME, '.gemini', 'tmp') +const CURSOR_ROOT = join(HOME, '.cursor', 'projects') +const HERMES_ROOT = join(HOME, '.hermes', 'sessions') +const OPENCLAW_ROOT = join(HOME, '.openclaw') +const COPILOT_ROOT = join(HOME, '.copilot', 'session-state') +const DEVIN_ROOT = join(HOME, '.local', 'share', 'devin', 'cli', 'transcripts') +const PI_ROOT = join(HOME, '.pi', 'agent', 'sessions') +const OMP_ROOT = join(HOME, '.omp', 'agent', 'sessions') +const CLAUDE_ROOT = join(HOME, '.claude', 'projects') +const CLAUDE_SESSION_ENV_ROOT = join(HOME, '.claude', 'session-env') +const ROVO_ROOT = join(HOME, '.rovodev', 'sessions') +const GROK_ROOT = join(HOME, '.grok', 'sessions') + +describe('validateAiVaultSessionDeleteTarget', () => { + it('allows a supported agent whose file resolves inside its known root', () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'gemini', + filePath: join(GEMINI_ROOT, 'project-a', 'session-1.json'), + executionHostId: 'local', + rootOptions: { geminiSessionsDir: GEMINI_ROOT } + }) + expect(result).toEqual({ + allowed: true, + agent: 'gemini', + resolvedPath: join(GEMINI_ROOT, 'project-a', 'session-1.json'), + removals: [ + { + path: join(GEMINI_ROOT, 'project-a', 'session-1.json'), + kind: 'file', + roots: [GEMINI_ROOT] + } + ] + }) + }) + + it('allows gemini .jsonl files too, since discovery accepts both extensions', () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'gemini', + filePath: join(GEMINI_ROOT, 'project-a', 'session-1.jsonl'), + executionHostId: 'local', + rootOptions: { geminiSessionsDir: GEMINI_ROOT } + }) + expect(result.allowed).toBe(true) + }) + + it('rejects a path that escapes its root via ..', () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'gemini', + filePath: join(GEMINI_ROOT, '..', '..', '..', 'etc', 'passwd.json'), + executionHostId: 'local', + rootOptions: { geminiSessionsDir: GEMINI_ROOT } + }) + expect(result).toEqual({ allowed: false, agent: 'gemini', reason: 'path-outside-known-roots' }) + }) + + it('rejects a path entirely outside the known roots', () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'gemini', + filePath: join(HOME, 'Documents', 'notes.json'), + executionHostId: 'local', + rootOptions: { geminiSessionsDir: GEMINI_ROOT } + }) + expect(result).toEqual({ allowed: false, agent: 'gemini', reason: 'path-outside-known-roots' }) + }) + + it('rejects a file whose extension the agent never discovers', () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'cursor', + filePath: join(CURSOR_ROOT, 'proj', 'agent-transcripts', 'session-1.txt'), + executionHostId: 'local', + rootOptions: { cursorProjectsDir: CURSOR_ROOT } + }) + expect(result).toEqual({ allowed: false, agent: 'cursor', reason: 'undiscoverable-path' }) + }) + + it('rejects a cursor path missing the agent-transcripts segment', () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'cursor', + filePath: join(CURSOR_ROOT, 'proj', 'session-1.jsonl'), + executionHostId: 'local', + rootOptions: { cursorProjectsDir: CURSOR_ROOT } + }) + expect(result).toEqual({ allowed: false, agent: 'cursor', reason: 'undiscoverable-path' }) + }) + + it('allows a cursor path that does carry the agent-transcripts segment', () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'cursor', + filePath: join(CURSOR_ROOT, 'proj', 'agent-transcripts', 'session-1.jsonl'), + executionHostId: 'local', + rootOptions: { cursorProjectsDir: CURSOR_ROOT } + }) + expect(result.allowed).toBe(true) + }) + + it('rejects a hermes file whose basename is missing the session_ prefix', () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'hermes', + filePath: join(HERMES_ROOT, 'not-a-session.json'), + executionHostId: 'local', + rootOptions: { hermesSessionsDir: HERMES_ROOT } + }) + expect(result).toEqual({ allowed: false, agent: 'hermes', reason: 'undiscoverable-path' }) + }) + + it('allows a hermes file whose basename carries the session_ prefix', () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'hermes', + filePath: join(HERMES_ROOT, 'session_abc123.json'), + executionHostId: 'local', + rootOptions: { hermesSessionsDir: HERMES_ROOT } + }) + expect(result.allowed).toBe(true) + }) + + it('allows an openclaw path under /agents/.../sessions/...', () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'openclaw', + filePath: join(OPENCLAW_ROOT, 'agents', 'agent-1', 'sessions', 'session-1.jsonl'), + executionHostId: 'local', + rootOptions: { openclawStateDir: OPENCLAW_ROOT } + }) + expect(result.allowed).toBe(true) + }) + + it('rejects an openclaw path missing the sessions segment', () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'openclaw', + filePath: join(OPENCLAW_ROOT, 'agents', 'agent-1', 'session-1.jsonl'), + executionHostId: 'local', + rootOptions: { openclawStateDir: OPENCLAW_ROOT } + }) + expect(result).toEqual({ allowed: false, agent: 'openclaw', reason: 'undiscoverable-path' }) + }) + + it('rejects an agent whose own registry would keep a dangling entry (codex)', () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'codex', + filePath: join(HOME, '.codex', 'sessions', 'rollout-1.jsonl'), + executionHostId: 'local' + }) + expect(result).toEqual({ allowed: false, agent: 'codex', reason: 'unsupported-agent' }) + }) + + it('rejects a non-local execution host', () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'gemini', + filePath: join(GEMINI_ROOT, 'project-a', 'session-1.json'), + executionHostId: 'ssh:some-host', + rootOptions: { geminiSessionsDir: GEMINI_ROOT } + }) + expect(result).toEqual({ allowed: false, agent: 'gemini', reason: 'non-local-host' }) + }) + + // A real OpenCode SQLite-row session (the `#` identity) + // reaches the validator as agent 'opencode', which is stopped at the agent + // gate — so this is how such a session actually enters judgement. + it('rejects a real opencode session as an unsupported agent', () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'opencode', + filePath: join(HOME, '.local', 'share', 'opencode', 'db.sqlite#session-1'), + executionHostId: 'local' + }) + expect(result).toEqual({ allowed: false, agent: 'opencode', reason: 'unsupported-agent' }) + }) + + // Defense-in-depth: even for a deletable agent, any '#'-bearing path is + // treated as a synthetic SQLite identity rather than a real file to delete. + it('rejects a deletable-agent path bearing a synthetic # marker', () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'gemini', + filePath: join(GEMINI_ROOT, 'project-a', 'db.sqlite#session-1'), + executionHostId: 'local', + rootOptions: { geminiSessionsDir: GEMINI_ROOT } + }) + expect(result).toEqual({ allowed: false, agent: 'gemini', reason: 'synthetic-path' }) + }) + + it('rejects a blank filePath', () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'gemini', + filePath: ' ', + executionHostId: 'local', + rootOptions: { geminiSessionsDir: GEMINI_ROOT } + }) + expect(result).toEqual({ allowed: false, agent: 'gemini', reason: 'invalid-path' }) + }) + + it('allows a droid path under either of its .factory roots', () => { + const sessionsRoot = join(HOME, '.factory', 'sessions') + const projectsRoot = join(HOME, '.factory', 'projects') + const underSessions = validateAiVaultSessionDeleteTarget({ + agent: 'droid', + filePath: join(sessionsRoot, 'session-1.jsonl'), + executionHostId: 'local', + rootOptions: { droidSessionsDir: sessionsRoot, droidProjectsDir: projectsRoot } + }) + expect(underSessions.allowed).toBe(true) + const underProjects = validateAiVaultSessionDeleteTarget({ + agent: 'droid', + filePath: join(projectsRoot, 'proj-a', 'session-2.jsonl'), + executionHostId: 'local', + rootOptions: { droidSessionsDir: sessionsRoot, droidProjectsDir: projectsRoot } + }) + expect(underProjects.allowed).toBe(true) + }) + + it('allows a copilot .jsonl file under its known root', () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'copilot', + filePath: join(COPILOT_ROOT, 'session-1.jsonl'), + executionHostId: 'local', + rootOptions: { copilotSessionsDir: COPILOT_ROOT } + }) + expect(result.allowed).toBe(true) + }) + + it('allows a devin .json file under its known root', () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'devin', + filePath: join(DEVIN_ROOT, 'session-1.json'), + executionHostId: 'local', + rootOptions: { devinTranscriptsDir: DEVIN_ROOT } + }) + expect(result.allowed).toBe(true) + }) + + it('allows a pi .jsonl file under its known root', () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'pi', + filePath: join(PI_ROOT, 'session-1.jsonl'), + executionHostId: 'local', + rootOptions: { piSessionsDir: PI_ROOT } + }) + expect(result.allowed).toBe(true) + }) + + it('allows an omp .jsonl file under its known root', () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'omp', + filePath: join(OMP_ROOT, 'session-1.jsonl'), + executionHostId: 'local', + rootOptions: { ompSessionsDir: OMP_ROOT } + }) + expect(result.allowed).toBe(true) + }) + + // Discovery matches extensions case-insensitively (walkSessionFiles folds via + // toLowerCase); the validator must accept the same uppercase-extension file. + it('allows an uppercase extension since discovery folds extension case', () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'gemini', + filePath: join(GEMINI_ROOT, 'project-a', 'session-1.JSON'), + executionHostId: 'local', + rootOptions: { geminiSessionsDir: GEMINI_ROOT } + }) + expect(result.allowed).toBe(true) + }) + + it('allows a gemini file under a WSL-expanded root', () => { + const wslHome = join('/tmp', 'orca-ai-vault-delete-fixture-wsl-home') + const result = validateAiVaultSessionDeleteTarget({ + agent: 'gemini', + filePath: join(wslHome, '.gemini', 'tmp', 'project-a', 'session-1.json'), + executionHostId: 'local', + wslHomeDirs: [wslHome], + rootOptions: { geminiSessionsDir: GEMINI_ROOT } + }) + expect(result.allowed).toBe(true) + }) + + // A stateDir that already ends in `agents` must not be doubled to + // `agents/agents`; openClawAgentsRootDir uses it as-is. + it('allows an openclaw path whose stateDir already ends in agents', () => { + const agentsRoot = join(OPENCLAW_ROOT, 'agents') + const result = validateAiVaultSessionDeleteTarget({ + agent: 'openclaw', + filePath: join(agentsRoot, 'agent-1', 'sessions', 'session-1.jsonl'), + executionHostId: 'local', + rootOptions: { openclawStateDir: agentsRoot } + }) + expect(result.allowed).toBe(true) + }) +}) + +// The directory-shaped agents. Their delete unit is a path set derived from the +// one file discovery surfaced, not that file alone. +describe('directory-shaped agents', () => { + it('plans claude companions before the transcript, so a failure keeps the row', () => { + const filePath = join(CLAUDE_ROOT, '-proj', 'sess-1.jsonl') + const result = validateAiVaultSessionDeleteTarget({ + agent: 'claude', + filePath, + executionHostId: 'local', + rootOptions: { claudeProjectsDir: CLAUDE_ROOT } + }) + + expect(result).toEqual({ + allowed: true, + agent: 'claude', + resolvedPath: filePath, + removals: [ + { + path: join(CLAUDE_ROOT, '-proj', 'sess-1'), + kind: 'directory', + roots: [CLAUDE_ROOT] + }, + { + path: join(CLAUDE_SESSION_ENV_ROOT, 'sess-1'), + kind: 'directory', + roots: [CLAUDE_SESSION_ENV_ROOT] + }, + { path: filePath, kind: 'file', roots: [CLAUDE_ROOT] } + ] + }) + }) + + it("never plans file-history, the rewind buffer for the user's own files", () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'claude', + filePath: join(CLAUDE_ROOT, '-proj', 'sess-1.jsonl'), + executionHostId: 'local', + rootOptions: { claudeProjectsDir: CLAUDE_ROOT } + }) + + expect(result.allowed).toBe(true) + expect( + result.allowed && result.removals.some((removal) => removal.path.includes('file-history')) + ).toBe(false) + }) + + it("pairs a WSL-home claude session with that distro's session-env, not the local one", () => { + const wslHome = join('/tmp', 'orca-wsl-home') + const filePath = join(wslHome, '.claude', 'projects', '-proj', 'sess-2.jsonl') + const result = validateAiVaultSessionDeleteTarget({ + agent: 'claude', + filePath, + executionHostId: 'local', + wslHomeDirs: [wslHome], + rootOptions: { claudeProjectsDir: CLAUDE_ROOT } + }) + + expect(result.allowed && result.removals[1]).toEqual({ + path: join(wslHome, '.claude', 'session-env', 'sess-2'), + kind: 'directory', + roots: [join(wslHome, '.claude', 'session-env')] + }) + }) + + it('refuses a transcript whose stem would resolve to the project dir itself', () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'claude', + filePath: join(CLAUDE_ROOT, '-proj', '..jsonl'), + executionHostId: 'local', + rootOptions: { claudeProjectsDir: CLAUDE_ROOT } + }) + + expect(result).toEqual({ + allowed: false, + agent: 'claude', + reason: 'no-session-directory' + }) + }) + + it('rejects a Task subagent transcript, which is only ever removed with its parent', () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'claude', + filePath: join(CLAUDE_ROOT, '-proj', 'sess-1', 'subagents', 'agent-abc.jsonl'), + executionHostId: 'local', + rootOptions: { claudeProjectsDir: CLAUDE_ROOT } + }) + + expect(result).toEqual({ + allowed: false, + agent: 'claude', + reason: 'undiscoverable-path' + }) + }) + + it("removes rovo's session directory, not just the metadata.json inside it", () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'rovo', + filePath: join(ROVO_ROOT, 'sess-1', 'metadata.json'), + executionHostId: 'local', + rootOptions: { rovoSessionsDir: ROVO_ROOT } + }) + + expect(result).toEqual({ + allowed: true, + agent: 'rovo', + resolvedPath: join(ROVO_ROOT, 'sess-1', 'metadata.json'), + removals: [{ path: join(ROVO_ROOT, 'sess-1'), kind: 'directory', roots: [ROVO_ROOT] }] + }) + }) + + it("removes grok's session directory under its encoded-cwd group", () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'grok', + filePath: join(GROK_ROOT, '-Users-me-proj', 'sess-1', 'summary.json'), + executionHostId: 'local', + rootOptions: { grokSessionsDir: GROK_ROOT } + }) + + expect(result.allowed && result.removals).toEqual([ + { path: join(GROK_ROOT, '-Users-me-proj', 'sess-1'), kind: 'directory', roots: [GROK_ROOT] } + ]) + }) + + it("rejects grok's chat_history.jsonl — only summary.json names a session", () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'grok', + filePath: join(GROK_ROOT, 'group', 'sess-1', 'chat_history.jsonl'), + executionHostId: 'local', + rootOptions: { grokSessionsDir: GROK_ROOT } + }) + + expect(result).toEqual({ allowed: false, agent: 'grok', reason: 'undiscoverable-path' }) + }) + + it('refuses a metadata.json sitting in the sessions root, which would trash every session', () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'rovo', + filePath: join(ROVO_ROOT, 'metadata.json'), + executionHostId: 'local', + rootOptions: { rovoSessionsDir: ROVO_ROOT } + }) + + expect(result).toEqual({ allowed: false, agent: 'rovo', reason: 'no-session-directory' }) + }) +}) diff --git a/src/main/ai-vault/session-delete-target.ts b/src/main/ai-vault/session-delete-target.ts new file mode 100644 index 00000000000..11ef38039dc --- /dev/null +++ b/src/main/ai-vault/session-delete-target.ts @@ -0,0 +1,145 @@ +import { basename, dirname, extname, join, resolve } from 'node:path' +import type { AiVaultAgent } from '../../shared/ai-vault-types' +import { + isAiVaultDeletableAgent, + isAiVaultSyntheticSessionPath, + type AiVaultDeletableAgent, + type AiVaultSessionDeleteRejectionCode, + type AiVaultSessionDeleteRemoval, + type AiVaultSessionDeleteValidationResult +} from '../../shared/ai-vault-session-deletion' +import { + LOCAL_EXECUTION_HOST_ID, + normalizeExecutionHostId, + type ExecutionHostId +} from '../../shared/execution-host' +import { isPathInsideOrEqual } from '../../shared/cross-platform-path' +import { AI_VAULT_AGENT_SOURCES, isDiscoverableSessionFile } from './session-scanner-agent-sources' +import { subagentTranscriptsDirFor } from './session-scanner-subagent-transcripts' +import type { AiVaultScanOptions } from './session-scanner-types' + +// Agents whose session IS the directory holding the scanned file: everything +// beside it belongs to the same session (rovo's session_context.json, grok's +// chat_history.jsonl), so the directory is the only complete delete unit. +const AI_VAULT_DIRECTORY_SHAPED_DELETE_AGENTS = new Set(['rovo', 'grok']) + +export type ValidateAiVaultSessionDeleteTargetArgs = { + agent: AiVaultAgent + filePath: string + executionHostId: ExecutionHostId | null | undefined + // Passed in rather than fetched (getAiVaultWslHomeDirs) so this stays + // synchronous and pure. + wslHomeDirs?: readonly string[] + // Per-agent root overrides so tests never depend on the real home directory. + rootOptions?: AiVaultScanOptions +} + +// Pure path-only judgement: never touches the filesystem, and never throws — +// a malformed or hostile filePath rejects like any other invalid input. +// `allowed: true` alone is NOT enough to delete; see the caller contract on +// AiVaultSessionDeleteAllowedResult. +export function validateAiVaultSessionDeleteTarget( + args: ValidateAiVaultSessionDeleteTargetArgs +): AiVaultSessionDeleteValidationResult { + const { agent } = args + const filePath = typeof args.filePath === 'string' ? args.filePath.trim() : '' + if (!filePath) { + return rejected(agent, 'invalid-path') + } + if (!isAiVaultDeletableAgent(agent)) { + return rejected(agent, 'unsupported-agent') + } + // Electron shell/fs APIs only act on this computer; an ssh/runtime session's + // path exists on the remote host. Same limit as Open/Reveal Log. + if (normalizeExecutionHostId(args.executionHostId) !== LOCAL_EXECUTION_HOST_ID) { + return rejected(agent, 'non-local-host') + } + if (isAiVaultSyntheticSessionPath(filePath)) { + return rejected(agent, 'synthetic-path') + } + + // resolve() collapses `..` first: isPathInsideOrEqual compares textually and + // would otherwise pass `/../../etc/x.jsonl`. + const resolvedPath = resolve(filePath) + const source = AI_VAULT_AGENT_SOURCES[agent] + const roots = source + .rootDirs(args.rootOptions ?? {}, args.wslHomeDirs ?? []) + // Why: OMP_CODING_AGENT_DIR='/' normalizes to '', which resolve()s to the + // process cwd — an empty root would silently allowlist it. + .filter((rootDir) => rootDir.trim().length > 0) + .map((rootDir) => resolve(rootDir)) + // Keep the root that actually contains this path: companion roots are derived + // from it, so a WSL-home session can't pair with the local host's companions. + const matchedRoot = roots.find((root) => isPathInsideOrEqual(root, resolvedPath)) + if (!matchedRoot) { + return rejected(agent, 'path-outside-known-roots') + } + // The scanner's own accept rule, so a path it would never have surfaced as a + // session row can't be accepted as a delete target either. + if (!isDiscoverableSessionFile(source, matchedRoot, resolvedPath)) { + return rejected(agent, 'undiscoverable-path') + } + + const removals = sessionDeleteRemovals({ agent, resolvedPath, matchedRoot, roots }) + if (!removals) { + return rejected(agent, 'no-session-directory') + } + + return { allowed: true, agent, resolvedPath, removals } +} + +// The ordered path set that removing this session means: companions first, the +// scanned path last. Null when the path names no session directory of its own, +// which would otherwise reach the root holding every session. +// +// `session-env//` is a companion — it holds that session's generated +// shell exports and nothing else. Its sibling `file-history//` is +// deliberately NOT: that is the rewind buffer holding earlier versions of the +// user's own files, and retiring a session is no reason to destroy the only +// copy that can restore them. +function sessionDeleteRemovals(args: { + agent: AiVaultDeletableAgent + resolvedPath: string + matchedRoot: string + roots: readonly string[] +}): readonly AiVaultSessionDeleteRemoval[] | null { + const { agent, resolvedPath, matchedRoot, roots } = args + + if (AI_VAULT_DIRECTORY_SHAPED_DELETE_AGENTS.has(agent)) { + const sessionDir = dirname(resolvedPath) + if (sessionDir === matchedRoot || !isPathInsideOrEqual(matchedRoot, sessionDir)) { + return null + } + return [{ path: sessionDir, kind: 'directory', roots }] + } + + if (agent === 'claude') { + const sessionId = basename(resolvedPath, extname(resolvedPath)) + // A degenerate stem ('.' from `..jsonl`, or empty) would resolve the session + // dir to the project dir and trash every session in it. + if (!sessionId || sessionId === '.' || sessionId === '..') { + return null + } + // //, named after the transcript. Taking it rather than just the + // `subagents/` it holds is what stops an empty / being left behind. + // Derived from the scanner's subagents path so the two can't drift. + const sessionDir = dirname(subagentTranscriptsDirFor(resolvedPath)) + // Derived from the matched root, so a WSL-home session cleans up that + // distro's session-env rather than the local host's. + const sessionEnvRoot = join(dirname(matchedRoot), 'session-env') + return [ + { path: sessionDir, kind: 'directory', roots }, + { path: join(sessionEnvRoot, sessionId), kind: 'directory', roots: [sessionEnvRoot] }, + { path: resolvedPath, kind: 'file', roots } + ] + } + + return [{ path: resolvedPath, kind: 'file', roots }] +} + +function rejected( + agent: AiVaultAgent, + reason: AiVaultSessionDeleteRejectionCode +): AiVaultSessionDeleteValidationResult { + return { allowed: false, agent, reason } +} diff --git a/src/main/ai-vault/session-delete.test.ts b/src/main/ai-vault/session-delete.test.ts new file mode 100644 index 00000000000..eb25b11a820 --- /dev/null +++ b/src/main/ai-vault/session-delete.test.ts @@ -0,0 +1,293 @@ +import { join } from 'node:path' +import { describe, expect, it, vi, beforeEach } from 'vitest' + +const { lstatMock, realpathMock, trashItemMock, tryDeleteWslUncPathMock } = vi.hoisted(() => ({ + lstatMock: vi.fn(), + realpathMock: vi.fn(), + trashItemMock: vi.fn(), + tryDeleteWslUncPathMock: vi.fn() +})) + +vi.mock('node:fs/promises', () => ({ + lstat: lstatMock, + realpath: realpathMock +})) + +vi.mock('electron', () => ({ + shell: { trashItem: trashItemMock } +})) + +vi.mock('../wsl-unc-delete', () => ({ + tryDeleteWslUncPath: tryDeleteWslUncPathMock +})) + +import { deleteAiVaultSessionFile } from './session-delete' + +const HOME = join('/tmp', 'orca-ai-vault-delete-exec-fixture-home') +const GEMINI_ROOT = join(HOME, '.gemini', 'tmp') +const CLAUDE_ROOT = join(HOME, '.claude', 'projects') +const ROVO_ROOT = join(HOME, '.rovodev', 'sessions') + +function enoent(): NodeJS.ErrnoException { + const error = new Error('not found') as NodeJS.ErrnoException + error.code = 'ENOENT' + return error +} + +function baseArgs(filePath: string) { + return { + agent: 'gemini' as const, + filePath, + executionHostId: 'local' as const, + rootOptions: { geminiSessionsDir: GEMINI_ROOT } + } +} + +describe('deleteAiVaultSessionFile', () => { + beforeEach(() => { + vi.clearAllMocks() + // Default: not a WSL UNC path, so the fs-guard branch runs. + tryDeleteWslUncPathMock.mockResolvedValue(false) + }) + + it('trashes a regular file whose realpath matches the resolved path', async () => { + const filePath = join(GEMINI_ROOT, 'project-a', 'session-1.json') + lstatMock.mockResolvedValue({ isFile: () => true }) + realpathMock.mockResolvedValue(filePath) + trashItemMock.mockResolvedValue(undefined) + + const result = await deleteAiVaultSessionFile(baseArgs(filePath)) + + expect(result).toEqual({ outcome: 'deleted' }) + expect(trashItemMock).toHaveBeenCalledWith(filePath) + }) + + it('rejects a directory instead of trashing it', async () => { + const filePath = join(GEMINI_ROOT, 'project-a', 'session-1.json') + lstatMock.mockResolvedValue({ isFile: () => false }) + + const result = await deleteAiVaultSessionFile(baseArgs(filePath)) + + expect(result).toEqual({ + outcome: 'rejected', + agent: 'gemini', + reason: 'unexpected-target-kind' + }) + expect(trashItemMock).not.toHaveBeenCalled() + }) + + it('rejects a symlink instead of trashing it (isFile() is false for a symlink under lstat)', async () => { + const filePath = join(GEMINI_ROOT, 'project-a', 'session-1.json') + lstatMock.mockResolvedValue({ isFile: () => false }) + + const result = await deleteAiVaultSessionFile(baseArgs(filePath)) + + expect(result).toEqual({ + outcome: 'rejected', + agent: 'gemini', + reason: 'unexpected-target-kind' + }) + expect(trashItemMock).not.toHaveBeenCalled() + }) + + it('rejects a regular file whose realpath escapes the known roots', async () => { + const filePath = join(GEMINI_ROOT, 'project-a', 'session-1.json') + const escaped = join(HOME, 'Documents', 'escaped.json') + lstatMock.mockResolvedValue({ isFile: () => true }) + // The file's realpath escapes; the root realpaths to itself (not symlinked). + realpathMock.mockImplementation((p: string) => Promise.resolve(p === filePath ? escaped : p)) + + const result = await deleteAiVaultSessionFile(baseArgs(filePath)) + + expect(result).toEqual({ + outcome: 'rejected', + agent: 'gemini', + reason: 'path-outside-known-roots' + }) + expect(trashItemMock).not.toHaveBeenCalled() + }) + + it('accepts a session under a symlinked root by realpath-resolving the roots too', async () => { + // GEMINI_ROOT is a symlink to a real target; the file's realpath lands under + // the real target, which the text-only root would not match. Realpath-ing + // the root as well keeps this legit delete from a false rejection. + const filePath = join(GEMINI_ROOT, 'project-a', 'session-1.json') + const realRoot = join('/real', '.gemini', 'tmp') + const realFile = join(realRoot, 'project-a', 'session-1.json') + lstatMock.mockResolvedValue({ isFile: () => true }) + realpathMock.mockImplementation((p: string) => + Promise.resolve(p === GEMINI_ROOT ? realRoot : p === filePath ? realFile : p) + ) + trashItemMock.mockResolvedValue(undefined) + + const result = await deleteAiVaultSessionFile(baseArgs(filePath)) + + expect(result).toEqual({ outcome: 'deleted' }) + expect(trashItemMock).toHaveBeenCalledWith(filePath) + }) + + it('treats ENOENT from lstat as an idempotent success', async () => { + const filePath = join(GEMINI_ROOT, 'project-a', 'session-1.json') + lstatMock.mockRejectedValue(enoent()) + + const result = await deleteAiVaultSessionFile(baseArgs(filePath)) + + expect(result).toEqual({ outcome: 'deleted' }) + expect(trashItemMock).not.toHaveBeenCalled() + }) + + it('treats ENOENT from trashItem as an idempotent success (race with an external delete)', async () => { + const filePath = join(GEMINI_ROOT, 'project-a', 'session-1.json') + lstatMock.mockResolvedValue({ isFile: () => true }) + realpathMock.mockResolvedValue(filePath) + trashItemMock.mockRejectedValue(enoent()) + + const result = await deleteAiVaultSessionFile(baseArgs(filePath)) + + expect(result).toEqual({ outcome: 'deleted' }) + }) + + it('returns a failure result when trashItem throws a non-ENOENT error', async () => { + const filePath = join(GEMINI_ROOT, 'project-a', 'session-1.json') + lstatMock.mockResolvedValue({ isFile: () => true }) + realpathMock.mockResolvedValue(filePath) + trashItemMock.mockRejectedValue(new Error('permission denied')) + + const result = await deleteAiVaultSessionFile(baseArgs(filePath)) + + expect(result).toEqual({ outcome: 'failed', agent: 'gemini', error: 'permission denied' }) + }) + + it('short-circuits a rejected validation (unsupported agent) before touching the filesystem', async () => { + const filePath = join(HOME, '.codex', 'sessions', 'rollout-1.jsonl') + + const result = await deleteAiVaultSessionFile({ + agent: 'codex', + filePath, + executionHostId: 'local' + }) + + expect(result).toEqual({ outcome: 'rejected', agent: 'codex', reason: 'unsupported-agent' }) + expect(lstatMock).not.toHaveBeenCalled() + expect(realpathMock).not.toHaveBeenCalled() + expect(trashItemMock).not.toHaveBeenCalled() + expect(tryDeleteWslUncPathMock).not.toHaveBeenCalled() + }) + + it('delegates a WSL UNC file to tryDeleteWslUncPath (non-recursive) and never calls trashItem', async () => { + const filePath = join(GEMINI_ROOT, 'project-a', 'session-1.json') + tryDeleteWslUncPathMock.mockResolvedValue(true) + + const result = await deleteAiVaultSessionFile(baseArgs(filePath)) + + expect(result).toEqual({ outcome: 'deleted' }) + expect(tryDeleteWslUncPathMock).toHaveBeenCalledWith(filePath, { recursive: false }) + expect(lstatMock).not.toHaveBeenCalled() + expect(trashItemMock).not.toHaveBeenCalled() + }) + + it('deletes a WSL UNC directory-shaped session recursively, never via trashItem', async () => { + // The bug this guards: a directory removal (rovo/grok/claude dir) on a WSL + // UNC path used to skip the WSL branch and hit shell.trashItem, which + // cannot trash a WSL volume item — so it failed on Windows or was silently + // stranded by the 9P filesystem's unreliable lstat. + tryDeleteWslUncPathMock.mockResolvedValue(true) + + const result = await deleteAiVaultSessionFile({ + agent: 'rovo', + filePath: join(ROVO_ROOT, 'sess-1', 'metadata.json'), + executionHostId: 'local', + rootOptions: { rovoSessionsDir: ROVO_ROOT } + }) + + expect(result).toEqual({ outcome: 'deleted' }) + expect(tryDeleteWslUncPathMock).toHaveBeenCalledWith(join(ROVO_ROOT, 'sess-1'), { + recursive: true + }) + expect(lstatMock).not.toHaveBeenCalled() + expect(trashItemMock).not.toHaveBeenCalled() + }) + + // A session whose delete unit is a path set. What matters here is the + // order — the transcript is what puts the row on screen, so it must be the + // last thing removed. + describe('directory-shaped agents', () => { + const claudeArgs = { + agent: 'claude' as const, + filePath: join(CLAUDE_ROOT, '-proj', 'sess-1.jsonl'), + executionHostId: 'local' as const, + rootOptions: { claudeProjectsDir: CLAUDE_ROOT } + } + + it('trashes the subagents and session-env dirs before the transcript', async () => { + lstatMock.mockImplementation((path: string) => + Promise.resolve( + path.endsWith('.jsonl') ? { isFile: () => true } : { isDirectory: () => true } + ) + ) + realpathMock.mockImplementation((path: string) => Promise.resolve(path)) + trashItemMock.mockResolvedValue(undefined) + + const result = await deleteAiVaultSessionFile(claudeArgs) + + expect(result).toEqual({ outcome: 'deleted' }) + expect(trashItemMock.mock.calls.map(([path]) => path)).toEqual([ + join(CLAUDE_ROOT, '-proj', 'sess-1'), + join(HOME, '.claude', 'session-env', 'sess-1'), + claudeArgs.filePath + ]) + }) + + it('still deletes when a companion is absent — most sessions spawn no subagent', async () => { + lstatMock.mockImplementation((path: string) => + path.endsWith('.jsonl') ? Promise.resolve({ isFile: () => true }) : Promise.reject(enoent()) + ) + realpathMock.mockImplementation((path: string) => Promise.resolve(path)) + trashItemMock.mockResolvedValue(undefined) + + const result = await deleteAiVaultSessionFile(claudeArgs) + + expect(result).toEqual({ outcome: 'deleted' }) + expect(trashItemMock.mock.calls.map(([path]) => path)).toEqual([claudeArgs.filePath]) + }) + + it('leaves the transcript in place when a companion removal is rejected', async () => { + // A file where the plan expects the session directory: refuse rather + // than coerce, and stop before the transcript so the row stays to retry. + lstatMock.mockImplementation((path: string) => + Promise.resolve( + path === join(CLAUDE_ROOT, '-proj', 'sess-1') + ? { isFile: () => true, isDirectory: () => false } + : { isFile: () => true, isDirectory: () => true } + ) + ) + realpathMock.mockImplementation((path: string) => Promise.resolve(path)) + + const result = await deleteAiVaultSessionFile(claudeArgs) + + expect(result).toEqual({ + outcome: 'rejected', + agent: 'claude', + reason: 'unexpected-target-kind' + }) + expect(trashItemMock).not.toHaveBeenCalled() + }) + + it("trashes rovo's session directory rather than the metadata file", async () => { + const sessionDir = join(ROVO_ROOT, 'sess-1') + lstatMock.mockResolvedValue({ isDirectory: () => true }) + realpathMock.mockImplementation((path: string) => Promise.resolve(path)) + trashItemMock.mockResolvedValue(undefined) + + const result = await deleteAiVaultSessionFile({ + agent: 'rovo', + filePath: join(sessionDir, 'metadata.json'), + executionHostId: 'local', + rootOptions: { rovoSessionsDir: ROVO_ROOT } + }) + + expect(result).toEqual({ outcome: 'deleted' }) + expect(trashItemMock).toHaveBeenCalledWith(sessionDir) + }) + }) +}) diff --git a/src/main/ai-vault/session-delete.ts b/src/main/ai-vault/session-delete.ts new file mode 100644 index 00000000000..d52725ff0fb --- /dev/null +++ b/src/main/ai-vault/session-delete.ts @@ -0,0 +1,104 @@ +import { lstat, realpath } from 'node:fs/promises' +import { shell } from 'electron' +import type { + AiVaultDeleteSessionResult, + AiVaultSessionDeleteRemoval +} from '../../shared/ai-vault-session-deletion' +import { isPathInsideOrEqual } from '../../shared/cross-platform-path' +import { + validateAiVaultSessionDeleteTarget, + type ValidateAiVaultSessionDeleteTargetArgs +} from './session-delete-target' +import { tryDeleteWslUncPath } from '../wsl-unc-delete' +import { isENOENT } from '../ipc/filesystem-auth' + +// Trashes a validated session's paths, companions first so a part-way failure +// leaves the row on screen to retry from instead of stranding the rest on disk. +// Never throws: IPC payloads are untyped at runtime, so a bad input and an fs +// error both resolve to a discriminated result the handler can render. +export async function deleteAiVaultSessionFile( + args: ValidateAiVaultSessionDeleteTargetArgs +): Promise { + const validation = validateAiVaultSessionDeleteTarget(args) + if (!validation.allowed) { + return { outcome: 'rejected', agent: validation.agent, reason: validation.reason } + } + const { agent, removals } = validation + + try { + for (const removal of removals) { + const rejection = await removeOne(removal) + if (rejection) { + return { outcome: 'rejected', agent, reason: rejection } + } + } + return { outcome: 'deleted' } + } catch (error) { + return { + outcome: 'failed', + agent, + error: error instanceof Error ? error.message : String(error) + } + } +} + +// Trash one planned path, or return the rejection code that stops the plan. A +// missing path is success everywhere: companions are optional (no subagents dir +// if none ever ran) and an externally-deleted transcript must stay idempotent. +async function removeOne( + removal: AiVaultSessionDeleteRemoval +): Promise<'unexpected-target-kind' | 'path-outside-known-roots' | null> { + // First, because a WSL UNC path defeats both guards below: 9P's stat is + // unreliable and the volume has no Recycle Bin for trashItem. Its own + // `rm -f`/`-rf` is idempotent and never follows a symlink. + if (await tryDeleteWslUncPath(removal.path, { recursive: removal.kind === 'directory' })) { + return null + } + + let stats + try { + stats = await lstat(removal.path) + } catch (error) { + if (isENOENT(error)) { + return null + } + throw error + } + // lstat (not stat) so a symlink is caught here rather than dereferenced. + const kindMatches = removal.kind === 'file' ? stats.isFile() : stats.isDirectory() + if (!kindMatches) { + return 'unexpected-target-kind' + } + + let realResolvedPath: string + try { + realResolvedPath = await realpath(removal.path) + } catch (error) { + if (isENOENT(error)) { + return null + } + throw error + } + // Catches a symlinked parent that escapes the agent's roots, which lstat on + // the leaf can't see. Roots are only resolve()'d, so realpath them too or a + // session under a symlinked root (~/.claude -> /Volumes/data/.claude) is + // falsely rejected; a root absent on this host falls back to its text form. + if (realResolvedPath !== removal.path) { + const realRoots = await Promise.all( + removal.roots.map((root) => realpath(root).catch(() => root)) + ) + if (!realRoots.some((root) => isPathInsideOrEqual(root, realResolvedPath))) { + return 'path-outside-known-roots' + } + } + + try { + await shell.trashItem(removal.path) + } catch (error) { + if (isENOENT(error)) { + return null + } + throw error + } + return null +} diff --git a/src/main/ai-vault/session-scanner-agent-sources.ts b/src/main/ai-vault/session-scanner-agent-sources.ts new file mode 100644 index 00000000000..33320498b44 --- /dev/null +++ b/src/main/ai-vault/session-scanner-agent-sources.ts @@ -0,0 +1,254 @@ +import { homedir } from 'node:os' +import { basename, dirname, extname, join, relative } from 'node:path' +import type { AiVaultAgent } from '../../shared/ai-vault-types' +import type { AiVaultDeletableAgent } from '../../shared/ai-vault-session-deletion' +import { resolveGrokSessionsDir } from '../../shared/grok-session-paths' +import { uniqueCodexSessionsDirs } from './session-scanner-codex-paths' +import { resolveKimiSessionsDir } from './session-scanner-kimi-paths' +import { OMP_SESSION_ARTIFACT_DIR_PATTERN } from './session-scanner-omp-subagent-transcripts' +import { claudeProjectsRootDirs, OMP_SESSIONS_DIR, sessionRootDirs } from './session-scanner-roots' +import { SUBAGENT_DIR_NAME } from './session-scanner-subagent-transcripts' +import type { AiVaultScanOptions } from './session-scanner-types' +import { normalizeAgentSessionsDir } from './session-scanner-values' + +export const DEFAULT_CODEX_HOME_DIR = join(homedir(), '.codex') +const CODEX_SESSIONS_DIR = join( + process.env.CODEX_HOME?.trim() || DEFAULT_CODEX_HOME_DIR, + 'sessions' +) +const GEMINI_SESSIONS_DIR = join(homedir(), '.gemini', 'tmp') +const COPILOT_SESSIONS_DIR = join( + process.env.COPILOT_HOME?.trim() || join(homedir(), '.copilot'), + 'session-state' +) +const CURSOR_PROJECTS_DIR = join(homedir(), '.cursor', 'projects') +const HERMES_SESSIONS_DIR = join(homedir(), '.hermes', 'sessions') +const ROVO_SESSIONS_DIR = join(homedir(), '.rovodev', 'sessions') +const OPENCLAW_STATE_DIR = process.env.OPENCLAW_STATE_DIR?.trim() || join(homedir(), '.openclaw') +const PI_SESSIONS_DIR = normalizeAgentSessionsDir( + process.env.PI_CODING_AGENT_DIR?.trim() || join(homedir(), '.pi', 'agent', 'sessions'), + '.pi' +) +// Why: Devin ATIF transcripts are stored under /transcripts. +const DEVIN_TRANSCRIPTS_DIR = join( + process.env.DEVIN_HOME?.trim() || join(homedir(), '.local', 'share', 'devin', 'cli'), + 'transcripts' +) +const DROID_SESSIONS_DIR = join(homedir(), '.factory', 'sessions') +const DROID_PROJECTS_DIR = join(homedir(), '.factory', 'projects') + +/** + * Where one agent's session files live and which of them count as sessions. + * + * The scanner walks these roots to list sessions; the delete validator accepts + * only paths this same shape would have surfaced. Both read this one table, so + * "deletable" and "discoverable" cannot drift apart. + */ +export type AiVaultAgentSource = { + // A function, not a value: grok resolves its root lazily because a + // module-scope call binds across chunks at init time and breaks on bundle + // ordering. Returns the local host root plus one per WSL distro home. + rootDirs: (options: AiVaultScanOptions, wslHomeDirs: readonly string[]) => string[] + extensions: readonly string[] + filePredicate?: (filePath: string) => boolean + // Return false to skip a directory; depth 0 is a child of the root. + directoryPredicate?: (name: string, depth: number) => boolean + // Roots that are alternates for one install rather than distinct locations, + // so the per-agent limit applies across them rather than to each. + mergeRootDiscoveries?: boolean +} + +// Every deletable agent needs an entry — the delete validator derives its roots +// and its accept rule from this table alone, so adding an agent to +// AI_VAULT_DELETABLE_AGENTS without a source here is a type error. +type AiVaultAgentSourceTable = Record & + Partial> + +// Agents absent from this table are discovered by shape-specific scanners +// instead: opencode (SQLite plus legacy files) and antigravity (brain dirs). +export const AI_VAULT_AGENT_SOURCES: AiVaultAgentSourceTable = { + claude: { + rootDirs: (options, wslHomeDirs) => + claudeProjectsRootDirs({ claudeProjectsDir: options.claudeProjectsDir, wslHomeDirs }), + extensions: ['.jsonl'], + // Why: Task subagent transcripts under `/subagents/` share the parent + // sessionId and aren't independently resumable, so they'd just duplicate the + // parent as untitled rows; prune the subtree and read them on demand under + // their parent instead. + directoryPredicate: (name) => name !== SUBAGENT_DIR_NAME + }, + codex: { + rootDirs: (options, wslHomeDirs) => + uniqueCodexSessionsDirs([ + options.codexSessionsDir ?? CODEX_SESSIONS_DIR, + ...wslHomeDirs.map((homeDir) => join(homeDir, '.codex', 'sessions')), + // Why: Orca-launched WSL Codex sessions use an Orca-owned CODEX_HOME, + // not the user's default ~/.codex history root. + ...wslHomeDirs.map((homeDir) => + join(homeDir, '.local', 'share', 'orca', 'codex-runtime-home', 'home', 'sessions') + ), + ...(options.additionalCodexSessionsDirs ?? []) + ]), + extensions: ['.jsonl'] + }, + gemini: { + rootDirs: (options, wslHomeDirs) => + sessionRootDirs(options.geminiSessionsDir ?? GEMINI_SESSIONS_DIR, wslHomeDirs, [ + '.gemini', + 'tmp' + ]), + extensions: ['.json', '.jsonl'] + }, + copilot: { + rootDirs: (options, wslHomeDirs) => + sessionRootDirs(options.copilotSessionsDir ?? COPILOT_SESSIONS_DIR, wslHomeDirs, [ + '.copilot', + 'session-state' + ]), + extensions: ['.jsonl'] + }, + cursor: { + rootDirs: (options, wslHomeDirs) => + sessionRootDirs(options.cursorProjectsDir ?? CURSOR_PROJECTS_DIR, wslHomeDirs, [ + '.cursor', + 'projects' + ]), + extensions: ['.jsonl'], + filePredicate: (filePath) => pathSegments(filePath).includes('agent-transcripts') + }, + grok: { + rootDirs: (options, wslHomeDirs) => + sessionRootDirs(options.grokSessionsDir ?? resolveGrokSessionsDir(), wslHomeDirs, [ + '.grok', + 'sessions' + ]), + extensions: ['.json'], + filePredicate: (filePath) => basename(filePath) === 'summary.json' + }, + devin: { + rootDirs: (options, wslHomeDirs) => + sessionRootDirs(options.devinTranscriptsDir ?? DEVIN_TRANSCRIPTS_DIR, wslHomeDirs, [ + '.local', + 'share', + 'devin', + 'cli', + 'transcripts' + ]), + extensions: ['.json'] + }, + hermes: { + rootDirs: (options, wslHomeDirs) => + sessionRootDirs(options.hermesSessionsDir ?? HERMES_SESSIONS_DIR, wslHomeDirs, [ + '.hermes', + 'sessions' + ]), + extensions: ['.json'], + filePredicate: (filePath) => basename(filePath).startsWith('session_') + }, + rovo: { + rootDirs: (options, wslHomeDirs) => + sessionRootDirs(options.rovoSessionsDir ?? ROVO_SESSIONS_DIR, wslHomeDirs, [ + '.rovodev', + 'sessions' + ]), + extensions: ['.json'], + filePredicate: (filePath) => basename(filePath) === 'metadata.json' + }, + pi: { + rootDirs: (options, wslHomeDirs) => + sessionRootDirs(options.piSessionsDir ?? PI_SESSIONS_DIR, wslHomeDirs, [ + '.pi', + 'agent', + 'sessions' + ]), + extensions: ['.jsonl'] + }, + omp: { + rootDirs: (options, wslHomeDirs) => + sessionRootDirs(options.ompSessionsDir ?? OMP_SESSIONS_DIR, wslHomeDirs, [ + '.omp', + 'agent', + 'sessions' + ]), + extensions: ['.jsonl'], + // Why: task subagent transcripts live inside the session's same-named + // artifact directory (`_/`); surfaced as top-level rows they + // drown coordinators under their own workers (#9330). Prune the subtree + // and read them on demand under their parent instead. Unlike Claude's, + // these carry their own sessionId and would resume by path — but OMP's + // own picker only globs `*/*.jsonl`, so it never offers them either. + // Depth 0 is the workspace dir, which is never an artifact dir. + directoryPredicate: (name, depth) => depth === 0 || !OMP_SESSION_ARTIFACT_DIR_PATTERN.test(name) + }, + openclaw: { + // Sessions live under /agents; a stateDir already ending in + // `agents` is used as-is. The current and legacy state dirs are the same + // install, so their discoveries merge. + rootDirs: (options, wslHomeDirs) => + [ + options.openclawStateDir ?? OPENCLAW_STATE_DIR, + options.openclawLegacyStateDir ?? join(homedir(), '.clawdbot'), + ...wslHomeDirs.map((homeDir) => join(homeDir, '.openclaw')), + ...wslHomeDirs.map((homeDir) => join(homeDir, '.clawdbot')) + ].map((stateDir) => (basename(stateDir) === 'agents' ? stateDir : join(stateDir, 'agents'))), + extensions: ['.jsonl'], + filePredicate: (filePath) => pathSegments(filePath).includes('sessions'), + mergeRootDiscoveries: true + }, + droid: { + rootDirs: (options, wslHomeDirs) => [ + ...sessionRootDirs(options.droidSessionsDir ?? DROID_SESSIONS_DIR, wslHomeDirs, [ + '.factory', + 'sessions' + ]), + ...sessionRootDirs(options.droidProjectsDir ?? DROID_PROJECTS_DIR, wslHomeDirs, [ + '.factory', + 'projects' + ]) + ], + extensions: ['.jsonl'] + }, + kimi: { + rootDirs: (options, wslHomeDirs) => + sessionRootDirs(resolveKimiSessionsDir(options.kimiSessionsDir), wslHomeDirs, [ + '.kimi-code', + 'sessions' + ]), + extensions: ['.json'], + // Why: each Kimi session is /wd_*/session_*/state.json; match + // only those (not the sibling agents/*/wire.jsonl transcripts). + filePredicate: (filePath) => + basename(filePath) === 'state.json' && basename(dirname(filePath)).startsWith('session_') + } +} + +/** + * Whether a scan rooted at `rootDir` would surface `filePath`: its extension, + * its file predicate, and every directory between the two passing the directory + * predicate. This is the delete validator's accept rule, so a path no scan + * would ever list can't become a delete target either. + */ +export function isDiscoverableSessionFile( + source: AiVaultAgentSource, + rootDir: string, + filePath: string +): boolean { + if (!source.extensions.includes(extname(filePath).toLowerCase())) { + return false + } + if (source.filePredicate && !source.filePredicate(filePath)) { + return false + } + const { directoryPredicate } = source + if (!directoryPredicate) { + return true + } + // Indexed like walkSessionFiles: depth 0 is a child of rootDir. + return pathSegments(relative(rootDir, dirname(filePath))) + .filter(Boolean) + .every((name, depth) => directoryPredicate(name, depth)) +} + +function pathSegments(filePath: string): string[] { + return filePath.split(/[\\/]/) +} diff --git a/src/main/ai-vault/session-scanner-discovery.ts b/src/main/ai-vault/session-scanner-discovery.ts index d93d6f01fea..39398fd4d09 100644 --- a/src/main/ai-vault/session-scanner-discovery.ts +++ b/src/main/ai-vault/session-scanner-discovery.ts @@ -1,5 +1,5 @@ import { readdir, stat } from 'node:fs/promises' -import { basename, delimiter, extname, join } from 'node:path' +import { extname, join } from 'node:path' import type { AiVaultAgent, AiVaultScanIssue } from '../../shared/ai-vault-types' import type { FileWithMtime, SessionFileDiscovery } from './session-scanner-types' import { errorMessage } from './session-scanner-values' @@ -42,30 +42,6 @@ export async function discoverFiles(args: { } } -export async function discoverOpenClawFiles(args: { - rootDirs: string[] - limit: number - issues: AiVaultScanIssue[] -}): Promise { - const discoveries = await Promise.all( - args.rootDirs.map((rootDir) => - discoverFiles({ - rootDir: basename(rootDir) === 'agents' ? rootDir : join(rootDir, 'agents'), - limit: args.limit, - agent: 'openclaw', - issues: args.issues, - extensions: ['.jsonl'], - filePredicate: (path) => path.split(/[\\/]/).includes('sessions') - }) - ) - ) - const files = discoveries - .flatMap((discovery) => discovery.files) - .sort((left, right) => right.mtimeMs - left.mtimeMs) - .slice(0, args.limit) - return { agent: 'openclaw', rootDir: args.rootDirs.join(delimiter), files } -} - export async function walkSessionFiles( dirPath: string, agent: AiVaultAgent, diff --git a/src/main/ai-vault/session-scanner-droid-kimi-sources.ts b/src/main/ai-vault/session-scanner-droid-kimi-sources.ts deleted file mode 100644 index 57ad71f0681..00000000000 --- a/src/main/ai-vault/session-scanner-droid-kimi-sources.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { homedir } from 'node:os' -import { basename, dirname, join } from 'node:path' -import type { AiVaultScanIssue } from '../../shared/ai-vault-types' -import { discoverFiles } from './session-scanner-discovery' -import { resolveKimiSessionsDir } from './session-scanner-kimi-paths' -import type { AiVaultScanOptions, SessionFileDiscovery } from './session-scanner-types' - -const DROID_SESSIONS_DIR = join(homedir(), '.factory', 'sessions') - -export function droidDiscoveries( - options: AiVaultScanOptions, - wslHomeDirs: readonly string[], - limit: number, - issues: AiVaultScanIssue[] -): Promise[] { - return [ - ...sessionRootDirs(options.droidSessionsDir ?? DROID_SESSIONS_DIR, wslHomeDirs, [ - '.factory', - 'sessions' - ]), - ...sessionRootDirs( - options.droidProjectsDir ?? join(homedir(), '.factory', 'projects'), - wslHomeDirs, - ['.factory', 'projects'] - ) - ].map((rootDir) => - discoverFiles({ rootDir, limit, agent: 'droid', issues, extensions: ['.jsonl'] }) - ) -} - -export function kimiDiscoveries( - options: AiVaultScanOptions, - wslHomeDirs: readonly string[], - limit: number, - issues: AiVaultScanIssue[] -): Promise[] { - return sessionRootDirs(resolveKimiSessionsDir(options.kimiSessionsDir), wslHomeDirs, [ - '.kimi-code', - 'sessions' - ]).map((rootDir) => - discoverFiles({ - rootDir, - limit, - agent: 'kimi', - issues, - extensions: ['.json'], - // Why: each Kimi session is /wd_*/session_*/state.json; match - // only those (not the sibling agents/*/wire.jsonl transcripts). - filePredicate: (path) => - basename(path) === 'state.json' && basename(dirname(path)).startsWith('session_') - }) - ) -} - -function sessionRootDirs( - hostRootDir: string, - wslHomeDirs: readonly string[], - segments: readonly string[] -): string[] { - return [hostRootDir, ...wslHomeDirs.map((homeDir) => join(homeDir, ...segments))] -} diff --git a/src/main/ai-vault/session-scanner-parse-cache.ts b/src/main/ai-vault/session-scanner-parse-cache.ts index 7d9068f8e39..8c11557e244 100644 --- a/src/main/ai-vault/session-scanner-parse-cache.ts +++ b/src/main/ai-vault/session-scanner-parse-cache.ts @@ -97,6 +97,12 @@ export function resetSessionParseCacheForTests(): void { cache.clear() } +// Drops one entry after its file is deleted. Cleanliness, not correctness: +// discovery walks disk first, so a trashed file is never rediscovered anyway. +export function invalidateSessionParseCacheEntry(path: string): void { + cache.delete(path) +} + // Persisted subset of a cache entry: the non-serializable `resume` parser // state is dropped (see session-parse-cache-persistence.ts). export type PersistedSessionParseCacheEntry = Omit diff --git a/src/main/ai-vault/session-scanner-source-discovery.ts b/src/main/ai-vault/session-scanner-source-discovery.ts index ba5d253fc8b..fd368f6c172 100644 --- a/src/main/ai-vault/session-scanner-source-discovery.ts +++ b/src/main/ai-vault/session-scanner-source-discovery.ts @@ -1,44 +1,13 @@ -import { homedir } from 'node:os' -import { basename, join } from 'node:path' -import type { AiVaultScanIssue } from '../../shared/ai-vault-types' -import { uniqueCodexSessionsDirs } from './session-scanner-codex-paths' -import { SUBAGENT_DIR_NAME } from './session-scanner-subagent-transcripts' -import { OMP_SESSION_ARTIFACT_DIR_PATTERN } from './session-scanner-omp-subagent-transcripts' -import { discoverFiles, discoverOpenClawFiles } from './session-scanner-discovery' -import { droidDiscoveries, kimiDiscoveries } from './session-scanner-droid-kimi-sources' +import { delimiter } from 'node:path' +import type { AiVaultAgent, AiVaultScanIssue } from '../../shared/ai-vault-types' +import { discoverFiles } from './session-scanner-discovery' import { opencodeDiscoveries } from './session-scanner-opencode-sources' -import type { AiVaultScanOptions, SessionFileDiscovery } from './session-scanner-types' -import { normalizeAgentSessionsDir } from './session-scanner-values' -import { - claudeProjectsRootDirs, - normalizedWslHomeDirs, - OMP_SESSIONS_DIR, - sessionRootDirs -} from './session-scanner-roots' -import { resolveGrokSessionsDir } from '../../shared/grok-session-paths' import { antigravityDiscoveries } from './session-scanner-antigravity-sources' +import { AI_VAULT_AGENT_SOURCES, type AiVaultAgentSource } from './session-scanner-agent-sources' +import { normalizedWslHomeDirs } from './session-scanner-roots' +import type { AiVaultScanOptions, SessionFileDiscovery } from './session-scanner-types' -export const DEFAULT_CODEX_HOME_DIR = join(homedir(), '.codex') -const CODEX_HOME_DIR = process.env.CODEX_HOME?.trim() || DEFAULT_CODEX_HOME_DIR -const CODEX_SESSIONS_DIR = join(CODEX_HOME_DIR, 'sessions') -const GEMINI_SESSIONS_DIR = join(homedir(), '.gemini', 'tmp') -const COPILOT_SESSIONS_DIR = join( - process.env.COPILOT_HOME?.trim() || join(homedir(), '.copilot'), - 'session-state' -) -const CURSOR_PROJECTS_DIR = join(homedir(), '.cursor', 'projects') -const HERMES_SESSIONS_DIR = join(homedir(), '.hermes', 'sessions') -const ROVO_SESSIONS_DIR = join(homedir(), '.rovodev', 'sessions') -const OPENCLAW_STATE_DIR = process.env.OPENCLAW_STATE_DIR?.trim() || join(homedir(), '.openclaw') -const PI_SESSIONS_DIR = normalizeAgentSessionsDir( - process.env.PI_CODING_AGENT_DIR?.trim() || join(homedir(), '.pi', 'agent', 'sessions'), - '.pi' -) -// Why: Devin ATIF transcripts are stored under /transcripts. -const DEVIN_TRANSCRIPTS_DIR = join( - process.env.DEVIN_HOME?.trim() || join(homedir(), '.local', 'share', 'devin', 'cli'), - 'transcripts' -) +export { DEFAULT_CODEX_HOME_DIR } from './session-scanner-agent-sources' export async function discoverAiVaultSessionSources(args: { options: AiVaultScanOptions @@ -47,258 +16,64 @@ export async function discoverAiVaultSessionSources(args: { }): Promise { const { options, limitPerAgent, issues } = args const wslHomeDirs = normalizedWslHomeDirs(options.wslHomeDirs) - const codexSessionsDirs = uniqueCodexSessionsDirs([ - options.codexSessionsDir ?? CODEX_SESSIONS_DIR, - ...wslHomeDirs.map((homeDir) => join(homeDir, '.codex', 'sessions')), - // Why: Orca-launched WSL Codex sessions use an Orca-owned CODEX_HOME, - // not the user's default ~/.codex history root. - ...wslHomeDirs.map((homeDir) => - join(homeDir, '.local', 'share', 'orca', 'codex-runtime-home', 'home', 'sessions') - ), - ...(options.additionalCodexSessionsDirs ?? []) - ]) return Promise.all([ // Why: OpenCode 1.17.x migrated sessions from per-session JSON files to a // SQLite DB. discoverOpenCodeSessions runs both the file scanner (legacy) // and the SQLite scanner (1.17.x); dedup by sessionId happens inside. ...opencodeDiscoveries(options, wslHomeDirs, limitPerAgent, issues), - ...claudeDiscoveries(options, wslHomeDirs, limitPerAgent, issues), - ...codexDiscoveries(codexSessionsDirs, limitPerAgent, issues), - ...standardDiscoveries(options, wslHomeDirs, limitPerAgent, issues), - openClawDiscovery(options, wslHomeDirs, limitPerAgent, issues), - ...droidDiscoveries(options, wslHomeDirs, limitPerAgent, issues), - ...kimiDiscoveries(options, wslHomeDirs, limitPerAgent, issues) + ...antigravityDiscoveries(options, wslHomeDirs, limitPerAgent, issues), + ...Object.entries(AI_VAULT_AGENT_SOURCES).flatMap(([agent, source]) => + source + ? agentDiscoveries( + agent as AiVaultAgent, + source, + options, + wslHomeDirs, + limitPerAgent, + issues + ) + : [] + ) ]) } -function claudeDiscoveries( +function agentDiscoveries( + agent: AiVaultAgent, + source: AiVaultAgentSource, options: AiVaultScanOptions, wslHomeDirs: readonly string[], limit: number, issues: AiVaultScanIssue[] ): Promise[] { - return claudeProjectsRootDirs({ - claudeProjectsDir: options.claudeProjectsDir, - wslHomeDirs - }).map((rootDir) => + const rootDirs = source.rootDirs(options, wslHomeDirs) + const discover = (rootDir: string): Promise => discoverFiles({ rootDir, limit, - agent: 'claude', + agent, issues, - extensions: ['.jsonl'], - // Why: Task subagent transcripts under `/subagents/` share the parent - // sessionId and aren't independently resumable, so they'd just duplicate the - // parent as untitled rows; prune the subtree and read them on demand under - // their parent instead. - directoryPredicate: (name) => name !== SUBAGENT_DIR_NAME + extensions: [...source.extensions], + filePredicate: source.filePredicate, + directoryPredicate: source.directoryPredicate }) - ) + return source.mergeRootDiscoveries + ? [mergedDiscovery(agent, rootDirs, limit, discover)] + : rootDirs.map(discover) } -function codexDiscoveries( +// Alternate roots for one install: the limit spans them, so they collapse into +// a single discovery rather than each claiming a full slice of it. +async function mergedDiscovery( + agent: AiVaultAgent, rootDirs: readonly string[], limit: number, - issues: AiVaultScanIssue[] -): Promise[] { - return rootDirs.map((rootDir) => - discoverFiles({ rootDir, limit, agent: 'codex', issues, extensions: ['.jsonl'] }) - ) -} - -function standardDiscoveries( - options: AiVaultScanOptions, - wslHomeDirs: readonly string[], - limit: number, - issues: AiVaultScanIssue[] -): Promise[] { - return [ - ...antigravityDiscoveries(options, wslHomeDirs, limit, issues), - ...sessionRootDirs(options.geminiSessionsDir ?? GEMINI_SESSIONS_DIR, wslHomeDirs, [ - '.gemini', - 'tmp' - ]).map((rootDir) => - discoverFiles({ rootDir, limit, agent: 'gemini', issues, extensions: ['.json', '.jsonl'] }) - ), - ...sessionRootDirs(options.copilotSessionsDir ?? COPILOT_SESSIONS_DIR, wslHomeDirs, [ - '.copilot', - 'session-state' - ]).map((rootDir) => - discoverFiles({ rootDir, limit, agent: 'copilot', issues, extensions: ['.jsonl'] }) - ), - ...cursorDiscoveries(options, wslHomeDirs, limit, issues), - ...grokDiscoveries(options, wslHomeDirs, limit, issues), - ...devinDiscoveries(options, wslHomeDirs, limit, issues), - ...hermesDiscoveries(options, wslHomeDirs, limit, issues), - ...rovoDiscoveries(options, wslHomeDirs, limit, issues), - ...piDiscoveries(options, wslHomeDirs, limit, issues), - ...ompDiscoveries(options, wslHomeDirs, limit, issues) - ] -} - -function cursorDiscoveries( - options: AiVaultScanOptions, - wslHomeDirs: readonly string[], - limit: number, - issues: AiVaultScanIssue[] -): Promise[] { - return sessionRootDirs(options.cursorProjectsDir ?? CURSOR_PROJECTS_DIR, wslHomeDirs, [ - '.cursor', - 'projects' - ]).map((rootDir) => - discoverFiles({ - rootDir, - limit, - agent: 'cursor', - issues, - extensions: ['.jsonl'], - filePredicate: (path) => path.split(/[\\/]/).includes('agent-transcripts') - }) - ) -} - -function grokDiscoveries( - options: AiVaultScanOptions, - wslHomeDirs: readonly string[], - limit: number, - issues: AiVaultScanIssue[] -): Promise[] { - // Resolved lazily: a module-scope call binds across chunks at init time, which - // breaks whenever bundle ordering puts this module before its import. - return sessionRootDirs(options.grokSessionsDir ?? resolveGrokSessionsDir(), wslHomeDirs, [ - '.grok', - 'sessions' - ]).map((rootDir) => - discoverFiles({ - rootDir, - limit, - agent: 'grok', - issues, - extensions: ['.json'], - filePredicate: (path) => basename(path) === 'summary.json' - }) - ) -} - -function devinDiscoveries( - options: AiVaultScanOptions, - wslHomeDirs: readonly string[], - limit: number, - issues: AiVaultScanIssue[] -): Promise[] { - return sessionRootDirs(options.devinTranscriptsDir ?? DEVIN_TRANSCRIPTS_DIR, wslHomeDirs, [ - '.local', - 'share', - 'devin', - 'cli', - 'transcripts' - ]).map((rootDir) => - discoverFiles({ rootDir, limit, agent: 'devin', issues, extensions: ['.json'] }) - ) -} - -function hermesDiscoveries( - options: AiVaultScanOptions, - wslHomeDirs: readonly string[], - limit: number, - issues: AiVaultScanIssue[] -): Promise[] { - return sessionRootDirs(options.hermesSessionsDir ?? HERMES_SESSIONS_DIR, wslHomeDirs, [ - '.hermes', - 'sessions' - ]).map((rootDir) => - discoverFiles({ - rootDir, - limit, - agent: 'hermes', - issues, - extensions: ['.json'], - filePredicate: (path) => basename(path).startsWith('session_') - }) - ) -} - -function rovoDiscoveries( - options: AiVaultScanOptions, - wslHomeDirs: readonly string[], - limit: number, - issues: AiVaultScanIssue[] -): Promise[] { - return sessionRootDirs(options.rovoSessionsDir ?? ROVO_SESSIONS_DIR, wslHomeDirs, [ - '.rovodev', - 'sessions' - ]).map((rootDir) => - discoverFiles({ - rootDir, - limit, - agent: 'rovo', - issues, - extensions: ['.json'], - filePredicate: (path) => basename(path) === 'metadata.json' - }) - ) -} - -function piDiscoveries( - options: AiVaultScanOptions, - wslHomeDirs: readonly string[], - limit: number, - issues: AiVaultScanIssue[] -): Promise[] { - return sessionRootDirs(options.piSessionsDir ?? PI_SESSIONS_DIR, wslHomeDirs, [ - '.pi', - 'agent', - 'sessions' - ]).map((rootDir) => - discoverFiles({ rootDir, limit, agent: 'pi', issues, extensions: ['.jsonl'] }) - ) -} - -function ompDiscoveries( - options: AiVaultScanOptions, - wslHomeDirs: readonly string[], - limit: number, - issues: AiVaultScanIssue[] -): Promise[] { - return sessionRootDirs(options.ompSessionsDir ?? OMP_SESSIONS_DIR, wslHomeDirs, [ - '.omp', - 'agent', - 'sessions' - ]).map((rootDir) => - discoverFiles({ - rootDir, - limit, - agent: 'omp', - issues, - extensions: ['.jsonl'], - // Why: task subagent transcripts live inside the session's same-named - // artifact directory (`_/`); surfaced as top-level rows they - // drown coordinators under their own workers (#9330). Prune the subtree - // and read them on demand under their parent instead. Unlike Claude's, - // these carry their own sessionId and would resume by path — but OMP's - // own picker only globs `*/*.jsonl`, so it never offers them either. - // Depth 0 is the workspace dir, which is never an artifact dir. - directoryPredicate: (name, depth) => - depth === 0 || !OMP_SESSION_ARTIFACT_DIR_PATTERN.test(name) - }) - ) -} - -function openClawDiscovery( - options: AiVaultScanOptions, - wslHomeDirs: readonly string[], - limit: number, - issues: AiVaultScanIssue[] + discover: (rootDir: string) => Promise ): Promise { - return discoverOpenClawFiles({ - rootDirs: [ - options.openclawStateDir ?? OPENCLAW_STATE_DIR, - options.openclawLegacyStateDir ?? join(homedir(), '.clawdbot'), - ...wslHomeDirs.map((homeDir) => join(homeDir, '.openclaw')), - ...wslHomeDirs.map((homeDir) => join(homeDir, '.clawdbot')) - ], - limit, - issues - }) + const discoveries = await Promise.all(rootDirs.map(discover)) + const files = discoveries + .flatMap((discovery) => discovery.files) + .sort((left, right) => right.mtimeMs - left.mtimeMs) + .slice(0, limit) + return { agent, rootDir: rootDirs.join(delimiter), files } } diff --git a/src/main/ipc/ai-vault-delete.ts b/src/main/ipc/ai-vault-delete.ts new file mode 100644 index 00000000000..de3507d034e --- /dev/null +++ b/src/main/ipc/ai-vault-delete.ts @@ -0,0 +1,57 @@ +import { ipcMain } from 'electron' +import { + getAiVaultWslHomeDirs, + invalidateAiVaultSessionListCache +} from '../ai-vault/cached-session-list' +import { deleteAiVaultSessionFile } from '../ai-vault/session-delete' +import { invalidateSessionParseCacheEntry } from '../ai-vault/session-scanner-parse-cache' +import type { AiVaultAgent } from '../../shared/ai-vault-types' +import type { + AiVaultDeleteSessionArgs, + AiVaultDeleteSessionResult +} from '../../shared/ai-vault-session-deletion' + +// Which cache backs the multi-host list is ai-vault.ts's concern, so its +// invalidation is injected rather than reached into from here. +type AiVaultDeleteDeps = { + invalidateMultiHostListCache: () => void +} + +// Binds the delete orchestration to the caller's cache-invalidation seam. +export function registerAiVaultDeleteHandler(deps: AiVaultDeleteDeps): void { + ipcMain.handle('aiVault:deleteSession', (_event, args?: AiVaultDeleteSessionArgs) => + deleteAiVaultSession(args, deps) + ) +} + +// Adapts the untyped IPC payload for the executor (which owns re-validation and +// the trash), then invalidates on a real delete — otherwise the caches keep +// serving the deleted session for up to the scan TTL. +export async function deleteAiVaultSession( + args: AiVaultDeleteSessionArgs | undefined, + deps: AiVaultDeleteDeps +): Promise { + // The validator tolerates a malformed agent/filePath but destructures `args`, + // so an absent payload is defaulted here to keep the never-throws boundary. + const wslHomeDirs = await getAiVaultWslHomeDirs() + const result = await deleteAiVaultSessionFile({ + agent: args?.agent as AiVaultAgent, + filePath: args?.filePath ?? '', + executionHostId: args?.executionHostId, + wslHomeDirs + }) + + if (result.outcome === 'deleted') { + // Three caches could otherwise resurrect it: the desktop per-host leg + // cache, the shared local-scope cache (also the runtime/mobile path), and + // this file's parse-cache entry. + deps.invalidateMultiHostListCache() + invalidateAiVaultSessionListCache() + // The parse cache is keyed by the raw path the scanner discovered (which is + // what the renderer echoes back as filePath), so invalidate with that exact + // key — resolve() could normalise it away from the stored key and miss. + invalidateSessionParseCacheEntry(args?.filePath ?? '') + } + + return result +} diff --git a/src/main/ipc/ai-vault-host-leg-cache.ts b/src/main/ipc/ai-vault-host-leg-cache.ts index 9911e81e98b..18b75517d6d 100644 --- a/src/main/ipc/ai-vault-host-leg-cache.ts +++ b/src/main/ipc/ai-vault-host-leg-cache.ts @@ -18,6 +18,11 @@ type CachedHostLeg = { // open. Legs are cached individually and the failing host is the only one // rescanned. const cachedHostLegs = new Map() +// Bumped on every invalidation. A leg scan that started before an invalidation +// carries the old generation and must not write its (now stale) result back — +// otherwise a delete's invalidation is silently undone by a scan that resolves +// just after it. +let cacheGeneration = 0 /** Serves one host's leg from the per-host TTL cache, and stores it only when * that host answered without a host issue — a failing host is retried on the @@ -30,6 +35,7 @@ export async function scanHostLegWithCache(args: { scan: () => Promise }): Promise { const now = Date.now() + const startGeneration = cacheGeneration const cached = cachedHostLegs.get(args.cacheKey) if ( !args.force && @@ -40,6 +46,11 @@ export async function scanHostLegWithCache(args: { return truncateAiVaultListResult(cached.result, args.depth, args.scopePaths) } const result = await args.scan() + // A delete's invalidation landed while this leg was scanning; caching a + // pre-delete result would resurrect the deleted session for the TTL. + if (startGeneration !== cacheGeneration) { + return result + } if (result.issues.some((issue) => issue.kind === 'host')) { const current = cachedHostLegs.get(args.cacheKey) if (!current || current.expiresAt <= Date.now()) { @@ -73,6 +84,15 @@ function pruneExpiredHostLegs(now: number): void { } } -export function resetAiVaultHostLegCacheForTests(): void { +// Drops every cached host leg after a session is deleted so a non-force list +// call within the TTL can't still serve the trashed session. Scan discovery +// walks disk first, so a deleted file is never rediscovered — this only guards +// the cached RESULT. +export function invalidateAiVaultHostLegCache(): void { + cacheGeneration++ cachedHostLegs.clear() } + +export function resetAiVaultHostLegCacheForTests(): void { + invalidateAiVaultHostLegCache() +} diff --git a/src/main/ipc/ai-vault-subagent-list.ts b/src/main/ipc/ai-vault-subagent-list.ts new file mode 100644 index 00000000000..49f609577c7 --- /dev/null +++ b/src/main/ipc/ai-vault-subagent-list.ts @@ -0,0 +1,51 @@ +import { resolve } from 'node:path' +import { getAiVaultWslHomeDirs } from '../ai-vault/cached-session-list' +import { listClaudeSubagentSessions } from '../ai-vault/session-scanner-claude-subagents' +import { listOmpSubagentSessions } from '../ai-vault/session-scanner-omp-subagent-listing' +import { claudeProjectsRootDirs, ompSessionsRootDirs } from '../ai-vault/session-scanner-roots' +import { isPathInsideOrEqual } from '../../shared/cross-platform-path' +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import type { + AiVaultSubagentListArgs, + AiVaultSubagentListResult +} from '../../shared/ai-vault-types' + +// Provider-gated: only Claude and OMP materialize Task subagent transcripts as +// sibling files today; other agents resolve to an empty list. +export async function listAiVaultSubagentSessions( + args?: AiVaultSubagentListArgs +): Promise { + // IPC payloads are untyped at runtime; malformed input resolves empty like + // every other rejected input instead of throwing. + if ( + !args || + (args.agent !== 'claude' && args.agent !== 'omp') || + typeof args.parentFilePath !== 'string' || + !args.parentFilePath.trim() + ) { + return { sessions: [], issues: [] } + } + // Why: subagent transcripts are read from the local filesystem. The UI + // skips remote sessions (their transcripts live on the remote host); return + // empty defensively rather than reading local paths for a remote session. + if ((args.executionHostId ?? LOCAL_EXECUTION_HOST_ID) !== LOCAL_EXECUTION_HOST_ID) { + return { sessions: [], issues: [] } + } + // Why: the path is renderer-supplied; only list files under the agent's + // known sessions roots so a crafted path can't readdir/preview arbitrary + // dirs. + // resolve() collapses `..` segments first — isPathInsideOrEqual compares + // textually and would otherwise pass `/../../etc/x.jsonl`. + const parentFilePath = resolve(args.parentFilePath) + const wslHomeDirs = await getAiVaultWslHomeDirs() + const roots = + args.agent === 'claude' + ? claudeProjectsRootDirs({ wslHomeDirs }) + : ompSessionsRootDirs({ wslHomeDirs }) + if (!roots.some((root) => isPathInsideOrEqual(resolve(root), parentFilePath))) { + return { sessions: [], issues: [] } + } + return args.agent === 'claude' + ? listClaudeSubagentSessions({ parentFilePath }) + : listOmpSubagentSessions({ parentFilePath }) +} diff --git a/src/main/ipc/ai-vault.test.ts b/src/main/ipc/ai-vault.test.ts index 82f3585e7e4..ac0427e061e 100644 --- a/src/main/ipc/ai-vault.test.ts +++ b/src/main/ipc/ai-vault.test.ts @@ -3,6 +3,8 @@ import { join, sep } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AiVaultListResult, AiVaultSession } from '../../shared/ai-vault-types' import type { IFilesystemProvider } from '../providers/types' +import type * as CachedSessionListModule from '../ai-vault/cached-session-list' +import type * as SessionParseCacheModule from '../ai-vault/session-scanner-parse-cache' import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' import { SSH_MUX_REQUEST_TIMEOUT_CODE } from '../ssh/ssh-channel-multiplexer' @@ -17,7 +19,10 @@ const mocks = vi.hoisted(() => ({ getActiveSshAiVaultHostInfo: vi.fn(), getActiveSshAiVaultHostInfos: vi.fn(), requestActiveSshAiVaultSessionList: vi.fn(), - ipcHandle: vi.fn() + ipcHandle: vi.fn(), + deleteAiVaultSessionFile: vi.fn(), + invalidateAiVaultSessionListCache: vi.fn(), + invalidateSessionParseCacheEntry: vi.fn() })) vi.mock('electron', () => ({ @@ -41,6 +46,29 @@ vi.mock('../ai-vault/session-scanner-omp-subagent-listing', () => ({ listOmpSubagentSessions: mocks.listOmpSubagentSessions })) +vi.mock('../ai-vault/session-delete', () => ({ + deleteAiVaultSessionFile: mocks.deleteAiVaultSessionFile +})) + +// Why: only the invalidation seam is replaced — everything else (cachedList, +// listAiVaultSessions, ...) keeps its real implementation so the existing +// host-routing/caching tests below stay exercising real behavior. +vi.mock('../ai-vault/cached-session-list', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + invalidateAiVaultSessionListCache: mocks.invalidateAiVaultSessionListCache + } +}) + +vi.mock('../ai-vault/session-scanner-parse-cache', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + invalidateSessionParseCacheEntry: mocks.invalidateSessionParseCacheEntry + } +}) + vi.mock('../wsl', () => ({ getWslHomeAsync: mocks.getAiVaultWslHomeDirs, listWslDistrosAsync: vi.fn().mockResolvedValue([]) @@ -674,6 +702,124 @@ describe('listAiVaultSubagentSessions gating', () => { }) }) +describe('deleteAiVaultSession', () => { + const args = { + agent: 'gemini' as const, + filePath: '/home/ada/.gemini/tmp/sess.json', + executionHostId: 'local' as const + } + + it('invalidates every AI Vault cache after a real delete', async () => { + mocks.deleteAiVaultSessionFile.mockResolvedValue({ outcome: 'deleted' }) + + const result = await _internals.deleteAiVaultSession(args) + + expect(result).toEqual({ outcome: 'deleted' }) + expect(mocks.deleteAiVaultSessionFile).toHaveBeenCalledWith( + expect.objectContaining({ + agent: 'gemini', + filePath: args.filePath, + executionHostId: 'local' + }) + ) + expect(mocks.invalidateAiVaultSessionListCache).toHaveBeenCalledTimes(1) + expect(mocks.invalidateSessionParseCacheEntry).toHaveBeenCalledWith(args.filePath) + }) + + it('does not invalidate any cache when the executor rejects (e.g. non-local host)', async () => { + mocks.deleteAiVaultSessionFile.mockResolvedValue({ + outcome: 'rejected', + agent: 'gemini', + reason: 'non-local-host' + }) + + const result = await _internals.deleteAiVaultSession({ + ...args, + executionHostId: 'ssh:dev-box' + }) + + expect(result).toEqual({ outcome: 'rejected', agent: 'gemini', reason: 'non-local-host' }) + expect(mocks.invalidateAiVaultSessionListCache).not.toHaveBeenCalled() + expect(mocks.invalidateSessionParseCacheEntry).not.toHaveBeenCalled() + }) + + it('does not invalidate any cache when the executor fails', async () => { + mocks.deleteAiVaultSessionFile.mockResolvedValue({ + outcome: 'failed', + agent: 'gemini', + error: 'EPERM' + }) + + await _internals.deleteAiVaultSession(args) + + expect(mocks.invalidateAiVaultSessionListCache).not.toHaveBeenCalled() + expect(mocks.invalidateSessionParseCacheEntry).not.toHaveBeenCalled() + }) + + it('resolves a malformed payload to a rejection instead of throwing', async () => { + mocks.deleteAiVaultSessionFile.mockResolvedValue({ + outcome: 'rejected', + agent: undefined, + reason: 'invalid-path' + }) + + await expect(_internals.deleteAiVaultSession(undefined)).resolves.toEqual({ + outcome: 'rejected', + agent: undefined, + reason: 'invalid-path' + }) + expect(mocks.deleteAiVaultSessionFile).toHaveBeenCalledWith( + expect.objectContaining({ filePath: '' }) + ) + expect(mocks.invalidateAiVaultSessionListCache).not.toHaveBeenCalled() + }) + + it('registers the aiVault:deleteSession IPC channel', () => { + registerAiVaultHandlers() + + expect(mocks.ipcHandle).toHaveBeenCalledWith('aiVault:deleteSession', expect.any(Function)) + }) + + // Observes the real multi-host invalidation seam (not mocked) end-to-end: + // deleting must clear this module's own cache, not only the two shared caches. + it('clears the multi-host scan-result cache after a real delete', async () => { + await _internals.listAiVaultSessions({ executionHostScope: 'ssh:dev-box' }) + await _internals.listAiVaultSessions({ executionHostScope: 'ssh:dev-box' }) + // Second list is a cache hit, so only one scan so far. + expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1) + + mocks.deleteAiVaultSessionFile.mockResolvedValue({ outcome: 'deleted' }) + await _internals.deleteAiVaultSession(args) + + // Cache was cleared, so the next list re-scans instead of serving the stale + // list that still contained the deleted session. + await _internals.listAiVaultSessions({ executionHostScope: 'ssh:dev-box' }) + expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(2) + }) + + // Generation guard: a scan already in flight when the delete lands must not + // write its pre-delete result back into the multi-host cache. + it('does not let an in-flight multi-host scan repopulate the cache after a delete', async () => { + let resolveScan: (value: AiVaultListResult) => void = () => {} + mocks.scanRemoteAiVaultSessions.mockReturnValueOnce( + new Promise((resolve) => { + resolveScan = resolve + }) + ) + const inFlight = _internals.listAiVaultSessions({ executionHostScope: 'ssh:dev-box' }) + + mocks.deleteAiVaultSessionFile.mockResolvedValue({ outcome: 'deleted' }) + await _internals.deleteAiVaultSession(args) + + resolveScan(result([session('ssh:dev-box', 'stale-session')])) + await inFlight + + // The late scan must not have cached its stale result: the next list re-scans. + await _internals.listAiVaultSessions({ executionHostScope: 'ssh:dev-box' }) + expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(2) + }) +}) + function hostInfo(targetId: string) { return { targetId, diff --git a/src/main/ipc/ai-vault.ts b/src/main/ipc/ai-vault.ts index f3e4c16151f..4db28cf3cef 100644 --- a/src/main/ipc/ai-vault.ts +++ b/src/main/ipc/ai-vault.ts @@ -1,16 +1,12 @@ import { app, ipcMain } from 'electron' -import { resolve } from 'node:path' import { configureAiVaultSessionSources, - getAiVaultWslHomeDirs, listAiVaultSessions as listCachedLocalAiVaultSessions, resetAiVaultSessionListCacheForTests, type AiVaultSessionSources } from '../ai-vault/cached-session-list' -import { listClaudeSubagentSessions } from '../ai-vault/session-scanner-claude-subagents' -import { listOmpSubagentSessions } from '../ai-vault/session-scanner-omp-subagent-listing' -import { claudeProjectsRootDirs, ompSessionsRootDirs } from '../ai-vault/session-scanner-roots' -import { isPathInsideOrEqual } from '../../shared/cross-platform-path' +import { deleteAiVaultSession, registerAiVaultDeleteHandler } from './ai-vault-delete' +import { listAiVaultSubagentSessions } from './ai-vault-subagent-list' import { aiVaultScanIssueResult, cancelledAiVaultListResult, @@ -18,6 +14,7 @@ import { } from '../ai-vault/session-list-results' import { scanSshAiVaultSessions } from '../ai-vault/ssh-session-list' import { AiVaultScanCoordinator } from '../ai-vault/ai-vault-scan-coordinator' +import type { AiVaultDeleteSessionArgs } from '../../shared/ai-vault-session-deletion' import { AI_VAULT_SCOPE_PATHS_MAX_COUNT, isAiVaultScanCancelledError, @@ -45,7 +42,11 @@ import { type RuntimeAiVaultHostInfo, type RuntimeAiVaultScanner } from './ai-vault-runtime-scan' -import { resetAiVaultHostLegCacheForTests, scanHostLegWithCache } from './ai-vault-host-leg-cache' +import { + invalidateAiVaultHostLegCache, + resetAiVaultHostLegCacheForTests, + scanHostLegWithCache +} from './ai-vault-host-leg-cache' import { requestedAiVaultSessionDepth } from '../../shared/ai-vault-session-depth' const AI_VAULT_ALL_HOST_RUNTIME_TIMEOUT_MS = 3_000 @@ -66,6 +67,9 @@ type AiVaultHandlerOptions = AiVaultSessionSources & let scanCoordinator = new AiVaultScanCoordinator() let handlerOptions: AiVaultHandlerOptions = {} const listCancellations = createSenderScopedRequestCancellations() +// Shared by the IPC registration and the test internals: a delete must drop +// the multi-host leg cache, which this module owns the only caller of. +const aiVaultDeleteDeps = { invalidateMultiHostListCache: invalidateAiVaultHostLegCache } async function listAiVaultSessions( args?: AiVaultListArgs, @@ -286,6 +290,7 @@ export function registerAiVaultHandlers(options: AiVaultHandlerOptions = {}): vo ipcMain.handle('aiVault:getFirstUserPrompt', (_event, args?: AiVaultFirstUserPromptArgs) => handleAiVaultGetFirstUserPrompt(args) ) + registerAiVaultDeleteHandler(aiVaultDeleteDeps) // DOM focus/visibility events don't fire in the renderer on macOS app // activation, so refresh-on-refocus needs this main-process signal. app.on('browser-window-focus', (_event, window) => { @@ -295,45 +300,6 @@ export function registerAiVaultHandlers(options: AiVaultHandlerOptions = {}): vo }) } -// Provider-gated: only Claude and OMP materialize Task subagent transcripts as -// sibling files today; other agents resolve to an empty list. -async function listAiVaultSubagentSessions( - args?: AiVaultSubagentListArgs -): Promise { - // IPC payloads are untyped at runtime; malformed input resolves empty like - // every other rejected input instead of throwing. - if ( - !args || - (args.agent !== 'claude' && args.agent !== 'omp') || - typeof args.parentFilePath !== 'string' || - !args.parentFilePath.trim() - ) { - return { sessions: [], issues: [] } - } - // Why: subagent transcripts are read from the local filesystem. The UI - // skips remote sessions (their transcripts live on the remote host); return - // empty defensively rather than reading local paths for a remote session. - if ((args.executionHostId ?? LOCAL_EXECUTION_HOST_ID) !== LOCAL_EXECUTION_HOST_ID) { - return { sessions: [], issues: [] } - } - // Why: the path is renderer-supplied; only list files under the agent's - // known sessions roots so a crafted path can't readdir/preview arbitrary - // dirs. resolve() collapses `..` segments first — isPathInsideOrEqual - // compares textually and would otherwise pass `/../../etc/x.jsonl`. - const parentFilePath = resolve(args.parentFilePath) - const wslHomeDirs = await getAiVaultWslHomeDirs() - const roots = - args.agent === 'claude' - ? claudeProjectsRootDirs({ wslHomeDirs }) - : ompSessionsRootDirs({ wslHomeDirs }) - if (!roots.some((root) => isPathInsideOrEqual(resolve(root), parentFilePath))) { - return { sessions: [], issues: [] } - } - return args.agent === 'claude' - ? listClaudeSubagentSessions({ parentFilePath }) - : listOmpSubagentSessions({ parentFilePath }) -} - function resetAiVaultCacheForTests(): void { resetAiVaultHostLegCacheForTests() scanCoordinator = new AiVaultScanCoordinator() @@ -346,5 +312,7 @@ function resetAiVaultCacheForTests(): void { export const _internals = { listAiVaultSessions, listAiVaultSubagentSessions, + deleteAiVaultSession: (args?: AiVaultDeleteSessionArgs) => + deleteAiVaultSession(args, aiVaultDeleteDeps), resetAiVaultCacheForTests } diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index b1d9feb03c7..a89debcce31 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -499,6 +499,10 @@ import type { OpenCodeUsageSnapshot, OpenCodeUsageSummary } from '../shared/opencode-usage-types' +import type { + AiVaultDeleteSessionArgs, + AiVaultDeleteSessionResult +} from '../shared/ai-vault-session-deletion' import type { AiVaultFirstUserPromptArgs, AiVaultFirstUserPromptResult, @@ -923,6 +927,8 @@ export type AiVaultApi = { listSubagentSessions: (args: AiVaultSubagentListArgs) => Promise /** Full first user prompt for copy/reuse (re-parses one transcript). */ getFirstUserPrompt: (args: AiVaultFirstUserPromptArgs) => Promise + /** Moves a deletable session's transcript to the OS trash; local sessions only. */ + deleteSession: (args: AiVaultDeleteSessionArgs) => Promise /** Fires when any app window regains OS focus; returns an unsubscribe. */ onWindowFocused: (callback: () => void) => () => void } diff --git a/src/preload/index.ts b/src/preload/index.ts index 34e195f3848..8c63d111d6d 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -242,6 +242,10 @@ import type { AutomationUpdateInput } from '../shared/automations-types' import type { KeybindingActionId, KeybindingFileSnapshot } from '../shared/keybindings' +import type { + AiVaultDeleteSessionArgs, + AiVaultDeleteSessionResult +} from '../shared/ai-vault-session-deletion' import type { AiVaultFirstUserPromptArgs, AiVaultListArgs, @@ -4242,6 +4246,8 @@ const api = { ipcRenderer.invoke('aiVault:listSubagentSessions', args), getFirstUserPrompt: (args: AiVaultFirstUserPromptArgs): Promise => ipcRenderer.invoke('aiVault:getFirstUserPrompt', args), + deleteSession: (args: AiVaultDeleteSessionArgs): Promise => + ipcRenderer.invoke('aiVault:deleteSession', args), onWindowFocused: (callback: () => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent) => callback() ipcRenderer.on('aiVault:windowFocused', listener) diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx index e048925c734..e5a18bc3014 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx @@ -49,6 +49,7 @@ import { import { usePersistedAiVaultViewOptions } from './use-persisted-ai-vault-view-options' import { AgentSessionContinuationDialog } from '@/components/agent-session-continuation/AgentSessionContinuationDialog' import { AiVaultScanIssueBanners } from './AiVaultScanIssueBanners' +import { useAiVaultSessionDeleteAction } from './ai-vault-session-delete-action' export default function AiVaultPanel(): React.JSX.Element { const activeWorktreeId = useActiveWorktreeId() @@ -304,6 +305,8 @@ export default function AiVaultPanel(): React.JSX.Element { }) }, []) + const requestDelete = useAiVaultSessionDeleteAction({ refresh }) + return (
void requestDelete(session)} /> {launchActions.continuationRequest && ( void onRevealLog?: () => void onOpenCwd?: () => void + // Null when Delete is offered; otherwise the tooltip explaining why it isn't. + deleteBlockedReason: string | null + onDelete: () => void }) { const Item = menuKind === 'context' ? ContextMenuItem : DropdownMenuItem const Separator = menuKind === 'context' ? ContextMenuSeparator : DropdownMenuSeparator const hasLocalPathActions = Boolean(onOpenLog || onRevealLog || onOpenCwd) + const deleteLabel = translate('auto.components.right.sidebar.AiVaultSessionRow.delete', 'Delete') + const deleteItem = ( + + + {deleteLabel} + + ) return ( <> @@ -126,6 +147,25 @@ export function SessionActionMenuItems({ {translate('auto.components.right.sidebar.AiVaultSessionRow.copyLogPath', 'Copy Log Path')} + + {deleteBlockedReason ? ( + + + {/* A disabled item is pointer-events:none, so the trigger needs this + wrapper to receive hover (as WorktreeContextMenu does). */} +
{deleteItem}
+
+ + {deleteBlockedReason} + +
+ ) : ( + deleteItem + )} ) } diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.test.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.test.tsx new file mode 100644 index 00000000000..ac3e3926d80 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.test.tsx @@ -0,0 +1,95 @@ +// @vitest-environment happy-dom + +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { TooltipProvider } from '@/components/ui/tooltip' +import type { AiVaultSession } from '../../../../shared/ai-vault-types' +import { VaultSessionRow } from './AiVaultSessionRow' + +const session = { + id: 'local:gemini:sess-1:/home/a/.gemini/s.json', + executionHostId: 'local', + agent: 'gemini', + sessionId: 'sess-1', + title: 'A session', + cwd: null, + branch: null, + model: null, + filePath: '/home/a/.gemini/s.json', + codexHome: null, + createdAt: null, + updatedAt: null, + modifiedAt: 0, + messageCount: 2, + totalTokens: 0, + previewMessages: [], + queuedMessageCount: 0, + subagentTranscriptCount: 0, + resumeCommand: 'gemini --resume sess-1', + subagent: null +} as unknown as AiVaultSession + +function renderRow(handlers: { onToggleDetails: () => void; onRequestDelete?: () => void }) { + return render( + + + + ) +} + +afterEach(() => { + vi.clearAllMocks() +}) + +describe('VaultSessionRow details toggle', () => { + it('does not expand the row when a menu action is chosen', async () => { + // Radix portals the menu out of the row's DOM, but React bubbles its + // clicks back through the component tree. Expanding here would leave the + // row open behind the confirm dialog, and still open after cancelling. + const onToggleDetails = vi.fn() + const onRequestDelete = vi.fn() + renderRow({ onToggleDetails, onRequestDelete }) + const user = userEvent.setup() + + await user.click(screen.getByTestId('ai-vault-session-more-actions')) + await user.click(await screen.findByRole('menuitem', { name: 'Delete' })) + + expect(onRequestDelete).toHaveBeenCalledTimes(1) + expect(onToggleDetails).not.toHaveBeenCalled() + }) + + it('still expands when the row itself is clicked', async () => { + const onToggleDetails = vi.fn() + const { container } = renderRow({ onToggleDetails }) + const user = userEvent.setup() + + // The session title: inside the row body, so its click reaches the row's + // own handler — the path a user takes to expand a row. Queried first-match + // because Radix's asChild trigger repeats the subtree. + const title = container.querySelector('[title="Drag to resume in a new tab"]') + expect(title).not.toBeNull() + await user.click(title as Element) + + expect(onToggleDetails).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx index 416fb2bb79e..9e2f1702f7c 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx @@ -14,6 +14,7 @@ import { SessionInlineDetails } from './AiVaultSessionDetails' import { latestSessionConversationTurn } from './ai-vault-session-display' import { SessionActionMenuItems } from './AiVaultSessionActionMenuItems' import { SessionRowTrailingActions } from './SessionRowTrailingActions' +import { aiVaultSessionDeleteBlockedReason } from './ai-vault-session-deletability' import type { AiVaultSessionResumeActions } from './ai-vault-session-resume' import { shouldShowAiVaultSessionWorktreeLine, @@ -51,7 +52,8 @@ export function VaultSessionRow({ onCopyPath, onOpenLog, onRevealLog, - onOpenCwd + onOpenCwd, + onRequestDelete }: { session: AiVaultSession liveState: AgentStatusState | null @@ -77,10 +79,14 @@ export function VaultSessionRow({ onOpenLog?: () => void onRevealLog?: () => void onOpenCwd?: () => void + onRequestDelete: (session: AiVaultSession) => void }) { const updatedAt = session.updatedAt ?? session.modifiedAt const detailsId = getSessionDetailsId(session.id) const latestTurn = latestSessionConversationTurn(session) + // Computed once so the dropdown menu and the context menu never disagree. + const deleteBlockedReason = aiVaultSessionDeleteBlockedReason(session, liveState) + const requestDelete = (): void => onRequestDelete(session) const detailsTooltip = detailsExpanded ? translate('auto.components.right.sidebar.AiVaultSessionRow.hideDetails', 'Hide Details') : translate('auto.components.right.sidebar.AiVaultSessionRow.showDetails', 'Show Details') @@ -120,7 +126,14 @@ export function VaultSessionRow({ 'group/session-row flex w-full min-w-0 cursor-pointer flex-col border-b border-sidebar-border px-3 py-2 text-left transition-colors hover:bg-sidebar-accent/55', !detailsExpanded && 'min-h-[98px]' )} - onClick={() => { + onClick={(event) => { + // Radix portals this row's menus out of its DOM, but React still + // bubbles their clicks here — without this, choosing Delete expands + // the row behind the dialog. + const target = event.target + if (target instanceof Node && !event.currentTarget.contains(target)) { + return + } onToggleDetails() }} > @@ -169,6 +182,8 @@ export function VaultSessionRow({ onOpenLog={onOpenLog} onRevealLog={onRevealLog} onOpenCwd={onOpenCwd} + deleteBlockedReason={deleteBlockedReason} + onRequestDelete={requestDelete} />
{detailsExpanded && shouldShowAiVaultSessionWorktreeLine(worktreeInfo, { vaultScope }) ? ( @@ -233,6 +248,8 @@ export function VaultSessionRow({ onOpenLog={onOpenLog} onRevealLog={onRevealLog} onOpenCwd={onOpenCwd} + deleteBlockedReason={deleteBlockedReason} + onDelete={requestDelete} /> diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx index 2139b60b01d..b56311e519b 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx @@ -66,7 +66,8 @@ export function AiVaultSessionVirtualList({ onCopyPath, onOpenLog, onRevealLog, - onOpenCwd + onOpenCwd, + onRequestDelete }: { groups: readonly AiVaultSessionGroup[] collapsedGroups: ReadonlySet @@ -93,6 +94,7 @@ export function AiVaultSessionVirtualList({ onOpenLog: (session: AiVaultSession) => void onRevealLog: (session: AiVaultSession) => void onOpenCwd: (session: AiVaultSession) => void + onRequestDelete: (session: AiVaultSession) => void }): React.JSX.Element { const listScrollRef = useRef(null) const stickyRangeStartIndexRef = useRef(0) @@ -229,6 +231,7 @@ export function AiVaultSessionVirtualList({ onOpenLog={onOpenLog} onRevealLog={onRevealLog} onOpenCwd={onOpenCwd} + onRequestDelete={onRequestDelete} /> ))}
@@ -263,7 +266,8 @@ function AiVaultVirtualRow({ onCopyPath, onOpenLog, onRevealLog, - onOpenCwd + onOpenCwd, + onRequestDelete }: { row: AiVaultListRow | undefined index: number @@ -291,6 +295,7 @@ function AiVaultVirtualRow({ onOpenLog: (session: AiVaultSession) => void onRevealLog: (session: AiVaultSession) => void onOpenCwd: (session: AiVaultSession) => void + onRequestDelete: (session: AiVaultSession) => void }): React.JSX.Element | null { if (!row) { return null @@ -401,6 +406,7 @@ function AiVaultVirtualRow({ onOpenCwd={ canOpenLocalSessionPaths && row.session.cwd ? () => onOpenCwd(row.session) : undefined } + onRequestDelete={onRequestDelete} /> )}
diff --git a/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.test.tsx b/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.test.tsx index c013c0b0422..9e48965099b 100644 --- a/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.test.tsx +++ b/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.test.tsx @@ -23,6 +23,8 @@ function renderActions(onContinueInNewSession?: () => void): string { onContinueInNewSession={onContinueInNewSession} onCopyId={vi.fn()} onCopyPath={vi.fn()} + deleteBlockedReason={null} + onRequestDelete={vi.fn()} /> ) diff --git a/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.tsx b/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.tsx index 4813930c290..c41c89ef966 100644 --- a/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.tsx +++ b/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.tsx @@ -58,7 +58,9 @@ export function SessionRowTrailingActions({ onCopyPath, onOpenLog, onRevealLog, - onOpenCwd + onOpenCwd, + deleteBlockedReason, + onRequestDelete }: { session: AiVaultSession detailsExpanded: boolean @@ -79,6 +81,9 @@ export function SessionRowTrailingActions({ onOpenLog?: () => void onRevealLog?: () => void onOpenCwd?: () => void + // Null when Delete is offered; otherwise the tooltip explaining why it isn't. + deleteBlockedReason: string | null + onRequestDelete: () => void }) { const jumpToWorktreeTooltip = aiVaultWorktreeJumpTooltip(worktreeInfo) @@ -260,6 +265,8 @@ export function SessionRowTrailingActions({ onOpenLog={onOpenLog} onRevealLog={onRevealLog} onOpenCwd={onOpenCwd} + deleteBlockedReason={deleteBlockedReason} + onDelete={onRequestDelete} /> diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-deletability.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-deletability.test.ts new file mode 100644 index 00000000000..49e8d39e123 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-deletability.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest' +import { aiVaultSessionDeleteBlockedReason } from './ai-vault-session-deletability' + +// translate() with no loaded catalog returns the English fallback, so these +// assertions pin the English copy as well as the gate order. +const NON_LOCAL = 'Only sessions on this device can be deleted.' +const SYNTHETIC = "This session can't be deleted from Orca." +const LIVE = 'This session is still running — wait for it to finish before deleting.' + +const localGeminiSession = { + agent: 'gemini' as const, + executionHostId: 'local' as const, + filePath: '/home/user/.gemini/sessions/log.jsonl' +} + +describe('aiVaultSessionDeleteBlockedReason', () => { + it('offers Delete for a deletable agent on a local, real path', () => { + expect(aiVaultSessionDeleteBlockedReason(localGeminiSession)).toBeNull() + }) + + it('offers Delete for a directory-shaped agent (claude)', () => { + expect( + aiVaultSessionDeleteBlockedReason({ + agent: 'claude', + executionHostId: 'local', + filePath: '/home/user/.claude/projects/-proj/sess-1.jsonl' + }) + ).toBeNull() + }) + + it('tells the user to wait while the agent is still running', () => { + for (const live of ['working', 'blocked', 'waiting'] as const) { + expect(aiVaultSessionDeleteBlockedReason(localGeminiSession, live)).toBe(LIVE) + } + }) + + it('offers Delete for a finished session (done) and one with no live state', () => { + expect(aiVaultSessionDeleteBlockedReason(localGeminiSession, 'done')).toBeNull() + expect(aiVaultSessionDeleteBlockedReason(localGeminiSession, null)).toBeNull() + }) + + it('keeps the permanent reason over "running" for an unsupported live session', () => { + // A live but unsupported agent stays "unsupported" — it would never become + // deletable, so "wait for it to finish" would mislead. + expect( + aiVaultSessionDeleteBlockedReason( + { agent: 'codex', executionHostId: 'local', filePath: '/home/user/.codex/x.jsonl' }, + 'working' + ) + ).toBe("Codex sessions can't be deleted from Orca.") + }) + + it('blocks ssh- and runtime-hosted sessions regardless of agent', () => { + for (const executionHostId of ['ssh:dev-box', 'runtime:gpu-box'] as const) { + expect(aiVaultSessionDeleteBlockedReason({ ...localGeminiSession, executionHostId })).toBe( + NON_LOCAL + ) + } + }) + + it('blocks a synthetic OpenCode SQLite row identity', () => { + expect( + aiVaultSessionDeleteBlockedReason({ + agent: 'opencode', + executionHostId: 'local', + filePath: '/home/user/.opencode/db.sqlite#sess_123' + }) + ).toBe(SYNTHETIC) + }) + + it('names the agent without explaining why it is unsupported', () => { + expect( + aiVaultSessionDeleteBlockedReason({ + agent: 'opencode', + executionHostId: 'local', + filePath: '/home/user/.opencode/sessions/log.jsonl' + }) + ).toBe("OpenCode sessions can't be deleted from Orca.") + }) + + it('gives a multi-cause agent (antigravity) the same single sentence', () => { + expect( + aiVaultSessionDeleteBlockedReason({ + agent: 'antigravity', + executionHostId: 'local', + filePath: '/home/user/.antigravity/brain/conv-1/.system_generated/logs/transcript.jsonl' + }) + ).toBe("Antigravity sessions can't be deleted from Orca.") + }) + + it('prioritizes the host gate over the unsupported-agent reason', () => { + expect( + aiVaultSessionDeleteBlockedReason({ + agent: 'claude', + executionHostId: 'ssh:dev-box', + filePath: '/home/user/.claude/sessions/sess-dir/log.jsonl' + }) + ).toBe(NON_LOCAL) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-deletability.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-deletability.ts new file mode 100644 index 00000000000..033ebd87c81 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-deletability.ts @@ -0,0 +1,60 @@ +import { isAiVaultDeletableAgent } from '../../../../shared/ai-vault-session-deletion' +import type { AiVaultSession } from '../../../../shared/ai-vault-types' +import type { AgentStatusState } from '../../../../shared/agent-status-types' +import { translate } from '@/i18n/i18n' +import { agentLabel } from './ai-vault-session-filters' +import { + canUseLocalAiVaultSessionPathActions, + isSyntheticAiVaultSessionPath +} from './ai-vault-session-path-actions' + +// Matches the active-dot rule in ai-vault-session-row-display. +function isSessionLive(liveState: AgentStatusState | null | undefined): boolean { + return liveState != null && liveState !== 'done' +} + +/** + * Why Delete is unavailable for this session, as the tooltip text to show — or + * null when it is offered. Each message says which sessions are affected, never + * why: a provider's storage layout is Orca's problem, not the reader's. + * + * NOT the security boundary — main re-validates the path on disk regardless. + * The two sides agree on deletable-or-not but deliberately not on the order they + * check, so an SSH session reads as "remote" rather than "unsupported agent". + * What must hold is that renderer-deletable is a subset of main-deletable, and + * it does: both consult the same shared agent set and host/synthetic predicates. + */ +export function aiVaultSessionDeleteBlockedReason( + session: Pick, + liveState?: AgentStatusState | null +): string | null { + if (!canUseLocalAiVaultSessionPathActions(session.executionHostId)) { + return translate( + 'auto.components.right.sidebar.AiVaultSessionRow.deleteReasonNonLocalHost', + 'Only sessions on this device can be deleted.' + ) + } + if (isSyntheticAiVaultSessionPath(session.filePath)) { + return translate( + 'auto.components.right.sidebar.AiVaultSessionRow.deleteReasonSyntheticPath', + "This session can't be deleted from Orca." + ) + } + if (!isAiVaultDeletableAgent(session.agent)) { + return translate( + 'auto.components.right.sidebar.AiVaultSessionRow.deleteReasonUnsupportedAgent', + "{{value0}} sessions can't be deleted from Orca.", + { value0: agentLabel(session.agent) } + ) + } + // Last, so an otherwise-deletable session reads as "wait for it to finish" + // rather than a permanent reason. Trashing a live transcript would drop the + // writes the agent is still appending. + if (isSessionLive(liveState)) { + return translate( + 'auto.components.right.sidebar.AiVaultSessionRow.deleteReasonSessionLive', + 'This session is still running — wait for it to finish before deleting.' + ) + } + return null +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-delete-action.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-delete-action.ts new file mode 100644 index 00000000000..629165a1a0c --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-delete-action.ts @@ -0,0 +1,68 @@ +import { useCallback } from 'react' +import { toast } from 'sonner' +import type { AiVaultSession } from '../../../../shared/ai-vault-types' +import { translate } from '@/i18n/i18n' +import { useConfirmationDialog } from '@/components/confirmation-dialog-context' +import { agentLabel } from './ai-vault-session-filters' + +/** + * Confirms, then deletes an AI Vault session: toasts the outcome and forces a + * refresh so the row goes away immediately. + */ +export function useAiVaultSessionDeleteAction({ + refresh +}: { + refresh: (options: { force: boolean }) => Promise +}): (session: AiVaultSession) => Promise { + const confirm = useConfirmationDialog() + + return useCallback( + async (session: AiVaultSession) => { + const confirmed = await confirm({ + title: translate( + 'auto.components.right.sidebar.AiVaultSessionDeleteDialog.title', + 'Delete this session?' + ), + description: translate( + 'auto.components.right.sidebar.AiVaultSessionDeleteDialog.description', + '"{{value0}}" will be deleted. Once deleted, it will no longer be resumable from {{value1}}\'s own command line either.', + { value0: session.title, value1: agentLabel(session.agent) } + ), + confirmLabel: translate( + 'auto.components.right.sidebar.AiVaultSessionDeleteDialog.confirm', + 'Delete' + ), + confirmVariant: 'destructive' + }) + if (!confirmed) { + return + } + try { + const result = await window.api.aiVault.deleteSession({ + agent: session.agent, + filePath: session.filePath, + executionHostId: session.executionHostId + }) + if (result.outcome !== 'deleted') { + // 'rejected' and 'failed' share one message: the specific reason is a + // main-side detail, not something to surface raw. + throw new Error(result.outcome) + } + toast.success( + translate('auto.components.right.sidebar.AiVaultPanel.sessionDeleted', 'Session deleted') + ) + // Main already invalidated its caches; this is only for immediate UX. + void refresh({ force: true }) + } catch { + // A rejected IPC invoke (transport/serialization) lands here too. + toast.error( + translate( + 'auto.components.right.sidebar.AiVaultPanel.sessionDeleteFailed', + "Couldn't delete the session" + ) + ) + } + }, + [confirm, refresh] + ) +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-path-actions.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-path-actions.ts index 5bbbdcf2d1d..cdf7d415671 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-path-actions.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-path-actions.ts @@ -4,6 +4,11 @@ import { type ExecutionHostId } from '../../../../shared/execution-host' import type { AiVaultSession } from '../../../../shared/ai-vault-types' +import { isAiVaultSyntheticSessionPath } from '../../../../shared/ai-vault-session-deletion' + +// One definition, shared with main's delete validator; re-exported here under +// the name this module's callers already use. +export { isAiVaultSyntheticSessionPath as isSyntheticAiVaultSessionPath } export function canUseLocalAiVaultSessionPathActions( executionHostId: ExecutionHostId | null | undefined @@ -13,14 +18,6 @@ export function canUseLocalAiVaultSessionPathActions( return normalizeExecutionHostId(executionHostId) === LOCAL_EXECUTION_HOST_ID } -export function isSyntheticAiVaultSessionPath(filePath: string): boolean { - // Why: newer OpenCode sessions use a synthetic `#` - // scanner identity backed by SQLite — not a real filesystem path. A '#' - // session marker never appears in a genuine local transcript path, so it is - // a reliable v1 signal that there is no single file to open in Orca. - return filePath.includes('#') -} - /** * Whether AI Vault `View Log` / `Open Log` can open this session's log inside * Orca as a read-only tab: a non-blank, local, single-file (non-synthetic) @@ -37,5 +34,5 @@ export function canOpenAiVaultSessionLogInOrca( if (!canUseLocalAiVaultSessionPathActions(session.executionHostId)) { return false } - return !isSyntheticAiVaultSessionPath(filePath) + return !isAiVaultSyntheticSessionPath(filePath) } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 3b986240bcf..76cd4dbb92e 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -11461,7 +11461,9 @@ "openSupportedWorkspace": "Open a workspace before resuming a session.", "sessionHostMismatchUnsupported": "This session belongs to a different host. Open a workspace on the same host to resume it.", "localSessionSshWorkspaceUnsupported": "This session's history is stored on this machine, so it can't resume in an SSH workspace. Open a local workspace instead.", - "prepareSessionResumeFailed": "Could not prepare this session for resume." + "prepareSessionResumeFailed": "Could not prepare this session for resume.", + "sessionDeleted": "Session deleted", + "sessionDeleteFailed": "Couldn't delete the session" }, "AiVaultPanelControls": { "scanningSessions": "Scanning sessions", @@ -11593,7 +11595,17 @@ "jumpToOriginalPane": "Jump to Original Pane", "jumpToWorktree": "Jump to Worktree", "subagentCountSingular": "1 subagent", - "subagentCountPlural": "{{value0}} subagents" + "subagentCountPlural": "{{value0}} subagents", + "delete": "Delete", + "deleteReasonNonLocalHost": "Only sessions on this device can be deleted.", + "deleteReasonSyntheticPath": "This session can't be deleted from Orca.", + "deleteReasonUnsupportedAgent": "{{value0}} sessions can't be deleted from Orca.", + "deleteReasonSessionLive": "This session is still running — wait for it to finish before deleting." + }, + "AiVaultSessionDeleteDialog": { + "title": "Delete this session?", + "description": "\"{{value0}}\" will be deleted. Once deleted, it will no longer be resumable from {{value1}}'s own command line either.", + "confirm": "Delete" }, "FileExplorerNameFilter": { "26fb73c6e3": "Find files", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index f0ff1fab3dd..35acb69e71c 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -11170,7 +11170,9 @@ "openSupportedWorkspace": "Abre un workspace antes de reanudar una sesión.", "localSessionSshWorkspaceUnsupported": "El historial de esta sesión solo existe en este equipo. Para reanudarla, abre un workspace local. Los workspaces SSH no tienen acceso a ese historial.", "sessionHostMismatchUnsupported": "Esta sesión pertenece a un host diferente. Abre un espacio de trabajo en el mismo host para reanudarla.", - "prepareSessionResumeFailed": "Could not prepare this session for resume." + "prepareSessionResumeFailed": "Could not prepare this session for resume.", + "sessionDeleted": "Sesión eliminada", + "sessionDeleteFailed": "No se pudo eliminar la sesión" }, "AiVaultPanelControls": { "scanningSessions": "Buscando sesiones", @@ -11302,7 +11304,17 @@ "jumpToWorktree": "Saltar al worktree", "recoverableBadge": "No guardada", "subagentCountSingular": "1 subagente", - "subagentCountPlural": "{{value0}} subagentes" + "subagentCountPlural": "{{value0}} subagentes", + "delete": "Eliminar", + "deleteReasonNonLocalHost": "Solo se pueden eliminar las sesiones de este dispositivo.", + "deleteReasonSyntheticPath": "Esta sesión no se puede eliminar desde Orca.", + "deleteReasonUnsupportedAgent": "Las sesiones de {{value0}} no se pueden eliminar desde Orca.", + "deleteReasonSessionLive": "Esta sesión aún se está ejecutando: espera a que termine antes de eliminarla." + }, + "AiVaultSessionDeleteDialog": { + "title": "¿Eliminar esta sesión?", + "description": "\"{{value0}}\" se eliminará. Una vez eliminada, tampoco podrá reanudarse desde la línea de comandos propia de {{value1}}.", + "confirm": "Eliminar" }, "FileExplorerNameFilter": { "26fb73c6e3": "Buscar archivos", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index aaa9248e68a..66b0606eab6 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -11170,7 +11170,9 @@ "openSupportedWorkspace": "Open a workspace before resuming a session.", "localSessionSshWorkspaceUnsupported": "このセッションの履歴はこのマシンに保存されているため、SSH ワークスペースでは再開できません。代わりにローカルワークスペースを開いてください。", "sessionHostMismatchUnsupported": "このセッションは別のホストに属しています。再開するには同じホスト上のワークスペースを開いてください。", - "prepareSessionResumeFailed": "Could not prepare this session for resume." + "prepareSessionResumeFailed": "Could not prepare this session for resume.", + "sessionDeleted": "セッションを削除しました", + "sessionDeleteFailed": "セッションを削除できませんでした" }, "AiVaultPanelControls": { "scanningSessions": "Scanning sessions", @@ -11302,7 +11304,17 @@ "jumpToWorktree": "ワークツリーにジャンプ", "recoverableBadge": "未保存", "subagentCountSingular": "1 件のサブエージェント", - "subagentCountPlural": "{{value0}} 件のサブエージェント" + "subagentCountPlural": "{{value0}} 件のサブエージェント", + "delete": "削除", + "deleteReasonNonLocalHost": "このデバイス上のセッションのみ削除できます。", + "deleteReasonSyntheticPath": "このセッションは Orca では削除できません。", + "deleteReasonUnsupportedAgent": "{{value0}} のセッションは Orca では削除できません。", + "deleteReasonSessionLive": "このセッションはまだ実行中です — 削除する前に完了を待ってください。" + }, + "AiVaultSessionDeleteDialog": { + "title": "このセッションを削除しますか?", + "description": "「{{value0}}」を削除します。削除すると、{{value1}} 自体のコマンドラインからも再開できなくなります。", + "confirm": "削除" }, "FileExplorerNameFilter": { "26fb73c6e3": "ファイルを検索", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index f462a445800..31e25fbb3c5 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -11170,7 +11170,9 @@ "openSupportedWorkspace": "세션을 재개하기 전에 워크스페이스를 여세요.", "localSessionSshWorkspaceUnsupported": "이 세션의 기록은 이 컴퓨터에 저장되어 있어 SSH 워크스페이스에서는 재개할 수 없습니다. 대신 로컬 워크스페이스를 여세요.", "sessionHostMismatchUnsupported": "이 세션은 다른 호스트에 속합니다. 재개하려면 같은 호스트의 워크스페이스를 여세요.", - "prepareSessionResumeFailed": "Could not prepare this session for resume." + "prepareSessionResumeFailed": "Could not prepare this session for resume.", + "sessionDeleted": "세션이 삭제되었습니다", + "sessionDeleteFailed": "세션을 삭제하지 못했습니다" }, "AiVaultPanelControls": { "scanningSessions": "세션 스캔 중", @@ -11302,7 +11304,17 @@ "jumpToWorktree": "작업 트리로 이동", "recoverableBadge": "저장 안 됨", "subagentCountSingular": "서브에이전트 1개", - "subagentCountPlural": "서브에이전트 {{value0}}개" + "subagentCountPlural": "서브에이전트 {{value0}}개", + "delete": "삭제", + "deleteReasonNonLocalHost": "이 기기에 있는 세션만 삭제할 수 있습니다.", + "deleteReasonSyntheticPath": "이 세션은 Orca에서 삭제할 수 없습니다.", + "deleteReasonUnsupportedAgent": "{{value0}} 세션은 Orca에서 삭제할 수 없습니다.", + "deleteReasonSessionLive": "이 세션은 아직 실행 중입니다 — 삭제하려면 완료될 때까지 기다리세요." + }, + "AiVaultSessionDeleteDialog": { + "title": "이 세션을 삭제할까요?", + "description": "\"{{value0}}\"이(가) 삭제됩니다. 삭제하면 {{value1}}의 자체 명령줄에서도 더 이상 재개할 수 없습니다.", + "confirm": "삭제" }, "FileExplorerNameFilter": { "26fb73c6e3": "파일 찾기", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 50700ac525e..76be01349b6 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -11190,7 +11190,9 @@ "openSupportedWorkspace": "在恢复会话之前,请先打开一个工作区。", "localSessionSshWorkspaceUnsupported": "此会话的历史记录存储在本机上,因此无法在 SSH 工作区中恢复。请改为打开一个本地工作区。", "sessionHostMismatchUnsupported": "此会话属于其他主机。请打开同一主机上的工作区以恢复它。", - "prepareSessionResumeFailed": "无法为恢复准备此会话。" + "prepareSessionResumeFailed": "无法为恢复准备此会话。", + "sessionDeleted": "会话已删除", + "sessionDeleteFailed": "无法删除会话" }, "AiVaultPanelControls": { "scanningSessions": "正在扫描会话", @@ -11322,7 +11324,17 @@ "jumpToWorktree": "跳转到工作树", "recoverableBadge": "未保存", "subagentCountSingular": "1 个子智能体", - "subagentCountPlural": "{{value0}} 个子智能体" + "subagentCountPlural": "{{value0}} 个子智能体", + "delete": "删除", + "deleteReasonNonLocalHost": "只能删除此设备上的会话。", + "deleteReasonSyntheticPath": "此会话无法在 Orca 中删除。", + "deleteReasonUnsupportedAgent": "{{value0}} 会话无法在 Orca 中删除。", + "deleteReasonSessionLive": "此会话仍在运行 — 请等待其完成后再删除。" + }, + "AiVaultSessionDeleteDialog": { + "title": "要删除此会话吗?", + "description": "“{{value0}}”将被删除。删除后,也将无法通过 {{value1}} 自身的命令行恢复。", + "confirm": "删除" }, "FileExplorerNameFilter": { "26fb73c6e3": "查找文件", diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 0067f122ae6..c90c7cd95ec 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -9,6 +9,7 @@ import type { import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' import { parseHostAccessLink } from '../../../shared/remote-pairing-address' import { verifyRemotePairingRuntimeStatus } from '../../../shared/remote-pairing-verification' +import type { AiVaultDeleteSessionArgs } from '../../../shared/ai-vault-session-deletion' import type { AiVaultListArgs, AiVaultListResult } from '../../../shared/ai-vault-types' import type { AiVaultPrepareSessionResumeArgs, @@ -1542,6 +1543,15 @@ function createAiVaultApi(): NonNullable['aiVault']> { listSubagentSessions: () => Promise.resolve({ sessions: [], issues: [] }), // Why: full first-prompt re-parse is local-FS only; web/runtime falls back to preview text. getFirstUserPrompt: () => Promise.resolve({ prompt: null }), + // Why: session deletion is local-only and has no runtime RPC; a web + // client's sessions are runtime-hosted, so report the same non-local + // rejection the UI already gates on rather than pretend to delete. + deleteSession: (args: AiVaultDeleteSessionArgs) => + Promise.resolve({ + outcome: 'rejected', + agent: args.agent, + reason: 'non-local-host' as const + }), onWindowFocused: () => noopUnsubscribe } } diff --git a/src/shared/ai-vault-session-deletion.ts b/src/shared/ai-vault-session-deletion.ts new file mode 100644 index 00000000000..ce605022236 --- /dev/null +++ b/src/shared/ai-vault-session-deletion.ts @@ -0,0 +1,105 @@ +import type { AiVaultAgent } from './ai-vault-types' +import type { ExecutionHostId } from './execution-host' + +// IPC payload for aiVault:deleteSession. +export type AiVaultDeleteSessionArgs = { + agent: AiVaultAgent + filePath: string + // The session's host; only a local session may be deleted. + executionHostId?: ExecutionHostId +} + +export type AiVaultDeleteSessionResult = + | { outcome: 'deleted' } + | { outcome: 'rejected'; agent: AiVaultAgent; reason: AiVaultSessionDeleteRejectionCode } + | { outcome: 'failed'; agent: AiVaultAgent; error: string } + +// Agents whose sessions Orca can remove completely: everything the session +// wrote is derivable from the one path the scanner surfaced, and none of it is +// shared with another session. +// +// The rest are excluded, recorded here because the UI deliberately won't say +// why (a provider's storage layout is Orca's problem, not the reader's): +// - antigravity, kimi: a separate registry (history.jsonl / session_index.jsonl) +// would keep a dangling entry. Antigravity's carries no conversation id, so +// which line to drop can't be determined at all. +// - codex: session_index.jsonl plus hardlink aliases between the Orca-managed +// home and ~/.codex, so a one-sided delete reappears on the next scan. +// - opencode 1.17.x: a SQLite row, not a file. +export const AI_VAULT_DELETABLE_AGENTS = [ + 'gemini', + 'copilot', + 'cursor', + 'hermes', + 'devin', + 'openclaw', + 'droid', + 'pi', + 'omp', + 'claude', + 'rovo', + 'grok' +] as const satisfies readonly AiVaultAgent[] + +export type AiVaultDeletableAgent = (typeof AI_VAULT_DELETABLE_AGENTS)[number] + +export function isAiVaultDeletableAgent(agent: AiVaultAgent): agent is AiVaultDeletableAgent { + return (AI_VAULT_DELETABLE_AGENTS as readonly AiVaultAgent[]).includes(agent) +} + +// A '#' marks an OpenCode 1.17.x SQLite row's synthetic `#` +// identity — no real file to open or delete. '#' never appears in a genuine +// transcript path. +export function isAiVaultSyntheticSessionPath(filePath: string): boolean { + return filePath.includes('#') +} + +export type AiVaultSessionDeleteRejectionCode = + | 'invalid-path' + | 'unsupported-agent' + | 'non-local-host' + | 'synthetic-path' + | 'path-outside-known-roots' + // The scanner would never have surfaced this path as a session row, so it is + // not a session to delete (wrong extension, or a pruned subagent transcript). + | 'undiscoverable-path' + // A directory-shaped agent's file sits directly in the sessions root, so it + // names no session dir of its own — removing it would trash every session. + | 'no-session-directory' + // fs-side guard: lstat disagrees with the removal's declared kind (a symlink, + // or a file where the plan expects a directory). + | 'unexpected-target-kind' + +// One path the executor removes. A `kind` mismatch on disk is a rejection, +// never a coerced delete. `roots` are what the path's realpath must still +// resolve inside — a symlinked parent escapes the validator's textual check. +export type AiVaultSessionDeleteRemoval = { + path: string + kind: 'file' | 'directory' + roots: readonly string[] +} + +// CALLER CONTRACT: `allowed: true` is a path-only judgement — the validator +// never touches disk, so it can't tell a session file from a same-named +// directory or a symlink planted inside a root. Before removing anything the +// caller MUST re-check each removal's `kind` and realpath it against `roots`. +export type AiVaultSessionDeleteAllowedResult = { + allowed: true + agent: AiVaultDeletableAgent + // The transcript path (also the last `removals` entry); cache invalidation + // keys off it. + resolvedPath: string + // Companions first, transcript last: a failed companion leaves the row on + // screen to retry from, where transcript-first would strand it on disk. + removals: readonly AiVaultSessionDeleteRemoval[] +} + +export type AiVaultSessionDeleteRejectedResult = { + allowed: false + agent: AiVaultAgent + reason: AiVaultSessionDeleteRejectionCode +} + +export type AiVaultSessionDeleteValidationResult = + | AiVaultSessionDeleteAllowedResult + | AiVaultSessionDeleteRejectedResult diff --git a/tests/e2e/ai-vault-session-delete.spec.ts b/tests/e2e/ai-vault-session-delete.spec.ts new file mode 100644 index 00000000000..db2b387e957 --- /dev/null +++ b/tests/e2e/ai-vault-session-delete.spec.ts @@ -0,0 +1,127 @@ +import { existsSync, mkdirSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import type { AiVaultSession } from '../../src/shared/ai-vault-types' +import type { AiVaultDeleteSessionResult } from '../../src/shared/ai-vault-session-deletion' + +// Exercises the real on-disk delete against an isolated, disposable HOME (the +// E2E harness redirects os.homedir() there). The unit tests mock lstat/realpath/ +// trashItem; this proves the whole IPC path actually removes files — and, for a +// directory-shaped agent, that the companion tree goes while the rewind buffer +// stays. Runs on Linux CI. + +function jsonl(records: unknown[]): string { + return `${records.map((record) => JSON.stringify(record)).join('\n')}\n` +} + +function isolatedHome(electronApp: ElectronApplication): Promise { + return electronApp.evaluate(({ app }) => app.getPath('home')) +} + +async function findSession( + page: Page, + agent: string, + title: string +): Promise { + const sessions = (await page.evaluate(async () => { + const result = await window.api.aiVault.listSessions({ + executionHostScope: 'local', + force: true + }) + return result.sessions + })) as AiVaultSession[] + return sessions.find((session) => session.agent === agent && session.title === title) +} + +function deleteSession(page: Page, session: AiVaultSession): Promise { + return page.evaluate( + async (target) => + window.api.aiVault.deleteSession({ + agent: target.agent, + filePath: target.filePath, + executionHostId: target.executionHostId + }), + session + ) +} + +test.describe('AI Vault session delete', () => { + test('trashes a single-file session and removes it from the list', async ({ + electronApp, + orcaPage + }) => { + const homeDir = await isolatedHome(electronApp) + const title = `E2E gemini ${Date.now()}` + const dir = path.join(homeDir, '.gemini', 'tmp', 'proj', 'chats') + mkdirSync(dir, { recursive: true }) + const filePath = path.join(dir, 'session-e2e.json') + writeFileSync( + filePath, + JSON.stringify({ + sessionId: 'gemini-e2e', + startTime: '2026-07-20T10:00:00.000Z', + lastUpdated: '2026-07-20T10:05:00.000Z', + messages: [{ type: 'user', content: title, timestamp: '2026-07-20T10:00:00.000Z' }] + }) + ) + + const session = await findSession(orcaPage, 'gemini', title) + expect(session, 'seeded gemini session should be listed').toBeTruthy() + + const result = await deleteSession(orcaPage, session as AiVaultSession) + + expect(result.outcome).toBe('deleted') + expect(existsSync(filePath), 'transcript should be gone from disk').toBe(false) + expect(await findSession(orcaPage, 'gemini', title)).toBeFalsy() + }) + + test('trashes a claude directory session and its companions but keeps file-history', async ({ + electronApp, + orcaPage + }) => { + const homeDir = await isolatedHome(electronApp) + const title = `E2E claude ${Date.now()}` + const uuid = '11111111-2222-3333-4444-555555555555' + const projectDir = path.join(homeDir, '.claude', 'projects', '-e2e-project') + mkdirSync(projectDir, { recursive: true }) + const transcript = path.join(projectDir, `${uuid}.jsonl`) + writeFileSync( + transcript, + jsonl([ + { + type: 'user', + sessionId: uuid, + timestamp: '2026-07-20T10:00:00.000Z', + cwd: projectDir, + message: { role: 'user', content: title } + } + ]) + ) + const subagentsDir = path.join(projectDir, uuid, 'subagents') + mkdirSync(subagentsDir, { recursive: true }) + writeFileSync(path.join(subagentsDir, 'agent-a1.jsonl'), jsonl([{ type: 'user' }])) + const sessionEnvDir = path.join(homeDir, '.claude', 'session-env', uuid) + mkdirSync(sessionEnvDir, { recursive: true }) + writeFileSync(path.join(sessionEnvDir, 'hook.sh'), 'export X=1\n') + const fileHistoryDir = path.join(homeDir, '.claude', 'file-history', uuid) + mkdirSync(fileHistoryDir, { recursive: true }) + const rewindFile = path.join(fileHistoryDir, 'deadbeef@v1') + writeFileSync(rewindFile, 'earlier version of a user file\n') + + const session = await findSession(orcaPage, 'claude', title) + expect(session, 'seeded claude session should be listed').toBeTruthy() + + const result = await deleteSession(orcaPage, session as AiVaultSession) + + expect(result.outcome).toBe('deleted') + expect(existsSync(transcript), 'transcript gone').toBe(false) + expect( + existsSync(path.join(projectDir, uuid)), + 'session directory gone (no empty shell left behind)' + ).toBe(false) + expect(existsSync(sessionEnvDir), 'session-env companion gone').toBe(false) + expect(existsSync(rewindFile), 'file-history rewind buffer preserved').toBe(true) + expect(await findSession(orcaPage, 'claude', title)).toBeFalsy() + }) +}) From 6382b8a053d0f1f5f4fdcd64f95fd4e5b369abd1 Mon Sep 17 00:00:00 2001 From: seoo2001 <96180608+seoo2001@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:50:38 -0700 Subject: [PATCH 018/289] fix(file-explorer): report the saved filename in the download toast (#12959) * fix(file-explorer): report the saved filename in the download toast The success toast named the remote node, so renaming a file in the native save dialog left the label disagreeing with its own Open action, which opens the real destination. Folder downloads had the same gap whenever sanitizeLocalDownloadFilename rewrote the remote basename. * fix(file-explorer): respect local download path semantics --------- Co-authored-by: Jinwoo-H --- .../right-sidebar/FileExplorer.test.tsx | 40 +++++++++++++++++-- .../right-sidebar/FileExplorerRow.tsx | 17 +++++++- 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/components/right-sidebar/FileExplorer.test.tsx b/src/renderer/src/components/right-sidebar/FileExplorer.test.tsx index 1688b0721f3..37fce9ae624 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorer.test.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorer.test.tsx @@ -293,6 +293,14 @@ function makeToolbar(overrides: Partial[0 }) } +function setDownloadPlatform(platform: NodeJS.Platform): void { + ;( + window as unknown as { + api: { platform: { get: () => { platform: NodeJS.Platform } } } + } + ).api.platform = { get: () => ({ platform }) } +} + beforeEach(() => { toastErrorMock.mockReset() toastSuccessMock.mockReset() @@ -677,7 +685,10 @@ describe('FileExplorerRow collapse folder action', () => { it('calls the preload download API and shows success only when not canceled', async () => { const downloadFile = vi .fn() - .mockResolvedValueOnce({ canceled: false, destinationPath: '/downloads/index.ts' }) + .mockResolvedValueOnce({ + canceled: false, + destinationPath: '/downloads/renamed\\entry.ts' + }) .mockResolvedValueOnce({ canceled: true }) const openPath = vi.fn().mockResolvedValue(undefined) ;( @@ -690,6 +701,7 @@ describe('FileExplorerRow collapse folder action', () => { } } ).window = { api: { fs: { downloadFile }, shell: { openPath } } } + setDownloadPlatform('linux') await downloadRemoteFile(fileNode, 'ssh-1') await downloadRemoteFile(fileNode, 'ssh-1') @@ -699,7 +711,7 @@ describe('FileExplorerRow collapse folder action', () => { connectionId: 'ssh-1' }) expect(toastSuccessMock).toHaveBeenCalledTimes(1) - expect(toastSuccessMock).toHaveBeenCalledWith("Downloaded 'index.ts'", { + expect(toastSuccessMock).toHaveBeenCalledWith("Downloaded 'renamed\\entry.ts'", { action: { label: 'Open', onClick: expect.any(Function) @@ -709,10 +721,30 @@ describe('FileExplorerRow collapse folder action', () => { | { onClick: () => void } | undefined action?.onClick() - expect(openPath).toHaveBeenCalledWith('/downloads/index.ts') + expect(openPath).toHaveBeenCalledWith('/downloads/renamed\\entry.ts') expect(toastErrorMock).not.toHaveBeenCalled() }) + it('reports the saved folder name from a Windows destination path', async () => { + const downloadFolder = vi.fn().mockResolvedValue({ + canceled: false, + destinationPath: 'C:\\Users\\dev\\Downloads\\src-copy' + }) + ;( + globalThis as unknown as { + window: { api: { fs: { downloadFolder: typeof downloadFolder } } } + } + ).window = { api: { fs: { downloadFolder } } } + setDownloadPlatform('win32') + + await downloadRemoteFile(directoryNode, 'ssh-1') + + expect(toastSuccessMock).toHaveBeenCalledWith( + "Downloaded folder 'src-copy'", + expect.objectContaining({ action: expect.anything() }) + ) + }) + it('calls the preload folder download API for SSH directory rows', async () => { const downloadFolder = vi.fn().mockResolvedValue({ canceled: false, @@ -729,6 +761,7 @@ describe('FileExplorerRow collapse folder action', () => { } } ).window = { api: { fs: { downloadFolder }, shell: { openPath } } } + setDownloadPlatform('linux') await downloadRemoteFile(directoryNode, 'ssh-1') @@ -765,6 +798,7 @@ describe('FileExplorerRow collapse folder action', () => { } } ).window = { api: { shell: { openPath } } } + setDownloadPlatform('linux') await downloadRemoteFile(fileNode, runtimeContext) diff --git a/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx b/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx index 1b621454a95..6167f08d5c6 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx @@ -342,6 +342,14 @@ export function shouldShowCopyFileAction( ) } +function getLocalDownloadName(destinationPath: string, platform: NodeJS.Platform): string { + const lastSeparatorIndex = + platform === 'win32' + ? Math.max(destinationPath.lastIndexOf('/'), destinationPath.lastIndexOf('\\')) + : destinationPath.lastIndexOf('/') + return destinationPath.slice(lastSeparatorIndex + 1) +} + export async function downloadRemoteFile( node: TreeNode, connectionIdOrRuntimeContext: string | RuntimeFileOperationArgs @@ -363,17 +371,22 @@ export async function downloadRemoteFile( if (result.canceled) { return } + // Why: POSIX permits backslashes in saved names; only Windows treats them as separators. + const savedName = getLocalDownloadName( + result.destinationPath, + window.api.platform.get().platform + ) toast.success( node.isDirectory ? translate( 'auto.components.right.sidebar.FileExplorerRow.a4029c996b', "Downloaded folder '{{value0}}'", - { value0: node.name } + { value0: savedName } ) : translate( 'auto.components.right.sidebar.FileExplorerRow.bce4d4e44f', "Downloaded '{{value0}}'", - { value0: node.name } + { value0: savedName } ), { action: { From 2e199cb2dd63ed81704559dd5dd0d89d541735b9 Mon Sep 17 00:00:00 2001 From: Wooseong Kim <2222333+innocarpe@users.noreply.github.com> Date: Sat, 8 Aug 2026 04:18:49 +0900 Subject: [PATCH 019/289] fix(ai-vault): tolerate unknown session agents (#12999) * fix: tolerate unknown AI Vault agents * fix: tolerate unknown AI Vault scan issues * test(ai-vault): cover malformed unknown-agent rows --------- Co-authored-by: Jinwoo-H --- .../session-list-result-validation.test.ts | 90 +++++++++++++++++-- .../session-list-result-validation.ts | 59 +++++++++--- 2 files changed, 132 insertions(+), 17 deletions(-) diff --git a/src/main/ai-vault/session-list-result-validation.test.ts b/src/main/ai-vault/session-list-result-validation.test.ts index 9cdc22a8fb4..af1a5e34ec2 100644 --- a/src/main/ai-vault/session-list-result-validation.test.ts +++ b/src/main/ai-vault/session-list-result-validation.test.ts @@ -19,6 +19,78 @@ describe('parseAiVaultListResult', () => { ) }) + it('keeps known-agent sessions while silently dropping unknown-agent rows', () => { + const parsed = parseAiVaultListResult({ + sessions: [validSession(), { ...validSession('session-new'), agent: 'future-agent' }], + issues: [], + scannedAt: '2026-07-27T00:00:00.000Z' + }) + + expect(parsed.sessions).toHaveLength(1) + expect(parsed.sessions[0]?.agent).toBe('codex') + expect(parsed.sessions[0]?.sessionId).toBe('session-1') + expect(parsed.issues).toEqual([]) + }) + + it('accepts an all-unknown-agent response as an empty supported session list', () => { + const parsed = parseAiVaultListResult({ + sessions: [ + { ...validSession('future-1'), agent: 'future-agent' }, + { ...validSession('future-2'), agent: 'newer-agent' } + ], + issues: [], + scannedAt: '2026-07-27T00:00:00.000Z' + }) + + expect(parsed.sessions).toEqual([]) + expect(parsed.issues).toEqual([]) + }) + + it('keeps known scan issues while silently dropping unknown-agent issues', () => { + const parsed = parseAiVaultListResult({ + sessions: [], + issues: [validScanIssue(), validScanIssue('future-agent')], + scannedAt: '2026-07-27T00:00:00.000Z' + }) + + expect(parsed.issues).toEqual([validScanIssue()]) + }) + + it('reports malformed scan issues without counting unknown-agent issues as invalid', () => { + const parsed = parseAiVaultListResult({ + sessions: [], + issues: [validScanIssue(), { agent: 'future-agent' }, validScanIssue('newer-agent')], + scannedAt: '2026-07-27T00:00:00.000Z' + }) + + expect(parsed.issues).toEqual([ + validScanIssue(), + expect.objectContaining({ + path: 'aiVault.listSessions', + message: expect.stringContaining('Skipped 1 invalid') + }) + ]) + }) + + it('does not throw when malformed rows are mixed with well-formed unknown sessions', () => { + const parsed = parseAiVaultListResult({ + sessions: [ + { ...validSession('malformed-future'), agent: 'future-agent', messageCount: 'invalid' }, + { ...validSession('valid-future'), agent: 'newer-agent' } + ], + issues: [], + scannedAt: '2026-07-27T00:00:00.000Z' + }) + + expect(parsed.sessions).toEqual([]) + expect(parsed.issues).toContainEqual( + expect.objectContaining({ + path: 'aiVault.listSessions', + message: expect.stringContaining('Skipped 1 invalid') + }) + ) + }) + it('preserves the optional session fields the panel renders', () => { const parsed = parseAiVaultListResult({ sessions: [ @@ -55,18 +127,18 @@ describe('parseAiVaultListResult', () => { }) }) -function validSession(): Record { +function validSession(sessionId = 'session-1'): Record { return { - id: 'local:codex:session-1:/tmp/session-1.jsonl', + id: `local:codex:${sessionId}:/tmp/${sessionId}.jsonl`, executionHostId: 'local', executionHostPlatform: 'linux', agent: 'codex', - sessionId: 'session-1', + sessionId, title: 'Session one', cwd: '/repo', branch: null, model: null, - filePath: '/tmp/session-1.jsonl', + filePath: `/tmp/${sessionId}.jsonl`, codexHome: null, createdAt: null, updatedAt: null, @@ -76,7 +148,15 @@ function validSession(): Record { previewMessages: [], queuedMessageCount: 0, subagentTranscriptCount: 0, - resumeCommand: 'codex resume session-1', + resumeCommand: `codex resume ${sessionId}`, subagent: null } } + +function validScanIssue(agent = 'codex'): Record { + return { + agent, + path: '/tmp/sessions', + message: 'permission denied' + } +} diff --git a/src/main/ai-vault/session-list-result-validation.ts b/src/main/ai-vault/session-list-result-validation.ts index 6d69a06af45..abcdd1612b3 100644 --- a/src/main/ai-vault/session-list-result-validation.ts +++ b/src/main/ai-vault/session-list-result-validation.ts @@ -1,7 +1,19 @@ import { z } from 'zod' -import { AI_VAULT_AGENTS, type AiVaultListResult } from '../../shared/ai-vault-types' +import { + AI_VAULT_AGENTS, + type AiVaultAgent, + type AiVaultListResult, + type AiVaultScanIssue, + type AiVaultSession +} from '../../shared/ai-vault-types' import { normalizeExecutionHostId } from '../../shared/execution-host' +const aiVaultAgentSet = new Set(AI_VAULT_AGENTS) + +function isAiVaultAgent(agent: string): agent is AiVaultAgent { + return aiVaultAgentSet.has(agent) +} + const nodePlatformSchema = z.enum([ 'aix', 'android', @@ -35,7 +47,7 @@ const aiVaultSessionSchema = z.object({ id: z.string(), executionHostId: executionHostIdSchema, executionHostPlatform: nodePlatformSchema.nullable().optional(), - agent: z.enum(AI_VAULT_AGENTS), + agent: z.string().min(1), sessionId: z.string(), title: z.string(), cwd: z.string().nullable(), @@ -67,7 +79,7 @@ const aiVaultSessionSchema = z.object({ const aiVaultScanIssueSchema = z.object({ executionHostId: executionHostIdSchema.optional(), - agent: z.enum(AI_VAULT_AGENTS), + agent: z.string().min(1), kind: z.enum(['host', 'scope', 'notice']).optional(), path: z.string(), message: z.string() @@ -84,19 +96,42 @@ export function parseAiVaultListResult(value: unknown): AiVaultListResult { if (!envelope.success) { throw new Error(envelope.error.issues[0]?.message ?? 'unexpected result shape') } - const sessions = envelope.data.sessions.flatMap((session) => { + const sessions: AiVaultSession[] = [] + let malformedSessionCount = 0 + let wellFormedSessionCount = 0 + for (const session of envelope.data.sessions) { const parsed = aiVaultSessionSchema.safeParse(session) - return parsed.success ? [parsed.data] : [] - }) - if (envelope.data.sessions.length > 0 && sessions.length === 0) { + if (!parsed.success) { + malformedSessionCount += 1 + continue + } + wellFormedSessionCount += 1 + if (!isAiVaultAgent(parsed.data.agent)) { + continue + } + sessions.push({ ...parsed.data, agent: parsed.data.agent }) + } + if ( + envelope.data.sessions.length > 0 && + malformedSessionCount > 0 && + wellFormedSessionCount === 0 + ) { throw new Error('all supplied Agent Session History sessions were invalid') } - const issues = envelope.data.issues.flatMap((issue) => { + const issues: AiVaultScanIssue[] = [] + let malformedIssueCount = 0 + for (const issue of envelope.data.issues) { const parsed = aiVaultScanIssueSchema.safeParse(issue) - return parsed.success ? [parsed.data] : [] - }) - const invalidCount = - envelope.data.sessions.length - sessions.length + (envelope.data.issues.length - issues.length) + if (!parsed.success) { + malformedIssueCount += 1 + continue + } + if (!isAiVaultAgent(parsed.data.agent)) { + continue + } + issues.push({ ...parsed.data, agent: parsed.data.agent }) + } + const invalidCount = malformedSessionCount + malformedIssueCount if (invalidCount > 0) { issues.push({ agent: 'codex', From 8c3e9535c75b379ca9ac68b5d35ddfca196ea3df Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 7 Aug 2026 12:29:00 -0700 Subject: [PATCH 020/289] fix(terminal): remove permanent link tooltip gap (#13075) --- .../terminal-container-geometry.test.ts | 18 ++++++++++++ src/renderer/src/assets/terminal.css | 9 ++---- .../issue-12656-terminal-link-tooltip.spec.ts | 28 ++++++++----------- 3 files changed, 32 insertions(+), 23 deletions(-) create mode 100644 src/renderer/src/assets/terminal-container-geometry.test.ts diff --git a/src/renderer/src/assets/terminal-container-geometry.test.ts b/src/renderer/src/assets/terminal-container-geometry.test.ts new file mode 100644 index 00000000000..db079c9095f --- /dev/null +++ b/src/renderer/src/assets/terminal-container-geometry.test.ts @@ -0,0 +1,18 @@ +import fs from 'node:fs' +import { describe, expect, it } from 'vitest' + +const terminalCss = fs.readFileSync(new URL('./terminal.css', import.meta.url), 'utf8') + +describe('terminal container geometry', () => { + it('keeps the hidden link tooltip out of the fitted terminal height', () => { + expect(terminalCss).toMatch( + /\.xterm-container\s*{[^}]*height:\s*calc\(100% - var\(--pane-padding-y, 4px\)\);/s + ) + expect(terminalCss).toMatch( + /\.pane\[data-has-title\] \.xterm-container\s*{[^}]*height:\s*calc\(100% - var\(--orca-pane-title-height\)\);/s + ) + expect(terminalCss).toMatch( + /\.pane-link-tooltip\s*{[^}]*height:\s*var\(--orca-terminal-link-tooltip-height\);/s + ) + }) +}) diff --git a/src/renderer/src/assets/terminal.css b/src/renderer/src/assets/terminal.css index 3d9a5df2169..ee5e0af01f5 100644 --- a/src/renderer/src/assets/terminal.css +++ b/src/renderer/src/assets/terminal.css @@ -6,8 +6,7 @@ position: relative; } -/* Why: .pane-manager-root matches no live element, so a var declared there - never resolves and every calc() using it is dropped. Keep it on :root. */ +/* Shared tooltip geometry for every terminal link provider. */ :root { --orca-terminal-link-tooltip-height: 25px; } @@ -506,7 +505,7 @@ box-sizing: border-box; position: relative; width: calc(100% - var(--pane-padding-x, 4px)); - height: calc(100% - var(--pane-padding-y, 4px) - var(--orca-terminal-link-tooltip-height, 25px)); + height: calc(100% - var(--pane-padding-y, 4px)); margin-top: var(--pane-padding-y, 4px); margin-left: var(--pane-padding-x, 4px); } @@ -517,9 +516,7 @@ same amount to prevent overflow/clipping. */ .pane[data-has-title] .xterm-container { margin-top: var(--orca-pane-title-height); - height: calc( - 100% - var(--orca-pane-title-height) - var(--orca-terminal-link-tooltip-height, 25px) - ); /* match margin-top and the tooltip reserve */ + height: calc(100% - var(--orca-pane-title-height)); /* match margin-top */ } /* Ghostty-style URL hover: glued to the pane's true bottom-left corner. */ diff --git a/tests/e2e/issue-12656-terminal-link-tooltip.spec.ts b/tests/e2e/issue-12656-terminal-link-tooltip.spec.ts index 56c27dc1925..74c7971b8d0 100644 --- a/tests/e2e/issue-12656-terminal-link-tooltip.spec.ts +++ b/tests/e2e/issue-12656-terminal-link-tooltip.spec.ts @@ -25,8 +25,8 @@ type TooltipState = { paneBottom: number terminalBottom: number tooltipTop: number + tooltipBottom: number tooltipHeight: number - reserveHeight: number } async function locateUrl(page: Page, url: string): Promise { @@ -98,7 +98,6 @@ async function readTooltipState(page: Page, tabId: string): Promise { - test('clears hover state on window blur and reserves the tooltip strip', async ({ + test('clears hover state without permanently shrinking the terminal', async ({ orcaPage }, testInfo) => { await waitForSessionReady(orcaPage) @@ -150,6 +149,8 @@ test.describe('Issue #12656 terminal link tooltip', () => { if (!probe) { throw new Error('URL probe disappeared before hover') } + const idle = await readTooltipState(orcaPage, probe.tabId) + expect(Math.abs(idle.paneBottom - idle.terminalBottom)).toBeLessThanOrEqual(1) await expect .poll(async () => { await moveToLink(orcaPage, probe) @@ -160,26 +161,19 @@ test.describe('Issue #12656 terminal link tooltip', () => { const hovered = await readTooltipState(orcaPage, probe.tabId) expect(hovered.text).toContain(url) expect(hovered.tooltipHeight).toBeGreaterThan(0) - expect(hovered.tooltipTop).toBeGreaterThanOrEqual(hovered.terminalBottom - 1) - // Why: bound the gap on both sides. A lower bound alone also passes when the - // reserve var fails to resolve and .xterm-container collapses to height:auto, - // which leaves a huge gap and an undersized terminal. - expect(hovered.paneBottom - hovered.terminalBottom).toBeGreaterThanOrEqual( - hovered.reserveHeight - 1 - ) - expect(hovered.paneBottom - hovered.terminalBottom).toBeLessThanOrEqual( - hovered.reserveHeight + 1 - ) + expect(Math.abs(hovered.paneBottom - hovered.terminalBottom)).toBeLessThanOrEqual(1) + expect(Math.abs(hovered.paneBottom - hovered.tooltipBottom)).toBeLessThanOrEqual(1) + expect(hovered.tooltipTop).toBeLessThan(hovered.terminalBottom) await captureProof(orcaPage, testInfo, 'issue-12656-fixed-hover.png') await orcaPage.evaluate(() => window.dispatchEvent(new Event('blur'))) await expect .poll(() => readTooltipState(orcaPage, probe.tabId)) .toMatchObject({ display: 'none', currentLinkText: null, cursor: 'text' }) + const cleared = await readTooltipState(orcaPage, probe.tabId) + expect(Math.abs(cleared.paneBottom - cleared.terminalBottom)).toBeLessThanOrEqual(1) await captureProof(orcaPage, testInfo, 'issue-12656-fixed-after-blur.png') - // Keep the output assertion adjacent to the visual state checks so the - // reserved strip cannot hide the final terminal line without detection. await expect.poll(() => getTerminalContent(orcaPage)).toContain(url) }) }) From f0443c326ad3e1e29b5ff5d5690b5a85138db513 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:06:29 -0700 Subject: [PATCH 021/289] fix(codex): recover interrupted state DB backfills (#12617) * fix(codex): recover interrupted state DB backfills * fix(codex): detect mixed-case backfill timeout * fix(codex): harden backfill recovery review findings * fix(codex): keep process identity retries safe --- config/tsconfig.cli.json | 2 + .../agent-hooks/managed-hook-install-lock.ts | 15 +- .../managed-hook-owner-identity.test.ts | 53 ++++ .../managed-hook-owner-identity.ts | 83 ++++- src/main/codex/codex-hook-trust-grant.test.ts | 16 + src/main/codex/codex-hook-trust-grant.ts | 9 +- .../codex-state-db-backfill-recovery.test.ts | 196 ++++++++++++ .../codex/codex-state-db-backfill-recovery.ts | 288 ++++++++++++++++++ src/main/codex/codex-state-db.test.ts | 76 +++++ src/main/codex/codex-state-db.ts | 115 +++++++ src/main/index.ts | 7 + src/main/ipc/pty.test.ts | 44 ++- src/main/ipc/pty.ts | 7 + src/main/rate-limits/codex-fetcher.test.ts | 36 ++- src/main/rate-limits/codex-fetcher.ts | 15 + .../codex-backfill-error-detector.test.ts | 21 ++ .../codex-backfill-error-detector.ts | 33 ++ .../terminal-pane/pty-connection.test.ts | 25 ++ .../terminal-pane/pty-connection.ts | 9 + 19 files changed, 1031 insertions(+), 19 deletions(-) create mode 100644 src/main/codex/codex-state-db-backfill-recovery.test.ts create mode 100644 src/main/codex/codex-state-db-backfill-recovery.ts create mode 100644 src/main/codex/codex-state-db.test.ts create mode 100644 src/main/codex/codex-state-db.ts create mode 100644 src/renderer/src/components/terminal-pane/codex-backfill-error-detector.test.ts create mode 100644 src/renderer/src/components/terminal-pane/codex-backfill-error-detector.ts diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 386fb6a1594..14295cb475a 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -35,6 +35,7 @@ "../src/main/codex/codex-hook-trust-grant.ts", "../src/main/codex/codex-managed-trust-reconciliation.ts", "../src/main/codex/codex-process-exit-deadline.ts", + "../src/main/codex/codex-state-db.ts", "../src/main/codex/codex-trust-config-rollback.ts", "../src/main/codex/codex-trust-grant-telemetry.ts", "../src/main/codex/codex-trust-grant-host.ts", @@ -77,6 +78,7 @@ // project's serve spec; the module has no imports, so listing it pulls in nothing else. "../src/main/startup/serve-mode-argv.ts", "../src/main/runtime/runtime-metadata.ts", + "../src/main/sqlite/sync-database.ts", "../src/main/win32-utils.ts" ], "compilerOptions": { diff --git a/src/main/agent-hooks/managed-hook-install-lock.ts b/src/main/agent-hooks/managed-hook-install-lock.ts index c283b99c41b..41dd21f9bda 100644 --- a/src/main/agent-hooks/managed-hook-install-lock.ts +++ b/src/main/agent-hooks/managed-hook-install-lock.ts @@ -46,7 +46,8 @@ async function releaseInstallLock( async function acquireInstallLock( home: string, signal?: AbortSignal, - suppliedHostIdentity?: string + suppliedHostIdentity?: string, + waitTimeoutMs = LOCK_WAIT_TIMEOUT_MS ): Promise<() => Promise> { const lockParent = join(home, '.orca') const lockPath = join(lockParent, 'managed-hook-install.lock') @@ -57,7 +58,7 @@ async function acquireInstallLock( if (typeof processIdentity !== 'string') { throw new Error('Could not identify the managed-hook installer process') } - const deadline = Date.now() + LOCK_WAIT_TIMEOUT_MS + const deadline = Date.now() + waitTimeoutMs while (true) { signal?.throwIfAborted() @@ -130,9 +131,15 @@ export async function withManagedHookInstallLock( home: string, signal: AbortSignal | undefined, run: () => Promise, - hostIdentity?: string + hostIdentity?: string, + options?: { waitTimeoutMs?: number } ): Promise { - const release = await acquireInstallLock(home, signal, hostIdentity) + const release = await acquireInstallLock( + home, + signal, + hostIdentity, + options?.waitTimeoutMs ?? LOCK_WAIT_TIMEOUT_MS + ) try { signal?.throwIfAborted() return await run() diff --git a/src/main/agent-hooks/managed-hook-owner-identity.test.ts b/src/main/agent-hooks/managed-hook-owner-identity.test.ts index ae8ac15dd12..9e8d5cc9bd7 100644 --- a/src/main/agent-hooks/managed-hook-owner-identity.test.ts +++ b/src/main/agent-hooks/managed-hook-owner-identity.test.ts @@ -67,6 +67,33 @@ async function loadLinuxIdentity(fixture: LinuxIdentityFixture) { return { identity: await import('./managed-hook-owner-identity'), readFile } } +async function loadWindowsIdentity() { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const execFileAsync = vi.fn(async (_file: string, args: string[]) => ({ + stdout: args.join(' ').includes('MachineGuid') + ? '\r\n MachineGuid REG_SZ AAAAAAAA-BBBB-4CCC-8DDD-EEEEEEEEEEEE\r\n' + : '1777777777000\r\n' + })) + vi.doMock('node:util', () => ({ promisify: () => execFileAsync })) + return { identity: await import('./managed-hook-owner-identity'), execFileAsync } +} + +async function loadDarwinIdentity() { + Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) + let psCalls = 0 + const execFileAsync = vi.fn(async (file: string) => { + if (file === 'sysctl') { + return { stdout: 'boot-session\n' } + } + if (file === 'ps' && ++psCalls === 1) { + throw new Error('transient ps failure') + } + return { stdout: 'Wed Aug 5 12:00:00 2026 node app\n' } + }) + vi.doMock('node:util', () => ({ promisify: () => execFileAsync })) + return await import('./managed-hook-owner-identity') +} + afterEach(() => { Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) if (originalGetuidDescriptor) { @@ -76,6 +103,8 @@ afterEach(() => { } vi.unstubAllEnvs() vi.doUnmock('node:fs/promises') + vi.doUnmock('node:child_process') + vi.doUnmock('node:util') vi.resetModules() }) @@ -178,4 +207,28 @@ describe('managed hook owner identity', () => { 'linux:pid:[4026533001]:current-boot-id:4242' ) }) + + it('uses machine and process creation identities on Windows', async () => { + const { identity, execFileAsync } = await loadWindowsIdentity() + + await expect(identity.readManagedHookHostIdentity()).resolves.toBe( + 'win32:aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee' + ) + await expect(identity.readManagedHookProcessIdentity(process.pid)).resolves.toBe( + `win32:${process.pid}:1777777777000` + ) + await expect(identity.readManagedHookProcessIdentity(process.pid)).resolves.toBe( + `win32:${process.pid}:1777777777000` + ) + expect(execFileAsync).toHaveBeenCalledTimes(2) + }) + + it('retries an unverified macOS process identity', async () => { + const identity = await loadDarwinIdentity() + + await expect(identity.readManagedHookProcessIdentity(process.pid)).resolves.toBeUndefined() + await expect(identity.readManagedHookProcessIdentity(process.pid)).resolves.toBe( + 'darwin:boot-session:Wed Aug 5 12:00:00 2026 node app' + ) + }) }) diff --git a/src/main/agent-hooks/managed-hook-owner-identity.ts b/src/main/agent-hooks/managed-hook-owner-identity.ts index 85941007e12..7f60956e6e4 100644 --- a/src/main/agent-hooks/managed-hook-owner-identity.ts +++ b/src/main/agent-hooks/managed-hook-owner-identity.ts @@ -1,15 +1,30 @@ import { execFile } from 'node:child_process' import { randomUUID } from 'node:crypto' import { link, lstat, mkdir, readFile, readlink, unlink, writeFile } from 'node:fs/promises' -import { join } from 'node:path' +import { join, win32 } from 'node:path' import { promisify } from 'node:util' -const execFileAsync = promisify(execFile) const runtimeHostIdentity = `runtime:${randomUUID()}` const runtimeProcessIdentity = `runtime:${randomUUID()}` let hostIdentityPromise: Promise | undefined let bootIdentityPromise: Promise | undefined +let selfProcessIdentityPromise: Promise | undefined const HOST_TOKEN_PATTERN = /^[\da-f]{8}-[\da-f]{4}-4[\da-f]{3}-[89ab][\da-f]{3}-[\da-f]{12}$/i +const WINDOWS_PROCESS_IDENTITY_TIMEOUT_MS = 5_000 + +function getWindowsPowerShellPath(): string { + return win32.join( + process.env.SystemRoot ?? 'C:\\Windows', + 'System32', + 'WindowsPowerShell', + 'v1.0', + 'powershell.exe' + ) +} + +function getWindowsRegistryPath(): string { + return win32.join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'reg.exe') +} function hasCode(error: unknown, code: string): boolean { return error instanceof Error && 'code' in error && error.code === code @@ -100,12 +115,29 @@ async function readHostIdentity(): Promise { // but a later process gets a different identity and cannot steal its residue. return runtimeHostIdentity } + if (process.platform === 'win32') { + try { + const { stdout } = await promisify(execFile)( + getWindowsRegistryPath(), + ['query', 'HKLM\\SOFTWARE\\Microsoft\\Cryptography', '/v', 'MachineGuid'], + { encoding: 'utf8', timeout: 1_000, windowsHide: true } + ) + const machineGuid = /^\s*MachineGuid\s+REG_\w+\s+(.+?)\s*$/im.exec(stdout)?.[1] + return machineGuid ? `win32:${machineGuid.toLowerCase()}` : runtimeHostIdentity + } catch { + return runtimeHostIdentity + } + } if (process.platform === 'darwin') { try { - const { stdout } = await execFileAsync('ioreg', ['-rd1', '-c', 'IOPlatformExpertDevice'], { - encoding: 'utf8', - timeout: 1_000 - }) + const { stdout } = await promisify(execFile)( + 'ioreg', + ['-rd1', '-c', 'IOPlatformExpertDevice'], + { + encoding: 'utf8', + timeout: 1_000 + } + ) const platformId = /"IOPlatformUUID"\s*=\s*"([^"]+)"/.exec(stdout)?.[1] return platformId ? `darwin:${platformId}` : runtimeHostIdentity } catch { @@ -127,7 +159,7 @@ export async function readBootIdentity(): Promise { if (process.platform === 'darwin') { try { - const { stdout } = await execFileAsync('sysctl', ['-n', 'kern.bootsessionuuid'], { + const { stdout } = await promisify(execFile)('sysctl', ['-n', 'kern.bootsessionuuid'], { encoding: 'utf8', timeout: 1_000 }) @@ -160,6 +192,14 @@ export function scopeManagedHookHostIdentity( export async function readManagedHookProcessIdentity( pid: number ): Promise { + if (process.platform === 'win32' && pid === process.pid) { + selfProcessIdentityPromise ??= readProcessIdentity(pid) + return await selfProcessIdentityPromise + } + return await readProcessIdentity(pid) +} + +async function readProcessIdentity(pid: number): Promise { if (process.platform === 'linux') { try { const [statLine, pidNamespace, bootIdentity] = await Promise.all([ @@ -179,10 +219,37 @@ export async function readManagedHookProcessIdentity( } } + if (process.platform === 'win32') { + try { + const { stdout } = await promisify(execFile)( + getWindowsPowerShellPath(), + [ + '-NoProfile', + '-NonInteractive', + '-Command', + `$ErrorActionPreference = 'Stop'; ` + + `$p = Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}"; ` + + `if (!$p) { 'missing'; exit 0 }; ` + + `[string]([DateTimeOffset]$p.CreationDate).ToUnixTimeMilliseconds()` + ], + { encoding: 'utf8', timeout: WINDOWS_PROCESS_IDENTITY_TIMEOUT_MS, windowsHide: true } + ) + const startedAt = stdout.trim() + return startedAt === 'missing' ? null : startedAt ? `win32:${pid}:${startedAt}` : undefined + } catch { + try { + process.kill(pid, 0) + return pid === process.pid ? runtimeProcessIdentity : undefined + } catch (error) { + return hasCode(error, 'ESRCH') ? null : undefined + } + } + } + bootIdentityPromise ??= readBootIdentity() const bootIdentity = await bootIdentityPromise try { - const { stdout } = await execFileAsync( + const { stdout } = await promisify(execFile)( 'ps', ['-o', 'lstart=', '-o', 'command=', '-p', String(pid)], { diff --git a/src/main/codex/codex-hook-trust-grant.test.ts b/src/main/codex/codex-hook-trust-grant.test.ts index 9f065f9db72..e1ca7c41076 100644 --- a/src/main/codex/codex-hook-trust-grant.test.ts +++ b/src/main/codex/codex-hook-trust-grant.test.ts @@ -97,6 +97,22 @@ function grantedSessionResult(entries: CodexTrustEntry[], hashPrefix = 'sha256:c } describe('grantManagedCodexHookTrust', () => { + it('does not let a short trust RPC claim an incomplete session index', () => { + const sessions = join(runtimeHomeDir, 'sessions') + mkdirSync(sessions, { recursive: true }) + for (let index = 0; index < 100; index += 1) { + writeFileSync(join(sessions, `${index}.jsonl`), '{}\n') + } + const runner = vi.fn() + _internals.setGrantSessionRunnerSync(runner) + + expect(grantManagedCodexHookTrust(buildPlan([managedEntry('stop')]))).toMatchObject({ + lane: 'fallback', + reason: 'retry-cached' + }) + expect(runner).not.toHaveBeenCalled() + }) + it('returns granted entries with codex-verbatim hashes and records the ledger', () => { const entries = [managedEntry('session_start'), managedEntry('stop')] const runner = vi.fn((_request: CodexHookTrustGrantRequest) => grantedSessionResult(entries)) diff --git a/src/main/codex/codex-hook-trust-grant.ts b/src/main/codex/codex-hook-trust-grant.ts index 94eb3117b45..9142a590ab3 100644 --- a/src/main/codex/codex-hook-trust-grant.ts +++ b/src/main/codex/codex-hook-trust-grant.ts @@ -35,6 +35,7 @@ import { resolveCodexTrustGrantHost, type CodexTrustGrantHost } from './codex-trust-grant-host' +import { isCodexStateDbBackfillPending } from './codex-state-db' // Why: a transiently hung app-server must not block launch prep on every pane. // The legacy lane remains available while a short, host-scoped cooldown runs. @@ -74,9 +75,7 @@ const diagnostics = { export type CodexTrustGrantDiagnostics = typeof diagnostics const transientRetryAfterByHost = new Map() -export function getCodexTrustGrantDiagnostics(): CodexTrustGrantDiagnostics { - return { ...diagnostics } -} +export const getCodexTrustGrantDiagnostics = (): CodexTrustGrantDiagnostics => ({ ...diagnostics }) type GrantSessionRunnerSync = ( request: CodexHookTrustGrantRequest @@ -192,6 +191,10 @@ export function grantManagedCodexHookTrust( diagnostics.ledgerHits += 1 return { lane: 'rpc', entries: ledgerEntries } } + if (isCodexStateDbBackfillPending(plan.runtimeHomePath)) { + // Why: a short trust RPC can refresh Codex's abandoned lease and strand every pane again. + return fallback(plan, 'retry-cached') + } const hostKey = getCodexAppServerHostKey(plan.host) if (!codexAppServerCapabilityCache.shouldTry(hostKey)) { diff --git a/src/main/codex/codex-state-db-backfill-recovery.test.ts b/src/main/codex/codex-state-db-backfill-recovery.test.ts new file mode 100644 index 00000000000..72f25b25bba --- /dev/null +++ b/src/main/codex/codex-state-db-backfill-recovery.test.ts @@ -0,0 +1,196 @@ +import { EventEmitter } from 'node:events' +import { link, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import * as ownerIdentity from '../agent-hooks/managed-hook-owner-identity' +import { + resolveCodexBackfillSupervisorLockRoot, + runCodexStateDbBackfillRecovery, + withCodexBackfillSupervisorLock +} from './codex-state-db-backfill-recovery' +import type { CodexStateDbBackfillStatus } from './codex-state-db' + +const temporaryRoots: string[] = [] +const originalPlatform = process.platform + +function createFakeChild(): EventEmitter & { + stdin: { end: ReturnType } + exitCode: number | null + signalCode: NodeJS.Signals | null + kill: ReturnType +} { + return Object.assign(new EventEmitter(), { + stdin: { end: vi.fn() }, + exitCode: null, + signalCode: null, + kill: vi.fn(() => true) + }) +} + +async function createTemporaryRoot(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-codex-backfill-recovery-')) + temporaryRoots.push(root) + return root +} + +afterEach(async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + vi.restoreAllMocks() + vi.unstubAllEnvs() + await Promise.all( + temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })) + ) +}) + +describe('Codex state DB backfill recovery', () => { + it('keeps the successful app-server claimant alive until Codex marks its DB complete', async () => { + const child = createFakeChild() + const terminate = vi.fn(async () => {}) + const readStatus = vi + .fn() + .mockReturnValueOnce({ kind: 'incomplete', stateDbPath: '/state.sqlite', status: 'running' }) + .mockReturnValue({ kind: 'complete', stateDbPath: '/state.sqlite' }) + + await expect( + runCodexStateDbBackfillRecovery('/managed-home', new AbortController().signal, { + spawnProcess: vi.fn(() => child) as never, + readStatus, + terminate, + sleep: vi.fn(async () => {}), + now: vi.fn(() => 1_000) + }) + ).resolves.toEqual({ outcome: 'completed', spawnCount: 1 }) + expect(terminate).toHaveBeenCalledWith(child) + }) + + it('retries a live foreign lease until one durable claimant can recover it', async () => { + const first = createFakeChild() + const second = createFakeChild() + const children = [first, second] + let now = 0 + let spawnCount = 0 + const spawnProcess = vi.fn(() => { + const child = children[spawnCount++] + if (child === first) { + queueMicrotask(() => child.emit('exit', 1, null)) + } + return child + }) + const readStatus = vi.fn( + (): CodexStateDbBackfillStatus => + spawnCount >= 2 + ? { kind: 'complete', stateDbPath: '/state.sqlite' } + : { kind: 'incomplete', stateDbPath: '/state.sqlite', status: 'running' } + ) + + await expect( + runCodexStateDbBackfillRecovery('/managed-home', new AbortController().signal, { + spawnProcess: spawnProcess as never, + readStatus, + terminate: vi.fn(async () => {}), + sleep: vi.fn(async (ms: number) => { + now += ms + await Promise.resolve() + }), + now: () => now + }) + ).resolves.toEqual({ outcome: 'completed', spawnCount: 2 }) + }) + + it('routes a WSL managed home through its distro and Linux CODEX_HOME', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const child = createFakeChild() + const spawnProcess = vi.fn(() => child) + const readStatus = vi + .fn() + .mockReturnValueOnce({ kind: 'incomplete', stateDbPath: 'state.sqlite', status: 'running' }) + .mockReturnValue({ kind: 'complete', stateDbPath: 'state.sqlite' }) + + await runCodexStateDbBackfillRecovery( + '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex', + new AbortController().signal, + { + spawnProcess: spawnProcess as never, + readStatus, + terminate: vi.fn(async () => {}), + sleep: vi.fn(async () => {}), + now: vi.fn(() => 1_000) + } + ) + + expect(spawnProcess).toHaveBeenCalledWith( + 'wsl.exe', + expect.arrayContaining(['-d', 'Ubuntu']), + expect.objectContaining({ windowsHide: true }) + ) + const spawnCall = spawnProcess.mock.calls[0] as unknown as [string, string[]] + const command = spawnCall[1].join(' ') + expect(command).toContain('export CODEX_HOME=') + expect(command).toContain('/home/alice/.codex') + }) +}) + +describe.skipIf(process.platform === 'win32')('Codex backfill supervisor owner lock', () => { + it('recovers a dead owner whose PID was reused with a different start identity', async () => { + const userData = await createTemporaryRoot() + vi.stubEnv('ORCA_USER_DATA_PATH', userData) + const home = join(userData, 'managed-home') + const lockRoot = resolveCodexBackfillSupervisorLockRoot(home) + const lockParent = join(lockRoot, '.orca') + const token = '00000000-0000-4000-8000-000000000000' + const ownerPath = join(lockParent, `managed-hook-install.owner-${token}.json`) + const lockPath = join(lockParent, 'managed-hook-install.lock') + await mkdir(lockParent, { recursive: true }) + await writeFile( + ownerPath, + JSON.stringify({ + token, + pid: process.pid, + hostIdentity: await ownerIdentity.readManagedHookHostIdentity(), + processIdentity: 'stale-process-start-time' + }) + ) + await link(ownerPath, lockPath) + + await expect( + withCodexBackfillSupervisorLock(home, undefined, async () => 'recovered') + ).resolves.toBe('recovered') + }) + + it('does not interfere with a live supervisor from another Orca instance', async () => { + const userData = await createTemporaryRoot() + vi.stubEnv('ORCA_USER_DATA_PATH', userData) + const home = join(userData, 'managed-home') + let releaseFirst!: () => void + const first = withCodexBackfillSupervisorLock( + home, + undefined, + async () => await new Promise((resolve) => (releaseFirst = resolve)) + ) + await vi.waitFor(async () => { + await expect( + import('node:fs/promises').then(({ readFile }) => + readFile( + join( + resolveCodexBackfillSupervisorLockRoot(home), + '.orca', + 'managed-hook-install.lock' + ), + 'utf8' + ) + ) + ).resolves.toContain('processIdentity') + }) + const controller = new AbortController() + const secondRun = vi.fn(async () => {}) + setTimeout(() => controller.abort(), 40) + + await expect( + withCodexBackfillSupervisorLock(home, controller.signal, secondRun) + ).rejects.toMatchObject({ name: 'AbortError' }) + expect(secondRun).not.toHaveBeenCalled() + releaseFirst() + await first + }) +}) diff --git a/src/main/codex/codex-state-db-backfill-recovery.ts b/src/main/codex/codex-state-db-backfill-recovery.ts new file mode 100644 index 00000000000..733a6b66963 --- /dev/null +++ b/src/main/codex/codex-state-db-backfill-recovery.ts @@ -0,0 +1,288 @@ +import { spawn, type ChildProcess } from 'node:child_process' +import { createHash } from 'node:crypto' +import { join } from 'node:path' +import { setTimeout as delay } from 'node:timers/promises' +import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' +import { parseWslUncPath } from '../../shared/wsl-paths' +import { withManagedHookInstallLock } from '../agent-hooks/managed-hook-install-lock' +import { readManagedHookHostIdentity } from '../agent-hooks/managed-hook-owner-identity' +import { buildWslCodexAppServerArgs } from '../codex-accounts/wsl-codex-command' +import { resolveCodexCommand } from '../codex-cli/command' +import { terminateCodexProbeChild } from '../rate-limits/codex-probe-termination' +import { getSpawnArgsForWindows } from '../win32-utils' +import { getOrcaUserDataPath } from './codex-home-paths' +import { + BACKFILL_PENDING_MIN_SESSION_FILES, + countCodexSessionFilesUpTo, + isCodexStateDbBackfillPending, + readCodexStateDbBackfillStatus, + type CodexStateDbBackfillStatus +} from './codex-state-db' + +const RECOVERY_POLL_INTERVAL_MS = 5_000 +const RECOVERY_RETRY_DELAY_MS = 2_000 +const RECOVERY_FAST_EXIT_MS = 10_000 +const RECOVERY_MAX_FAST_FAILURES = 5 +const RECOVERY_MAX_TOTAL_MS = 60 * 60_000 +const RECOVERY_OWNER_CHECK_TIMEOUT_MS = 1_000 +const RECOVERY_CODEX_ARGS = ['-s', 'read-only', '-a', 'untrusted', 'app-server'] as const + +export type CodexStateDbBackfillRecoverySummary = { + outcome: + | 'completed' + | 'already-complete' + | 'not-needed' + | 'unreadable' + | 'stopped' + | 'gave-up' + | 'codex-unavailable' + spawnCount: number +} + +type RecoveryDependencies = { + spawnProcess: typeof spawn + resolveCommand: () => string + readStatus: (codexHomePath: string) => CodexStateDbBackfillStatus + countSessions: (sessionsRoot: string, limit: number) => number + now: () => number + sleep: (ms: number, signal: AbortSignal) => Promise + terminate: (child: ChildProcess) => Promise +} + +const defaultDependencies: RecoveryDependencies = { + spawnProcess: spawn, + resolveCommand: resolveCodexCommand, + readStatus: readCodexStateDbBackfillStatus, + countSessions: countCodexSessionFilesUpTo, + now: Date.now, + sleep: async (ms, signal) => await delay(ms, undefined, { signal }), + terminate: async (child) => await terminateCodexProbeChild(child) +} + +function finish( + outcome: CodexStateDbBackfillRecoverySummary['outcome'], + spawnCount: number +): CodexStateDbBackfillRecoverySummary { + return { outcome, spawnCount } +} + +function initialRecoveryDecision( + codexHomePath: string, + dependencies: RecoveryDependencies +): CodexStateDbBackfillRecoverySummary['outcome'] | null { + const status = dependencies.readStatus(codexHomePath) + if (status.kind === 'complete') { + return 'already-complete' + } + if (status.kind === 'unreadable') { + return 'unreadable' + } + if ( + (status.kind === 'missing' || status.kind === 'not-tracked') && + dependencies.countSessions( + join(codexHomePath, 'sessions'), + BACKFILL_PENDING_MIN_SESSION_FILES + ) < BACKFILL_PENDING_MIN_SESSION_FILES + ) { + return 'not-needed' + } + return null +} + +function spawnRecoveryProcess( + codexHomePath: string, + dependencies: RecoveryDependencies +): ChildProcess { + const wslHome = process.platform === 'win32' ? parseWslUncPath(codexHomePath) : null + if (wslHome) { + return dependencies.spawnProcess( + 'wsl.exe', + buildWslCodexAppServerArgs(wslHome.distro, wslHome.linuxPath), + { + stdio: ['pipe', 'ignore', 'ignore'], + windowsHide: true, + env: process.env + } + ) + } + const command = dependencies.resolveCommand() + const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(command, [...RECOVERY_CODEX_ARGS]) + return dependencies.spawnProcess(spawnCmd, spawnArgs, { + cwd: codexHomePath, + stdio: ['pipe', 'ignore', 'ignore'], + windowsHide: true, + env: { ...process.env, CODEX_HOME: codexHomePath } + }) +} + +/** Keeps a sanctioned app-server claimant alive until Codex completes its own backfill. */ +export async function runCodexStateDbBackfillRecovery( + codexHomePath: string, + signal: AbortSignal, + dependenciesOverride: Partial = {} +): Promise { + const dependencies = { ...defaultDependencies, ...dependenciesOverride } + const initialOutcome = initialRecoveryDecision(codexHomePath, dependencies) + if (initialOutcome) { + return finish(initialOutcome, 0) + } + + const deadline = dependencies.now() + RECOVERY_MAX_TOTAL_MS + let spawnCount = 0 + let fastFailures = 0 + while (!signal.aborted && dependencies.now() < deadline) { + const spawnedAt = dependencies.now() + const child = spawnRecoveryProcess(codexHomePath, dependencies) + spawnCount += 1 + let childDown = false + let spawnFailed = false + let exitedAt = spawnedAt + child.once('error', () => { + childDown = true + spawnFailed = true + exitedAt = dependencies.now() + }) + child.once('exit', () => { + childDown = true + exitedAt = dependencies.now() + }) + + try { + while (!childDown && !signal.aborted && dependencies.now() < deadline) { + await dependencies.sleep(RECOVERY_POLL_INTERVAL_MS, signal) + const status = dependencies.readStatus(codexHomePath) + if (status.kind === 'complete') { + await dependencies.terminate(child) + return finish('completed', spawnCount) + } + if (status.kind === 'unreadable') { + await dependencies.terminate(child) + return finish('unreadable', spawnCount) + } + } + } catch (error) { + if (!signal.aborted) { + await dependencies.terminate(child) + throw error + } + } + + if (signal.aborted) { + await dependencies.terminate(child) + return finish('stopped', spawnCount) + } + if (!childDown) { + await dependencies.terminate(child) + return finish('gave-up', spawnCount) + } + if (spawnFailed && spawnCount === 1) { + return finish('codex-unavailable', spawnCount) + } + if (exitedAt - spawnedAt < RECOVERY_FAST_EXIT_MS) { + fastFailures += 1 + if (fastFailures >= RECOVERY_MAX_FAST_FAILURES) { + return finish('gave-up', spawnCount) + } + } + try { + await dependencies.sleep(RECOVERY_RETRY_DELAY_MS, signal) + } catch { + return finish('stopped', spawnCount) + } + } + return finish(signal.aborted ? 'stopped' : 'gave-up', spawnCount) +} + +export function resolveCodexBackfillSupervisorLockRoot(codexHomePath: string): string { + const homeKey = normalizeRuntimePathForComparison(codexHomePath) + const digest = createHash('sha256').update(homeKey).digest('hex') + return join(getOrcaUserDataPath(), 'codex-state-db-backfill-locks', digest) +} + +function scopeRecoveryHostIdentity(hostIdentity: string, codexHomePath: string): string { + const wslHome = process.platform === 'win32' ? parseWslUncPath(codexHomePath) : null + return wslHome ? `${hostIdentity}:wsl:${wslHome.distro.toLowerCase()}` : hostIdentity +} + +export async function withCodexBackfillSupervisorLock( + codexHomePath: string, + signal: AbortSignal | undefined, + run: () => Promise +): Promise { + const hostIdentity = scopeRecoveryHostIdentity(await readManagedHookHostIdentity(), codexHomePath) + // Reuse the crash-safe hard-link claim protocol; its storage root is Codex-specific. + return await withManagedHookInstallLock( + resolveCodexBackfillSupervisorLockRoot(codexHomePath), + signal, + run, + hostIdentity, + { waitTimeoutMs: RECOVERY_OWNER_CHECK_TIMEOUT_MS } + ) +} + +type ActiveRecovery = { + controller: AbortController + ready: Promise + task: Promise +} + +const activeRecoveries = new Map() +let stopping = false + +export function startCodexStateDbBackfillRecoveryInBackground( + codexHomePath: string +): Promise { + const key = normalizeRuntimePathForComparison(codexHomePath) + const existing = activeRecoveries.get(key) + if (existing) { + return existing.task + } + if (stopping || !isCodexStateDbBackfillPending(codexHomePath)) { + return Promise.resolve(null) + } + const controller = new AbortController() + let markReady!: () => void + const ready = new Promise((resolve) => (markReady = resolve)) + const task = withCodexBackfillSupervisorLock(codexHomePath, controller.signal, async () => { + console.info(`[codex-state-db-backfill] supervising Codex index at ${codexHomePath}`) + markReady() + return await runCodexStateDbBackfillRecovery(codexHomePath, controller.signal) + }).catch((error: unknown) => { + if (!controller.signal.aborted) { + console.warn('[codex-state-db-backfill] recovery supervisor stopped:', error) + } + return null + }) + void task.finally(markReady) + activeRecoveries.set(key, { controller, ready, task }) + void task.finally(() => { + if (activeRecoveries.get(key)?.task === task) { + activeRecoveries.delete(key) + } + }) + return task +} + +/** Waits only for exact-owner arbitration, never for the potentially long Codex index. */ +export async function ensureCodexStateDbBackfillRecoveryStarted( + codexHomePath: string +): Promise { + void startCodexStateDbBackfillRecoveryInBackground(codexHomePath) + await activeRecoveries.get(normalizeRuntimePathForComparison(codexHomePath))?.ready +} + +export async function stopCodexStateDbBackfillRecoveries(): Promise { + stopping = true + const recoveries = [...activeRecoveries.values()] + for (const recovery of recoveries) { + recovery.controller.abort() + } + await Promise.allSettled(recoveries.map(({ task }) => task)) +} + +export const _internals = { + resetForTests(): void { + stopping = false + activeRecoveries.clear() + } +} diff --git a/src/main/codex/codex-state-db.test.ts b/src/main/codex/codex-state-db.test.ts new file mode 100644 index 00000000000..7cf0ac88ac5 --- /dev/null +++ b/src/main/codex/codex-state-db.test.ts @@ -0,0 +1,76 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import SyncDatabase from '../sqlite/sync-database' +import { + findNewestCodexStateDbPath, + isCodexStateDbBackfillPending, + readCodexStateDbBackfillStatus +} from './codex-state-db' + +const temporaryHomes: string[] = [] + +async function createHome(): Promise { + const home = await mkdtemp(join(tmpdir(), 'orca-codex-state-db-')) + temporaryHomes.push(home) + return home +} + +function createBackfillDb(home: string, version: number, status: string): string { + const path = join(home, `state_${version}.sqlite`) + const db = new SyncDatabase(path) + db.exec( + 'CREATE TABLE backfill_state (id INTEGER PRIMARY KEY, status TEXT NOT NULL); ' + + `INSERT INTO backfill_state (id, status) VALUES (1, '${status}')` + ) + db.close() + return path +} + +afterEach(async () => { + await Promise.all( + temporaryHomes.splice(0).map((home) => rm(home, { recursive: true, force: true })) + ) +}) + +describe('Codex state DB backfill status', () => { + it('reads the newest schema DB without mutating an incomplete row', async () => { + const home = await createHome() + createBackfillDb(home, 4, 'complete') + const newest = createBackfillDb(home, 5, 'running') + + expect(findNewestCodexStateDbPath(home)).toBe(newest) + expect(readCodexStateDbBackfillStatus(home)).toEqual({ + kind: 'incomplete', + stateDbPath: newest, + status: 'running' + }) + + const db = new SyncDatabase(newest, { readonly: true, fileMustExist: true }) + expect(db.prepare('SELECT status FROM backfill_state WHERE id = 1').get()).toEqual({ + status: 'running' + }) + db.close() + }) + + it('treats a large unindexed rollout history as pending', async () => { + const home = await createHome() + const sessions = join(home, 'sessions', '2026', '08', '04') + await mkdir(sessions, { recursive: true }) + await Promise.all( + Array.from({ length: 100 }, (_, index) => + writeFile(join(sessions, `rollout-${index}.jsonl`), '{}\n') + ) + ) + + expect(isCodexStateDbBackfillPending(home)).toBe(true) + }) + + it('does not call a complete backfill pending', async () => { + const home = await createHome() + createBackfillDb(home, 5, 'complete') + + expect(isCodexStateDbBackfillPending(home)).toBe(false) + }) +}) diff --git a/src/main/codex/codex-state-db.ts b/src/main/codex/codex-state-db.ts new file mode 100644 index 00000000000..e2b437aea3a --- /dev/null +++ b/src/main/codex/codex-state-db.ts @@ -0,0 +1,115 @@ +import { readdirSync } from 'node:fs' +import { join } from 'node:path' +import SyncDatabase from '../sqlite/sync-database' + +const STATE_DB_FILE_PATTERN = /^state_(\d+)\.sqlite$/ + +export type CodexStateDbBackfillStatus = + | { kind: 'complete'; stateDbPath: string } + | { kind: 'incomplete'; stateDbPath: string; status: string } + | { kind: 'missing' } + | { kind: 'not-tracked'; stateDbPath: string } + | { kind: 'unreadable'; stateDbPath: string; error: string } + +export function findNewestCodexStateDbPath(codexHomePath: string): string | null { + let entries: string[] + try { + entries = readdirSync(codexHomePath) + } catch { + return null + } + let newest: { version: number; name: string } | null = null + for (const name of entries) { + const match = STATE_DB_FILE_PATTERN.exec(name) + if (!match) { + continue + } + const version = Number(match[1]) + if (!newest || version > newest.version) { + newest = { version, name } + } + } + return newest ? join(codexHomePath, newest.name) : null +} + +/** Reads Codex-owned backfill metadata without creating or mutating its database. */ +export function readCodexStateDbBackfillStatus(codexHomePath: string): CodexStateDbBackfillStatus { + const stateDbPath = findNewestCodexStateDbPath(codexHomePath) + if (!stateDbPath) { + return { kind: 'missing' } + } + let db: SyncDatabase | null = null + try { + db = new SyncDatabase(stateDbPath, { readonly: true, fileMustExist: true }) + const table = db + .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'backfill_state'") + .get() + if (!table) { + return { kind: 'not-tracked', stateDbPath } + } + const row = db.prepare('SELECT status FROM backfill_state WHERE id = 1').get() as + | { status?: unknown } + | undefined + if (!row || typeof row.status !== 'string') { + return { kind: 'not-tracked', stateDbPath } + } + return row.status === 'complete' + ? { kind: 'complete', stateDbPath } + : { kind: 'incomplete', stateDbPath, status: row.status } + } catch (error) { + return { + kind: 'unreadable', + stateDbPath, + error: error instanceof Error ? error.message : String(error) + } + } finally { + try { + db?.close() + } catch { + // A close failure cannot change the read-only result already collected. + } + } +} + +export function countCodexSessionFilesUpTo(sessionsRoot: string, limit: number): number { + let count = 0 + const pendingDirectories = [sessionsRoot] + while (pendingDirectories.length > 0 && count < limit) { + const directory = pendingDirectories.pop() as string + let entries + try { + entries = readdirSync(directory, { withFileTypes: true }) + } catch { + continue + } + for (const entry of entries) { + if (entry.isDirectory()) { + pendingDirectories.push(join(directory, entry.name)) + } else if (entry.isFile() && entry.name.endsWith('.jsonl')) { + count += 1 + if (count >= limit) { + break + } + } + } + } + return count +} + +export const BACKFILL_PENDING_MIN_SESSION_FILES = 100 + +export function isCodexStateDbBackfillPending(codexHomePath: string): boolean { + const status = readCodexStateDbBackfillStatus(codexHomePath) + if (status.kind === 'incomplete') { + return true + } + if (status.kind !== 'missing' && status.kind !== 'not-tracked') { + return false + } + return ( + countCodexSessionFilesUpTo( + join(codexHomePath, 'sessions'), + BACKFILL_PENDING_MIN_SESSION_FILES + ) >= BACKFILL_PENDING_MIN_SESSION_FILES + ) +} diff --git a/src/main/index.ts b/src/main/index.ts index e51db18a9c6..912941275d5 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -219,6 +219,10 @@ import { import { setCodexTrustGrantTelemetry } from './codex/codex-trust-grant-telemetry' import { startCodexSessionBackfillInBackground } from './codex/codex-session-backfill' import { startCodexSessionIndexHealInBackground } from './codex/codex-session-index-heal' +import { + startCodexStateDbBackfillRecoveryInBackground, + stopCodexStateDbBackfillRecoveries +} from './codex/codex-state-db-backfill-recovery' import { createCodexSessionMigrationScheduler } from './codex/codex-session-migration-scheduler' import { prepareLegacySharedCodexSessionResume } from './codex/codex-legacy-session-resume' import { resolveHostCodexSessionSourceHome } from './codex/codex-session-source-home' @@ -2232,6 +2236,7 @@ void app.whenReady().then(async () => { openCodeUsage = new OpenCodeUsageStore(store) rateLimits = new RateLimitService() codexRuntimeHome = new CodexRuntimeHomeService(store) + void startCodexStateDbBackfillRecoveryInBackground(getOrcaManagedCodexHomePath()) // Why: an incapable trust-grant host must fall back to the managed home for // every consumer (PTY env, rate limits, commit messages) in one place. codexRuntimeHome.setRealHomeLaneGate(() => isRealHomeCodexHookLaneUsable()) @@ -3059,6 +3064,7 @@ app.on('will-quit', (e) => { pluginMarketplaceService = null pluginMarketplaceInstaller = null const pluginHostShutdown = pluginService?.dispose() ?? Promise.resolve() + const codexBackfillRecoveryShutdown = stopCodexStateDbBackfillRecoveries() pluginService = null setUnreadDockBadgeCount(0) agentHookServer.stop() @@ -3118,6 +3124,7 @@ app.on('will-quit', (e) => { { name: 'emulator', promise: emulatorShutdown }, { name: 'ssh', promise: sshShutdown }, { name: 'plugin-hosts', promise: pluginHostShutdown }, + { name: 'codex-backfill-recovery', promise: codexBackfillRecoveryShutdown }, { name: 'usage-cache', promise: usageCacheFlush }, { name: 'stats', promise: statsFlush }, { name: 'state', promise: storeFlush } diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 678cc877465..c3d3a7795dc 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -65,7 +65,8 @@ const { clearMigrationUnsupportedPtysForPaneKeyMock, clearPaneKeyAliasesForPtyMock, recordCodexPaneAccountMock, - forgetCodexPaneAccountMock + forgetCodexPaneAccountMock, + ensureCodexBackfillRecoveryMock } = vi.hoisted(() => ({ handleMock: vi.fn(), onMock: vi.fn(), @@ -99,7 +100,8 @@ const { clearMigrationUnsupportedPtysForPaneKeyMock: vi.fn(), clearPaneKeyAliasesForPtyMock: vi.fn(), recordCodexPaneAccountMock: vi.fn(), - forgetCodexPaneAccountMock: vi.fn() + forgetCodexPaneAccountMock: vi.fn(), + ensureCodexBackfillRecoveryMock: vi.fn(() => Promise.resolve()) })) vi.mock('electron', () => ({ @@ -209,6 +211,10 @@ vi.mock('../codex/codex-pane-account-registry', () => ({ recordCodexPaneAccount: recordCodexPaneAccountMock, forgetCodexPaneAccount: forgetCodexPaneAccountMock })) + +vi.mock('../codex/codex-state-db-backfill-recovery', () => ({ + ensureCodexStateDbBackfillRecoveryStarted: ensureCodexBackfillRecoveryMock +})) import { LocalPtyProvider, _resetLocalPtyProviderStateForTest @@ -383,6 +389,8 @@ describe('registerPtyHandlers', () => { clearPaneKeyAliasesForPtyMock.mockReset() recordCodexPaneAccountMock.mockReset() forgetCodexPaneAccountMock.mockReset() + ensureCodexBackfillRecoveryMock.mockReset() + ensureCodexBackfillRecoveryMock.mockResolvedValue(undefined) mainWindow.webContents.on.mockReset() mainWindow.webContents.send.mockReset() mainWindow.webContents.removeListener.mockReset() @@ -3060,6 +3068,38 @@ describe('registerPtyHandlers', () => { }) }) + it('arbitrates the exact backfill owner before spawning Codex', async () => { + let releaseRecovery!: () => void + ensureCodexBackfillRecoveryMock.mockReturnValue( + new Promise((resolve) => (releaseRecovery = resolve)) + ) + readFileSyncMock.mockReturnValue(TEST_CODEX_AUTH_JSON) + handlers.clear() + registerPtyHandlers(mainWindow as never, undefined, () => TEST_CODEX_HOME, (() => ({ + codexManagedAccounts: [ + { + id: 'account-1', + managedHomePath: TEST_CODEX_HOME, + managedHomeRuntime: 'host' + } + ] + })) as never) + + const spawnPromise = handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + launchAgent: 'codex' + }) + await vi.waitFor(() => + expect(ensureCodexBackfillRecoveryMock).toHaveBeenCalledWith(TEST_CODEX_HOME) + ) + expect(spawnMock).not.toHaveBeenCalled() + + releaseRecovery() + await spawnPromise + expect(spawnMock).toHaveBeenCalledTimes(1) + }) + it('does not gate a bare local shell on managed Codex auth', async () => { readFileSyncMock.mockImplementation((filePath: string) => { if (filePath.endsWith('auth.json')) { diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 46498fdf0a3..54ce28b6825 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -199,6 +199,7 @@ import { } from '../codex/codex-pane-account-registry' import { resolveCodexPaneLaunchAccount } from '../codex/codex-pane-launch-account' import { getSystemCodexHomePath } from '../codex/codex-home-paths' +import { ensureCodexStateDbBackfillRecoveryStarted } from '../codex/codex-state-db-backfill-recovery' import { environmentCodexHomeOverrideContextsEqual, getCustomCodexHomeOverrideForLaunch, @@ -4495,6 +4496,9 @@ export function registerPtyHandlers( }) selectedCodexHomePath = resolution instanceof Promise ? await resolution : resolution } + if (args.launchAgent === 'codex' && selectedCodexHomePath) { + await ensureCodexStateDbBackfillRecoveryStarted(selectedCodexHomePath) + } const codexResumeHomeSelected = Boolean( codexResumeHome && codexHomePathsEqual(selectedCodexHomePath, codexResumeHome.codexHomePath) ) @@ -5956,6 +5960,9 @@ export function registerPtyHandlers( }) selectedCodexHomePath = resolution instanceof Promise ? await resolution : resolution } + if (args.launchAgent === 'codex' && selectedCodexHomePath) { + await ensureCodexStateDbBackfillRecoveryStarted(selectedCodexHomePath) + } const codexResumeHomeSelected = Boolean( codexResumeHome && codexHomePathsEqual(selectedCodexHomePath, codexResumeHome.codexHomePath) diff --git a/src/main/rate-limits/codex-fetcher.test.ts b/src/main/rate-limits/codex-fetcher.test.ts index aedd386563a..2f7ee834d93 100644 --- a/src/main/rate-limits/codex-fetcher.test.ts +++ b/src/main/rate-limits/codex-fetcher.test.ts @@ -2,11 +2,20 @@ import { EventEmitter } from 'node:events' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { childSpawnMock, readFileMock, resolveCodexCommandMock, ptySpawnMock } = vi.hoisted(() => ({ +const { + childSpawnMock, + readFileMock, + resolveCodexCommandMock, + ptySpawnMock, + isBackfillPendingMock, + startBackfillRecoveryMock +} = vi.hoisted(() => ({ childSpawnMock: vi.fn(), readFileMock: vi.fn(), resolveCodexCommandMock: vi.fn(), - ptySpawnMock: vi.fn() + ptySpawnMock: vi.fn(), + isBackfillPendingMock: vi.fn(() => false), + startBackfillRecoveryMock: vi.fn(() => Promise.resolve(null)) })) vi.mock('node:child_process', () => ({ @@ -25,6 +34,14 @@ vi.mock('node-pty', () => ({ spawn: ptySpawnMock })) +vi.mock('../codex/codex-state-db', () => ({ + isCodexStateDbBackfillPending: isBackfillPendingMock +})) + +vi.mock('../codex/codex-state-db-backfill-recovery', () => ({ + startCodexStateDbBackfillRecoveryInBackground: startBackfillRecoveryMock +})) + // Default to signed-in so the spawn paths under test still run; the auth gate // itself is covered by codex-auth-presence.test.ts and the no-auth case below. vi.mock('./codex-auth-presence', () => ({ @@ -116,6 +133,7 @@ describe('fetchCodexRateLimits', () => { resolveCodexCommandMock.mockReturnValue('codex') vi.mocked(probeCodexAuthPresence).mockResolvedValue('present') readFileMock.mockRejectedValue(new Error('no auth fixture')) + isBackfillPendingMock.mockReturnValue(false) vi.stubGlobal('fetch', vi.fn()) }) @@ -138,6 +156,20 @@ describe('fetchCodexRateLimits', () => { expect(ptySpawnMock).not.toHaveBeenCalled() }) + it('does not let a quota probe steal an incomplete state-DB backfill lease', async () => { + isBackfillPendingMock.mockReturnValue(true) + + await expect( + fetchCodexRateLimits({ codexHomePath: '/managed-home', allowPtyFallback: false }) + ).resolves.toMatchObject({ + status: 'error', + error: expect.stringContaining('session index') + }) + expect(startBackfillRecoveryMock).toHaveBeenCalledWith('/managed-home') + expect(childSpawnMock).not.toHaveBeenCalled() + expect(ptySpawnMock).not.toHaveBeenCalled() + }) + it('preserves the aborted result when cancellation lands during the auth check', async () => { let resolveAuth!: (presence: 'absent') => void vi.mocked(probeCodexAuthPresence).mockReturnValueOnce( diff --git a/src/main/rate-limits/codex-fetcher.ts b/src/main/rate-limits/codex-fetcher.ts index 50c97ff5da7..381875741d6 100644 --- a/src/main/rate-limits/codex-fetcher.ts +++ b/src/main/rate-limits/codex-fetcher.ts @@ -41,6 +41,8 @@ import { resolveCodexHomeProcessLockKey, withCodexHomeProcessLock } from '../codex-cli/codex-home-process-lock' +import { isCodexStateDbBackfillPending } from '../codex/codex-state-db' +import { startCodexStateDbBackfillRecoveryInBackground } from '../codex/codex-state-db-backfill-recovery' const RPC_TIMEOUT_MS = 10_000 const WSL_RPC_TIMEOUT_MS = 25_000 @@ -1249,6 +1251,19 @@ export async function fetchCodexRateLimits( } } + if (options?.codexHomePath && isCodexStateDbBackfillPending(options.codexHomePath)) { + // Why: a bounded quota probe can steal an expired backfill lease, then die before indexing finishes. + void startCodexStateDbBackfillRecoveryInBackground(options.codexHomePath) + return { + provider: 'codex', + session: null, + weekly: null, + updatedAt: Date.now(), + error: 'Codex is rebuilding its session index; usage will refresh when recovery finishes', + status: 'error' + } + } + // Why: probes spawn a real codex process inside the live credential home; // the per-home lock keeps Orca's own spawns (probe vs probe, probe vs // commit-message run) from refreshing one auth.json concurrently. diff --git a/src/renderer/src/components/terminal-pane/codex-backfill-error-detector.test.ts b/src/renderer/src/components/terminal-pane/codex-backfill-error-detector.test.ts new file mode 100644 index 00000000000..6226d178eff --- /dev/null +++ b/src/renderer/src/components/terminal-pane/codex-backfill-error-detector.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import { + CODEX_BACKFILL_RECOVERY_NOTICE, + createCodexBackfillErrorDetector +} from './codex-backfill-error-detector' + +describe('Codex backfill error detector', () => { + it('recognizes the timeout across ANSI-decorated chunks once', () => { + const detector = createCodexBackfillErrorDetector() + + expect(detector.observe('\u001b[31mError: timed out waiting for state DB back')).toBeNull() + expect(detector.observe('fill\u001b[0m\r\n')).toBe(CODEX_BACKFILL_RECOVERY_NOTICE) + expect(detector.observe('timed out waiting for state db backfill')).toBeNull() + }) + + it('does not classify the generic damaged-database message', () => { + const detector = createCodexBackfillErrorDetector() + + expect(detector.observe('local database appears to be damaged')).toBeNull() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/codex-backfill-error-detector.ts b/src/renderer/src/components/terminal-pane/codex-backfill-error-detector.ts new file mode 100644 index 00000000000..7c95bd7e46c --- /dev/null +++ b/src/renderer/src/components/terminal-pane/codex-backfill-error-detector.ts @@ -0,0 +1,33 @@ +export const CODEX_BACKFILL_TIMEOUT_SIGNATURE = 'timed out waiting for state db backfill' + +export const CODEX_BACKFILL_RECOVERY_NOTICE = [ + 'Codex could not start because its session-history index is still incomplete.', + 'Keep Orca open for a few minutes, then retry this pane. Orca attempts background recovery for managed local and WSL homes.' +].join('\n') + +const ANSI_ESCAPE_PATTERN = + // eslint-disable-next-line no-control-regex -- terminal escape sequences contain control bytes + /\u001b(?:\[[0-9;?]*[ -/]*[@-~]|\][^\u0007\u001b]*(?:\u0007|\u001b\\)?)/g +const DETECTOR_BUFFER_MAX_CHARS = 4096 + +export type CodexBackfillErrorDetector = { observe(chunk: string): string | null } + +/** Scans one Codex pane's output once for the unambiguous backfill timeout. */ +export function createCodexBackfillErrorDetector(): CodexBackfillErrorDetector { + let tail = '' + let armed = true + return { + observe(chunk: string): string | null { + if (!armed) { + return null + } + const normalized = (tail + chunk).replace(ANSI_ESCAPE_PATTERN, '').replace(/\r/g, '') + tail = normalized.slice(-DETECTOR_BUFFER_MAX_CHARS) + if (!tail.toLowerCase().includes(CODEX_BACKFILL_TIMEOUT_SIGNATURE)) { + return null + } + armed = false + return CODEX_BACKFILL_RECOVERY_NOTICE + } + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 29f693640bb..58408b8ba61 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -2324,6 +2324,31 @@ describe('connectPanePty', () => { ) }) + it('surfaces an actionable error when a Codex backfill timeout drops to the shell', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-codex-backfill-timeout' + }) + transportFactoryQueue.push(transport) + const deps = createDeps({ + tabId: 'tab-codex-backfill-timeout', + startup: { command: 'codex', launchAgent: 'codex' } + }) + + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + await flushAsyncTicks() + capturedDataCallback.current?.('timed out waiting for state db back') + capturedDataCallback.current?.('fill\r\n') + + expect(deps.onPtyErrorRef.current).toHaveBeenCalledWith( + 1, + expect.stringContaining('Orca attempts background recovery for managed local and WSL homes') + ) + }) + it('drops keystrokes while the replay guard is engaged, then forwards once it releases', async () => { // Regression (cold-restore reattach lockout): a stuck replay guard dropped every keystroke ("can't type after reconnecting"); engaged guard suppresses input, released forwards it. const { connectPanePty } = await import('./pty-connection') diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 9b843723a2b..9d14131a435 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -235,6 +235,7 @@ import { getTerminalPasteSshRemotePlatform } from './terminal-paste-ssh-platform import { resolveTerminalPasteRuntime } from './terminal-paste-runtime' import { isKnownTuiAgentTerminalStartupCommand } from './terminal-startup-command-classifier' import { createCommandCodeOutputStatusDetector } from '../../../../shared/command-code-output-status' +import { createCodexBackfillErrorDetector } from './codex-backfill-error-detector' import type { PtyDataMeta } from './pty-dispatcher' import { getEagerPtyBufferHandle } from './pty-dispatcher' import { createTerminalGitHubPRLinkDetector } from '../../../../shared/terminal-github-pr-link-detector' @@ -4630,6 +4631,10 @@ export function connectPanePty( } deps.onPtyErrorRef?.current?.(pane.id, message) } + const codexBackfillErrorDetector = + paneStartup?.launchAgent === 'codex' || tab?.launchAgent === 'codex' + ? createCodexBackfillErrorDetector() + : null // Why: shared registration so both fresh-spawn and reattach paths install // the same SerializeAddon-backed serializer plus the onTitleChange wrapper @@ -7747,6 +7752,10 @@ export function connectPanePty( commandLifecycle.handlePtyData(data) } commandCodeOutputStatusDetector?.observe(data) + const codexBackfillNotice = codexBackfillErrorDetector?.observe(data) + if (codexBackfillNotice) { + reportError(codexBackfillNotice) + } // Why: split panes have visible-but-inactive panes the user watches; throttle only when the pane or whole document is hidden. const foreground = shouldWritePtyOutputForeground(deps.isVisibleRef.current) && meta?.background !== true From 6aafb1d3180479ffab2d0a5e79389cfcae789f1a Mon Sep 17 00:00:00 2001 From: Pongsakorn Paetrakul Date: Sat, 8 Aug 2026 04:11:31 +0700 Subject: [PATCH 022/289] fix(gitlab): include bridge/child pipeline jobs in Checks (#12863) Co-authored-by: Jinwoo-H --- src/main/gitlab/client-mr.test.ts | 14 +- src/main/gitlab/client.ts | 6 +- src/main/gitlab/mappers.test.ts | 36 +++ src/main/gitlab/mappers.ts | 32 ++- src/main/gitlab/work-item-details.test.ts | 174 +++++++++++++++ src/main/gitlab/work-item-details.ts | 210 +++++++++++++++++- .../source-control/forge-review-mappers.ts | 1 + .../gitlab-mr-merge-state.test.ts | 116 ++++++++++ .../right-sidebar/gitlab-mr-merge-state.ts | 157 ++++++++++++- src/renderer/src/i18n/locales/en.json | 19 +- src/shared/gitlab-types.ts | 2 + 11 files changed, 745 insertions(+), 22 deletions(-) create mode 100644 src/renderer/src/components/right-sidebar/gitlab-mr-merge-state.test.ts diff --git a/src/main/gitlab/client-mr.test.ts b/src/main/gitlab/client-mr.test.ts index c3c4de25a3f..089de41d877 100644 --- a/src/main/gitlab/client-mr.test.ts +++ b/src/main/gitlab/client-mr.test.ts @@ -398,12 +398,24 @@ describe('gitlab client — MR operations', () => { expect(glabExecFileAsyncMock).toHaveBeenCalledWith( [ 'api', - 'projects/g%2Fp/merge_requests?source_branch=feature%2Ffoo&order_by=updated_at&sort=desc&per_page=1' + 'projects/g%2Fp/merge_requests?source_branch=feature%2Ffoo&order_by=updated_at&sort=desc&per_page=1&with_merge_status_recheck=true' ], { cwd: '/repo' } ) }) + // Why: GitLab does not proactively recompute merge status on list endpoints, so without the + // recheck request the row can sit at `unchecked` forever and the sidebar merge button — which + // gates on MERGEABLE — never becomes available. + it('asks GitLab to recheck merge status on the branch lookup', async () => { + getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' }) + glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }) + + await getMergeRequestForBranch('/repo', 'feature/recheck') + const callArgs = glabExecFileAsyncMock.mock.calls[0][0] as string[] + expect(callArgs[1]).toContain('with_merge_status_recheck=true') + }) + it('uses legacy pipeline payloads when branch MR lists omit head_pipeline', async () => { getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' }) glabExecFileAsyncMock.mockResolvedValueOnce({ diff --git a/src/main/gitlab/client.ts b/src/main/gitlab/client.ts index 74815729ebc..2e25ebb6dec 100644 --- a/src/main/gitlab/client.ts +++ b/src/main/gitlab/client.ts @@ -321,7 +321,11 @@ export async function getMergeRequestForBranch( [ 'api', ...glabHostnameArgs(projectRef, connectionId), - `projects/${encodedProject(projectRef.path)}/merge_requests?source_branch=${encodeURIComponent(branchName)}&order_by=updated_at&sort=desc&per_page=1` + // Why: GitLab does not proactively recompute merge status on list endpoints, so this row + // can sit at `unchecked` forever — and the sidebar merge button gates on MERGEABLE. Ask + // for the async recalculation (best-effort; ignored for non-Developers when + // `restrict_merge_status_recheck` is on) so polling converges instead of stalling. + `projects/${encodedProject(projectRef.path)}/merge_requests?source_branch=${encodeURIComponent(branchName)}&order_by=updated_at&sort=desc&per_page=1&with_merge_status_recheck=true` ], glabRepoExecOptions(repoPath, connectionId, localGitOptions) ) diff --git a/src/main/gitlab/mappers.test.ts b/src/main/gitlab/mappers.test.ts index 8aef3b11511..ac0f2fdd468 100644 --- a/src/main/gitlab/mappers.test.ts +++ b/src/main/gitlab/mappers.test.ts @@ -154,6 +154,7 @@ describe('mapMRInfo', () => { pipelineStatus: 'success', updatedAt: '2026-05-05T10:00:00Z', mergeable: 'MERGEABLE', + mergeStateStatus: 'mergeable', headSha: 'deadbeef' }) }) @@ -170,6 +171,7 @@ describe('mapMRInfo', () => { 'pending' ) expect(info.mergeable).toBe('CONFLICTING') + expect(info.mergeStateStatus).toBe('conflict') }) it('marks UNKNOWN when detailed_merge_status is non-mergeable but not a conflict', () => { @@ -178,6 +180,40 @@ describe('mapMRInfo', () => { 'pending' ) expect(info.mergeable).toBe('UNKNOWN') + expect(info.mergeStateStatus).toBe('checking') + }) + + // Why: GitLab < 15.6 has no detailed_merge_status. Without the legacy fallback these MRs stay + // UNKNOWN, and the merge UI (which gates on MERGEABLE) shows "Checking" with no merge button. + it('falls back to legacy merge_status when detailed_merge_status is absent', () => { + const info = mapMRInfo( + { iid: 1, title: 't', state: 'opened', merge_status: 'can_be_merged' }, + 'success' + ) + expect(info.mergeable).toBe('MERGEABLE') + }) + + it('does not let legacy merge_status override a present detailed_merge_status', () => { + const info = mapMRInfo( + { + iid: 1, + title: 't', + state: 'opened', + detailed_merge_status: 'not_approved', + merge_status: 'can_be_merged' + }, + 'success' + ) + expect(info.mergeable).toBe('UNKNOWN') + expect(info.mergeStateStatus).toBe('not_approved') + }) + + it('keeps legacy cannot_be_merged as UNKNOWN rather than guessing a conflict', () => { + const info = mapMRInfo( + { iid: 1, title: 't', state: 'opened', merge_status: 'cannot_be_merged' }, + 'success' + ) + expect(info.mergeable).toBe('UNKNOWN') }) it('returns draft state when draft flag is set', () => { diff --git a/src/main/gitlab/mappers.ts b/src/main/gitlab/mappers.ts index ca1c5d794ee..cbc47eef6ae 100644 --- a/src/main/gitlab/mappers.ts +++ b/src/main/gitlab/mappers.ts @@ -97,12 +97,16 @@ type GitLabMRRaw = { sha?: string has_conflicts?: boolean detailed_merge_status?: string + /** Deprecated since GitLab 15.6, but the only merge signal older instances return. */ + merge_status?: string description?: string | null target_branch?: string author?: { username?: string | null; avatar_url?: string | null } | null } export function mapMRInfo(data: GitLabMRRaw, pipelineStatus: CheckStatus): MRInfo { + const mergeable = deriveMergeable(data) + const mergeStateStatus = deriveMergeStateStatus(data, mergeable) return { number: data.iid ?? data.number ?? 0, title: data.title, @@ -110,7 +114,8 @@ export function mapMRInfo(data: GitLabMRRaw, pipelineStatus: CheckStatus): MRInf url: data.web_url ?? data.url ?? '', pipelineStatus, updatedAt: data.updated_at ?? data.updatedAt ?? '', - mergeable: deriveMergeable(data), + mergeable, + ...(mergeStateStatus ? { mergeStateStatus } : {}), headSha: data.sha, baseRefName: data.target_branch, // Why: detail-endpoint payloads include `description`; list endpoints @@ -137,9 +142,34 @@ function deriveMergeable(data: GitLabMRRaw): MRInfo['mergeable'] { if (data.detailed_merge_status === 'broken_status' || data.detailed_merge_status === 'conflict') { return 'CONFLICTING' } + // Why: `detailed_merge_status` only exists from GitLab 15.6. Without this fallback an older + // instance reports UNKNOWN forever, and the merge UI (which now gates on MERGEABLE) would + // permanently show "Checking" with no merge button. Only the positive legacy value is trusted; + // `cannot_be_merged` stays UNKNOWN because `has_conflicts` above already owns the conflict case. + if (data.detailed_merge_status === undefined && data.merge_status === 'can_be_merged') { + return 'MERGEABLE' + } return 'UNKNOWN' } +/** Project GitLab detailed_merge_status onto a short reason the merge UI can label. */ +function deriveMergeStateStatus( + data: GitLabMRRaw, + mergeable: MRInfo['mergeable'] +): string | undefined { + if (mergeable === 'CONFLICTING') { + return 'conflict' + } + if (mergeable === 'MERGEABLE') { + return 'mergeable' + } + const status = data.detailed_merge_status?.toLowerCase() + if (!status) { + return undefined + } + return status +} + // ── Pipeline rollup (parallel to GitHub deriveCheckStatus) ────────── // Why: GitLab returns a single pipeline `status` for the head commit; we // can also receive an array of jobs and roll them up the same way the diff --git a/src/main/gitlab/work-item-details.test.ts b/src/main/gitlab/work-item-details.test.ts index 38e434278d8..b5bbef40e56 100644 --- a/src/main/gitlab/work-item-details.test.ts +++ b/src/main/gitlab/work-item-details.test.ts @@ -102,6 +102,9 @@ describe('getWorkItemDetails', () => { ]) } } + if (endpoint === 'projects/g%2Fp/pipelines/99/bridges?per_page=100') { + return { stdout: '[]' } + } if (endpoint === 'projects/g%2Fp/merge_requests/12/reviewers') { return { stdout: '[]' } } @@ -142,6 +145,177 @@ describe('getWorkItemDetails', () => { expect(glabExecFileAsyncMock.mock.calls.flatMap(([args]) => args)).not.toContain('--paginate') }) + it('expands bridge child-pipeline jobs into the checks list', async () => { + glabExecFileAsyncMock.mockImplementation(async (args: string[]) => { + const endpoint = args.at(-1) + if (endpoint === 'projects/g%2Fp/merge_requests/12') { + return { + stdout: JSON.stringify({ + id: 120, + iid: 12, + title: 'Bridge pipeline', + state: 'opened', + web_url: 'https://gitlab.com/g/p/-/merge_requests/12', + updated_at: '2026-05-31T12:00:00Z', + source_branch: 'feature/bridges', + target_branch: 'main', + description: 'MR body', + sha: 'head-sha', + head_pipeline: { id: 99 } + }) + } + } + if (endpoint === 'projects/g%2Fp/merge_requests/12/discussions?per_page=100') { + return { stdout: '[]' } + } + if (endpoint === 'projects/g%2Fp/pipelines/99/jobs?per_page=100') { + return { + stdout: JSON.stringify([ + { + id: 10, + name: 'semgrep-sast', + stage: 'test', + status: 'success', + web_url: 'https://gitlab.com/g/p/-/jobs/10', + duration: 12 + } + ]) + } + } + if (endpoint === 'projects/g%2Fp/pipelines/99/bridges?per_page=100') { + return { + stdout: JSON.stringify([ + { + id: 50, + name: 'trigger-ci', + stage: 'test', + status: 'success', + web_url: 'https://gitlab.com/g/p/-/jobs/50', + downstream_pipeline: { + id: 200, + status: 'failed', + web_url: 'https://gitlab.com/g/p/-/pipelines/200' + } + } + ]) + } + } + if (endpoint === 'projects/g%2Fp/pipelines/200/jobs?per_page=100') { + return { + stdout: JSON.stringify([ + { + id: 300, + name: 'unit', + stage: 'test', + status: 'failed', + web_url: 'https://gitlab.com/g/p/-/jobs/300', + duration: 40 + }, + { + id: 301, + name: 'lint', + stage: 'test', + status: 'success', + web_url: 'https://gitlab.com/g/p/-/jobs/301', + duration: 20 + } + ]) + } + } + if (endpoint === 'projects/g%2Fp/merge_requests/12/reviewers') { + return { stdout: '[]' } + } + if (endpoint === 'projects/g%2Fp/merge_requests/12/approvals') { + return { stdout: JSON.stringify({ approvals_required: 0, approvals_left: 0 }) } + } + if (endpoint === 'projects/g%2Fp/merge_requests/12/approval_state') { + return { stdout: JSON.stringify({ rules: [] }) } + } + if (endpoint === 'projects/g%2Fp/merge_requests/12/diffs?per_page=100') { + return { stdout: '[]' } + } + throw new Error(`unexpected glab call: ${args.join(' ')}`) + }) + + const details = await getWorkItemDetails('/repo', 12, 'mr') + const names = (details?.pipelineJobs ?? []).map((job) => job.name).sort() + expect(names).toEqual(['lint', 'semgrep-sast', 'trigger-ci', 'unit']) + expect(details?.pipelineJobs?.find((job) => job.name === 'unit')).toMatchObject({ + id: 300, + status: 'failed', + pipelineId: 200 + }) + expect(details?.pipelineJobs?.find((job) => job.name === 'trigger-ci')).toMatchObject({ + id: 0, + status: 'failed', + pipelineId: 99 + }) + }) + + // Why: each fetch spawns a `glab` binary (a remote exec over SSH) and this runs on the Checks + // poll timer, so a bridge-heavy MR must trickle its children rather than burst them. + it('bounds concurrent child-pipeline fetches', async () => { + const BRIDGE_COUNT = 25 + let inFlightJobPages = 0 + let peakJobPages = 0 + + glabExecFileAsyncMock.mockImplementation(async (args: string[]) => { + const endpoint = args.at(-1) as string + if (endpoint === 'projects/g%2Fp/merge_requests/12') { + return { + stdout: JSON.stringify({ + iid: 12, + title: 'Fan out', + state: 'opened', + web_url: 'https://gitlab.com/g/p/-/merge_requests/12', + updated_at: '2026-05-31T12:00:00Z', + source_branch: 'f', + target_branch: 'main', + sha: 'head-sha', + head_pipeline: { id: 99 } + }) + } + } + if (endpoint === 'projects/g%2Fp/pipelines/99/bridges?per_page=100') { + return { + stdout: JSON.stringify( + Array.from({ length: BRIDGE_COUNT }, (_, i) => ({ + id: 500 + i, + name: `trigger-${i}`, + stage: 'trigger', + status: 'success', + downstream_pipeline: { + id: 1000 + i, + status: 'running', + web_url: `https://gitlab.com/g/p/-/pipelines/${1000 + i}` + } + })) + ) + } + } + if (/pipelines\/\d+\/jobs/.test(endpoint)) { + inFlightJobPages += 1 + peakJobPages = Math.max(peakJobPages, inFlightJobPages) + await new Promise((resolve) => setTimeout(resolve, 2)) + inFlightJobPages -= 1 + return { stdout: '[]' } + } + if (endpoint.endsWith('/approvals')) { + return { stdout: JSON.stringify({ approvals_required: 0, approvals_left: 0 }) } + } + if (endpoint.endsWith('/approval_state')) { + return { stdout: JSON.stringify({ rules: [] }) } + } + return { stdout: '[]' } + }) + + const details = await getWorkItemDetails('/repo', 12, 'mr') + // Parent page can overlap the capped child pool, so allow one above the child limit. + expect(peakJobPages).toBeLessThanOrEqual(5) + // Every bridge still gets a rollup row even past the 20-child expansion cap. + expect(details?.pipelineJobs).toHaveLength(BRIDGE_COUNT) + }) + it('routes local WSL MR detail fetches through project resolution and glab options', async () => { const localGitOptions = { wslDistro: 'Ubuntu' } glabExecFileAsyncMock.mockImplementation(async (args: string[]) => { diff --git a/src/main/gitlab/work-item-details.ts b/src/main/gitlab/work-item-details.ts index 85ddb450b17..638a35dd847 100644 --- a/src/main/gitlab/work-item-details.ts +++ b/src/main/gitlab/work-item-details.ts @@ -105,6 +105,18 @@ async function fetchDiscussions( } // ── Pipeline jobs ────────────────────────────────────────────────── +// Why: GitLab's `/pipelines/:id/jobs` only returns jobs owned by that pipeline. +// Trigger/include bridges live under `/bridges` and their real CI jobs live on +// the child pipeline — Orca used to show only the parent (often just SAST), so +// Checks looked empty next to gitlab.com's full graph. + +const PIPELINE_JOB_PAGE_SIZE = 100 +/** Cap expanded child-pipeline fan-out so one MR details load stays bounded. */ +const MAX_CHILD_PIPELINES_TO_EXPAND = 20 +// Why: every fetch spawns a `glab` binary (a remote exec over SSH), and this runs on +// the Checks poll timer. Match gl-utils' MAX_CONCURRENT so a bridge-heavy MR trickles +// its children instead of bursting 20 processes at once. +const MAX_CONCURRENT_CHILD_FETCHES = 4 type GitLabRawJob = { id?: number @@ -115,6 +127,21 @@ type GitLabRawJob = { duration?: number | null } +type GitLabRawBridge = { + id?: number + name?: string + stage?: string + status?: string + web_url?: string + duration?: number | null + downstream_pipeline?: { + id?: number + project_id?: number + status?: string + web_url?: string + } | null +} + type GitLabRawUser = { id?: number username?: string | null @@ -148,6 +175,117 @@ function mapPipelineJob(raw: GitLabRawJob, pipelineId: number): GitLabPipelineJo } } +function mapBridgeAsJob(raw: GitLabRawBridge, pipelineId: number): GitLabPipelineJob { + const childStatus = raw.downstream_pipeline?.status + return { + // Why: bridges are not real jobs — omit a positive id so Checks won't try + // job-trace/retry APIs on them. Rows still render via name + webUrl. + id: 0, + pipelineId, + name: raw.name ?? 'bridge', + stage: raw.stage ?? '', + // Prefer the child pipeline's rollup when present — the bridge job itself + // often stays `success` while the downstream graph is still running/failed. + status: childStatus || raw.status || '', + webUrl: raw.downstream_pipeline?.web_url ?? raw.web_url ?? '', + duration: typeof raw.duration === 'number' ? raw.duration : null + } +} + +async function fetchPipelineJobPage( + repoPath: string, + projectRef: ProjectRef, + pipelineId: number, + connectionId: string | null | undefined, + localGitOptions: LocalGitExecOptions +): Promise { + const { stdout } = await glabExecFileAsync( + [ + 'api', + ...glabHostnameArgs(projectRef, connectionId), + `projects/${encodedProject(projectRef.path)}/pipelines/${pipelineId}/jobs?per_page=${PIPELINE_JOB_PAGE_SIZE}` + ], + glabRepoExecOptions(repoPath, connectionId, localGitOptions) + ) + const data = JSON.parse(stdout) as GitLabRawJob[] + if (!Array.isArray(data)) { + return [] + } + return data.map((job) => mapPipelineJob(job, pipelineId)) +} + +async function fetchPipelineBridges( + repoPath: string, + projectRef: ProjectRef, + pipelineId: number, + connectionId: string | null | undefined, + localGitOptions: LocalGitExecOptions +): Promise { + const { stdout } = await glabExecFileAsync( + [ + 'api', + ...glabHostnameArgs(projectRef, connectionId), + `projects/${encodedProject(projectRef.path)}/pipelines/${pipelineId}/bridges?per_page=${PIPELINE_JOB_PAGE_SIZE}` + ], + glabRepoExecOptions(repoPath, connectionId, localGitOptions) + ) + const data = JSON.parse(stdout) as GitLabRawBridge[] + return Array.isArray(data) ? data : [] +} + +function childPipelineTarget( + bridge: GitLabRawBridge, + parentProjectRef: ProjectRef +): { projectRef: ProjectRef; pipelineId: number } | null { + const childId = bridge.downstream_pipeline?.id + if (typeof childId !== 'number') { + return null + } + // Why: prefer path from web_url so same- and cross-project children both work + // without a project-id lookup. If the URL is missing/unparseable, fall back to + // the parent project (same-project triggers); wrong-project calls fail soft. + const webUrl = bridge.downstream_pipeline?.web_url + if (webUrl) { + try { + const url = new URL(webUrl) + // web_url shape: https://host/group/project/-/pipelines/123 + const marker = url.pathname.indexOf('/-/pipelines/') + if (marker > 0) { + const path = url.pathname.slice(1, marker).replace(/\/$/, '') + if (path) { + return { + projectRef: { host: url.host || parentProjectRef.host, path }, + pipelineId: childId + } + } + } + } catch { + // fall through to parent project + } + } + return { projectRef: parentProjectRef, pipelineId: childId } +} + +/** Results stay in input order; a shared cursor keeps fast workers from idling behind a slow batch. */ +async function mapWithConcurrencyLimit( + items: T[], + limit: number, + run: (item: T) => Promise +): Promise { + const out = Array.from({ length: items.length }) as R[] + let cursor = 0 + await Promise.all( + Array.from({ length: Math.min(limit, items.length) }, async () => { + while (cursor < items.length) { + const index = cursor + cursor += 1 + out[index] = await run(items[index]) + } + }) + ) + return out +} + async function fetchPipelineJobs( repoPath: string, projectRef: ProjectRef, @@ -155,18 +293,68 @@ async function fetchPipelineJobs( connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - const { stdout } = await glabExecFileAsync( - [ - 'api', - ...glabHostnameArgs(projectRef, connectionId), - // Why: one MR details load should not fetch every job page from very - // large pipelines; the first 100 jobs match the visible summary budget. - `projects/${encodedProject(projectRef.path)}/pipelines/${pipelineId}/jobs?per_page=100` - ], - glabRepoExecOptions(repoPath, connectionId, localGitOptions) + const [parentJobs, bridges] = await Promise.all([ + fetchPipelineJobPage(repoPath, projectRef, pipelineId, connectionId, localGitOptions), + fetchPipelineBridges(repoPath, projectRef, pipelineId, connectionId, localGitOptions).catch( + () => [] as GitLabRawBridge[] + ) + ]) + + const bridgeRows = bridges.map((bridge) => mapBridgeAsJob(bridge, pipelineId)) + const childTargets: { projectRef: ProjectRef; pipelineId: number }[] = [] + const seenChildIds = new Set() + for (const bridge of bridges) { + const target = childPipelineTarget(bridge, projectRef) + if (!target || seenChildIds.has(target.pipelineId)) { + continue + } + seenChildIds.add(target.pipelineId) + childTargets.push(target) + if (childTargets.length >= MAX_CHILD_PIPELINES_TO_EXPAND) { + break + } + } + + const childJobBatches = await mapWithConcurrencyLimit( + childTargets, + MAX_CONCURRENT_CHILD_FETCHES, + (target) => + fetchPipelineJobPage( + repoPath, + target.projectRef, + target.pipelineId, + connectionId, + localGitOptions + ).catch(() => [] as GitLabPipelineJob[]) ) - const data = JSON.parse(stdout) as GitLabRawJob[] - return data.map((job) => mapPipelineJob(job, pipelineId)) + + // Parent jobs first, then each child's jobs. Bridge rollup rows last and only + // when no expanded child job already carries the same name (avoid duplicates). + const seenJobIds = new Set() + const seenNames = new Set() + const out: GitLabPipelineJob[] = [] + for (const job of [...parentJobs, ...childJobBatches.flat()]) { + if (job.id) { + if (seenJobIds.has(job.id)) { + continue + } + seenJobIds.add(job.id) + } + out.push(job) + if (job.name) { + seenNames.add(job.name) + } + } + for (const bridge of bridgeRows) { + if (bridge.name && seenNames.has(bridge.name)) { + continue + } + out.push(bridge) + if (bridge.name) { + seenNames.add(bridge.name) + } + } + return out } /** diff --git a/src/main/source-control/forge-review-mappers.ts b/src/main/source-control/forge-review-mappers.ts index 2e167c4078b..5d13599ccc2 100644 --- a/src/main/source-control/forge-review-mappers.ts +++ b/src/main/source-control/forge-review-mappers.ts @@ -26,6 +26,7 @@ export function mapGitLabReview(mr: MRInfo): HostedReviewInfo { status: mr.pipelineStatus, updatedAt: mr.updatedAt, mergeable: mr.mergeable, + ...(mr.mergeStateStatus !== undefined ? { mergeStateStatus: mr.mergeStateStatus } : {}), ...(mr.headSha ? { headSha: mr.headSha } : {}), ...(mr.conflictSummary ? { conflictSummary: mr.conflictSummary } : {}) } diff --git a/src/renderer/src/components/right-sidebar/gitlab-mr-merge-state.test.ts b/src/renderer/src/components/right-sidebar/gitlab-mr-merge-state.test.ts new file mode 100644 index 00000000000..7c7bd0a4f53 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/gitlab-mr-merge-state.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest' +import { presentGitLabMRMergeState } from './gitlab-mr-merge-state' + +describe('presentGitLabMRMergeState', () => { + it('does not treat UNKNOWN as able to merge', () => { + expect( + presentGitLabMRMergeState({ + state: 'open', + status: 'success', + mergeable: 'UNKNOWN', + mergeStateStatus: 'not_approved' + }) + ).toMatchObject({ + label: 'Approval required', + directMergeAvailable: false + }) + }) + + it('labels common blocked detailed_merge_status values', () => { + expect( + presentGitLabMRMergeState({ + state: 'open', + status: 'pending', + mergeable: 'UNKNOWN', + mergeStateStatus: 'ci_still_running' + }).label + ).toBe('Checks pending') + expect( + presentGitLabMRMergeState({ + state: 'open', + status: 'success', + mergeable: 'UNKNOWN', + mergeStateStatus: 'discussions_not_resolved' + }).label + ).toBe('Unresolved threads') + }) + + // Why: an unrecognised/absent reason used to render a bare "Checking", hiding the red pipeline + // that is the actual thing the user has to act on. + it('surfaces a failed pipeline when GitLab reports no usable merge reason', () => { + expect( + presentGitLabMRMergeState({ + state: 'open', + status: 'failure', + mergeable: 'UNKNOWN', + mergeStateStatus: undefined + }) + ).toMatchObject({ + label: 'Checks failed', + directMergeAvailable: false + }) + }) + + // Why: GitLab keeps adding detailed_merge_status values, so a reason we have never seen must + // degrade the same way an absent one does rather than falling through to a merge-ready label. + it('treats an unrecognised merge reason like an absent one', () => { + expect( + presentGitLabMRMergeState({ + state: 'open', + status: 'failure', + mergeable: 'UNKNOWN', + mergeStateStatus: 'some_future_gitlab_status' + }) + ).toMatchObject({ + label: 'Checks failed', + directMergeAvailable: false + }) + expect( + presentGitLabMRMergeState({ + state: 'open', + status: 'success', + mergeable: 'UNKNOWN', + mergeStateStatus: 'some_future_gitlab_status' + }) + ).toMatchObject({ + label: 'Checking', + directMergeAvailable: false + }) + }) + + it('still reports Checking when there is no reason and no failing pipeline', () => { + expect( + presentGitLabMRMergeState({ + state: 'open', + status: 'pending', + mergeable: 'UNKNOWN', + mergeStateStatus: undefined + }).label + ).toBe('Checking') + }) + + it('keeps an explicit GitLab reason ahead of the pipeline result', () => { + expect( + presentGitLabMRMergeState({ + state: 'open', + status: 'failure', + mergeable: 'UNKNOWN', + mergeStateStatus: 'not_approved' + }).label + ).toBe('Approval required') + }) + + it('only offers direct merge when GitLab reports MERGEABLE', () => { + expect( + presentGitLabMRMergeState({ + state: 'open', + status: 'success', + mergeable: 'MERGEABLE', + mergeStateStatus: 'mergeable' + }) + ).toMatchObject({ + label: 'Able to merge', + directMergeAvailable: true + }) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/gitlab-mr-merge-state.ts b/src/renderer/src/components/right-sidebar/gitlab-mr-merge-state.ts index c80eff932f3..ff70f22e1d2 100644 --- a/src/renderer/src/components/right-sidebar/gitlab-mr-merge-state.ts +++ b/src/renderer/src/components/right-sidebar/gitlab-mr-merge-state.ts @@ -1,13 +1,151 @@ import type { HostedReviewInfo } from '../../../../shared/hosted-review' import { translate } from '@/i18n/i18n' -type GitLabMRMergeStateReview = Pick +type GitLabMRMergeStateReview = Pick< + HostedReviewInfo, + 'state' | 'status' | 'mergeable' | 'mergeStateStatus' +> -export function presentGitLabMRMergeState(review: GitLabMRMergeStateReview): { +type MergePresentation = { label: string tooltip: string directMergeAvailable: boolean -} { +} + +function blockedPresentation(label: string, tooltip: string): MergePresentation { + return { label, tooltip, directMergeAvailable: false } +} + +/** Map GitLab detailed_merge_status (stored on mergeStateStatus) to a blocked/checking label. */ +function presentUnknownMergeState( + mergeStateStatus: string | null | undefined, + checksStatus: GitLabMRMergeStateReview['status'] +): MergePresentation { + const status = (mergeStateStatus ?? '').toLowerCase() + switch (status) { + case 'not_approved': + return blockedPresentation( + translate( + 'auto.components.right.sidebar.gitlab.mr.merge.state.5105b0e584', + 'Approval required' + ), + translate( + 'auto.components.right.sidebar.gitlab.mr.merge.state.46dc85711b', + 'GitLab requires approval before this MR can merge' + ) + ) + case 'requested_changes': + return blockedPresentation( + translate( + 'auto.components.right.sidebar.gitlab.mr.merge.state.893a999c8c', + 'Changes requested' + ), + translate( + 'auto.components.right.sidebar.gitlab.mr.merge.state.c65408a99d', + 'A reviewer requested changes on this merge request' + ) + ) + case 'discussions_not_resolved': + return blockedPresentation( + translate( + 'auto.components.right.sidebar.gitlab.mr.merge.state.01ab632a21', + 'Unresolved threads' + ), + translate( + 'auto.components.right.sidebar.gitlab.mr.merge.state.4191fdfcec', + 'GitLab requires unresolved discussions to be resolved before merge' + ) + ) + case 'ci_must_pass': + return blockedPresentation( + translate( + 'auto.components.right.sidebar.gitlab.mr.merge.state.bf628700f6', + 'Checks must pass' + ), + translate( + 'auto.components.right.sidebar.gitlab.mr.merge.state.37d8637c70', + 'GitLab requires the pipeline to succeed before this MR can merge' + ) + ) + case 'ci_still_running': + return blockedPresentation( + translate( + 'auto.components.right.sidebar.gitlab.mr.merge.state.65c847ad1e', + 'Checks pending' + ), + translate( + 'auto.components.right.sidebar.gitlab.mr.merge.state.049ea91a82', + 'The pipeline is still running' + ) + ) + case 'need_rebase': + return blockedPresentation( + translate('auto.components.right.sidebar.gitlab.mr.merge.state.382c70faeb', 'Behind'), + translate( + 'auto.components.right.sidebar.gitlab.mr.merge.state.2c61100b3a', + 'Update the branch before merging' + ) + ) + case 'draft_status': + return blockedPresentation( + translate('auto.components.right.sidebar.gitlab.mr.merge.state.b2715092c6', 'Draft'), + translate( + 'auto.components.right.sidebar.gitlab.mr.merge.state.d63bb6f76e', + 'This merge request is still a draft' + ) + ) + case 'checking': + case 'unchecked': + case 'preparing': + return blockedPresentation( + translate('auto.components.right.sidebar.gitlab.mr.merge.state.195917574e', 'Checking'), + translate( + 'auto.components.right.sidebar.gitlab.mr.merge.state.ff6db2db63', + 'GitLab is still computing this merge request status' + ) + ) + case 'blocked_status': + case 'policies_denied': + case 'external_status_checks': + case 'security_policy_violations': + case 'locked_paths': + case 'locked_lfs_files': + case 'jira_association_missing': + case 'title_regex': + case 'not_open': + return blockedPresentation( + translate('auto.components.right.sidebar.gitlab.mr.merge.state.4ecc0d90ee', 'Blocked'), + translate( + 'auto.components.right.sidebar.gitlab.mr.merge.state.a0f3de7023', + 'GitLab reports this merge request is blocked' + ) + ) + default: + // Why: with no usable reason from GitLab, a red pipeline is the most actionable thing we + // know — reporting a bare "Checking" hid the failure the user actually has to fix. + if (checksStatus === 'failure') { + return blockedPresentation( + translate( + 'auto.components.right.sidebar.gitlab.mr.merge.state.49ac4fec10', + 'Checks failed' + ), + translate( + 'auto.components.right.sidebar.gitlab.mr.merge.state.804ecf93e8', + 'GitLab has not reported a final merge status' + ) + ) + } + return blockedPresentation( + translate('auto.components.right.sidebar.gitlab.mr.merge.state.195917574e', 'Checking'), + translate( + 'auto.components.right.sidebar.gitlab.mr.merge.state.804ecf93e8', + 'GitLab has not reported a final merge status' + ) + ) + } +} + +export function presentGitLabMRMergeState(review: GitLabMRMergeStateReview): MergePresentation { if (review.state === 'merged') { return { label: translate('auto.components.right.sidebar.gitlab.mr.merge.state.fae95ae20d', 'Merged'), @@ -51,6 +189,11 @@ export function presentGitLabMRMergeState(review: GitLabMRMergeStateReview): { directMergeAvailable: false } } + // Why: only GitLab's explicit `mergeable` projects to MERGEABLE. UNKNOWN used to fall through + // to "Able to merge", so not_approved / discussions_not_resolved / ci_must_pass looked ready. + if (review.mergeable !== 'MERGEABLE') { + return presentUnknownMergeState(review.mergeStateStatus, review.status) + } if (review.status === 'failure') { return { label: translate( @@ -82,10 +225,10 @@ export function presentGitLabMRMergeState(review: GitLabMRMergeStateReview): { 'auto.components.right.sidebar.gitlab.mr.merge.state.04a3015a12', 'Able to merge' ), - tooltip: - review.mergeable === 'UNKNOWN' - ? 'GitLab has not reported a final merge status' - : 'GitLab says this MR can merge', + tooltip: translate( + 'auto.components.right.sidebar.gitlab.mr.merge.state.a5c049afe4', + 'GitLab says this MR can merge' + ), directMergeAvailable: true } } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 76cd4dbb92e..169c5cad344 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -11192,7 +11192,24 @@ "2388413f28": "This merge request is closed", "88d044c42f": "Closed", "ee482a2bad": "This merge request is already merged", - "fae95ae20d": "Merged" + "fae95ae20d": "Merged", + "5105b0e584": "Approval required", + "46dc85711b": "GitLab requires approval before this MR can merge", + "893a999c8c": "Changes requested", + "c65408a99d": "A reviewer requested changes on this merge request", + "01ab632a21": "Unresolved threads", + "4191fdfcec": "GitLab requires unresolved discussions to be resolved before merge", + "bf628700f6": "Checks must pass", + "37d8637c70": "GitLab requires the pipeline to succeed before this MR can merge", + "049ea91a82": "The pipeline is still running", + "382c70faeb": "Behind", + "2c61100b3a": "Update the branch before merging", + "195917574e": "Checking", + "ff6db2db63": "GitLab is still computing this merge request status", + "4ecc0d90ee": "Blocked", + "a0f3de7023": "GitLab reports this merge request is blocked", + "804ecf93e8": "GitLab has not reported a final merge status", + "a5c049afe4": "GitLab says this MR can merge" } } } diff --git a/src/shared/gitlab-types.ts b/src/shared/gitlab-types.ts index 503f2e36055..703085fb8a2 100644 --- a/src/shared/gitlab-types.ts +++ b/src/shared/gitlab-types.ts @@ -37,6 +37,8 @@ export type MRInfo = { pipelineStatus: CheckStatus updatedAt: string mergeable: MRMergeableState + /** GitLab `detailed_merge_status` (or a short alias) for UI that needs more than MERGEABLE/CONFLICTING/UNKNOWN. */ + mergeStateStatus?: string | null /** Full markdown description. Optional — list endpoints omit it; populated on single-MR fetch (`getMR`). */ description?: string /** Author username (GitLab `username`). Optional for the same reason. */ From 9e948fbdf462ede3c0160c719474100fc5cbefb7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 21:21:43 +0000 Subject: [PATCH 023/289] release: v1.4.177-rc.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 75a8ad9acef..76b037f94d9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.176-rc.1", + "version": "1.4.177-rc.0", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 360c496b9b56f6b7ced8657e59cc5188d531c9b5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:36:22 -0700 Subject: [PATCH 024/289] fix(agent-map): draw every visible orchestration edge, and let the filter hide them (#13087) The agent map dropped real parent -> child dispatch edges and mislabeled the ones it kept. - Remove the `child.y <= parent.y` gate on agent lineage. Both endpoints are drawn nodes, so the relationship is real whatever the layout ranked them; the gate silently hid edges that packing pressure placed side by side. `lineagePath` is now direction-aware so an upward edge does not exit the wrong side and draw back through both nodes. - Fix the relation attribute. `parent.card.parentPaneKey ? 'subagent' : ...` asked whether the PARENT has a parent and said nothing about the child, so a 3-deep chain rendered solid as if it were an in-process subagent. Every map node is a top-level pane agent -- build-dashboard-snapshot folds subagent rows into the parent card's roster and never makes them cards -- so every card-to-card edge is an orchestration edge. Dead CSS removed. - Add a "Orchestration links" toggle under Filter > Map content, default on, hiding both same-worktree and cross-worktree edges while keeping the nodes. Lineage still comes from the existing active-or-recently-settled dispatch context; making it historical would be a main-process change and is out of scope here by design. Extractions are max-lines pressure, not drive-by refactors: the toolbar hit 404/400 and AgentMapCanvas 403/400, and the repo forbids new max-lines suppressions. --- .../AgentDashboardMapView.tsx | 4 + .../AgentDashboardToolbar.tsx | 54 ++++++------ .../dashboard-popout/AgentMap.test.tsx | 83 ++++++++++++++++--- .../components/dashboard-popout/AgentMap.tsx | 4 + .../dashboard-popout/AgentMapCanvas.tsx | 31 ++----- .../AgentMapContentFilterItems.tsx | 59 +++++++++++++ .../AgentMapProjectLabel.test.tsx | 1 + .../dashboard-popout/AgentMapScene.tsx | 56 +++++++------ .../AgentMapWorktreeRingNode.tsx | 28 +++++-- .../dashboard-popout/FilterOptionCount.tsx | 4 + .../dashboard-popout/agent-map-canvas-zoom.ts | 25 ++++++ .../dashboard-popout/agent-map-layout.ts | 8 ++ .../components/dashboard-popout/agent-map.css | 4 - src/renderer/src/i18n/locales/en.json | 1 + 14 files changed, 257 insertions(+), 105 deletions(-) create mode 100644 src/renderer/src/components/dashboard-popout/AgentMapContentFilterItems.tsx create mode 100644 src/renderer/src/components/dashboard-popout/FilterOptionCount.tsx create mode 100644 src/renderer/src/components/dashboard-popout/agent-map-canvas-zoom.ts diff --git a/src/renderer/src/components/dashboard-popout/AgentDashboardMapView.tsx b/src/renderer/src/components/dashboard-popout/AgentDashboardMapView.tsx index 4010b5a0c30..5445a40ee47 100644 --- a/src/renderer/src/components/dashboard-popout/AgentDashboardMapView.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentDashboardMapView.tsx @@ -62,6 +62,7 @@ export function AgentDashboardMapView({ }: AgentDashboardMapViewProps): React.JSX.Element { const { agentStates, toggleAgentState, resetAgentStates } = useAgentMapStateFilter() const [showAgentlessWorkspaces, setShowAgentlessWorkspaces] = useState(false) + const [showOrchestrationLinks, setShowOrchestrationLinks] = useState(true) const agentlessWorkspaces = useMemo( () => selectAgentlessMapWorkspaces({ @@ -94,6 +95,8 @@ export function AgentDashboardMapView({ showAgentlessWorkspaces={showAgentlessWorkspaces} agentlessWorkspaceCount={agentlessWorkspaces.length} onShowAgentlessWorkspacesChange={setShowAgentlessWorkspaces} + showOrchestrationLinks={showOrchestrationLinks} + onShowOrchestrationLinksChange={setShowOrchestrationLinks} searchInputRef={searchInputRef} />
@@ -107,6 +110,7 @@ export function AgentDashboardMapView({ compact={dialogCard !== null} selectedPaneKey={dialogCard?.paneKey} enabledStates={agentStates} + showOrchestrationLinks={showOrchestrationLinks} launchableAgentsByWorktreeId={snapshot.launchableAgentsByWorktreeId} workspaceContextMenusEnabled={workspaceContextMenusEnabled} onWorkspaceContextMenuOpenChange={onWorkspaceContextMenuOpenChange} diff --git a/src/renderer/src/components/dashboard-popout/AgentDashboardToolbar.tsx b/src/renderer/src/components/dashboard-popout/AgentDashboardToolbar.tsx index ab4e1beed6c..53fa18a7398 100644 --- a/src/renderer/src/components/dashboard-popout/AgentDashboardToolbar.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentDashboardToolbar.tsx @@ -27,6 +27,8 @@ import { } from './agent-board-filtering' import { countAgentMapCards, type AgentMapState } from './agent-map-filter' import { AgentDashboardFilterChips } from './AgentDashboardFilterChips' +import { AgentMapContentFilterItems } from './AgentMapContentFilterItems' +import { FilterOptionCount } from './FilterOptionCount' import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo' type FilterOption = { id: string; label: string; count: number; color?: string } @@ -46,6 +48,9 @@ type AgentDashboardToolbarProps = { showAgentlessWorkspaces?: boolean agentlessWorkspaceCount?: number onShowAgentlessWorkspacesChange?: (show: boolean) => void + /** Map-only: the dashed parent→child dispatch edges. */ + showOrchestrationLinks?: boolean + onShowOrchestrationLinksChange?: (show: boolean) => void searchInputRef: React.RefObject } @@ -157,10 +162,6 @@ function reviewStateLabel(state: DashboardReviewFilter): string { } } -function OptionCount({ count }: { count: number }): React.JSX.Element { - return {count} -} - export function AgentDashboardToolbar({ cards, filterOptions, @@ -175,6 +176,8 @@ export function AgentDashboardToolbar({ showAgentlessWorkspaces, agentlessWorkspaceCount = 0, onShowAgentlessWorkspacesChange, + showOrchestrationLinks, + onShowOrchestrationLinksChange, searchInputRef }: AgentDashboardToolbarProps): React.JSX.Element { const isMac = navigator.userAgent.includes('Mac') @@ -190,7 +193,9 @@ export function AgentDashboardToolbar({ const activeCount = activeDashboardFilterCount(filters) + mutedStateCount + - (showAgentlessWorkspaces === true ? 1 : 0) + (showAgentlessWorkspaces === true ? 1 : 0) + + // Links show by default, so only hiding them is a deviation worth badging. + (showOrchestrationLinks === false ? 1 : 0) const toggleProject = (id: string): void => onFiltersChange({ ...filters, projects: toggleDashboardFilter(filters.projects, id) }) const toggleStatus = (id: string): void => @@ -207,6 +212,7 @@ export function AgentDashboardToolbar({ onFiltersChange({ projects: [], workspaceStatuses: [], reviewStates: [] }) onAgentStatesReset?.() onShowAgentlessWorkspacesChange?.(false) + onShowOrchestrationLinksChange?.(true) } const reviewLabel = (id: DashboardReviewFilter): string => translate('dashboardPopout.filters.reviewChip', 'Review: {{state}}', { @@ -274,26 +280,16 @@ export function AgentDashboardToolbar({ - {showAgentlessWorkspaces !== undefined && onShowAgentlessWorkspacesChange ? ( - <> - - {translate('dashboardPopout.map.filters.workspaceVisibility', 'Map content')} - - onShowAgentlessWorkspacesChange(checked === true)} - onSelect={(event) => event.preventDefault()} - > - - {translate( - 'dashboardPopout.map.filters.agentlessWorkspaces', - 'Workspaces without agents' - )} - - - - - + {showAgentlessWorkspaces !== undefined && + onShowAgentlessWorkspacesChange && + onShowOrchestrationLinksChange ? ( + ) : null} {agentStates && agentStateCounts ? ( <> @@ -309,7 +305,7 @@ export function AgentDashboardToolbar({ > {agentStateLabel(state)} - + ))} @@ -326,7 +322,7 @@ export function AgentDashboardToolbar({ onSelect={(event) => event.preventDefault()} > {option.label} - + ))} @@ -348,7 +344,7 @@ export function AgentDashboardToolbar({ > {option.label} - + ) })} @@ -364,7 +360,7 @@ export function AgentDashboardToolbar({ onSelect={(event) => event.preventDefault()} > {reviewStateLabel(option)} - + ))} diff --git a/src/renderer/src/components/dashboard-popout/AgentMap.test.tsx b/src/renderer/src/components/dashboard-popout/AgentMap.test.tsx index 483e9d1e9ed..60f587bc907 100644 --- a/src/renderer/src/components/dashboard-popout/AgentMap.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentMap.test.tsx @@ -49,6 +49,7 @@ function renderMap( compact = false, workspaceContextMenusEnabled = false, enabledStates, + showOrchestrationLinks, launchableAgentsByWorktreeId, onSpawnAgent, onSleepWorkspace @@ -58,6 +59,7 @@ function renderMap( compact?: boolean workspaceContextMenusEnabled?: boolean enabledStates?: ReadonlySet + showOrchestrationLinks?: boolean launchableAgentsByWorktreeId?: Record onSpawnAgent?: (args: DashboardSpawnAgentArgs) => void onSleepWorkspace?: (args: DashboardSleepWorkspaceArgs) => void @@ -72,6 +74,7 @@ function renderMap( compact={compact} workspaceContextMenusEnabled={workspaceContextMenusEnabled} enabledStates={enabledStates} + showOrchestrationLinks={showOrchestrationLinks} launchableAgentsByWorktreeId={launchableAgentsByWorktreeId} onSpawnAgent={onSpawnAgent} onSleepWorkspace={onSleepWorkspace} @@ -350,18 +353,19 @@ describe('AgentMap', () => { }) const { container } = renderMap([parent, child, nested, orphan]) const workerLink = container.querySelector('[data-child-pane-key="child"]') - const subagentLink = container.querySelector('[data-child-pane-key="nested"]') + const nestedLink = container.querySelector('[data-child-pane-key="nested"]') expect(screen.getByRole('button', { name: /Coordinator/ })).toBeInTheDocument() expect(screen.getByRole('button', { name: /^Worker/ })).toBeInTheDocument() expect(container.querySelectorAll('[data-agent-map-lineage-link]')).toHaveLength(2) expect(workerLink).toHaveClass('agent-map-lineage-link') - expect(workerLink).not.toHaveClass('is-subagent') expect(workerLink).toHaveAttribute('data-agent-map-lineage-relation', 'orchestration') expect(workerLink).toHaveAttribute('data-parent-pane-key', 'parent') - expect(subagentLink).toHaveClass('agent-map-lineage-link', 'is-subagent') - expect(subagentLink).toHaveAttribute('data-agent-map-lineage-relation', 'subagent') - expect(subagentLink).toHaveAttribute('data-parent-pane-key', 'child') + // Why orchestration, not subagent: `nested` is a card, and in-process subagents + // never become cards — so a grandchild dispatch is still an orchestration edge. + expect(nestedLink).toHaveClass('agent-map-lineage-link') + expect(nestedLink).toHaveAttribute('data-agent-map-lineage-relation', 'orchestration') + expect(nestedLink).toHaveAttribute('data-parent-pane-key', 'child') }) it('connects spawned workers across worktree rings', () => { @@ -387,13 +391,12 @@ describe('AgentMap', () => { }) const { container } = renderMap([parent, child, nested]) const workerLink = container.querySelector('[data-child-pane-key="child"]') - const subagentLink = container.querySelector('[data-child-pane-key="nested"]') + const nestedLink = container.querySelector('[data-child-pane-key="nested"]') expect(workerLink).toHaveClass('agent-map-lineage-link', 'is-cross-worktree') - expect(workerLink).not.toHaveClass('is-subagent') expect(workerLink).toHaveAttribute('data-agent-map-lineage-relation', 'orchestration') - expect(subagentLink).toHaveClass('agent-map-lineage-link', 'is-cross-worktree', 'is-subagent') - expect(subagentLink).toHaveAttribute('data-agent-map-lineage-relation', 'subagent') + expect(nestedLink).toHaveClass('agent-map-lineage-link', 'is-cross-worktree') + expect(nestedLink).toHaveAttribute('data-agent-map-lineage-relation', 'orchestration') }) it('keeps lineage styling to one lightweight path per relationship at fleet scale', () => { @@ -409,13 +412,71 @@ describe('AgentMap', () => { expect(container.querySelectorAll('[data-agent-map-lineage-link]')).toHaveLength(239) expect( container.querySelectorAll('[data-agent-map-lineage-relation="orchestration"]') - ).toHaveLength(1) + ).toHaveLength(239) expect(container.querySelectorAll('[data-agent-map-lineage-relation="subagent"]')).toHaveLength( - 238 + 0 ) expect(container.querySelectorAll('filter, animate, animateTransform')).toHaveLength(0) }) + it('hides orchestration links when the filter turns them off, keeping the agents', () => { + const sameWorktree = [ + card({ paneKey: 'parent', conversationName: 'Coordinator' }), + card({ paneKey: 'child', parentPaneKey: 'parent', conversationName: 'Worker' }) + ] + const crossWorktree = [ + card({ paneKey: 'far-parent', worktreeId: 'wt-a', worktreeName: 'A' }), + card({ + paneKey: 'far-child', + worktreeId: 'wt-b', + worktreeName: 'B', + parentPaneKey: 'far-parent' + }) + ] + const cards = [...sameWorktree, ...crossWorktree] + + const shown = renderMap(cards) + expect(shown.container.querySelectorAll('[data-agent-map-lineage-link]')).toHaveLength(2) + cleanup() + + const hidden = renderMap(cards, { showOrchestrationLinks: false }) + // Both same-worktree and cross-worktree edges go; the nodes themselves stay. + expect(hidden.container.querySelectorAll('[data-agent-map-lineage-link]')).toHaveLength(0) + expect(screen.getByRole('button', { name: /Coordinator/ })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /^Worker/ })).toBeInTheDocument() + }) + + it('draws no lineage edge for an agent that lists itself as its own parent', () => { + const { container } = renderMap([ + card({ paneKey: 'self', parentPaneKey: 'self', conversationName: 'Self parent' }), + card({ paneKey: 'other', conversationName: 'Unrelated' }) + ]) + + expect(container.querySelectorAll('[data-agent-map-lineage-link]')).toHaveLength(0) + expect(screen.getByRole('button', { name: /Self parent/ })).toBeInTheDocument() + }) + + it('connects lineage between visible nodes even when the child is not ranked below its parent', () => { + // A 2-cycle cannot be ranked consistently, so the bounded layout (>256 agents) + // must place one of its edges pointing upward. Both nodes are drawn, so both + // edges must be too — the old y-ordering gate silently dropped the upward one. + const cards = [ + card({ paneKey: 'cycle-a', parentPaneKey: 'cycle-b', conversationName: 'Cycle A' }), + card({ paneKey: 'cycle-b', parentPaneKey: 'cycle-a', conversationName: 'Cycle B' }), + ...Array.from({ length: 255 }, (_, index) => + card({ + paneKey: `filler-${index}`, + parentPaneKey: index === 0 ? undefined : `filler-${index - 1}`, + conversationName: `Filler ${index}` + }) + ) + ] + const { container } = renderMap(cards, { selectedPaneKey: 'cycle-a' }) + + expect(container.querySelector('[data-child-pane-key="cycle-a"]')).not.toBeNull() + expect(container.querySelector('[data-child-pane-key="cycle-b"]')).not.toBeNull() + }) + it('connects visible child worktrees beneath their parent ring', () => { const { container } = renderMap([ card({ paneKey: 'parent', worktreeId: 'parent-worktree', worktreeName: 'Parent' }), diff --git a/src/renderer/src/components/dashboard-popout/AgentMap.tsx b/src/renderer/src/components/dashboard-popout/AgentMap.tsx index 4e9c3c413ca..6bce95eaeac 100644 --- a/src/renderer/src/components/dashboard-popout/AgentMap.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentMap.tsx @@ -31,6 +31,8 @@ type AgentMapProps = { /** Owned by the board so the shared toolbar filter can drive it. Defaults to * every state, i.e. the map shows whatever it is handed. */ enabledStates?: ReadonlySet + /** Owned by the board's filter menu. Defaults to shown. */ + showOrchestrationLinks?: boolean launchableAgentsByWorktreeId?: Record workspaceContextMenusEnabled?: boolean onWorkspaceContextMenuOpenChange?: (open: boolean) => void @@ -72,6 +74,7 @@ export function AgentMap({ compact = false, selectedPaneKey = null, enabledStates = ALL_AGENT_STATES, + showOrchestrationLinks = true, launchableAgentsByWorktreeId, workspaceContextMenusEnabled = false, onWorkspaceContextMenuOpenChange, @@ -169,6 +172,7 @@ export function AgentMap({ repoIconsByRepoId={repoIconsByRepoId} selectedPaneKey={selectedPaneKey} allowAggregation + showOrchestrationLinks={showOrchestrationLinks} launchableAgentsByWorktreeId={launchableAgentsByWorktreeId} workspaceContextMenusEnabled={workspaceContextMenusEnabled} onWorkspaceContextMenuOpenChange={onWorkspaceContextMenuOpenChange} diff --git a/src/renderer/src/components/dashboard-popout/AgentMapCanvas.tsx b/src/renderer/src/components/dashboard-popout/AgentMapCanvas.tsx index 33cd767cc6a..06e52c84453 100644 --- a/src/renderer/src/components/dashboard-popout/AgentMapCanvas.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentMapCanvas.tsx @@ -16,13 +16,9 @@ import type { } from '../../../../shared/dashboard-snapshot' import type { RepoIcon } from '../../../../shared/repo-icon' import type { TuiAgent } from '../../../../shared/types' -import { - AGENT_MAP_AGENT_RADIUS, - type AgentMapAgentNode, - type AgentMapProjectRing, - type AgentMapLayout -} from './agent-map-layout' +import type { AgentMapAgentNode, AgentMapProjectRing, AgentMapLayout } from './agent-map-layout' import { AgentMapScene } from './AgentMapScene' +import { agentFocusZoom, clamp, MAX_ZOOM, MIN_ZOOM } from './agent-map-canvas-zoom' import { AgentMapViewportControls } from './AgentMapViewportControls' import { agentMapAgents, @@ -35,10 +31,7 @@ import { useAgentMapCanvasSize } from './useAgentMapCanvasSize' import { useAgentMapSelectedFocus } from './useAgentMapSelectedFocus' import { useAgentMapViewportTransition } from './useAgentMapViewportTransition' -const MIN_ZOOM = 0.7 -const MAX_ZOOM = 24 const AGENT_FOCUS_DURATION_MS = 240 -const AGENT_FOCUS_RADIUS_PX = 24 type Point = { x: number; y: number } @@ -52,6 +45,7 @@ type AgentMapCanvasProps = { repoIconsByRepoId?: Record selectedPaneKey: string | null allowAggregation: boolean + showOrchestrationLinks: boolean launchableAgentsByWorktreeId?: Record workspaceContextMenusEnabled?: boolean onWorkspaceContextMenuOpenChange?: (open: boolean) => void @@ -60,23 +54,6 @@ type AgentMapCanvasProps = { onSleepWorkspace?: (args: DashboardSleepWorkspaceArgs) => void } -function clamp(value: number, minimum: number, maximum: number): number { - return Math.max(minimum, Math.min(maximum, value)) -} - -function agentFocusZoom(layout: AgentMapLayout, width: number, height: number): number { - const aspect = width / Math.max(1, height) - const baseWidth = Math.max(layout.width, layout.height * aspect) - return clamp( - Math.max( - 2, - (baseWidth * AGENT_FOCUS_RADIUS_PX) / (Math.max(1, width) * AGENT_MAP_AGENT_RADIUS) - ), - MIN_ZOOM, - MAX_ZOOM - ) -} - export const AgentMapCanvas = forwardRef( function AgentMapCanvas( { @@ -84,6 +61,7 @@ export const AgentMapCanvas = forwardRef void + showOrchestrationLinks: boolean + onShowOrchestrationLinksChange: (show: boolean) => void +} + +/** + * The map-only "Map content" rows of the shared dashboard filter menu. These + * govern what the map draws rather than which cards survive filtering, so they + * sit apart from the project/status/review sections. + */ +export function AgentMapContentFilterItems({ + showAgentlessWorkspaces, + agentlessWorkspaceCount, + onShowAgentlessWorkspacesChange, + showOrchestrationLinks, + onShowOrchestrationLinksChange +}: AgentMapContentFilterItemsProps): React.JSX.Element { + return ( + <> + + {translate('dashboardPopout.map.filters.workspaceVisibility', 'Map content')} + + onShowAgentlessWorkspacesChange(checked === true)} + onSelect={(event) => event.preventDefault()} + > + + {translate( + 'dashboardPopout.map.filters.agentlessWorkspaces', + 'Workspaces without agents' + )} + + + + onShowOrchestrationLinksChange(checked === true)} + onSelect={(event) => event.preventDefault()} + > + + {translate('dashboardPopout.map.filters.orchestrationLinks', 'Orchestration links')} + + + + + ) +} diff --git a/src/renderer/src/components/dashboard-popout/AgentMapProjectLabel.test.tsx b/src/renderer/src/components/dashboard-popout/AgentMapProjectLabel.test.tsx index 5cbf41b7abb..7846e010c78 100644 --- a/src/renderer/src/components/dashboard-popout/AgentMapProjectLabel.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentMapProjectLabel.test.tsx @@ -41,6 +41,7 @@ describe('AgentMapScene project labels', () => { mapScale={0.5} selectedPaneKey={null} allowAggregation + showOrchestrationLinks nodeRefs={{ current: new Map() }} onSelectAgent={vi.fn()} onAgentKeyDown={vi.fn()} diff --git a/src/renderer/src/components/dashboard-popout/AgentMapScene.tsx b/src/renderer/src/components/dashboard-popout/AgentMapScene.tsx index 2719f4f7921..0dc09b6f0dd 100644 --- a/src/renderer/src/components/dashboard-popout/AgentMapScene.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentMapScene.tsx @@ -10,7 +10,7 @@ import type { AgentMapProjectRing, AgentMapWorktreeRing } from './agent-map-layout' -import { shouldAggregateAgentMapWorktree } from './agent-map-layout' +import { AGENT_MAP_LINEAGE_RELATION, shouldAggregateAgentMapWorktree } from './agent-map-layout' import { selectVisibleAgentMapLabels } from './agent-map-label-declutter' import { AgentMapWorktreeLabel } from './AgentMapWorktreeLabel' import { AgentMapWorktreeRingNode } from './AgentMapWorktreeRingNode' @@ -23,6 +23,7 @@ type AgentMapSceneProps = { mapScale: number selectedPaneKey: string | null allowAggregation: boolean + showOrchestrationLinks: boolean launchableAgentsByWorktreeId?: Record nodeRefs: MutableRefObject> onSelectAgent: (card: DashboardCard) => void @@ -71,6 +72,7 @@ export const AgentMapScene = memo(function AgentMapScene({ mapScale, selectedPaneKey, allowAggregation, + showOrchestrationLinks, launchableAgentsByWorktreeId, nodeRefs, onSelectAgent, @@ -114,17 +116,19 @@ export const AgentMapScene = memo(function AgentMapScene({ '{{agents}} agents · {{workspaces}} workspaces', { agents: project.agentCount, workspaces: project.worktrees.length } ).toUpperCase() - const crossWorktreeLineage = project.worktrees.flatMap((worktree) => - worktree.agents.flatMap((child) => { - const parent = child.card.parentPaneKey - ? visibleAgentsByPaneKey.get(child.card.parentPaneKey) - : undefined - const childLocation = visibleAgentsByPaneKey.get(child.card.paneKey) - return parent && childLocation && parent.worktreeId !== childLocation.worktreeId - ? [{ parent: parent.agent, child }] - : [] - }) - ) + const crossWorktreeLineage = !showOrchestrationLinks + ? [] + : project.worktrees.flatMap((worktree) => + worktree.agents.flatMap((child) => { + const parent = child.card.parentPaneKey + ? visibleAgentsByPaneKey.get(child.card.parentPaneKey) + : undefined + const childLocation = visibleAgentsByPaneKey.get(child.card.paneKey) + return parent && childLocation && parent.worktreeId !== childLocation.worktreeId + ? [{ parent: parent.agent, child }] + : [] + }) + ) return ( - {crossWorktreeLineage.map(({ parent, child }) => { - const relation = parent.card.parentPaneKey ? 'subagent' : 'orchestration' - return ( - - ) - })} + {crossWorktreeLineage.map(({ parent, child }) => ( + + ))} {project.worktrees.map((worktree) => ( > onSelectAgent: (card: DashboardCard) => void @@ -49,9 +50,13 @@ function formatDuration(minutes: number): string { }) } +// Why direction-aware: packing can place a child level with or above its parent, and +// a fixed downward elbow would then exit the wrong side and draw back through both +// nodes. Leaving the rank tie at +1 keeps the common downward case byte-identical. function lineagePath(parent: AgentMapAgentNode, child: AgentMapAgentNode): string { - const startY = parent.y + parent.radius - const endY = child.y - child.radius + const direction = child.y < parent.y ? -1 : 1 + const startY = parent.y + parent.radius * direction + const endY = child.y - child.radius * direction const branchY = (startY + endY) / 2 return `M ${parent.x} ${startY} L ${parent.x} ${branchY} L ${child.x} ${branchY} L ${child.x} ${endY}` } @@ -178,6 +183,7 @@ export const AgentMapWorktreeRingNode = memo(function AgentMapWorktreeRingNode({ mapScale, selectedPaneKey, allowAggregation, + showOrchestrationLinks, launchableAgents, nodeRefs, onSelectAgent, @@ -268,20 +274,26 @@ export const AgentMapWorktreeRingNode = memo(function AgentMapWorktreeRingNode({ ) : ( <> - {worktree.agents.map((child) => { + {(showOrchestrationLinks ? worktree.agents : []).map((child) => { const parent = child.card.parentPaneKey ? agentsByPaneKey.get(child.card.parentPaneKey) : undefined - if (!parent || child.y <= parent.y) { + // Why no y-gate: both endpoints are drawn nodes, so the relationship is + // real whatever the layout ranked them. Suppressing the edge only hid + // lineage that packing pressure had placed side by side. + // + // Self-parents are dropped explicitly — the layout already ignores them + // (`layoutAgentMapLineage`), and the y-gate used to mask them here by + // accident since a node never ranks below itself. + if (!parent || parent.card.paneKey === child.card.paneKey) { return null } - const relation = parent.card.parentPaneKey ? 'subagent' : 'orchestration' return ( {count} +} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-canvas-zoom.ts b/src/renderer/src/components/dashboard-popout/agent-map-canvas-zoom.ts new file mode 100644 index 00000000000..ab2d846a387 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-canvas-zoom.ts @@ -0,0 +1,25 @@ +import { AGENT_MAP_AGENT_RADIUS, type AgentMapLayout } from './agent-map-layout' + +export const MIN_ZOOM = 0.7 +export const MAX_ZOOM = 24 + +/** Screen radius a single agent should occupy once focused. */ +const AGENT_FOCUS_RADIUS_PX = 24 + +export function clamp(value: number, minimum: number, maximum: number): number { + return Math.max(minimum, Math.min(maximum, value)) +} + +/** Zoom that brings one agent up to `AGENT_FOCUS_RADIUS_PX` on screen. */ +export function agentFocusZoom(layout: AgentMapLayout, width: number, height: number): number { + const aspect = width / Math.max(1, height) + const baseWidth = Math.max(layout.width, layout.height * aspect) + return clamp( + Math.max( + 2, + (baseWidth * AGENT_FOCUS_RADIUS_PX) / (Math.max(1, width) * AGENT_MAP_AGENT_RADIUS) + ), + MIN_ZOOM, + MAX_ZOOM + ) +} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-layout.ts b/src/renderer/src/components/dashboard-popout/agent-map-layout.ts index 68602f95c04..b75b072643f 100644 --- a/src/renderer/src/components/dashboard-popout/agent-map-layout.ts +++ b/src/renderer/src/components/dashboard-popout/agent-map-layout.ts @@ -25,6 +25,14 @@ export const AGENT_MAP_AGENT_RADIUS = 20 export const AGENT_MAP_AGGREGATE_ZOOM = 1.15 export const AGENT_MAP_RING_HEADER_HEIGHT = 40 +/** + * Every map node is a top-level pane agent — in-process subagent rows are folded + * into their parent card's `subagents` roster and never become cards themselves + * (`build-dashboard-snapshot.ts`). So an edge between two nodes is always an + * orchestration dispatch, and there is no second relation to distinguish. + */ +export const AGENT_MAP_LINEAGE_RELATION = 'orchestration' + const PROJECT_PADDING = 12 const WORLD_MARGIN = 32 const RING_CONTENT_OFFSET = AGENT_MAP_RING_HEADER_HEIGHT / 2 diff --git a/src/renderer/src/components/dashboard-popout/agent-map.css b/src/renderer/src/components/dashboard-popout/agent-map.css index 1170931f1b8..12e2ad578a2 100644 --- a/src/renderer/src/components/dashboard-popout/agent-map.css +++ b/src/renderer/src/components/dashboard-popout/agent-map.css @@ -205,10 +205,6 @@ vector-effect: non-scaling-stroke; } -.agent-map-lineage-link.is-subagent { - stroke-dasharray: none; -} - .agent-map-agent-hit { fill: transparent; stroke: transparent; diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 169c5cad344..9cdfd6ecc18 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -15055,6 +15055,7 @@ "showStates": "Agent states", "canvasSummary": "{{shown}} of {{total}} agents shown", "agentlessWorkspaces": "Workspaces without agents", + "orchestrationLinks": "Orchestration links", "workspaceVisibility": "Map content" }, "liveContainmentMap": "Live containment map", From 2396e5e3e583a9dd8d237602372e5b66a780e6ac Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:01:11 -0700 Subject: [PATCH 025/289] fix(browser-pane): reschedule remote stream restart with bounded backoff (STA-3483) (#12787) --- config/reliability-gates.jsonc | 114 +- .../components/browser-pane/BrowserPane.tsx | 910 +++++---------- .../remote-browser-page-session.ts | 148 +++ .../remote-browser-screencast-subscription.ts | 111 ++ .../remote-browser-stream-errors.test.ts | 72 ++ .../remote-browser-stream-errors.ts | 107 ++ .../remote-browser-stream-lifecycle-deps.ts | 26 + .../remote-browser-stream-lifecycle.test.ts | 1036 +++++++++++++++++ .../remote-browser-stream-lifecycle.ts | 373 ++++++ .../remote-browser-stream-liveness.ts | 67 ++ .../remote-browser-stream-restart-attempt.ts | 85 ++ ...e-browser-stream-restart-scheduler.test.ts | 231 ++++ ...remote-browser-stream-restart-scheduler.ts | 109 ++ .../remote-browser-stream-status.test.ts | 81 ++ .../remote-browser-stream-status.ts | 95 ++ .../remote-browser-stream-tokens.ts | 146 +++ src/renderer/src/i18n/locales/en.json | 7 + ...ed-remote-browser-stream-reconnect.spec.ts | 266 +++++ 18 files changed, 3345 insertions(+), 639 deletions(-) create mode 100644 src/renderer/src/components/browser-pane/remote-browser-page-session.ts create mode 100644 src/renderer/src/components/browser-pane/remote-browser-screencast-subscription.ts create mode 100644 src/renderer/src/components/browser-pane/remote-browser-stream-errors.test.ts create mode 100644 src/renderer/src/components/browser-pane/remote-browser-stream-errors.ts create mode 100644 src/renderer/src/components/browser-pane/remote-browser-stream-lifecycle-deps.ts create mode 100644 src/renderer/src/components/browser-pane/remote-browser-stream-lifecycle.test.ts create mode 100644 src/renderer/src/components/browser-pane/remote-browser-stream-lifecycle.ts create mode 100644 src/renderer/src/components/browser-pane/remote-browser-stream-liveness.ts create mode 100644 src/renderer/src/components/browser-pane/remote-browser-stream-restart-attempt.ts create mode 100644 src/renderer/src/components/browser-pane/remote-browser-stream-restart-scheduler.test.ts create mode 100644 src/renderer/src/components/browser-pane/remote-browser-stream-restart-scheduler.ts create mode 100644 src/renderer/src/components/browser-pane/remote-browser-stream-status.test.ts create mode 100644 src/renderer/src/components/browser-pane/remote-browser-stream-status.ts create mode 100644 src/renderer/src/components/browser-pane/remote-browser-stream-tokens.ts create mode 100644 tests/e2e/paired-remote-browser-stream-reconnect.spec.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index f7935dbfc1f..056d295c677 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "updatedAt": "2026-08-04", + "updatedAt": "2026-08-07", "policy": { "maturityLevels": ["experimental", "soak", "blocking", "accepted-gap", "deprecated"], "blockingPromotion": { @@ -1361,6 +1361,118 @@ ], "demotionRule": "Demote if a retired runtime transport can leave a renderer subscription without a close event, if a close is delivered more than once, if one failing teardown strands its sibling subscriptions, if payload frames from a retired transport reach the renderer, or if a revealed remote terminal can stay blank or reject input after a reconnect." }, + { + "id": "browser-stream.bounded-reconnect", + "title": "Remote browser streams recover within a bounded retry budget", + "maturity": "experimental", + "protection": "partial", + "owner": "browser-runtime", + "layer": "renderer-state-machine-paired-runtime", + "surfaces": [ + "paired remote browser panes", + "browser screencast disconnect and reconnect", + "remote browser viewport restart" + ], + "platforms": ["macos", "linux", "windows"], + "providers": ["remote-runtime"], + "coveredPlatforms": ["macos", "linux"], + "coveredProviders": ["remote-runtime"], + "coverageNotes": "Deterministic fake-clock tests cover counted backoff, in-flight ownership and handoff, cancellation, supersession, transient and permanent failures, late stale rejection, silent hosts, flapping streams, viewport restarts, stale events, budget exhaustion, and manual recovery. A headless paired-runtime journey drops the real runtime connection, proves the bounded retry state and visible Reconnect control, reconnects the same host, and requires a newly painted frame. Headed paired-server, Windows, sleep/wake, and real network-partition evidence remain uncollected.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/pull/12787", + "https://linear.app/stably/issue/STA-3483" + ], + "invariant": "A current remote browser stream that closes must retry transient failures on one counted, capped backoff chain. Cancellation, pane or runtime supersession, missing pages, and permanent failures stop that chain without stale state mutation. A stream that stays healthy refills the budget; a silent or flapping stream cannot retry forever. Exhaustion and every non-retrying failure leave an actionable Reconnect control, and manual reconnect creates a fresh stream without resurrecting stale subscriptions.", + "oracle": "With one fake clock and exact pane, operation, stream, page, and runtime identities, drop a confirmed stream and require the counted delay sequence, one active chain, stale-token rejection, exact cancellation, full cleanup, and either a new live subscription or one stopped state with Reconnect after exhaustion. Inject transport error without close, close racing rejection, never-ready subscriptions, ready-then-end flapping, viewport supersession, missing pages, and permanent failures. In a separate paired client against an isolated headless Orca host, paint one remote frame, disconnect the owning runtime, require the bounded-retry notice and Reconnect control, reconnect, click once, and require a different frame object URL from the recovered stream.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/browser-pane/remote-browser-stream-errors.test.ts src/renderer/src/components/browser-pane/remote-browser-stream-lifecycle.test.ts src/renderer/src/components/browser-pane/remote-browser-stream-restart-scheduler.test.ts src/renderer/src/components/browser-pane/remote-browser-stream-status.test.ts", + "pnpm exec playwright test tests/e2e/paired-remote-browser-stream-reconnect.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" + ], + "testFiles": [ + "src/renderer/src/components/browser-pane/remote-browser-stream-errors.test.ts", + "src/renderer/src/components/browser-pane/remote-browser-stream-lifecycle.test.ts", + "src/renderer/src/components/browser-pane/remote-browser-stream-restart-scheduler.test.ts", + "src/renderer/src/components/browser-pane/remote-browser-stream-status.test.ts", + "tests/e2e/paired-remote-browser-stream-reconnect.spec.ts" + ], + "assertionRefs": [ + { + "file": "src/renderer/src/components/browser-pane/remote-browser-stream-restart-scheduler.test.ts", + "assertions": [ + "transient failures retry until recovery on counted delays", + "budget exhaustion stops exactly once", + "cancellation retires an in-flight attempt without re-arming", + "a close during an in-flight attempt queues one replacement chain" + ] + }, + { + "file": "src/renderer/src/components/browser-pane/remote-browser-stream-lifecycle.test.ts", + "assertions": [ + "dropped, silent, and flapping streams converge on a bounded retry or actionable stop", + "stale, superseded, missing-page, and permanent-failure work cannot mutate the current pane", + "manual reopen starts a fresh budget and adopts one current subscription", + "a late stale rejection cannot clear the replacement stream's ready deadline" + ] + }, + { + "file": "tests/e2e/paired-remote-browser-stream-reconnect.spec.ts", + "assertions": [ + "a real paired-runtime disconnect ends with an actionable Reconnect control", + "manual reconnect paints a new remote frame instead of retaining only the frozen frame", + "a pane whose first open fails can recover from no prior subscription" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-08-07", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/browser-pane/remote-browser-stream-errors.test.ts src/renderer/src/components/browser-pane/remote-browser-stream-lifecycle.test.ts src/renderer/src/components/browser-pane/remote-browser-stream-restart-scheduler.test.ts src/renderer/src/components/browser-pane/remote-browser-stream-status.test.ts", + "result": "passed", + "durationSeconds": 0.36, + "summary": "Four focused files passed 61 deterministic error, status, scheduler, lifecycle, in-flight ownership, stale-rejection, liveness, and cleanup tests." + }, + { + "date": "2026-08-07", + "runner": "ci", + "platform": "linux", + "command": "pnpm exec playwright test tests/e2e/paired-remote-browser-stream-reconnect.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "result": "passed", + "durationSeconds": 59.8, + "summary": "Both isolated headless paired-runtime scenarios passed: bounded recovery after a live-stream disconnect and manual recovery when the first stream never opened." + } + ], + "runtimeBudget": { + "p95Seconds": 120, + "scope": "focused fake-clock renderer contracts plus one isolated headless paired-runtime journey" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "The deterministic suite and one changed-E2E CI run passed; soak history has not started." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "The byte-identical scheduler oracle fails with the reschedule disabled at one attempt instead of four and passes after restoration. On the PR head, the delayed handoff oracle counted two scheduled attempts instead of one, and the late stale-rejection oracle lost the replacement ready deadline and never offered reconnect; both pass after the ownership fixes. Current main structurally contains the original one-shot timer dead end. The focused suite passes 61 tests, and the isolated paired-runtime CI journey passes both disconnect and cold-open recovery scenarios." + }, + "performanceBudget": { + "required": true, + "evidence": "Each pane retains at most one retry timer, one in-flight attempt, and one ready deadline. Automatic recovery makes at most five counted attempts with no global scan, subprocess, polling loop, or cross-pane fanout; exhaustion stops background work until explicit user reconnect." + }, + "promotionCriteria": [ + "Run the same journey against a headed Orca desktop server and retain independent host/client state evidence.", + "Collect Linux and physical Windows paired-server evidence plus sleep/wake or network-partition recovery.", + "Collect 100 consecutive focused CI passes or 14 days without an unexplained flake.", + "Replace the E2E's post-exhaustion elapsed-time check with an exact subscribe-attempt oracle." + ], + "knownGaps": [ + "The paired journey covers headless orca serve, not the primary headed desktop-server topology.", + "The live fault is an explicit disconnect rather than sleep/wake or a real network partition.", + "The E2E observes UI convergence and new frame identity but does not count screencast subscribe attempts directly.", + "Live Linux and Windows evidence is uncollected." + ], + "demotionRule": "Keep experimental or demote if retries can stop before recovery or exhaustion, continue after exhaustion, run in parallel, mutate a superseded pane, hide the Reconnect control, leak a subscription or timer, or fail to paint a new frame after manual reconnect." + }, { "id": "editor.live-log-append-stability", "title": "Long live session logs retain their Monaco viewport while appending", diff --git a/src/renderer/src/components/browser-pane/BrowserPane.tsx b/src/renderer/src/components/browser-pane/BrowserPane.tsx index d1aca3c49b6..4ed12bc4f7e 100644 --- a/src/renderer/src/components/browser-pane/BrowserPane.tsx +++ b/src/renderer/src/components/browser-pane/BrowserPane.tsx @@ -84,6 +84,13 @@ import { } from '../../../../shared/browser-viewport-presets' import { rememberLiveBrowserUrl } from './browser-runtime' import { ensureBrowserPageWebview } from './browser-page-webview' +import { RemoteBrowserStreamLifecycle } from './remote-browser-stream-lifecycle' +import { isRemoteBrowserPageMissingError } from './remote-browser-stream-errors' +import type { + RemoteBrowserOperationToken, + RemoteBrowserStreamToken, + RemoteBrowserViewportSize +} from './remote-browser-stream-tokens' import { destroyPersistentWebview, isBrowserPageRendererRecoveryPending, @@ -157,7 +164,6 @@ import { import { callRuntimeRpc, runtimeEnvironmentSupportsCapability, - RuntimeRpcCallError, type RuntimeClientTarget } from '@/runtime/runtime-rpc-client' import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' @@ -165,15 +171,12 @@ import type { BrowserBackResult, BrowserGotoResult, BrowserReloadResult, - BrowserScreencastResult, - BrowserTabInfo, - RuntimeStatus + BrowserTabInfo } from '../../../../shared/runtime-types' import { decodeBrowserScreencastFrame, type BrowserScreencastFrameMetadata } from '../../../../shared/browser-screencast-protocol' -import { withBrowserPaneUiRuntimeRpcSource } from '../../../../shared/runtime-rpc-feature-interaction-source' import { formatByteCount, formatPermissionNotice, formatPopupNotice } from './browser-notices' import { getDriverForBrowserPage, @@ -195,6 +198,13 @@ import { createBrowserPageGuestRecovery } from './browser-page-guest-recovery' import { subscribeBrowserSystemResume } from './browser-system-resume' +import { + canReconnectRemoteBrowserStream, + isRemoteBrowserStreamBusy, + REMOTE_BROWSER_STREAM_IDLE, + remoteBrowserStreamNotice, + type RemoteBrowserStreamStatus +} from './remote-browser-stream-status' import { type BrowserReloadTrigger, reloadBrowserPageWebview, @@ -281,26 +291,6 @@ function decodeRemoteBrowserFrameUrl(url: string): Promise { }) } -type RemoteBrowserStreamToken = { - tabId: string - environmentId: string - remotePageId: string - generation: number - operationGeneration: number -} - -type RemoteBrowserStreamSubscription = { - token: RemoteBrowserStreamToken - unsubscribe: () => void -} - -type RemoteBrowserOperationToken = { - tabId: string - environmentId: string - remotePageId: string | null - generation: number -} - type RemoteBrowserContextMenu = { x: number y: number @@ -309,11 +299,6 @@ type RemoteBrowserContextMenu = { selectionText: string } -type RemoteBrowserViewportSize = { - width: number - height: number -} - function getBrowserPageRuntimeEnvironmentId( page: BrowserPageState, inferredRuntimeEnvironmentId: string | null | undefined @@ -547,26 +532,32 @@ function PendingBrowserAnnotationCard({ ) } +// The pane-owned effects the stream lifecycle calls back into: frame paint, viewport measurement, +// and the store/tab-close decision for a page that is gone. +type RemoteBrowserStreamBridge = { + applyTabInfo: (tab: Pick) => void + clearFrame: () => void + handleFrameBytes: (token: RemoteBrowserStreamToken, bytes: Uint8Array) => void + closeMissingRemotePage: (remotePageId: string | null) => void + waitForViewportSize: () => Promise + syncViewport: (pageId: string) => Promise +} + +const NO_REMOTE_BROWSER_STREAM_BRIDGE: RemoteBrowserStreamBridge = { + applyTabInfo: () => {}, + clearFrame: () => {}, + handleFrameBytes: () => {}, + closeMissingRemotePage: () => {}, + waitForViewportSize: async () => null, + syncViewport: async () => {} +} + function browserPageExists(tabId: string): boolean { return Object.values(useAppStore.getState().browserPagesByWorkspace).some((pages) => pages.some((page) => page.id === tabId) ) } -function isRemoteBrowserPageMissingError(error: unknown): boolean { - if (error instanceof RuntimeRpcCallError) { - return isRemoteBrowserPageMissingCode(error.code) - } - if (!error || typeof error !== 'object' || !('code' in error)) { - return false - } - return isRemoteBrowserPageMissingCode((error as { code: unknown }).code) -} - -function isRemoteBrowserPageMissingCode(code: unknown): boolean { - return code === 'browser_tab_not_found' || code === 'browser_no_tab' -} - function buildLoadError(event: { errorCode?: number errorDescription?: string @@ -701,16 +692,6 @@ function getPositiveFiniteNumber(value: unknown): number | null { return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null } -function areRemoteViewportSizesNear( - a: RemoteBrowserViewportSize | null, - b: RemoteBrowserViewportSize | null -): boolean { - if (!a || !b) { - return false - } - return Math.abs(a.width - b.width) <= 3 && Math.abs(a.height - b.height) <= 3 -} - function getRemoteBrowserDeviceScaleFactor(): number { if (typeof window === 'undefined') { return 1 @@ -931,41 +912,59 @@ function RemoteBrowserPagePane({ const [addressBarValue, setAddressBarValue] = useState(toDisplayUrl(browserTab.url)) const [frameUrl, setFrameUrl] = useState(null) const [frameMetadata, setFrameMetadata] = useState(null) - const [remoteError, setRemoteError] = useState(null) + // Pane-owned notices, split by what they are ABOUT, because that decides who outranks whom: + // + // 'direct' — feedback on what the user just did (URL validation). Always shown: it is the + // only response to their action, and suppressing it makes Enter look broken. + // 'consequence' — an operation that failed BECAUSE the stream is down (input, navigation RPCs). + // Outranked by the stream's own notice, which explains the cause; otherwise + // these repaint raw transport text over it on every stray click. + // + // Kept as one slot so the newest notice replaces the previous one, as a single toast should. + const [paneNotice, setPaneNotice] = useState<{ + kind: 'direct' | 'consequence' + text: string + } | null>(null) + // The single source for what the stream is doing. busy, the notice, and whether the reconnect + // control renders are all derived below, so they cannot disagree — see + // remote-browser-stream-status.ts for the four ways they used to. + const [streamStatus, setStreamStatus] = useState( + REMOTE_BROWSER_STREAM_IDLE + ) + // Bumped by Reconnect to re-run the open effect from scratch. See reconnectRemoteStream. + const [reopenNonce, setReopenNonce] = useState(0) const [contextMenu, setContextMenu] = useState(null) - const [busy, setBusy] = useState(false) + const [paneBusy, setPaneBusy] = useState(false) + // Derived, never stored. The stream's own notice wins over an incidental one: while the stream is + // down every input RPC fails as a matter of course, and those failures must not overwrite the + // message that explains why — nor can the reconnect control depend on one of them being present. + // A stopped stream delivers no frames that could clear paneBusy, so it must force busy off. + const busy = + streamStatus.kind === 'stopped' ? false : paneBusy || isRemoteBrowserStreamBusy(streamStatus) + const streamNotice = remoteBrowserStreamNotice(streamStatus) + const remoteError = + paneNotice?.kind === 'direct' ? paneNotice.text : (streamNotice ?? paneNotice?.text ?? null) const contextMenuRef = useRef(null) - const remotePageIdRef = useRef(null) const remoteViewportSizeRef = useRef(null) const remoteCssViewportSizeRef = useRef(null) - const remoteStreamViewportSizeRef = useRef(null) const remoteViewportTimerRef = useRef(null) const streamFrameUrlRef = useRef(null) - const streamSubscriptionRef = useRef(null) - const streamRestartTimerRef = useRef(null) - const remoteTabRefreshTimerRef = useRef(null) const remoteInputQueueRef = useRef>(Promise.resolve()) const pendingRemoteWheelRef = useRef(null) const remoteWheelFrameRef = useRef(null) const remoteWheelInFlightRef = useRef(false) const pendingFrameDecodeRef = useRef(0) - const streamGenerationRef = useRef(0) - const remoteOperationGenerationRef = useRef(0) - const activeStreamTokenRef = useRef(null) const mountedRef = useRef(true) const isActiveRef = useRef(isActive) const currentBrowserTabIdRef = useRef(browserTab.id) const currentBrowserTabUrlRef = useRef(browserTab.url) const runtimeWorktree = useMemo(() => toRuntimeWorktreeSelector(worktreeId), [worktreeId]) + const runtimeWorktreeRef = useRef(runtimeWorktree) const activeRuntimeEnvironmentIdRef = useRef(activeRuntimeEnvironmentId) - const startRemoteStreamRef = useRef< - (pageId: string) => Promise - >(async () => null) - const restartRemoteStreamForViewportRef = useRef<(pageId: string) => void>(() => {}) - const fetchRemoteTabInfoRef = useRef< - (token: RemoteBrowserOperationToken) => Promise - >(async () => null) - const setRemoteBrowserPageHandle = useAppStore((s) => s.setRemoteBrowserPageHandle) + // Why: the stream lifecycle is built once per pane, before the callbacks it needs exist. It + // reaches them through this bridge so it never captures a render's stale closure. + const streamBridgeRef = useRef(NO_REMOTE_BROWSER_STREAM_BRIDGE) + const lifecycleRef = useRef(null) const certificateFailure = useAppStore( (s) => s.browserCertificateFailuresByPageId[browserTab.id] ?? null ) @@ -1006,10 +1005,60 @@ function RemoteBrowserPagePane({ } }, [remoteCertificateEnvironmentId, certificateChallengeId]) - currentBrowserTabIdRef.current = browserTab.id - currentBrowserTabUrlRef.current = browserTab.url - activeRuntimeEnvironmentIdRef.current = activeRuntimeEnvironmentId - isActiveRef.current = isActive + useLayoutEffect(() => { + currentBrowserTabIdRef.current = browserTab.id + currentBrowserTabUrlRef.current = browserTab.url + activeRuntimeEnvironmentIdRef.current = activeRuntimeEnvironmentId + isActiveRef.current = isActive + runtimeWorktreeRef.current = runtimeWorktree + }, [activeRuntimeEnvironmentId, browserTab.id, browserTab.url, isActive, runtimeWorktree]) + + if (!lifecycleRef.current) { + lifecycleRef.current = new RemoteBrowserStreamLifecycle({ + identity: { + isMounted: () => mountedRef.current, + isActive: () => isActiveRef.current, + getTabId: () => currentBrowserTabIdRef.current, + getEnvironmentId: () => activeRuntimeEnvironmentIdRef.current, + browserPageExists + }, + callRpc: callRuntimeRpc, + subscribeScreencast: (args, callbacks) => + window.api.runtimeEnvironments.subscribe(args, callbacks), + getWorktreeSelector: () => runtimeWorktreeRef.current, + getCurrentUrl: () => currentBrowserTabUrlRef.current, + readStoredHandle: () => + useAppStore.getState().remoteBrowserPageHandlesByPageId[currentBrowserTabIdRef.current] ?? + null, + writeStoredHandle: (handle) => + useAppStore.getState().setRemoteBrowserPageHandle(currentBrowserTabIdRef.current, handle), + removeStoredHandle: (remotePageId) => { + useAppStore + .getState() + .removeRemoteBrowserPageHandle(currentBrowserTabIdRef.current, remotePageId) + }, + getDeviceScaleFactor: getRemoteBrowserDeviceScaleFactor, + setStatus: (status) => { + setStreamStatus(status) + // Why every status change clears the pane's notice, not just the recovering ones: a pane + // notice describes the situation the PREVIOUS status described, so any transition makes it + // stale. Clearing only on live/opening left a 'direct' notice — which outranks the stream's + // own — on screen after the stream stopped, so a stranded pane showed "Enter a valid http(s) + // or localhost URL." beside its Reconnect button and never showed the actual cause. + // It also has no other owner: nothing else would dismiss it. + setPaneNotice(null) + }, + applyTabInfo: (tab) => streamBridgeRef.current.applyTabInfo(tab), + clearFrame: () => streamBridgeRef.current.clearFrame(), + handleFrameBytes: (token, bytes) => streamBridgeRef.current.handleFrameBytes(token, bytes), + closeMissingRemotePage: (remotePageId) => + streamBridgeRef.current.closeMissingRemotePage(remotePageId), + waitForViewportSize: () => streamBridgeRef.current.waitForViewportSize(), + readViewportSize: () => remoteViewportSizeRef.current, + syncViewport: (pageId) => streamBridgeRef.current.syncViewport(pageId) + }) + } + const lifecycle = lifecycleRef.current const runtimeTarget = useCallback(() => { return activeRuntimeEnvironmentId @@ -1025,13 +1074,13 @@ function RemoteBrowserPagePane({ const prevUrl = streamFrameUrlRef.current streamFrameUrlRef.current = null remoteCssViewportSizeRef.current = null - remoteStreamViewportSizeRef.current = null + lifecycle.forgetStreamViewportSize() setFrameMetadata(null) setFrameUrl(null) if (prevUrl) { URL.revokeObjectURL(prevUrl) } - }, []) + }, [lifecycle]) const clearPendingRemoteWheel = useCallback((): void => { pendingRemoteWheelRef.current = null @@ -1043,33 +1092,20 @@ function RemoteBrowserPagePane({ }, []) const closeMissingRemotePage = useCallback( - (remotePageId: string | null = remotePageIdRef.current): void => { + (remotePageId: string | null = lifecycle.tokens.remotePage): void => { const state = useAppStore.getState() if (remotePageId) { state.removeRemoteBrowserPageHandle(browserTab.id, remotePageId) } - remotePageIdRef.current = null - remoteOperationGenerationRef.current += 1 - streamGenerationRef.current += 1 - activeStreamTokenRef.current = null - streamSubscriptionRef.current?.unsubscribe() - streamSubscriptionRef.current = null - if (streamRestartTimerRef.current !== null) { - window.clearTimeout(streamRestartTimerRef.current) - streamRestartTimerRef.current = null - } + lifecycle.abandonRemotePage() if (remoteViewportTimerRef.current !== null) { window.clearTimeout(remoteViewportTimerRef.current) remoteViewportTimerRef.current = null } - if (remoteTabRefreshTimerRef.current !== null) { - window.clearTimeout(remoteTabRefreshTimerRef.current) - remoteTabRefreshTimerRef.current = null - } remoteInputQueueRef.current = Promise.resolve() clearStreamFrame() - setRemoteError(null) - setBusy(false) + setPaneNotice(null) + setPaneBusy(false) // Why: a runtime-side tab close mirrors closing the visible tab; don't leave a dead pane behind. const workspacePageCount = state.browserPagesByWorkspace[browserTab.workspaceId]?.length ?? 0 if (workspacePageCount <= 1) { @@ -1078,7 +1114,14 @@ function RemoteBrowserPagePane({ } closeBrowserPage(browserTab.id) }, - [browserTab.id, browserTab.workspaceId, clearStreamFrame, closeBrowserPage, closeBrowserTab] + [ + browserTab.id, + browserTab.workspaceId, + clearStreamFrame, + closeBrowserPage, + closeBrowserTab, + lifecycle + ] ) const rememberRemoteViewportSize = useCallback( @@ -1178,57 +1221,14 @@ function RemoteBrowserPagePane({ }, []) const createRemoteOperationToken = useCallback( - (remotePageId: string | null = null): RemoteBrowserOperationToken | null => { - const target = runtimeTarget() - if (!target) { - return null - } - return { - tabId: browserTab.id, - environmentId: target.environmentId, - remotePageId, - generation: remoteOperationGenerationRef.current - } - }, - [browserTab.id, runtimeTarget] + (remotePageId: string | null = null): RemoteBrowserOperationToken | null => + lifecycle.tokens.createOperationToken(remotePageId), + [lifecycle] ) const isCurrentRemoteOperationToken = useCallback( - (token: RemoteBrowserOperationToken): boolean => - mountedRef.current && - isActiveRef.current && - browserPageExists(token.tabId) && - currentBrowserTabIdRef.current === token.tabId && - activeRuntimeEnvironmentIdRef.current === token.environmentId && - remoteOperationGenerationRef.current === token.generation && - (token.remotePageId === null || remotePageIdRef.current === token.remotePageId), - [] - ) - - const isCurrentRemoteStreamOperation = useCallback( - (token: RemoteBrowserStreamToken): boolean => - isCurrentRemoteOperationToken({ - tabId: token.tabId, - environmentId: token.environmentId, - remotePageId: token.remotePageId, - generation: token.operationGeneration - }), - [isCurrentRemoteOperationToken] - ) - - const isCurrentRemoteStreamToken = useCallback( - (token: RemoteBrowserStreamToken): boolean => { - const activeToken = activeStreamTokenRef.current - return ( - activeToken?.generation === token.generation && - activeToken.operationGeneration === token.operationGeneration && - activeToken.tabId === token.tabId && - activeToken.environmentId === token.environmentId && - activeToken.remotePageId === token.remotePageId && - isCurrentRemoteStreamOperation(token) - ) - }, - [isCurrentRemoteStreamOperation] + (token: RemoteBrowserOperationToken): boolean => lifecycle.tokens.isCurrent(token), + [lifecycle] ) useEffect(() => { @@ -1236,38 +1236,32 @@ function RemoteBrowserPagePane({ mountedRef.current = true return () => { mountedRef.current = false - remoteOperationGenerationRef.current += 1 - streamGenerationRef.current += 1 pendingFrameDecodeRef.current += 1 - activeStreamTokenRef.current = null - remoteStreamViewportSizeRef.current = null - if (streamRestartTimerRef.current !== null) { - window.clearTimeout(streamRestartTimerRef.current) - streamRestartTimerRef.current = null - } + lifecycle.dispose() if (remoteViewportTimerRef.current !== null) { window.clearTimeout(remoteViewportTimerRef.current) remoteViewportTimerRef.current = null } - if (remoteTabRefreshTimerRef.current !== null) { - window.clearTimeout(remoteTabRefreshTimerRef.current) - remoteTabRefreshTimerRef.current = null - } clearPendingRemoteWheel() - restartRemoteStreamForViewportRef.current = () => {} if (streamFrameUrlRef.current) { URL.revokeObjectURL(streamFrameUrlRef.current) streamFrameUrlRef.current = null } } - }, [clearPendingRemoteWheel]) + }, [clearPendingRemoteWheel, lifecycle]) useEffect(() => { // Why: only reset frame/wheel on identity change; bumping the stream/operation generations here races the streaming effect and wedges the pane. - remoteStreamViewportSizeRef.current = null + lifecycle.forgetStreamViewportSize() clearPendingRemoteWheel() clearStreamFrame() - }, [activeRuntimeEnvironmentId, browserTab.id, clearPendingRemoteWheel, clearStreamFrame]) + }, [ + activeRuntimeEnvironmentId, + browserTab.id, + clearPendingRemoteWheel, + clearStreamFrame, + lifecycle + ]) useEffect(() => { if (!isActive) { @@ -1284,12 +1278,12 @@ function RemoteBrowserPagePane({ } remoteViewportTimerRef.current = window.setTimeout(() => { remoteViewportTimerRef.current = null - const pageId = remotePageIdRef.current + const pageId = lifecycle.tokens.remotePage if (!pageId || !isActiveRef.current) { return } void syncRemoteViewport(pageId) - .then(() => restartRemoteStreamForViewportRef.current(pageId)) + .then(() => lifecycle.restartForViewport(pageId)) .catch(() => {}) }, 150) } @@ -1303,7 +1297,7 @@ function RemoteBrowserPagePane({ remoteViewportTimerRef.current = null } } - }, [isActive, readRemoteViewportSize, syncRemoteViewport]) + }, [isActive, lifecycle, readRemoteViewportSize, syncRemoteViewport]) useEffect(() => { if (document.activeElement === addressBarInputRef.current) { @@ -1353,7 +1347,7 @@ function RemoteBrowserPagePane({ return } return () => { - const remotePageId = remotePageIdRef.current + const remotePageId = lifecycle.tokens.remotePage if (!remotePageId) { return } @@ -1364,7 +1358,7 @@ function RemoteBrowserPagePane({ return } const removedHandle = state.removeRemoteBrowserPageHandle(browserTab.id, remotePageId) - remotePageIdRef.current = null + lifecycle.tokens.setRemotePage(null) if (!removedHandle) { return } @@ -1376,7 +1370,7 @@ function RemoteBrowserPagePane({ { timeoutMs: 15_000, suppressFeatureInteraction: true } ).catch(() => {}) } - }, [activeRuntimeEnvironmentId, browserTab.id, runtimeWorktree, worktreeId]) + }, [activeRuntimeEnvironmentId, browserTab.id, lifecycle, runtimeWorktree, worktreeId]) const applyRemoteTabInfo = useCallback( (tab: Pick): void => { @@ -1396,7 +1390,7 @@ function RemoteBrowserPagePane({ const updateStreamFrame = useCallback( (token: RemoteBrowserStreamToken, bytes: Uint8Array): void => { - if (!isCurrentRemoteStreamToken(token)) { + if (!lifecycle.tokens.isCurrentStreamToken(token)) { return } const frame = decodeBrowserScreencastFrame(bytes) @@ -1416,7 +1410,7 @@ function RemoteBrowserPagePane({ .then(() => { if ( pendingFrameDecodeRef.current !== decodeGeneration || - !isCurrentRemoteStreamToken(token) + !lifecycle.tokens.isCurrentStreamToken(token) ) { URL.revokeObjectURL(nextUrl) return @@ -1425,7 +1419,7 @@ function RemoteBrowserPagePane({ streamFrameUrlRef.current = nextUrl setFrameMetadata(frame.metadata) setFrameUrl(nextUrl) - setBusy(false) + setPaneBusy(false) if (prevUrl) { URL.revokeObjectURL(prevUrl) } @@ -1434,7 +1428,7 @@ function RemoteBrowserPagePane({ URL.revokeObjectURL(nextUrl) }) }, - [isCurrentRemoteStreamToken] + [lifecycle] ) const getRemoteImagePoint = useCallback( @@ -1466,467 +1460,64 @@ function RemoteBrowserPagePane({ [frameMetadata] ) - const ensureRemotePage = useCallback( - async (token: RemoteBrowserOperationToken): Promise => { - if (!isCurrentRemoteOperationToken(token)) { - return null - } - const target = { kind: 'environment' as const, environmentId: token.environmentId } - const createRemotePage = async (): Promise => { - const currentUrl = currentBrowserTabUrlRef.current - const initialUrl = - currentUrl === ORCA_BROWSER_BLANK_URL ? 'about:blank' : currentUrl || 'about:blank' - const created = await callRuntimeRpc<{ browserPageId: string }>( - target, - 'browser.tabCreate', - { worktree: runtimeWorktree, url: initialUrl }, - { timeoutMs: 30_000, suppressFeatureInteraction: true } - ) - if (!isCurrentRemoteOperationToken(token)) { - void callRuntimeRpc( - target, - 'browser.tabClose', - { worktree: runtimeWorktree, page: created.browserPageId }, - { timeoutMs: 15_000, suppressFeatureInteraction: true } - ).catch(() => {}) - return null - } - remotePageIdRef.current = created.browserPageId - setRemoteBrowserPageHandle(browserTab.id, { - environmentId: target.environmentId, - remotePageId: created.browserPageId - }) - return created.browserPageId - } - - const existingHandle = useAppStore.getState().remoteBrowserPageHandlesByPageId[browserTab.id] - if (existingHandle?.environmentId === target.environmentId) { - const cachedToken = { ...token, remotePageId: existingHandle.remotePageId } - remotePageIdRef.current = existingHandle.remotePageId - try { - const cachedTab = await fetchRemoteTabInfoRef.current(cachedToken) - if (!cachedTab) { - return null - } - return existingHandle.remotePageId - } catch (error) { - if (!isRemoteBrowserPageMissingError(error)) { - throw error - } - useAppStore - .getState() - .removeRemoteBrowserPageHandle(browserTab.id, existingHandle.remotePageId) - if (remotePageIdRef.current === existingHandle.remotePageId) { - remotePageIdRef.current = null - } - if (!isCurrentRemoteOperationToken(token)) { - return null - } - closeMissingRemotePage(existingHandle.remotePageId) - return null - } - } - return createRemotePage() - }, - [ - browserTab.id, - closeMissingRemotePage, - isCurrentRemoteOperationToken, - setRemoteBrowserPageHandle, - runtimeWorktree - ] - ) - - const fetchRemoteTabInfo = useCallback( - async (token: RemoteBrowserOperationToken): Promise => { - if (!isCurrentRemoteOperationToken(token) || !token.remotePageId) { - return null - } - const shown = await callRuntimeRpc<{ tab: BrowserTabInfo }>( - { kind: 'environment', environmentId: token.environmentId }, - 'browser.tabShow', - { worktree: runtimeWorktree, page: token.remotePageId }, - { timeoutMs: 15_000, suppressFeatureInteraction: true } - ) - return shown.tab - }, - [isCurrentRemoteOperationToken, runtimeWorktree] - ) - fetchRemoteTabInfoRef.current = fetchRemoteTabInfo - const scheduleRemoteTabInfoRefresh = useCallback( (token: RemoteBrowserOperationToken, delayMs = 250): void => { - if (!isCurrentRemoteOperationToken(token)) { - return - } - if (remoteTabRefreshTimerRef.current !== null) { - window.clearTimeout(remoteTabRefreshTimerRef.current) - } - remoteTabRefreshTimerRef.current = window.setTimeout(() => { - remoteTabRefreshTimerRef.current = null - if (!isCurrentRemoteOperationToken(token)) { - return - } - void fetchRemoteTabInfo(token) - .then((tab) => { - if (tab && isCurrentRemoteOperationToken(token)) { - applyRemoteTabInfo(tab) - } - }) - .catch((error: unknown) => { - if (isCurrentRemoteOperationToken(token) && isRemoteBrowserPageMissingError(error)) { - closeMissingRemotePage(token.remotePageId) - } - }) - }, delayMs) + lifecycle.session.scheduleTabInfoRefresh(token, delayMs) }, - [applyRemoteTabInfo, closeMissingRemotePage, fetchRemoteTabInfo, isCurrentRemoteOperationToken] + [lifecycle] ) - const scheduleRemoteStreamRestart = useCallback( - (token: RemoteBrowserStreamToken): void => { - if (!isCurrentRemoteStreamOperation(token) || streamRestartTimerRef.current !== null) { - return - } - streamRestartTimerRef.current = window.setTimeout(() => { - streamRestartTimerRef.current = null - if (!isCurrentRemoteStreamOperation(token)) { - return - } - setBusy(true) - const operationToken: RemoteBrowserOperationToken = { - tabId: token.tabId, - environmentId: token.environmentId, - remotePageId: token.remotePageId, - generation: token.operationGeneration - } - void fetchRemoteTabInfo(operationToken) - .then((tab) => { - if (!tab || !isCurrentRemoteStreamOperation(token)) { - return - } - applyRemoteTabInfo(tab) - }) - .catch(() => {}) - .then(() => { - if (!isCurrentRemoteStreamOperation(token)) { - return null - } - return startRemoteStreamRef.current(token.remotePageId) - }) - .then((subscription) => { - if (!subscription) { - return - } - if (!isCurrentRemoteStreamToken(subscription.token)) { - subscription?.unsubscribe() - return - } - streamSubscriptionRef.current = subscription - }) - .catch((error: unknown) => { - if (!isCurrentRemoteStreamOperation(token)) { - return - } - if (isRemoteBrowserPageMissingError(error)) { - closeMissingRemotePage(token.remotePageId) - return - } - setRemoteError( - error instanceof Error ? error.message : 'Failed to restart remote browser stream.' - ) - setBusy(false) - }) - }, 500) - }, - [ - applyRemoteTabInfo, + // Publish only callbacks from a committed render. + useLayoutEffect(() => { + streamBridgeRef.current = { + applyTabInfo: applyRemoteTabInfo, + clearFrame: clearStreamFrame, + handleFrameBytes: updateStreamFrame, closeMissingRemotePage, - fetchRemoteTabInfo, - isCurrentRemoteStreamOperation, - isCurrentRemoteStreamToken - ] - ) + waitForViewportSize: waitForRemoteViewportSize, + syncViewport: syncRemoteViewport + } + }, [ + applyRemoteTabInfo, + clearStreamFrame, + closeMissingRemotePage, + syncRemoteViewport, + updateStreamFrame, + waitForRemoteViewportSize + ]) - const handleRemoteStreamClosed = useCallback( - (token: RemoteBrowserStreamToken, restart: boolean): void => { - if (!isCurrentRemoteStreamToken(token)) { - return - } - setBusy(restart) - const current = streamSubscriptionRef.current - streamSubscriptionRef.current = null - activeStreamTokenRef.current = null - remoteStreamViewportSizeRef.current = null - // Why: navigation recreates the screencast stream; keep the last frame during restart so panes don't flash the loading placeholder. - if (!restart) { - clearStreamFrame() - } - current?.unsubscribe() - if (restart) { - scheduleRemoteStreamRestart(token) - } - }, - [clearStreamFrame, isCurrentRemoteStreamToken, scheduleRemoteStreamRestart] - ) - - const startRemoteStream = useCallback( - async (pageId: string): Promise => { - const target = runtimeTarget() - if (!target) { - return null - } - const operationToken = createRemoteOperationToken(pageId) - if (!operationToken || !isCurrentRemoteOperationToken(operationToken)) { - return null - } - const status = await callRuntimeRpc(target, 'status.get', undefined, { - timeoutMs: 15_000 - }) - if (!status.capabilities?.includes('browser.screencast.v1')) { - throw new Error('The selected runtime does not support remote browser streaming.') - } - if (!isCurrentRemoteOperationToken(operationToken)) { - return null - } - const viewportSize = await waitForRemoteViewportSize() - remoteStreamViewportSizeRef.current = viewportSize - const token: RemoteBrowserStreamToken = { - tabId: browserTab.id, - environmentId: target.environmentId, - remotePageId: pageId, - generation: streamGenerationRef.current + 1, - operationGeneration: operationToken.generation - } - streamGenerationRef.current = token.generation - activeStreamTokenRef.current = token - try { - const subscription = await window.api.runtimeEnvironments.subscribe( - { - selector: target.environmentId, - method: 'browser.screencast', - params: withBrowserPaneUiRuntimeRpcSource({ - worktree: runtimeWorktree, - page: pageId, - format: 'jpeg', - quality: 70, - maxWidth: 3840, - maxHeight: 2160, - viewportWidth: viewportSize?.width, - viewportHeight: viewportSize?.height, - deviceScaleFactor: getRemoteBrowserDeviceScaleFactor(), - everyNthFrame: 2 - }), - timeoutMs: 15_000 - }, - { - onResponse: (response) => { - if (!isCurrentRemoteStreamToken(token)) { - return - } - if (response.ok === false) { - if (isRemoteBrowserPageMissingCode(response.error.code)) { - closeMissingRemotePage(pageId) - return - } - setRemoteError(response.error.message) - handleRemoteStreamClosed(token, false) - return - } - const event = response.result as BrowserScreencastResult - if (event.type === 'ready') { - applyRemoteTabInfo(event.tab) - void syncRemoteViewport(event.browserPageId).catch(() => {}) - setBusy(false) - } else if (event.type === 'end') { - handleRemoteStreamClosed(token, true) - } else if (event.type === 'error') { - setRemoteError(event.message) - handleRemoteStreamClosed(token, false) - } - }, - onBinary: (bytes) => updateStreamFrame(token, bytes), - onError: (error) => { - if (!isCurrentRemoteStreamToken(token)) { - return - } - if (isRemoteBrowserPageMissingError(error)) { - closeMissingRemotePage(pageId) - return - } - setRemoteError(error.message) - setBusy(false) - }, - onClose: () => { - handleRemoteStreamClosed(token, true) - } - } - ) - return { token, unsubscribe: subscription.unsubscribe } - } catch (error) { - if (isCurrentRemoteStreamToken(token)) { - activeStreamTokenRef.current = null - } - throw error - } - }, - [ - applyRemoteTabInfo, - browserTab.id, - closeMissingRemotePage, - createRemoteOperationToken, - handleRemoteStreamClosed, - isCurrentRemoteOperationToken, - isCurrentRemoteStreamToken, - runtimeTarget, - syncRemoteViewport, - updateStreamFrame, - waitForRemoteViewportSize, - runtimeWorktree - ] - ) - - const restartRemoteStreamForViewport = useCallback( - (pageId: string): void => { - const current = streamSubscriptionRef.current - const nextViewportSize = remoteViewportSizeRef.current - if ( - !current || - current.token.remotePageId !== pageId || - !nextViewportSize || - areRemoteViewportSizesNear(remoteStreamViewportSizeRef.current, nextViewportSize) || - !isCurrentRemoteStreamToken(current.token) - ) { - return - } - - // Why: the runtime stream validates frames against its start viewport, so restart media after resize or new-size frames get rejected. - streamGenerationRef.current += 1 - activeStreamTokenRef.current = null - streamSubscriptionRef.current = null - remoteStreamViewportSizeRef.current = null - if (streamRestartTimerRef.current !== null) { - window.clearTimeout(streamRestartTimerRef.current) - streamRestartTimerRef.current = null - } - setBusy(true) - current.unsubscribe() - void startRemoteStreamRef - .current(pageId) - .then((subscription) => { - if (!subscription) { - if (mountedRef.current && isActiveRef.current && remotePageIdRef.current === pageId) { - setBusy(false) - } - return - } - if (!isCurrentRemoteStreamToken(subscription.token)) { - subscription.unsubscribe() - return - } - streamSubscriptionRef.current = subscription - }) - .catch((error: unknown) => { - if (!mountedRef.current || !isActiveRef.current || remotePageIdRef.current !== pageId) { - return - } - if (isRemoteBrowserPageMissingError(error)) { - closeMissingRemotePage(pageId) - return - } - setRemoteError( - error instanceof Error ? error.message : 'Failed to resize remote browser stream.' - ) - setBusy(false) - }) - }, - [closeMissingRemotePage, isCurrentRemoteStreamToken] - ) - - useEffect(() => { - startRemoteStreamRef.current = startRemoteStream - restartRemoteStreamForViewportRef.current = restartRemoteStreamForViewport - }, [restartRemoteStreamForViewport, startRemoteStream]) + const reconnectRemoteStream = useCallback((): void => { + // No status write here: bumping the nonce re-runs the open effect, and open() publishes + // 'opening'. Setting it from two places is how the old three-variable version drifted. + setPaneNotice(null) + // Why re-run the whole open effect rather than resume the stream: reconnect has to work in the + // cases where there is nothing to resume — the remote page was never created, or the very first + // open failed. Resuming a token only covers a stream that once existed. + setReopenNonce((nonce) => nonce + 1) + }, []) useEffect(() => { if (!isActive) { return } - let cancelled = false - setBusy(true) - setRemoteError(null) - remoteOperationGenerationRef.current += 1 - streamGenerationRef.current += 1 - activeStreamTokenRef.current = null - streamSubscriptionRef.current?.unsubscribe() - streamSubscriptionRef.current = null - if (streamRestartTimerRef.current !== null) { - window.clearTimeout(streamRestartTimerRef.current) - streamRestartTimerRef.current = null - } - const operationToken = createRemoteOperationToken() - if (!operationToken) { - setBusy(false) - return - } - void ensureRemotePage(operationToken) - .then(async (pageId) => { - if (!pageId || cancelled || !isCurrentRemoteOperationToken(operationToken)) { - return - } - const pageToken = { ...operationToken, remotePageId: pageId } - const tab = await fetchRemoteTabInfo(pageToken) - if (tab && !cancelled && isCurrentRemoteOperationToken(pageToken)) { - applyRemoteTabInfo(tab) - } - if (cancelled || !isCurrentRemoteOperationToken(pageToken)) { - return - } - const subscription = await startRemoteStream(pageId) - if (cancelled || !subscription) { - subscription?.unsubscribe() - return - } - if (!isCurrentRemoteStreamToken(subscription.token)) { - subscription.unsubscribe() - return - } - streamSubscriptionRef.current = subscription - }) - .catch((error: unknown) => { - if (!cancelled) { - if (isRemoteBrowserPageMissingError(error)) { - closeMissingRemotePage() - return - } - setRemoteError(error instanceof Error ? error.message : 'Failed to open remote browser.') - setBusy(false) - } - }) + const closeStream = lifecycle.open() return () => { - cancelled = true - remoteOperationGenerationRef.current += 1 - streamGenerationRef.current += 1 - activeStreamTokenRef.current = null + closeStream() clearPendingRemoteWheel() - streamSubscriptionRef.current?.unsubscribe() - streamSubscriptionRef.current = null - if (streamRestartTimerRef.current !== null) { - window.clearTimeout(streamRestartTimerRef.current) - streamRestartTimerRef.current = null - } } + // Why: the lifecycle reads tab/environment/worktree live, so it only needs to reopen when the + // pane's identity actually changes — not when an unrelated callback identity does. + // + // browserTab.id is load-bearing because lifecycle.open() reads tab identity through refs. + // reopenNonce re-runs the full open path for an explicit reconnect. }, [ + activeRuntimeEnvironmentId, + browserTab.id, clearPendingRemoteWheel, - createRemoteOperationToken, - ensureRemotePage, - fetchRemoteTabInfo, isActive, - closeMissingRemotePage, - isCurrentRemoteOperationToken, - isCurrentRemoteStreamToken, - applyRemoteTabInfo, - startRemoteStream + lifecycle, + reopenNonce, + runtimeWorktree ]) useEffect(() => { @@ -1978,7 +1569,7 @@ function RemoteBrowserPagePane({ if (!operationToken) { return } - const pageId = await ensureRemotePage(operationToken) + const pageId = await lifecycle.session.ensureRemotePage(operationToken) if (!pageId) { return } @@ -1986,8 +1577,8 @@ function RemoteBrowserPagePane({ if (!isCurrentRemoteOperationToken(pageToken)) { return } - setBusy(true) - setRemoteError(null) + setPaneBusy(true) + setPaneNotice(null) onUpdatePageState(browserTab.id, { loading: true, loadError: null }) try { const params = @@ -2009,7 +1600,7 @@ function RemoteBrowserPagePane({ return } const message = error instanceof Error ? error.message : 'Remote browser command failed.' - setRemoteError(message) + setPaneNotice({ kind: 'consequence', text: message }) onUpdatePageState(browserTab.id, { loading: false, // Why: validatedUrl is persisted, so redact the Kagi session token like the main-process failure path does. @@ -2021,7 +1612,7 @@ function RemoteBrowserPagePane({ }) } finally { if (isCurrentRemoteOperationToken(pageToken)) { - setBusy(false) + setPaneBusy(false) } } }, @@ -2030,7 +1621,7 @@ function RemoteBrowserPagePane({ browserTab.id, browserTab.url, createRemoteOperationToken, - ensureRemotePage, + lifecycle, closeMissingRemotePage, isCurrentRemoteOperationToken, onUpdatePageState, @@ -2078,7 +1669,9 @@ function RemoteBrowserPagePane({ }) if (!nextUrl) { const message = 'Enter a valid http(s) or localhost URL.' - setRemoteError(message) + // 'direct': the only response to what the user just typed. With an empty address bar no + // load-error overlay renders either, so outranking this would make Enter do nothing visible. + setPaneNotice({ kind: 'direct', text: message }) onUpdatePageState(browserTab.id, { loadError: { code: 0, @@ -2096,7 +1689,7 @@ function RemoteBrowserPagePane({ return } const target = runtimeTarget() - const pageId = remotePageIdRef.current + const pageId = lifecycle.tokens.remotePage const image = imageRef.current const operationToken = pageId ? createRemoteOperationToken(pageId) : null const point = getRemoteImagePoint(event) @@ -2110,7 +1703,7 @@ function RemoteBrowserPagePane({ event.preventDefault() image.focus() setContextMenu(null) - setRemoteError(null) + setPaneNotice(null) enqueueRemoteInput(async () => { if (!isCurrentRemoteOperationToken(operationToken)) { return @@ -2135,7 +1728,10 @@ function RemoteBrowserPagePane({ closeMissingRemotePage(pageId) return } - setRemoteError(error instanceof Error ? error.message : 'Remote mouse input failed.') + setPaneNotice({ + kind: 'consequence', + text: error instanceof Error ? error.message : 'Remote mouse input failed.' + }) } } }) @@ -2146,7 +1742,7 @@ function RemoteBrowserPagePane({ return } const target = runtimeTarget() - const pageId = remotePageIdRef.current + const pageId = lifecycle.tokens.remotePage const operationToken = pageId ? createRemoteOperationToken(pageId) : null const point = getRemoteImagePoint(event) const button = getRemoteBrowserMouseButton(event.button) @@ -2157,7 +1753,7 @@ function RemoteBrowserPagePane({ return } event.preventDefault() - setRemoteError(null) + setPaneNotice(null) enqueueRemoteInput(async () => { if (!isCurrentRemoteOperationToken(operationToken)) { return @@ -2183,7 +1779,10 @@ function RemoteBrowserPagePane({ closeMissingRemotePage(pageId) return } - setRemoteError(error instanceof Error ? error.message : 'Remote mouse input failed.') + setPaneNotice({ + kind: 'consequence', + text: error instanceof Error ? error.message : 'Remote mouse input failed.' + }) } } }) @@ -2194,14 +1793,14 @@ function RemoteBrowserPagePane({ return } const target = runtimeTarget() - const pageId = remotePageIdRef.current + const pageId = lifecycle.tokens.remotePage const point = getRemoteImagePoint(event) if (!target || !pageId || !point) { return } event.preventDefault() imageRef.current?.focus() - setRemoteError(null) + setPaneNotice(null) setContextMenu({ x: event.clientX, y: event.clientY, @@ -2256,7 +1855,7 @@ function RemoteBrowserPagePane({ return } const target = runtimeTarget() - const pageId = remotePageIdRef.current + const pageId = lifecycle.tokens.remotePage const operationToken = pageId ? createRemoteOperationToken(pageId) : null if (!target || !pageId || !operationToken) { return @@ -2267,7 +1866,7 @@ function RemoteBrowserPagePane({ return } event.preventDefault() - setRemoteError(null) + setPaneNotice(null) enqueueRemoteInput(async () => { if (!isCurrentRemoteOperationToken(operationToken)) { return @@ -2294,7 +1893,10 @@ function RemoteBrowserPagePane({ closeMissingRemotePage(pageId) return } - setRemoteError(error instanceof Error ? error.message : 'Remote keyboard input failed.') + setPaneNotice({ + kind: 'consequence', + text: error instanceof Error ? error.message : 'Remote keyboard input failed.' + }) } } }) @@ -2342,7 +1944,10 @@ function RemoteBrowserPagePane({ closeMissingRemotePage(pageId) return } - setRemoteError(error instanceof Error ? error.message : 'Remote scroll failed.') + setPaneNotice({ + kind: 'consequence', + text: error instanceof Error ? error.message : 'Remote scroll failed.' + }) } } }).finally(() => { @@ -2367,14 +1972,14 @@ function RemoteBrowserPagePane({ return } const target = runtimeTarget() - const pageId = remotePageIdRef.current + const pageId = lifecycle.tokens.remotePage const operationToken = pageId ? createRemoteOperationToken(pageId) : null const point = getRemoteImagePoint(event) if (!target || !pageId || !operationToken || !point) { return } event.preventDefault() - setRemoteError(null) + setPaneNotice(null) const deltaMultiplier = event.deltaMode === WHEEL_DELTA_LINE ? 16 @@ -2412,6 +2017,7 @@ function RemoteBrowserPagePane({ busy, createRemoteOperationToken, getRemoteImagePoint, + lifecycle, runtimeTarget, schedulePendingRemoteWheel ] @@ -2458,7 +2064,12 @@ function RemoteBrowserPagePane({ }) return ( -
+ // The testid scopes E2E queries to this pane: a workspace can hold more than one browser pane, + // and controls like the address bar are otherwise ambiguous across them. +
{contextMenu ? createPortal( <> @@ -2699,6 +2310,7 @@ function RemoteBrowserPagePane({ ) : null} {frameUrl ? ( ) : null} - {remoteError ? ( -
- {remoteError} + {/* Why the reconnect control also opens this toast: the control renders inside it, so + gating the toast on the message alone made an empty message ('' from a host that failed + with no text) swallow the user's only way back — the original stranding bug, reachable + through three sites that forward host-supplied text into `stopped`. */} + {remoteError || canReconnectRemoteBrowserStream(streamStatus) ? ( +
+ {remoteError} + {canReconnectRemoteBrowserStream(streamStatus) ? ( + + ) : null}
) : null}
diff --git a/src/renderer/src/components/browser-pane/remote-browser-page-session.ts b/src/renderer/src/components/browser-pane/remote-browser-page-session.ts new file mode 100644 index 00000000000..8cedddf81e4 --- /dev/null +++ b/src/renderer/src/components/browser-pane/remote-browser-page-session.ts @@ -0,0 +1,148 @@ +import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' +import { ORCA_BROWSER_BLANK_URL } from '../../../../shared/constants' +import type { BrowserTabInfo } from '../../../../shared/runtime-types' +import { isRemoteBrowserPageMissingError } from './remote-browser-stream-errors' +import type { + RemoteBrowserOperationToken, + RemoteBrowserOperationTokens +} from './remote-browser-stream-tokens' + +export type RemoteBrowserRpcCall = ( + target: RuntimeClientTarget, + method: string, + params?: unknown, + options?: { timeoutMs?: number; suppressFeatureInteraction?: boolean } +) => Promise + +export type RemoteBrowserPageHandle = { + environmentId: string + remotePageId: string +} + +export type RemoteBrowserPageSessionDeps = { + tokens: RemoteBrowserOperationTokens + callRpc: RemoteBrowserRpcCall + getWorktreeSelector(): string + getCurrentUrl(): string + readStoredHandle(): RemoteBrowserPageHandle | null + writeStoredHandle(handle: RemoteBrowserPageHandle): void + removeStoredHandle(remotePageId: string): void + applyTabInfo(tab: Pick): void + closeMissingRemotePage(remotePageId: string | null): void +} + +// Owns the runtime-side page this pane is bound to: creating or adopting it, reading its tab info, +// and the debounced post-input refresh. Split from the stream lifecycle so page identity stays +// testable on its own. +export class RemoteBrowserPageSession { + private tabRefreshTimer: ReturnType | null = null + + constructor(private readonly deps: RemoteBrowserPageSessionDeps) {} + + async fetchTabInfo(token: RemoteBrowserOperationToken): Promise { + if (!this.deps.tokens.isCurrent(token) || !token.remotePageId) { + return null + } + const shown = await this.deps.callRpc<{ tab: BrowserTabInfo }>( + { kind: 'environment', environmentId: token.environmentId }, + 'browser.tabShow', + { worktree: this.deps.getWorktreeSelector(), page: token.remotePageId }, + { timeoutMs: 15_000, suppressFeatureInteraction: true } + ) + return shown.tab + } + + async ensureRemotePage(token: RemoteBrowserOperationToken): Promise { + const { tokens } = this.deps + if (!tokens.isCurrent(token)) { + return null + } + const existingHandle = this.deps.readStoredHandle() + if (existingHandle?.environmentId !== token.environmentId) { + return this.createRemotePage(token) + } + tokens.setRemotePage(existingHandle.remotePageId) + try { + const cachedTab = await this.fetchTabInfo({ + ...token, + remotePageId: existingHandle.remotePageId + }) + return cachedTab ? existingHandle.remotePageId : null + } catch (error) { + if (!isRemoteBrowserPageMissingError(error)) { + throw error + } + this.deps.removeStoredHandle(existingHandle.remotePageId) + if (tokens.remotePage === existingHandle.remotePageId) { + tokens.setRemotePage(null) + } + if (tokens.isCurrent(token)) { + this.deps.closeMissingRemotePage(existingHandle.remotePageId) + } + return null + } + } + + scheduleTabInfoRefresh(token: RemoteBrowserOperationToken, delayMs = 250): void { + const { tokens } = this.deps + if (!tokens.isCurrent(token)) { + return + } + this.cancelTabInfoRefresh() + this.tabRefreshTimer = setTimeout(() => { + this.tabRefreshTimer = null + if (!tokens.isCurrent(token)) { + return + } + void this.fetchTabInfo(token) + .then((tab) => { + if (tab && tokens.isCurrent(token)) { + this.deps.applyTabInfo(tab) + } + }) + .catch((error: unknown) => { + if (tokens.isCurrent(token) && isRemoteBrowserPageMissingError(error)) { + this.deps.closeMissingRemotePage(token.remotePageId) + } + }) + }, delayMs) + } + + cancelTabInfoRefresh(): void { + if (this.tabRefreshTimer !== null) { + clearTimeout(this.tabRefreshTimer) + this.tabRefreshTimer = null + } + } + + private async createRemotePage(token: RemoteBrowserOperationToken): Promise { + const target: RuntimeClientTarget = { kind: 'environment', environmentId: token.environmentId } + const worktree = this.deps.getWorktreeSelector() + const currentUrl = this.deps.getCurrentUrl() + const initialUrl = + currentUrl === ORCA_BROWSER_BLANK_URL ? 'about:blank' : currentUrl || 'about:blank' + const created = await this.deps.callRpc<{ browserPageId: string }>( + target, + 'browser.tabCreate', + { worktree, url: initialUrl }, + { timeoutMs: 30_000, suppressFeatureInteraction: true } + ) + if (!this.deps.tokens.isCurrent(token)) { + void this.deps + .callRpc( + target, + 'browser.tabClose', + { worktree, page: created.browserPageId }, + { timeoutMs: 15_000, suppressFeatureInteraction: true } + ) + .catch(() => {}) + return null + } + this.deps.tokens.setRemotePage(created.browserPageId) + this.deps.writeStoredHandle({ + environmentId: target.environmentId, + remotePageId: created.browserPageId + }) + return created.browserPageId + } +} diff --git a/src/renderer/src/components/browser-pane/remote-browser-screencast-subscription.ts b/src/renderer/src/components/browser-pane/remote-browser-screencast-subscription.ts new file mode 100644 index 00000000000..9968722864f --- /dev/null +++ b/src/renderer/src/components/browser-pane/remote-browser-screencast-subscription.ts @@ -0,0 +1,111 @@ +import type { RuntimeRpcResponse } from '../../../../shared/runtime-rpc-envelope' +import type { + BrowserScreencastReadyResult, + BrowserScreencastResult +} from '../../../../shared/runtime-types' +import { withBrowserPaneUiRuntimeRpcSource } from '../../../../shared/runtime-rpc-feature-interaction-source' +import { isRemoteBrowserPageMissingCode } from './remote-browser-stream-errors' +import type { RemoteBrowserViewportSize } from './remote-browser-stream-tokens' + +export type RemoteBrowserScreencastSubscribe = ( + args: { selector: string; method: string; params?: unknown; timeoutMs?: number }, + callbacks: { + onResponse: (response: RuntimeRpcResponse) => void + onBinary?: (bytes: Uint8Array) => void + onError?: (error: { code: string; message: string }) => void + onClose?: () => void + } +) => Promise<{ unsubscribe: () => void }> + +export type RemoteBrowserScreencastRequest = { + environmentId: string + worktree: string + pageId: string + viewportSize: RemoteBrowserViewportSize | null + deviceScaleFactor: number +} + +// Why: isCurrent gates the two channels the runtime can deliver a stale event on; frames and close +// carry their own supersession checks downstream, so they are forwarded unguarded. +export type RemoteBrowserScreencastEvents = { + isCurrent: () => boolean + onReady: (event: BrowserScreencastReadyResult) => void + onEnded: () => void + onFailed: (message: string) => void + // Why: a transport-level error reports without closing the stream, which the runtime still owns. + onTransportError: (message: string) => void + onPageMissing: () => void + onFrame: (bytes: Uint8Array) => void + onClosed: () => void +} + +// Translates one runtime screencast subscription's raw RPC envelope traffic into the semantic +// events a pane cares about. +export async function openRemoteBrowserScreencastStream( + subscribe: RemoteBrowserScreencastSubscribe, + request: RemoteBrowserScreencastRequest, + events: RemoteBrowserScreencastEvents +): Promise<{ unsubscribe: () => void }> { + return subscribe( + { + selector: request.environmentId, + method: 'browser.screencast', + params: withBrowserPaneUiRuntimeRpcSource({ + worktree: request.worktree, + page: request.pageId, + format: 'jpeg', + quality: 70, + maxWidth: 3840, + maxHeight: 2160, + viewportWidth: request.viewportSize?.width, + viewportHeight: request.viewportSize?.height, + deviceScaleFactor: request.deviceScaleFactor, + everyNthFrame: 2 + }), + timeoutMs: 15_000 + }, + { + onResponse: (response) => dispatchScreencastResponse(response, events), + onBinary: (bytes) => events.onFrame(bytes), + onError: (error) => { + if (!events.isCurrent()) { + return + } + if (isRemoteBrowserPageMissingCode(error.code)) { + events.onPageMissing() + return + } + events.onTransportError(error.message) + }, + onClose: () => events.onClosed() + } + ) +} + +function dispatchScreencastResponse( + response: RuntimeRpcResponse, + events: RemoteBrowserScreencastEvents +): void { + if (!events.isCurrent()) { + return + } + if (response.ok === false) { + if (isRemoteBrowserPageMissingCode(response.error.code)) { + events.onPageMissing() + return + } + events.onFailed(response.error.message) + return + } + if (!response.result || typeof response.result !== 'object' || !('type' in response.result)) { + return + } + const event = response.result as BrowserScreencastResult + if (event.type === 'ready') { + events.onReady(event) + } else if (event.type === 'end') { + events.onEnded() + } else if (event.type === 'error') { + events.onFailed(event.message) + } +} diff --git a/src/renderer/src/components/browser-pane/remote-browser-stream-errors.test.ts b/src/renderer/src/components/browser-pane/remote-browser-stream-errors.test.ts new file mode 100644 index 00000000000..9dcf0cfccea --- /dev/null +++ b/src/renderer/src/components/browser-pane/remote-browser-stream-errors.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest' +import { + isPermanentRemoteBrowserStreamFailure, + remoteBrowserStreamUnsupportedError, + resolveRemoteBrowserStreamRestartFailure +} from './remote-browser-stream-errors' + +function rpcError(code: string, message = 'boom'): Error { + return Object.assign(new Error(message), { code }) +} + +// Why this file exists: the permanent set decides whether the pane ever retries on its own, and it +// was enforced by nothing — re-adding `selector_not_found` (the exact code 08260a54bf had to walk +// back out) left the whole suite green, as did deleting two of the three members. +describe('remote browser stream failure classification', () => { + it('treats only codes that prove the target is gone as permanent', () => { + for (const code of [ + 'worktree_not_found_on_server', + 'repo_not_found', + 'capability_unsupported' + ]) { + expect(isPermanentRemoteBrowserStreamFailure(rpcError(code))).toBe(true) + } + expect(isPermanentRemoteBrowserStreamFailure(remoteBrowserStreamUnsupportedError())).toBe(true) + }) + + // The regression guard. `selector_not_found` means "I could not resolve this right now" — its + // producer is a live worktree scan behind a 1s TTL cache, and the connectionId-gated fallback that + // shields SSH repos from scan lag does not cover purely local ones. Treating it as permanent + // stopped retries on a blip and stranded the pane, which is the bug this area exists to prevent. + it('does not treat an unresolved selector as proof the target is gone', () => { + expect(isPermanentRemoteBrowserStreamFailure(rpcError('selector_not_found'))).toBe(false) + expect( + resolveRemoteBrowserStreamRestartFailure(rpcError('selector_not_found')).shouldRetry + ).toBe(true) + }) + + it('keeps unknown and transport failures retryable', () => { + for (const code of ['runtime_unavailable', 'runtime_timeout', 'socket_closed']) { + expect(isPermanentRemoteBrowserStreamFailure(rpcError(code))).toBe(false) + } + expect(isPermanentRemoteBrowserStreamFailure(new Error('no code at all'))).toBe(false) + expect(isPermanentRemoteBrowserStreamFailure(null)).toBe(false) + }) + + it('keeps a permanent failure specific and replaces only raw transient text', () => { + const permanent = resolveRemoteBrowserStreamRestartFailure( + rpcError('worktree_not_found_on_server', 'worktree is gone') + ) + // Its own message says something true that a generic "lost connection" would not. + expect(permanent.message).toBe('worktree is gone') + expect(permanent.shouldRetry).toBe(false) + expect(permanent.logRawError).toBe(false) + + const transient = resolveRemoteBrowserStreamRestartFailure( + rpcError('runtime_unavailable', 'Runtime environment is manually disconnected.') + ) + expect(transient.message).toBe('Lost connection to the remote server.') + expect(transient.shouldRetry).toBe(true) + // Dropped from the UI but not lost: nothing else records it. + expect(transient.logRawError).toBe(true) + }) + + it('uses localized copy for permanent failures without an Error message', () => { + const failure = resolveRemoteBrowserStreamRestartFailure({ + code: 'worktree_not_found_on_server' + }) + + expect(failure.message).toBe('Failed to restart remote browser stream.') + expect(failure.shouldRetry).toBe(false) + }) +}) diff --git a/src/renderer/src/components/browser-pane/remote-browser-stream-errors.ts b/src/renderer/src/components/browser-pane/remote-browser-stream-errors.ts new file mode 100644 index 00000000000..7db8458d266 --- /dev/null +++ b/src/renderer/src/components/browser-pane/remote-browser-stream-errors.ts @@ -0,0 +1,107 @@ +import { RuntimeRpcCallError } from '@/runtime/runtime-rpc-client' +import { + remoteBrowserStreamLostNotice, + remoteBrowserStreamRestartFailedNotice, + remoteBrowserStreamUnsupportedNotice +} from './remote-browser-stream-status' + +// Why: a runtime lacking browser.screencast.v1 will not grow it while this connection lives, so +// retrying that failure is unbounded work with a visible error each round. Tagged rather than +// message-matched so a reworded string cannot silently turn it back into an infinite retry. +export const REMOTE_BROWSER_STREAM_UNSUPPORTED = 'remote_browser_stream_unsupported' + +// Why: the runtime answers with these when the thing the stream is anchored to is gone from the +// host (worktree deleted, repo unregistered, capability absent). Retrying cannot bring it back, so +// they are permanent for this connection exactly like the capability tag above. Codes come from +// src/main/runtime/rpc/errors.ts rather than being invented here. +// Why `selector_not_found` is NOT here: it means "I could not resolve this right now", which is +// UNKNOWN, not proof the target is gone. Its producer is a live worktree scan behind a 1s-TTL cache, +// and the connectionId-gated fallback that shields SSH repos from scan lag does not cover purely +// local ones — so a slow scan can surface it transiently. Treating that as permanent would strand +// the pane forever, which is the exact bug this file exists to prevent. The three below are +// unambiguous: the host is telling us the thing itself no longer exists. +const REMOTE_BROWSER_STREAM_TARGET_GONE_CODES: ReadonlySet = new Set([ + 'worktree_not_found_on_server', + 'repo_not_found', + 'capability_unsupported' +]) + +const REMOTE_BROWSER_PAGE_MISSING_CODES: ReadonlySet = new Set([ + 'browser_tab_not_found', + 'browser_no_tab' +]) + +export function remoteBrowserStreamUnsupportedError(): Error { + return Object.assign(new Error(remoteBrowserStreamUnsupportedNotice()), { + code: REMOTE_BROWSER_STREAM_UNSUPPORTED + }) +} + +function readErrorCode(error: unknown): string | null { + if (error instanceof RuntimeRpcCallError) { + return error.code + } + if (!error || typeof error !== 'object' || !('code' in error)) { + return null + } + const code = (error as { code: unknown }).code + return typeof code === 'string' ? code : null +} + +export function isRemoteBrowserStreamUnsupportedError(error: unknown): boolean { + return readErrorCode(error) === REMOTE_BROWSER_STREAM_UNSUPPORTED +} + +export function isRemoteBrowserPageMissingCode(code: unknown): boolean { + return typeof code === 'string' && REMOTE_BROWSER_PAGE_MISSING_CODES.has(code) +} + +export function isRemoteBrowserPageMissingError(error: unknown): boolean { + return isRemoteBrowserPageMissingCode(readErrorCode(error)) +} + +// Why: restart retries must stop for failures the host cannot recover from on its own; anything +// else is unproven and must keep retrying rather than strand the pane with a dead subscription. +export function isPermanentRemoteBrowserStreamFailure(error: unknown): boolean { + const code = readErrorCode(error) + if (code === null) { + return false + } + return ( + code === REMOTE_BROWSER_STREAM_UNSUPPORTED || REMOTE_BROWSER_STREAM_TARGET_GONE_CODES.has(code) + ) +} + +export type RemoteBrowserStreamRestartFailure = { + /** What the pane shows. */ + message: string + /** False once the failure is proven unrecoverable on this connection. */ + shouldRetry: boolean + /** Whether the raw error is worth logging — true only when the pane replaced the message. */ + logRawError: boolean +} + +// Why the message is decided here rather than at the call site: whether a failure is permanent and +// what the user should be told are the same judgement. A permanent failure is one we classified and +// understand, so its own message says something true and specific ("The selected runtime does not +// support remote browser streaming."); flattening that to "Lost connection" would be vaguer and +// wrong, since nothing was lost. Everything else carries a raw transport string written for logs, +// so the pane speaks for itself there and the raw text is left to the caller to log. +// +// Note what this deliberately does NOT decide: whether the user is offered a way back. Stopping +// automatic retries and removing the user's only recovery are different things, and conflating them +// is what stranded the pane in the first place. `selector_not_found` already had to be walked back +// out of the permanent set once (08260a54bf) — proof this classification can be wrong — so a +// misjudgement here must stay recoverable by hand. +export function resolveRemoteBrowserStreamRestartFailure( + error: unknown +): RemoteBrowserStreamRestartFailure { + if (isPermanentRemoteBrowserStreamFailure(error)) { + return { + message: error instanceof Error ? error.message : remoteBrowserStreamRestartFailedNotice(), + shouldRetry: false, + logRawError: false + } + } + return { message: remoteBrowserStreamLostNotice(), shouldRetry: true, logRawError: true } +} diff --git a/src/renderer/src/components/browser-pane/remote-browser-stream-lifecycle-deps.ts b/src/renderer/src/components/browser-pane/remote-browser-stream-lifecycle-deps.ts new file mode 100644 index 00000000000..e0a5216db76 --- /dev/null +++ b/src/renderer/src/components/browser-pane/remote-browser-stream-lifecycle-deps.ts @@ -0,0 +1,26 @@ +import type { RemoteBrowserPageSessionDeps } from './remote-browser-page-session' +import type { RemoteBrowserScreencastSubscribe } from './remote-browser-screencast-subscription' +import type { RemoteBrowserStreamStatus } from './remote-browser-stream-status' +import type { + RemoteBrowserPaneIdentity, + RemoteBrowserStreamToken, + RemoteBrowserViewportSize +} from './remote-browser-stream-tokens' + +// Everything RemoteBrowserStreamLifecycle needs from the React pane, kept separate so the pane can +// be read against one surface and the lifecycle can be driven from tests without one. +export type RemoteBrowserStreamLifecycleDeps = Omit & { + identity: RemoteBrowserPaneIdentity + subscribeScreencast: RemoteBrowserScreencastSubscribe + waitForViewportSize: () => Promise + readViewportSize: () => RemoteBrowserViewportSize | null + syncViewport: (pageId: string) => Promise + getDeviceScaleFactor: () => number + // Why one setter instead of separate busy/error/reconnect calls: those three always described a + // single thing — what this stream is doing — and writing them independently is what let them + // disagree. Only this class knows whether a failure still has retry budget left, so it publishes + // the state and the pane derives every visual from it. See remote-browser-stream-status.ts. + setStatus: (status: RemoteBrowserStreamStatus) => void + clearFrame: () => void + handleFrameBytes: (token: RemoteBrowserStreamToken, bytes: Uint8Array) => void +} diff --git a/src/renderer/src/components/browser-pane/remote-browser-stream-lifecycle.test.ts b/src/renderer/src/components/browser-pane/remote-browser-stream-lifecycle.test.ts new file mode 100644 index 00000000000..3c68bcfe1b7 --- /dev/null +++ b/src/renderer/src/components/browser-pane/remote-browser-stream-lifecycle.test.ts @@ -0,0 +1,1036 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { RemoteBrowserStreamLifecycle } from './remote-browser-stream-lifecycle' +import type { RemoteBrowserPageHandle, RemoteBrowserRpcCall } from './remote-browser-page-session' +import type { RemoteBrowserScreencastSubscribe } from './remote-browser-screencast-subscription' +import type { RemoteBrowserViewportSize } from './remote-browser-stream-tokens' +import { + canReconnectRemoteBrowserStream, + isRemoteBrowserStreamBusy, + remoteBrowserStreamNotice, + type RemoteBrowserStreamStatus +} from './remote-browser-stream-status' +import { isBrowserPaneUiRuntimeRpcParams } from '../../../../shared/runtime-rpc-feature-interaction-source' + +type Gate = { + wait: Promise + release: () => void + fail: (error: unknown) => void +} + +function createGate(): Gate { + let release!: () => void + let fail!: (error: unknown) => void + const wait = new Promise((resolve, reject) => { + release = () => resolve() + fail = (error: unknown) => reject(error) + }) + // Why: the gate is released by the test, not by this tick; an unhandled rejection would fail the run. + wait.catch(() => {}) + return { wait, release, fail } +} + +type FakeScreencastStream = { + pageId: string + params: unknown + viewportWidth: number | undefined + unsubscribeCount: number + emitReady: () => void + emitEnd: () => void + emitStreamError: (message: string) => void + emitMalformedSuccess: () => void + emitResponseFailure: (code: string, message: string) => void + emitTransportError: (code: string, message: string) => void + emitClose: () => void +} + +function rpcError(code: string, message: string): Error { + return Object.assign(new Error(message), { code }) +} + +function createHarness() { + const identity = { + mounted: true, + active: true, + tabId: 'tab-1', + environmentId: 'env-1' as string | null, + pageExists: true + } + const statusLog: RemoteBrowserStreamStatus[] = [] + const appliedTitles: string[] = [] + const closedPages: (string | null)[] = [] + const streams: FakeScreencastStream[] = [] + const rpcLog: string[] = [] + + let capabilities: string[] = ['browser.screencast.v1'] + let storedHandle: RemoteBrowserPageHandle | null = null + let viewportSize: RemoteBrowserViewportSize | null = { width: 800, height: 600 } + let statusGate: Gate | null = null + let viewportGate: Gate | null = null + let tabShowGate: Gate | null = null + let subscribeGate: Gate | null = null + let persistentSubscribeError: unknown = null + const subscribeErrorQueue: unknown[] = [] + let subscribeAttempts = 0 + let closeBeforeNextSubscribeRejects = false + + const callRpc = (async (_target: unknown, method: string) => { + rpcLog.push(method) + if (method === 'status.get') { + if (statusGate) { + const gate = statusGate + statusGate = null + await gate.wait + } + return { capabilities } + } + if (method === 'browser.tabShow') { + if (tabShowGate) { + const gate = tabShowGate + tabShowGate = null + await gate.wait + } + return { tab: { url: 'https://example.test/', title: 'Example' } } + } + if (method === 'browser.tabCreate') { + return { browserPageId: 'page-1' } + } + return {} + }) as unknown as RemoteBrowserRpcCall + + const subscribeScreencast: RemoteBrowserScreencastSubscribe = async (args, callbacks) => { + subscribeAttempts += 1 + if (subscribeGate) { + const gate = subscribeGate + subscribeGate = null + await gate.wait + } + // Models the host closing the subscription and only then rejecting the request, which is what + // src/main/ipc/runtime-environments.ts does on a stale pairing. + if (closeBeforeNextSubscribeRejects) { + closeBeforeNextSubscribeRejects = false + callbacks.onClose?.() + throw rpcError( + 'runtime_unavailable', + 'Runtime environment pairing changed; refresh and try again' + ) + } + const error = subscribeErrorQueue.shift() ?? persistentSubscribeError + if (error) { + throw error + } + const params = args.params as { + page: string + viewportWidth?: number + } + const respond = (result: unknown): void => { + callbacks.onResponse({ id: 'sub-1', ok: true, result, _meta: { runtimeId: 'runtime-1' } }) + } + const stream: FakeScreencastStream = { + pageId: params.page, + params: args.params, + viewportWidth: params.viewportWidth, + unsubscribeCount: 0, + emitReady: () => + respond({ + type: 'ready', + subscriptionId: 'sub-1', + browserPageId: params.page, + format: 'jpeg', + tab: { url: 'https://example.test/', title: 'Example' } + }), + emitEnd: () => respond({ type: 'end', subscriptionId: 'sub-1' }), + emitStreamError: (message) => respond({ type: 'error', message }), + emitMalformedSuccess: () => respond(null), + emitResponseFailure: (code, message) => + callbacks.onResponse({ + id: 'sub-1', + ok: false, + error: { code, message }, + _meta: { runtimeId: 'runtime-1' } + }), + emitTransportError: (code, message) => callbacks.onError?.({ code, message }), + emitClose: () => callbacks.onClose?.() + } + streams.push(stream) + return { + unsubscribe: () => { + stream.unsubscribeCount += 1 + } + } + } + + const lifecycle = new RemoteBrowserStreamLifecycle({ + identity: { + isMounted: () => identity.mounted, + isActive: () => identity.active, + getTabId: () => identity.tabId, + getEnvironmentId: () => identity.environmentId, + browserPageExists: () => identity.pageExists + }, + callRpc, + subscribeScreencast, + getWorktreeSelector: () => 'worktree:wt-1', + getCurrentUrl: () => 'https://example.test/', + readStoredHandle: () => storedHandle, + writeStoredHandle: (handle) => { + storedHandle = handle + }, + removeStoredHandle: () => { + storedHandle = null + }, + applyTabInfo: (tab) => appliedTitles.push(tab.title ?? ''), + closeMissingRemotePage: (remotePageId) => closedPages.push(remotePageId), + waitForViewportSize: async () => { + if (viewportGate) { + const gate = viewportGate + viewportGate = null + await gate.wait + } + return viewportSize + }, + readViewportSize: () => viewportSize, + syncViewport: async () => {}, + getDeviceScaleFactor: () => 1, + setStatus: (status) => statusLog.push(status), + clearFrame: () => {}, + handleFrameBytes: () => {} + }) + + return { + lifecycle, + identity, + statusLog, + appliedTitles, + closedPages, + streams, + rpcLog, + get subscribeAttempts(): number { + return subscribeAttempts + }, + // Kept as accessors so the assertions written against the old three-variable shape still read + // naturally — they now derive from the one status, which is the point of the change. + get errorLog(): (string | null)[] { + return statusLog.map(remoteBrowserStreamNotice) + }, + get busyLog(): boolean[] { + return statusLog.map(isRemoteBrowserStreamBusy) + }, + get reconnectOffered(): boolean { + const status = statusLog.at(-1) + return status ? canReconnectRemoteBrowserStream(status) : false + }, + get currentStatusKind(): string | null { + return statusLog.at(-1)?.kind ?? null + }, + get currentError(): string | null { + const status = statusLog.at(-1) + return status ? remoteBrowserStreamNotice(status) : null + }, + setCapabilities: (next: string[]) => { + capabilities = next + }, + setViewportSize: (next: RemoteBrowserViewportSize | null) => { + viewportSize = next + }, + queueSubscribeError: (error: unknown) => { + subscribeErrorQueue.push(error) + }, + failEverySubscribe: (error: unknown) => { + persistentSubscribeError = error + }, + closeThenRejectNextSubscribe: () => { + closeBeforeNextSubscribeRejects = true + }, + holdNextViewportSize: (): Gate => { + const gate = createGate() + viewportGate = gate + return gate + }, + holdNextStatusGet: (): Gate => { + const gate = createGate() + statusGate = gate + return gate + }, + holdNextTabShow: (): Gate => { + const gate = createGate() + tabShowGate = gate + return gate + }, + holdNextSubscribe: (): Gate => { + const gate = createGate() + subscribeGate = gate + return gate + } + } +} + +type Harness = ReturnType + +async function settle(): Promise { + await vi.advanceTimersByTimeAsync(0) +} + +async function openStreamAndConfirmReady(harness: Harness): Promise<() => void> { + const close = harness.lifecycle.open() + await settle() + harness.streams[0].emitReady() + await settle() + return close +} + +describe('RemoteBrowserStreamLifecycle', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('opens a stream for the pane and reports it live', async () => { + const harness = createHarness() + await openStreamAndConfirmReady(harness) + + expect(harness.streams).toHaveLength(1) + expect(harness.streams[0].pageId).toBe('page-1') + expect(harness.busyLog.at(-1)).toBe(false) + }) + + it('tags the screencast request as browser-pane UI traffic', async () => { + const harness = createHarness() + await openStreamAndConfirmReady(harness) + + expect(isBrowserPaneUiRuntimeRpcParams(harness.streams[0].params)).toBe(true) + }) + + it('unsubscribes the live stream when the pane closes it', async () => { + const harness = createHarness() + const close = await openStreamAndConfirmReady(harness) + + close() + + expect(harness.streams[0].unsubscribeCount).toBe(1) + }) + + // STA-3483: the shipped bug was a single 500ms retry that never rescheduled. + it('keeps retrying a dropped stream with backoff instead of stopping after one attempt', async () => { + const harness = createHarness() + await openStreamAndConfirmReady(harness) + harness.queueSubscribeError(new Error('stream refused')) + + harness.streams[0].emitEnd() + await settle() + expect(harness.subscribeAttempts).toBe(1) + + await vi.advanceTimersByTimeAsync(500) + expect(harness.subscribeAttempts).toBe(2) + expect(harness.streams).toHaveLength(1) + + // Why: the second attempt is the next backoff step (1000ms), not another 500ms tick. + await vi.advanceTimersByTimeAsync(999) + expect(harness.subscribeAttempts).toBe(2) + + await vi.advanceTimersByTimeAsync(1) + expect(harness.subscribeAttempts).toBe(3) + expect(harness.streams).toHaveLength(2) + }) + + it('does not retry a runtime that cannot stream at all', async () => { + const harness = createHarness() + await openStreamAndConfirmReady(harness) + harness.setCapabilities([]) + + harness.streams[0].emitEnd() + await vi.advanceTimersByTimeAsync(500) + const attemptsAfterFirstRetry = harness.subscribeAttempts + + await vi.advanceTimersByTimeAsync(120_000) + + expect(harness.subscribeAttempts).toBe(attemptsAfterFirstRetry) + expect(harness.currentError).toBe( + 'The selected runtime does not support remote browser streaming.' + ) + }) + + // Fix 2: a worktree the host reports as genuinely gone cannot come back on this connection, so + // retrying it is unbounded work with a permanent error toast. Note the code used here is one the + // host only sends about the thing itself — `selector_not_found` is deliberately excluded, because + // it also covers a resolution that merely failed right now. + it('stops retrying when the runtime reports the stream target is gone', async () => { + const harness = createHarness() + await openStreamAndConfirmReady(harness) + harness.failEverySubscribe(rpcError('worktree_not_found_on_server', 'worktree is gone')) + + harness.streams[0].emitEnd() + await vi.advanceTimersByTimeAsync(500) + expect(harness.subscribeAttempts).toBe(2) + expect(harness.currentError).toBe('worktree is gone') + + await vi.advanceTimersByTimeAsync(120_000) + + expect(harness.subscribeAttempts).toBe(2) + }) + + it('keeps retrying a failure the host could still recover from', async () => { + const harness = createHarness() + await openStreamAndConfirmReady(harness) + harness.failEverySubscribe(rpcError('runtime_timeout', 'runtime timed out')) + + harness.streams[0].emitEnd() + await vi.advanceTimersByTimeAsync(500) + expect(harness.subscribeAttempts).toBe(2) + + await vi.advanceTimersByTimeAsync(1000) + + expect(harness.subscribeAttempts).toBe(3) + }) + + // Fix 3: a pane that healed itself must not keep showing the failure toast it recovered from. + it('clears the failure a restart reported once the new stream goes live', async () => { + const harness = createHarness() + await openStreamAndConfirmReady(harness) + harness.queueSubscribeError(new Error('stream refused')) + + harness.streams[0].emitEnd() + await vi.advanceTimersByTimeAsync(500) + expect(harness.currentError).toBe('Lost connection to the remote server.') + + await vi.advanceTimersByTimeAsync(1000) + harness.streams[1].emitReady() + await settle() + + expect(harness.currentError).toBeNull() + }) + + // A stream that lasted must forget prior failures, or the next drop inherits their backoff. + it('backs off from scratch after a stream is confirmed live again', async () => { + const harness = createHarness() + await openStreamAndConfirmReady(harness) + harness.queueSubscribeError(new Error('stream refused')) + + harness.streams[0].emitEnd() + await vi.advanceTimersByTimeAsync(500) + await vi.advanceTimersByTimeAsync(1000) + harness.streams[1].emitReady() + // Why the wait: 'ready' alone no longer refills the budget — only a stream that stayed up does. + await vi.advanceTimersByTimeAsync(15_000) + + harness.streams[1].emitEnd() + await settle() + const attemptsBeforeSecondDrop = harness.subscribeAttempts + + await vi.advanceTimersByTimeAsync(500) + + expect(harness.subscribeAttempts).toBe(attemptsBeforeSecondDrop + 1) + }) + + it('restarts the stream for a viewport change and adopts the new subscription', async () => { + const harness = createHarness() + await openStreamAndConfirmReady(harness) + + harness.setViewportSize({ width: 1200, height: 900 }) + harness.lifecycle.restartForViewport('page-1') + await settle() + + expect(harness.streams).toHaveLength(2) + expect(harness.streams[0].unsubscribeCount).toBe(1) + expect(harness.streams[1].viewportWidth).toBe(1200) + }) + + // waitForViewportSize can block for a few frames while the element is unmeasurable. An attempt + // superseded during that window used to resume and claim the stream token anyway, stranding the + // live stream: its 'ready' was then dropped as stale, and because one ready-deadline is kept per + // pane, the resuming attempt cancelled the safety net too. The pane sat in 'opening' with nothing + // pending — no retry, no reconnect, forever. + it('drops an attempt superseded while it waited for the viewport', async () => { + const harness = createHarness() + await openStreamAndConfirmReady(harness) + + const viewport = harness.holdNextViewportSize() + harness.setViewportSize({ width: 1400, height: 900 }) + harness.lifecycle.restartForViewport('page-1') + await settle() + + // A reopen (tab switch, environment change, Reconnect) supersedes the blocked resize. + harness.lifecycle.open() + await settle() + const supersedingStream = harness.streams.at(-1)! + + viewport.release() + await settle() + + // The reopen's own stream must still own the pane: its 'ready' has to land. + supersedingStream.emitReady() + await settle() + expect(harness.currentStatusKind).toBe('live') + }) + + it('ignores a viewport change that is within measurement jitter', async () => { + const harness = createHarness() + await openStreamAndConfirmReady(harness) + + harness.setViewportSize({ width: 802, height: 601 }) + harness.lifecycle.restartForViewport('page-1') + await settle() + + expect(harness.streams).toHaveLength(1) + }) + + // Fix 1a: a superseded viewport restart must not clear busy for the operation that replaced it. + it('does not clear busy when a superseded viewport restart resolves with no stream', async () => { + const harness = createHarness() + await openStreamAndConfirmReady(harness) + harness.setViewportSize({ width: 1200, height: 900 }) + const statusGate = harness.holdNextStatusGet() + + harness.lifecycle.restartForViewport('page-1') + await settle() + + // The isActive effect re-runs for the same page and bumps the operation generation. + harness.lifecycle.open() + await settle() + harness.streams.at(-1)!.emitReady() + await settle() + const busyAfterNewOperation = [...harness.busyLog] + + statusGate.release() + await settle() + + expect(harness.busyLog).toEqual(busyAfterNewOperation) + }) + + // Fix 1b: nor may it raise the error of a restart that is no longer the current operation. + it('does not surface the error of a superseded viewport restart', async () => { + const harness = createHarness() + await openStreamAndConfirmReady(harness) + harness.setViewportSize({ width: 1200, height: 900 }) + const statusGate = harness.holdNextStatusGet() + + harness.lifecycle.restartForViewport('page-1') + await settle() + + harness.lifecycle.open() + await settle() + harness.streams.at(-1)!.emitReady() + await settle() + + statusGate.fail(new Error('stale restart failed')) + await settle() + + expect(harness.errorLog).not.toContain('stale restart failed') + expect(harness.currentError).toBeNull() + }) + + // The generation bump in restartForViewport is what retires other in-flight operation work. + it('retires in-flight tab refreshes when a viewport restart supersedes them', async () => { + const harness = createHarness() + await openStreamAndConfirmReady(harness) + const refreshToken = harness.lifecycle.tokens.createOperationToken('page-1')! + const tabShowGate = harness.holdNextTabShow() + + harness.lifecycle.session.scheduleTabInfoRefresh(refreshToken, 100) + await vi.advanceTimersByTimeAsync(100) + const titlesBeforeRestart = [...harness.appliedTitles] + + harness.setViewportSize({ width: 1200, height: 900 }) + harness.lifecycle.restartForViewport('page-1') + tabShowGate.release() + await settle() + + expect(harness.appliedTitles).toEqual(titlesBeforeRestart) + }) + + it('closes a page the runtime reports as missing instead of retrying it', async () => { + const harness = createHarness() + await openStreamAndConfirmReady(harness) + + harness.streams[0].emitResponseFailure('browser_tab_not_found', 'page is gone') + await settle() + + expect(harness.closedPages).toEqual(['page-1']) + expect(harness.subscribeAttempts).toBe(1) + }) + + it('ignores a malformed success payload without poisoning the live subscription', async () => { + const harness = createHarness() + harness.lifecycle.open() + await settle() + + expect(() => harness.streams[0].emitMalformedSuccess()).not.toThrow() + harness.streams[0].emitReady() + await settle() + + expect(harness.currentStatusKind).toBe('live') + }) + + // A transport error is NOT guaranteed to be followed by a close: the web client's + // notifySubscriptionsError clears its subscription map and then delivers onError only. So this + // must land somewhere the user can act from, or the pane is stranded busy with no way back. + it('leaves a transport error actionable when no close follows it', async () => { + const harness = createHarness() + await openStreamAndConfirmReady(harness) + + harness.streams[0].emitTransportError('runtime_timeout', 'socket hiccup') + await vi.advanceTimersByTimeAsync(120_000) + + // Pane-authored, not the transport's own string: 'socket hiccup' names our plumbing and tells + // the user nothing they can act on. + expect(harness.currentError).toBe('Lost connection to the remote server.') + expect(harness.subscribeAttempts).toBe(1) + expect(harness.reconnectOffered).toBe(true) + // Critically: not busy. A spinner here would also disable the pane's own input handlers. + expect(harness.busyLog.at(-1)).toBe(false) + }) + + // The same path, but before the host confirmed anything. This is the one stream-ending path that + // deliberately keeps its stream token, so the 'never said ready' deadline stayed armed and its + // guard still passed — firing 30s later against a stream already declared stopped and withdrawing + // the reconnect control with no user action at all. + it('keeps a transport error actionable when the stream never said ready', async () => { + const harness = createHarness() + harness.lifecycle.open() + await settle() + expect(harness.streams).toHaveLength(1) + + harness.streams[0].emitTransportError('runtime_timeout', 'socket hiccup') + await settle() + expect(harness.reconnectOffered).toBe(true) + + // Long enough for the ready deadline to have fired. + await vi.advanceTimersByTimeAsync(120_000) + + expect(harness.currentError).toBe('Lost connection to the remote server.') + expect(harness.reconnectOffered).toBe(true) + expect(harness.busyLog.at(-1)).toBe(false) + expect(harness.subscribeAttempts).toBe(1) + }) + + it('ignores stream events once the pane is unmounted', async () => { + const harness = createHarness() + await openStreamAndConfirmReady(harness) + const errorsBeforeUnmount = harness.errorLog.length + + harness.identity.mounted = false + harness.lifecycle.dispose() + harness.streams[0].emitStreamError('too late') + await vi.advanceTimersByTimeAsync(120_000) + + expect(harness.errorLog).toHaveLength(errorsBeforeUnmount) + expect(harness.subscribeAttempts).toBe(1) + }) + + it('reopens with the same lifecycle after a StrictMode cleanup cycle', async () => { + const harness = createHarness() + const closeStream = await openStreamAndConfirmReady(harness) + + harness.identity.mounted = false + harness.lifecycle.dispose() + closeStream() + harness.identity.mounted = true + harness.lifecycle.open() + await settle() + + expect(harness.streams).toHaveLength(2) + harness.streams[1].emitReady() + await settle() + expect(harness.currentStatusKind).toBe('live') + }) + + it('keeps the replacement ready deadline when a superseded subscribe rejects late', async () => { + const harness = createHarness() + const staleSubscribe = harness.holdNextSubscribe() + const closeStaleOpen = harness.lifecycle.open() + await settle() + + closeStaleOpen() + harness.lifecycle.open() + await settle() + expect(harness.streams).toHaveLength(1) + + staleSubscribe.fail(rpcError('runtime_unavailable', 'stale subscribe failed')) + await settle() + await vi.advanceTimersByTimeAsync(400_000) + + expect(harness.reconnectOffered).toBe(true) + }) +}) + +// Why these exist: deleting every setReconnectAvailable call site left the whole suite green, so the +// affordance had no unit coverage at all — only two E2E paths. Each test below fails if its own call +// site is removed. +describe('RemoteBrowserStreamLifecycle reconnect affordance', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('withholds reconnect while retries remain, then offers it once the budget is spent', async () => { + const harness = createHarness() + await openStreamAndConfirmReady(harness) + harness.failEverySubscribe(rpcError('runtime_unavailable', 'socket died')) + + harness.streams[0].emitEnd() + await vi.advanceTimersByTimeAsync(500) + // One attempt spent, four remain: offering a control here invites fighting the retry loop. + expect(harness.reconnectOffered).toBe(false) + + await vi.advanceTimersByTimeAsync(60_000) + expect(harness.reconnectOffered).toBe(true) + expect(harness.currentError).toBe('Lost connection to the remote server.') + }) + + // The regression the review caught: stopping automatic retries is not the same as removing the + // user's last resort. selector_not_found already had to leave the permanent set once, so a + // misclassification here must stay recoverable by hand. + it('offers reconnect even when it stops retrying a permanent failure', async () => { + const harness = createHarness() + await openStreamAndConfirmReady(harness) + harness.failEverySubscribe(rpcError('worktree_not_found_on_server', 'worktree is gone')) + + harness.streams[0].emitEnd() + await vi.advanceTimersByTimeAsync(60_000) + + expect(harness.reconnectOffered).toBe(true) + // The specific message survives: it says something true that "Lost connection" would not. + expect(harness.currentError).toBe('worktree is gone') + }) + + it('offers reconnect when the stream never opened at all', async () => { + const harness = createHarness() + harness.failEverySubscribe(rpcError('runtime_unavailable', 'host is down')) + harness.lifecycle.open() + await vi.advanceTimersByTimeAsync(60_000) + + expect(harness.reconnectOffered).toBe(true) + expect(harness.currentError).toBe('Cannot reach the remote server.') + }) + + it('clears the offer when the pane reopens and the stream comes back', async () => { + const harness = createHarness() + await openStreamAndConfirmReady(harness) + harness.failEverySubscribe(rpcError('runtime_unavailable', 'socket died')) + harness.streams[0].emitEnd() + await vi.advanceTimersByTimeAsync(60_000) + expect(harness.reconnectOffered).toBe(true) + + // NOTE ON WHAT THIS PINS: the clear comes from open(), not from onReady. Once the budget is + // spent the scheduler has stopped, so nothing reaches onReady except a fresh open() — which + // clears first. onReady's own clear is therefore defence-in-depth against a future path that + // sets the flag and then recovers in place; it is deliberately NOT claimed as covered. + harness.failEverySubscribe(null) + harness.lifecycle.open() + await settle() + harness.streams.at(-1)!.emitReady() + await settle() + + expect(harness.reconnectOffered).toBe(false) + }) + + // A reopen starts a fresh budget, so a flag left over from a previous exhaustion would show the + // control while attempts remain. + it('does not carry a spent offer into the next open', async () => { + const harness = createHarness() + await openStreamAndConfirmReady(harness) + harness.failEverySubscribe(rpcError('runtime_unavailable', 'socket died')) + harness.streams[0].emitEnd() + await vi.advanceTimersByTimeAsync(60_000) + expect(harness.reconnectOffered).toBe(true) + + // Failures cleared and no 'ready' emitted yet: the flag must already be down from open() alone, + // not from the later confirmation. + harness.failEverySubscribe(null) + harness.lifecycle.open() + await settle() + expect(harness.reconnectOffered).toBe(false) + }) +}) + +// NEW-1 repro: a budget can drain without any attempt throwing. Each restart subscribes fine, then +// the stream ends before 'ready'. The catch never runs, so setError is never called — and the +// Reconnect control renders only inside the error toast, so exhaustion produces an invisible +// affordance, busy stuck true, and no further retries. On main this cycle retried forever and +// self-healed, so it is a regression in the exact class this work exists to remove. +describe('RemoteBrowserStreamLifecycle silent budget drain', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('reports a failure when the budget drains without any attempt throwing', async () => { + const harness = createHarness() + await openStreamAndConfirmReady(harness) + + for (let round = 0; round < 6; round++) { + harness.streams.at(-1)!.emitEnd() + await vi.advanceTimersByTimeAsync(20_000) + } + + expect(harness.reconnectOffered).toBe(true) + // Without a message the button cannot render at all. + expect(harness.currentError).not.toBeNull() + // A spinner left running over a frozen frame also blocks the pane's input handlers. + expect(harness.busyLog.at(-1)).toBe(false) + }) +}) + +// A transport error is NOT a stop. On a real socket failure the client calls onError and then +// onClose (src/shared/remote-runtime-client.ts fail()), and it is onClose that starts the retry +// budget. Announcing a stop on the error therefore raises the reconnect control ~500ms before the +// first automatic attempt — the one state this pane must never present, and with raw transport copy +// the pane exists to keep out of the UI. The E2E cannot see this: manual disconnect delivers close +// without error, which is the single drop shape where the bug is absent. +describe('RemoteBrowserStreamLifecycle transport error', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + // Why this pins the METHOD and not just the call site: cancelling the deadline on a transport + // error must not also forget how long the stream had been alive, because a close that follows has + // to refill the budget for a stream that had proved healthy. Swapping stopWaitingForReady's + // clearDeadline() for clear() — deleting exactly what its comment argues for — left all 322 tests + // green while silently costing the user one automatic retry. + it('still refills the budget when a close follows a transport error on a healthy stream', async () => { + const harness = createHarness() + await openStreamAndConfirmReady(harness) + + // Spend one budget step: a drop whose retry succeeds. + harness.streams[0].emitEnd() + await vi.advanceTimersByTimeAsync(500) + harness.streams[1].emitReady() + await settle() + + // Let the replacement prove itself — longer than the window that earns a refill. + await vi.advanceTimersByTimeAsync(15_000) + + harness.streams[1].emitTransportError('runtime_timeout', 'socket hiccup') + await settle() + harness.failEverySubscribe(rpcError('runtime_unavailable', 'still down')) + harness.streams[1].emitClose() + await settle() + const attemptsAfterClose = harness.subscribeAttempts + + await vi.advanceTimersByTimeAsync(120_000) + + // A full ladder, not the tail of the earlier one. + expect(harness.subscribeAttempts - attemptsAfterClose).toBe(5) + expect(harness.reconnectOffered).toBe(true) + }) + + // The usual case: a close does follow. It must take the control back down for the whole ladder, + // so the user is not offered a manual retry that competes with the automatic one about to run. + it('withdraws the offer once the close starts the retry budget', async () => { + const harness = createHarness() + await openStreamAndConfirmReady(harness) + + harness.streams[0].emitTransportError('runtime_unavailable', 'socket reset by peer') + await settle() + expect(harness.reconnectOffered).toBe(true) + + harness.failEverySubscribe(rpcError('runtime_unavailable', 'still down')) + harness.streams[0].emitClose() + await settle() + expect(harness.reconnectOffered).toBe(false) + + await vi.advanceTimersByTimeAsync(600) + expect(harness.reconnectOffered).toBe(false) + + await vi.advanceTimersByTimeAsync(60_000) + expect(harness.reconnectOffered).toBe(true) + }) + + // The budget absorbs a blip invisibly: a drop whose first retry succeeds must say nothing at all. + it('says nothing when the first retry succeeds', async () => { + const harness = createHarness() + await openStreamAndConfirmReady(harness) + + harness.streams[0].emitClose() + await vi.advanceTimersByTimeAsync(600) + harness.streams.at(-1)!.emitReady() + await settle() + + expect(harness.errorLog.filter((entry) => entry !== null)).toEqual([]) + }) +}) + +// These pin the remaining announce sites. Each was verified to leave the suite green when its +// announce was downgraded to a bare setError — the gap that let a wrong one ship. +describe('RemoteBrowserStreamLifecycle stop announcements', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('offers reconnect when the runtime reports the stream itself failed', async () => { + const harness = createHarness() + await openStreamAndConfirmReady(harness) + + harness.streams[0].emitStreamError('screencast pipeline exploded') + await vi.advanceTimersByTimeAsync(60_000) + + // Non-restarting by design, so nothing else would ever hand the user a way back. + expect(harness.reconnectOffered).toBe(true) + expect(harness.currentError).toBe('screencast pipeline exploded') + expect(harness.busyLog.at(-1)).toBe(false) + }) + + it('offers reconnect when a viewport restart cannot re-subscribe', async () => { + const harness = createHarness() + await openStreamAndConfirmReady(harness) + + harness.failEverySubscribe(rpcError('runtime_unavailable', 'resize refused')) + harness.setViewportSize({ width: 1400, height: 900 }) + harness.lifecycle.restartForViewport(harness.streams[0].pageId) + await vi.advanceTimersByTimeAsync(60_000) + + // A resize during a blip tears down a live subscription and never reaches the retry budget. + expect(harness.reconnectOffered).toBe(true) + expect(harness.busyLog.at(-1)).toBe(false) + }) + + // This was the one failure path still forwarding raw transport text, which is written for logs and + // names our internals. The pane speaks for itself everywhere else. + it('speaks for itself when a viewport restart fails transiently', async () => { + const harness = createHarness() + await openStreamAndConfirmReady(harness) + + harness.failEverySubscribe( + rpcError('runtime_unavailable', 'Runtime environment pairing changed; refresh and try again') + ) + harness.setViewportSize({ width: 1400, height: 900 }) + harness.lifecycle.restartForViewport(harness.streams[0].pageId) + await vi.advanceTimersByTimeAsync(60_000) + + expect(harness.currentError).toBe('Lost connection to the remote server.') + }) + + // ...but a failure we classified ourselves keeps its own message, which says something true. + it('keeps a permanent viewport-restart failure specific', async () => { + const harness = createHarness() + await openStreamAndConfirmReady(harness) + + harness.failEverySubscribe(rpcError('worktree_not_found_on_server', 'worktree is gone')) + harness.setViewportSize({ width: 1400, height: 900 }) + harness.lifecycle.restartForViewport(harness.streams[0].pageId) + await vi.advanceTimersByTimeAsync(60_000) + + expect(harness.currentError).toBe('worktree is gone') + }) +}) + +// Two writers, one value — the shape every round of this review has found. The stream token is +// claimed before subscribe is awaited, so a close can arrive and arm a restart while the subscribe +// promise is still rejecting. The host does exactly this: it closes the subscription and only then +// throws (src/main/ipc/runtime-environments.ts, stale pairing). +describe('RemoteBrowserStreamLifecycle close racing a rejected subscribe', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('does not offer reconnect over a restart that is already armed', async () => { + const harness = createHarness() + harness.closeThenRejectNextSubscribe() + harness.lifecycle.open() + await settle() + + // The armed restart owns the status: offering a manual control here shows it for one backoff + // step and then swaps it for a spinner, which is the state this pane must never present. + expect(harness.reconnectOffered).toBe(false) + expect(harness.busyLog.at(-1)).toBe(true) + expect(harness.currentError).toBeNull() + }) + + it('still reaches a reconnect once that restart budget is spent', async () => { + const harness = createHarness() + harness.closeThenRejectNextSubscribe() + harness.lifecycle.open() + await settle() + harness.failEverySubscribe(rpcError('runtime_unavailable', 'still down')) + + await vi.advanceTimersByTimeAsync(60_000) + + expect(harness.reconnectOffered).toBe(true) + expect(harness.currentError).toBe('Lost connection to the remote server.') + }) +}) + +// A subscribe resolves as soon as the request is sent, so a host that accepts and then goes silent +// is bounded by nothing on the client. Both busy states hide the reconnect, so this stranded the +// pane behind a permanent spinner with dead input handlers — the exact failure this work removes. +describe('RemoteBrowserStreamLifecycle host that never says ready', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('does not sit busy forever when the host accepts the subscribe and goes silent', async () => { + const harness = createHarness() + harness.lifecycle.open() + await settle() + expect(harness.streams).toHaveLength(1) + + // Nothing has failed yet, so the pane is right to still be working. + await vi.advanceTimersByTimeAsync(20_000) + expect(harness.reconnectOffered).toBe(false) + expect(harness.busyLog.at(-1)).toBe(true) + + await vi.advanceTimersByTimeAsync(400_000) + + expect(harness.reconnectOffered).toBe(true) + expect(harness.busyLog.at(-1)).toBe(false) + expect(harness.currentError).not.toBeNull() + }) + + it('releases the hung subscription rather than leaking it', async () => { + const harness = createHarness() + harness.lifecycle.open() + await settle() + + await vi.advanceTimersByTimeAsync(400_000) + + expect(harness.streams[0].unsubscribeCount).toBeGreaterThan(0) + }) +}) + +// 'ready' proves the host accepted the subscribe, not that the stream is sustained. CDP allows one +// screencast per page, so a second subscriber on the same remote page evicts the first on every +// attempt. Refilling the budget on 'ready' turned that into the unbounded retry loop the budget +// exists to prevent. +describe('RemoteBrowserStreamLifecycle flapping host', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('stops a host that flaps ready-then-end instead of retrying forever', async () => { + const harness = createHarness() + await openStreamAndConfirmReady(harness) + + for (let round = 0; round < 8; round++) { + harness.streams.at(-1)!.emitEnd() + // Long enough for the next backoff step to fire, short enough that the stream never proves + // itself healthy. + await vi.advanceTimersByTimeAsync(9_000) + harness.streams.at(-1)!.emitReady() + await settle() + } + + expect(harness.reconnectOffered).toBe(true) + expect(harness.busyLog.at(-1)).toBe(false) + }) +}) diff --git a/src/renderer/src/components/browser-pane/remote-browser-stream-lifecycle.ts b/src/renderer/src/components/browser-pane/remote-browser-stream-lifecycle.ts new file mode 100644 index 00000000000..30ce057690d --- /dev/null +++ b/src/renderer/src/components/browser-pane/remote-browser-stream-lifecycle.ts @@ -0,0 +1,373 @@ +import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' +import type { RuntimeStatus } from '../../../../shared/runtime-types' +import { RemoteBrowserPageSession } from './remote-browser-page-session' +import { openRemoteBrowserScreencastStream } from './remote-browser-screencast-subscription' +import { RemoteBrowserStreamRestartScheduler } from './remote-browser-stream-restart-scheduler' +import { RemoteBrowserStreamLiveness } from './remote-browser-stream-liveness' +import { createRemoteBrowserStreamRestartAttempt } from './remote-browser-stream-restart-attempt' +import { + REMOTE_BROWSER_STREAM_LIVE, + REMOTE_BROWSER_STREAM_OPENING, + remoteBrowserStreamLostNotice, + remoteBrowserStreamRetrying, + remoteBrowserStreamStopped, + remoteBrowserStreamUnreachableNotice, + REMOTE_BROWSER_STREAM_IDLE +} from './remote-browser-stream-status' +import { + isPermanentRemoteBrowserStreamFailure, + isRemoteBrowserPageMissingError, + remoteBrowserStreamUnsupportedError, + resolveRemoteBrowserStreamRestartFailure +} from './remote-browser-stream-errors' +import { + areRemoteViewportSizesNear, + RemoteBrowserOperationTokens, + type RemoteBrowserStreamSubscription, + type RemoteBrowserStreamToken, + type RemoteBrowserViewportSize +} from './remote-browser-stream-tokens' +export type { RemoteBrowserStreamLifecycleDeps } from './remote-browser-stream-lifecycle-deps' +import type { RemoteBrowserStreamLifecycleDeps } from './remote-browser-stream-lifecycle-deps' + +// Owns the remote browser screencast lifecycle for one pane: opening the stream, self-healing a +// dropped one with bounded backoff, restarting it after a viewport change, and tearing everything +// down. Kept out of React so each of those paths can be driven from a test with injected deps. +export class RemoteBrowserStreamLifecycle { + readonly tokens: RemoteBrowserOperationTokens + readonly session: RemoteBrowserPageSession + private readonly restartScheduler: RemoteBrowserStreamRestartScheduler + private subscription: RemoteBrowserStreamSubscription | null = null + private streamViewportSize: RemoteBrowserViewportSize | null = null + private readonly liveness = new RemoteBrowserStreamLiveness() + + constructor(private readonly deps: RemoteBrowserStreamLifecycleDeps) { + this.tokens = new RemoteBrowserOperationTokens(deps.identity) + this.session = new RemoteBrowserPageSession({ ...deps, tokens: this.tokens }) + // Why exhaustion publishes a message too: a budget can drain without any attempt throwing — + // each restart subscribes fine, then the stream ends before 'ready', so the catch that normally + // reports never runs. 'stopped' carries its notice, so that case cannot go silent. + this.restartScheduler = new RemoteBrowserStreamRestartScheduler(undefined, () => + deps.setStatus(remoteBrowserStreamStopped(remoteBrowserStreamLostNotice())) + ) + } + + forgetStreamViewportSize(): void { + this.streamViewportSize = null + } + + // Opens the stream for the active pane and returns the teardown for that attempt. + open(): () => void { + let cancelled = false + const { tokens, deps } = this + // A reopen (tab switch, environment or worktree change, or Reconnect) starts a fresh budget, so + // 'opening' replaces whatever the previous attempt ended on — including a spent 'stopped'. + deps.setStatus(REMOTE_BROWSER_STREAM_OPENING) + this.retireInFlightWork()?.unsubscribe() + const operationToken = tokens.createOperationToken() + if (!operationToken) { + deps.setStatus(REMOTE_BROWSER_STREAM_IDLE) + return () => {} + } + void this.session + .ensureRemotePage(operationToken) + .then(async (pageId) => { + if (!pageId || cancelled || !tokens.isCurrent(operationToken)) { + return + } + const pageToken = { ...operationToken, remotePageId: pageId } + const tab = await this.session.fetchTabInfo(pageToken) + if (tab && !cancelled && tokens.isCurrent(pageToken)) { + deps.applyTabInfo(tab) + } + if (cancelled || !tokens.isCurrent(pageToken)) { + return + } + const subscription = await this.startStream(pageId) + if (cancelled || !subscription) { + subscription?.unsubscribe() + return + } + this.adoptSubscription(subscription) + }) + .catch((error: unknown) => { + if (cancelled) { + return + } + if (isRemoteBrowserPageMissingError(error)) { + deps.closeMissingRemotePage(tokens.remotePage) + return + } + // Why classified here too: the same condition (a host that cannot stream) reaches both this + // path and the restart path, and it must not read as "unreachable" here and as its own + // specific message there. + const permanent = isPermanentRemoteBrowserStreamFailure(error) + if (!permanent) { + console.warn('[browser-pane] remote browser failed to open:', error) + } + // Why this can be the loser of a race: the stream token is claimed before subscribe is + // awaited, so a close can arrive and arm a restart while this promise is still rejecting — + // the host closes the subscription and only then throws (src/main/ipc/runtime-environments.ts + // 'pairing changed'). Publishing 'stopped' over an armed restart shows a manual control for + // one backoff step and then swaps it for a spinner. The armed restart is the newer owner. + if (this.restartScheduler.isScheduled) { + return + } + // "Unreachable" rather than "lost": nothing was ever established here. And this path never + // had a stream, so the retry budget never runs — 'stopped' is what hands the user a way back. + deps.setStatus( + remoteBrowserStreamStopped( + permanent && error instanceof Error + ? error.message + : remoteBrowserStreamUnreachableNotice() + ) + ) + }) + return () => { + cancelled = true + this.retireInFlightWork()?.unsubscribe() + } + } + + // Why: the runtime stream validates frames against its start viewport, so a resize needs a fresh + // subscription or the new-size frames get rejected. + restartForViewport(pageId: string): void { + const current = this.subscription + const nextViewportSize = this.deps.readViewportSize() + if ( + !current || + current.token.remotePageId !== pageId || + !nextViewportSize || + areRemoteViewportSizesNear(this.streamViewportSize, nextViewportSize) || + !this.tokens.isCurrentStreamToken(current.token) + ) { + return + } + // Why: cancel() cannot recall a retry already dispatched into an await. Bumping the operation + // generation — as every other supersession site does — makes that attempt's own token guard + // fail, so it cannot subscribe concurrently with the restart below. + this.retireInFlightWork() + this.streamViewportSize = null + // Why: without a token the .then/.catch below would mutate busy/remoteError on behalf of a + // restart that a newer operation has already replaced. + const restartToken = this.tokens.createOperationToken(pageId) + this.deps.setStatus(REMOTE_BROWSER_STREAM_OPENING) + current.unsubscribe() + void this.startStream(pageId) + .then((subscription) => { + // Why no status write: startStream returns null only when this very token was superseded, + // so a newer operation already owns the status and writing here would clobber it. + if (!subscription) { + return + } + this.adoptSubscription(subscription) + }) + .catch((error: unknown) => { + if (!restartToken || !this.tokens.isCurrent(restartToken)) { + return + } + if (isRemoteBrowserPageMissingError(error)) { + this.deps.closeMissingRemotePage(pageId) + return + } + // Same race as open()'s catch: a close can arm a restart while this rejection is in flight. + if (this.restartScheduler.isScheduled) { + return + } + // Why classified rather than forwarded: this was the one failure path still putting raw + // transport text in the UI ("Runtime environment pairing changed; refresh and try again"), + // which is written for logs and names our internals. The other two paths already classify. + const failure = resolveRemoteBrowserStreamRestartFailure(error) + if (failure.logRawError) { + console.warn('[browser-pane] remote stream resize failed:', error) + } + // A resize during a blip tears down a live subscription and never reaches the budget. + this.deps.setStatus(remoteBrowserStreamStopped(failure.message)) + }) + } + + // Drops every handle this pane holds on a runtime page that is gone. + abandonRemotePage(): void { + this.tokens.setRemotePage(null) + this.retireInFlightWork()?.unsubscribe() + this.session.cancelTabInfoRefresh() + } + + // Unmount: retire in-flight work without unsubscribing, which the stream effect's own teardown owns. + dispose(): void { + this.tokens.supersedeOperations() + this.tokens.supersedeStream() + this.tokens.releaseStreamToken() + this.streamViewportSize = null + this.liveness.clear() + this.restartScheduler.cancel() + this.session.cancelTabInfoRefresh() + } + + // Retires every in-flight guard token and the pending retry, and hands back the subscription the + // caller is displacing so it can be released at the point the original code released it. + private retireInFlightWork(): RemoteBrowserStreamSubscription | null { + this.tokens.supersedeOperations() + this.tokens.supersedeStream() + this.tokens.releaseStreamToken() + this.liveness.clear() + const previous = this.subscription + this.subscription = null + this.restartScheduler.cancel() + return previous + } + + private adoptSubscription(subscription: RemoteBrowserStreamSubscription): void { + if (!this.tokens.isCurrentStreamToken(subscription.token)) { + subscription.unsubscribe() + return + } + this.subscription = subscription + } + + private async startStream(pageId: string): Promise { + const { tokens, deps } = this + const operationToken = tokens.createOperationToken(pageId) + if (!operationToken || !tokens.isCurrent(operationToken)) { + return null + } + const target: RuntimeClientTarget = { + kind: 'environment', + environmentId: operationToken.environmentId + } + const status = await deps.callRpc(target, 'status.get', undefined, { + timeoutMs: 15_000 + }) + if (!status.capabilities?.includes('browser.screencast.v1')) { + throw remoteBrowserStreamUnsupportedError() + } + if (!tokens.isCurrent(operationToken)) { + return null + } + const viewportSize = await deps.waitForViewportSize() + // Why this guard, which every other await here already had: waitForViewportSize can block for a + // few frames while the element is unmeasurable, and a superseded attempt resuming after that + // would claim the stream token out from under the live stream — and, since one deadline is kept + // per pane, cancel its safety net too. The pane then sits in 'opening' with nothing pending. + if (!tokens.isCurrent(operationToken)) { + return null + } + this.streamViewportSize = viewportSize + const token = tokens.claimStreamToken(operationToken, pageId) + // Treated as a drop rather than announced directly, so a hung host spends the same budget and + // ends at the same 'stopped' with a reconnect as a host that refuses outright. + this.liveness.watch(() => { + if (this.tokens.isCurrentStreamToken(token)) { + this.handleStreamClosed(token, true) + } + }) + try { + const subscription = await openRemoteBrowserScreencastStream( + deps.subscribeScreencast, + { + environmentId: target.environmentId, + worktree: deps.getWorktreeSelector(), + pageId, + viewportSize, + deviceScaleFactor: deps.getDeviceScaleFactor() + }, + { + isCurrent: () => tokens.isCurrentStreamToken(token), + onReady: (event) => { + // Why a timestamp and not restartScheduler.reset(): the budget is refilled at close + // time, and only by a stream that lasted — see REMOTE_BROWSER_STREAM_HEALTHY_MS. + // 'live' carries no notice, so a toast the retry already healed cannot survive + // (STA-3483). + this.liveness.markReady() + deps.setStatus(REMOTE_BROWSER_STREAM_LIVE) + deps.applyTabInfo(event.tab) + void deps.syncViewport(event.browserPageId).catch(() => {}) + }, + onEnded: () => this.handleStreamClosed(token, true), + // Why onFailed announces a stop: the runtime reported the stream itself failed, and the + // close below is deliberately non-restarting, so nothing else would hand the user a way + // back. Its message is the host's own, which is more specific than any substitute. + onFailed: (message) => { + deps.setStatus(remoteBrowserStreamStopped(message)) + this.handleStreamClosed(token, false) + }, + // Why 'stopped' and not 'retrying': a transport error is NOT guaranteed to be followed by + // a close. The web client's notifySubscriptionsError clears its subscription map and then + // delivers onError only, so on that path no close ever arrives — and 'retrying' would + // leave the pane busy forever with no way back, worse than the bug this PR fixes. When a + // close does follow (the usual case) it publishes 'retrying', which replaces this within + // the same tick. Landing in the actionable state and being corrected is safe; the reverse + // is not. + // Why the deadline is cancelled here: this is the one stream-ending path that keeps its + // token, so the 'never said ready' deadline stays armed and its guard still passes. It + // would then fire against a stream already declared stopped and republish 'retrying' — + // withdrawing the reconnect control 30s after handing it over, with no user action, and + // reopening the strand this whole feature exists to remove. Not clear(): a close that + // does follow must still refill the budget for a stream that had been healthy. + onTransportError: () => { + this.liveness.stopWaitingForReady() + deps.setStatus(remoteBrowserStreamStopped(remoteBrowserStreamLostNotice())) + }, + onPageMissing: () => deps.closeMissingRemotePage(pageId), + onFrame: (bytes) => deps.handleFrameBytes(token, bytes), + onClosed: () => this.handleStreamClosed(token, true) + } + ) + return { token, unsubscribe: subscription.unsubscribe } + } catch (error) { + if (tokens.isCurrentStreamToken(token)) { + this.liveness.clear() + tokens.releaseStreamToken() + } + throw error + } + } + + private handleStreamClosed(token: RemoteBrowserStreamToken, restart: boolean): void { + if (!this.tokens.isCurrentStreamToken(token)) { + return + } + // Only a stream that proved itself refills the budget; see REMOTE_BROWSER_STREAM_HEALTHY_MS. + const wasHealthy = this.liveness.settle() + if (restart && wasHealthy) { + this.restartScheduler.reset() + } + // Why no notice yet: the budget exists to absorb a blip invisibly, so nothing is said until an + // attempt has actually failed. A non-restarting close leaves whatever the caller published. + if (restart) { + this.deps.setStatus(remoteBrowserStreamRetrying(null)) + } + const current = this.subscription + this.subscription = null + this.tokens.releaseStreamToken() + this.streamViewportSize = null + // Why: navigation recreates the screencast stream; keep the last frame during restart so panes + // don't flash the loading placeholder. + if (!restart) { + this.deps.clearFrame() + } + current?.unsubscribe() + if (restart) { + this.scheduleStreamRestart(token) + } + } + + // Why: a failed self-heal attempt must keep retrying with bounded backoff (STA-3483) instead of + // leaving the pane holding a dead subscription with no way back. + private scheduleStreamRestart(token: RemoteBrowserStreamToken): void { + if (!this.tokens.isCurrentStreamOperation(token)) { + return + } + this.restartScheduler.schedule( + createRemoteBrowserStreamRestartAttempt(token, { + tokens: this.tokens, + session: this.session, + setStatus: (status) => this.deps.setStatus(status), + applyTabInfo: (tab) => this.deps.applyTabInfo(tab), + closeMissingRemotePage: (remotePageId) => this.deps.closeMissingRemotePage(remotePageId), + startStream: (pageId) => this.startStream(pageId), + adoptSubscription: (subscription) => this.adoptSubscription(subscription) + }) + ) + } +} diff --git a/src/renderer/src/components/browser-pane/remote-browser-stream-liveness.ts b/src/renderer/src/components/browser-pane/remote-browser-stream-liveness.ts new file mode 100644 index 00000000000..54c06d05bc7 --- /dev/null +++ b/src/renderer/src/components/browser-pane/remote-browser-stream-liveness.ts @@ -0,0 +1,67 @@ +// Why a deadline on a stream that subscribed but never said 'ready': subscribe resolves as soon as +// the request is sent, so a host that accepts and then goes silent (a hung CDP teardown does exactly +// this) leaves nothing on the client to time out. Both states that cover a pending stream are busy +// and offer no reconnect, so without this the pane sits behind a spinner with dead input handlers +// and no way back — the very strand this work exists to remove. +export const REMOTE_BROWSER_STREAM_READY_DEADLINE_MS = 30_000 + +// Why 'ready' alone does not prove a stream is worth trusting: it says the host accepted the +// subscribe, not that the stream is sustained. CDP allows one screencast per page, so a second +// subscriber on the same remote page evicts the first on every attempt. Treating every 'ready' as +// healthy refills the retry budget forever, which is the unbounded retry the budget exists to stop. +export const REMOTE_BROWSER_STREAM_HEALTHY_MS = 10_000 + +// Tracks whether one screencast subscription ever came alive, and for how long. Kept apart from the +// lifecycle because "is this stream actually alive?" is a different question from "what should the +// pane do about it?", and only the second needs tokens, status, or the retry budget. +export class RemoteBrowserStreamLiveness { + private readyAt: number | null = null + private deadlineTimer: ReturnType | null = null + + /** + * Starts watching a newly subscribed stream. `onNeverReady` fires once if it stays silent, and the + * caller decides what that means — treating it as a drop keeps hung and refused hosts on one path. + */ + watch(onNeverReady: () => void): void { + this.clear() + this.deadlineTimer = setTimeout(() => { + this.deadlineTimer = null + onNeverReady() + }, REMOTE_BROWSER_STREAM_READY_DEADLINE_MS) + } + + markReady(): void { + this.clearDeadline() + this.readyAt = Date.now() + } + + /** + * The stream is known not to be coming up, so stop expecting 'ready' — but keep what we already + * know about how long it lived. Distinct from clear(): a caller that has declared the stream + * stopped while deliberately holding its token still needs a later close to refill the budget + * correctly, which depends on the timestamp this preserves. + */ + stopWaitingForReady(): void { + this.clearDeadline() + } + + /** Ends the current watch and reports whether the stream stayed up long enough to be trusted. */ + settle(): boolean { + this.clearDeadline() + const readyAt = this.readyAt + this.readyAt = null + return readyAt !== null && Date.now() - readyAt >= REMOTE_BROWSER_STREAM_HEALTHY_MS + } + + clear(): void { + this.clearDeadline() + this.readyAt = null + } + + private clearDeadline(): void { + if (this.deadlineTimer !== null) { + clearTimeout(this.deadlineTimer) + this.deadlineTimer = null + } + } +} diff --git a/src/renderer/src/components/browser-pane/remote-browser-stream-restart-attempt.ts b/src/renderer/src/components/browser-pane/remote-browser-stream-restart-attempt.ts new file mode 100644 index 00000000000..648dcf52d8d --- /dev/null +++ b/src/renderer/src/components/browser-pane/remote-browser-stream-restart-attempt.ts @@ -0,0 +1,85 @@ +import type { RemoteBrowserPageSession } from './remote-browser-page-session' +import { + isRemoteBrowserPageMissingError, + resolveRemoteBrowserStreamRestartFailure +} from './remote-browser-stream-errors' +import { + remoteBrowserStreamRetrying, + remoteBrowserStreamStopped, + type RemoteBrowserStreamStatus +} from './remote-browser-stream-status' +import { + toOperationToken, + type RemoteBrowserOperationTokens, + type RemoteBrowserStreamSubscription, + type RemoteBrowserStreamToken +} from './remote-browser-stream-tokens' +import type { BrowserTabInfo } from '../../../../shared/runtime-types' + +export type RemoteBrowserStreamRestartAttemptDeps = { + tokens: RemoteBrowserOperationTokens + session: RemoteBrowserPageSession + setStatus: (status: RemoteBrowserStreamStatus) => void + applyTabInfo: (tab: BrowserTabInfo) => void + closeMissingRemotePage: (remotePageId: string) => void + startStream: (pageId: string) => Promise + adoptSubscription: (subscription: RemoteBrowserStreamSubscription) => void +} + +// One self-heal attempt for a dropped stream, as the scheduler wants it: resolves true to keep +// retrying, false to stop. Split from the lifecycle because a single attempt needs none of the +// lifecycle's own state — only the token it is retrying on behalf of. +export function createRemoteBrowserStreamRestartAttempt( + token: RemoteBrowserStreamToken, + deps: RemoteBrowserStreamRestartAttemptDeps +): () => Promise { + const { tokens } = deps + // Keeps a failure visible across the wait before the next attempt instead of blinking off. + let lastNotice: string | null = null + return async (): Promise => { + if (!tokens.isCurrentStreamOperation(token)) { + return false + } + deps.setStatus(remoteBrowserStreamRetrying(lastNotice)) + const operationToken = toOperationToken(token) + try { + const tab = await deps.session.fetchTabInfo(operationToken).catch(() => null) + if (tab && tokens.isCurrentStreamOperation(token)) { + deps.applyTabInfo(tab) + } + if (!tokens.isCurrentStreamOperation(token)) { + return false + } + const subscription = await deps.startStream(token.remotePageId) + if (subscription) { + deps.adoptSubscription(subscription) + } + return false + } catch (error) { + if (!tokens.isCurrentStreamOperation(token)) { + return false + } + if (isRemoteBrowserPageMissingError(error)) { + deps.closeMissingRemotePage(token.remotePageId) + return false + } + const failure = resolveRemoteBrowserStreamRestartFailure(error) + if (failure.logRawError) { + // The raw text is transport-level and written for logs; keep it out of the UI but not out of + // reach, since nothing else records it. + console.warn('[browser-pane] remote stream restart failed:', error) + } + // Why giving up publishes 'stopped': abandoning automatic retries is not the same as taking + // away the user's last resort, and a classification we got wrong would otherwise strand the + // pane exactly as it did before this work. While attempts remain it stays 'retrying', which + // reports the failure without offering a control that competes with the next attempt. + lastNotice = failure.message + deps.setStatus( + failure.shouldRetry + ? remoteBrowserStreamRetrying(failure.message) + : remoteBrowserStreamStopped(failure.message) + ) + return failure.shouldRetry + } + } +} diff --git a/src/renderer/src/components/browser-pane/remote-browser-stream-restart-scheduler.test.ts b/src/renderer/src/components/browser-pane/remote-browser-stream-restart-scheduler.test.ts new file mode 100644 index 00000000000..6109a64e40b --- /dev/null +++ b/src/renderer/src/components/browser-pane/remote-browser-stream-restart-scheduler.test.ts @@ -0,0 +1,231 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { RemoteBrowserStreamRestartScheduler } from './remote-browser-stream-restart-scheduler' + +afterEach(() => { + vi.restoreAllMocks() + vi.useRealTimers() +}) + +describe('RemoteBrowserStreamRestartScheduler', () => { + it('retries transient failures until it recovers, without spending the whole budget', async () => { + vi.useFakeTimers() + const scheduler = new RemoteBrowserStreamRestartScheduler() + let attempts = 0 + const FAILURES_BEFORE_SUCCESS = 3 + + const run = vi.fn(async () => { + attempts += 1 + return attempts <= FAILURES_BEFORE_SUCCESS + }) + + scheduler.schedule(run) + await vi.advanceTimersByTimeAsync(31_000) + + expect(attempts).toBe(FAILURES_BEFORE_SUCCESS + 1) + expect(scheduler.isScheduled).toBe(false) + + await vi.advanceTimersByTimeAsync(60_000) + expect(attempts).toBe(FAILURES_BEFORE_SUCCESS + 1) + }) + + // The property the pane depends on: retries are finite, and it is told when they run out. An + // unbounded scheduler leaves a dead stream retrying forever behind a recurring error. + it('stops after the budget and reports it exactly once', async () => { + vi.useFakeTimers() + const onExhausted = vi.fn() + const scheduler = new RemoteBrowserStreamRestartScheduler(undefined, onExhausted) + const run = vi.fn(async () => true) + + scheduler.schedule(run) + await vi.advanceTimersByTimeAsync(60_000) + + expect(run).toHaveBeenCalledTimes(5) + expect(onExhausted).toHaveBeenCalledTimes(1) + expect(scheduler.isBudgetExhausted).toBe(true) + expect(scheduler.isScheduled).toBe(false) + + await vi.advanceTimersByTimeAsync(300_000) + expect(run).toHaveBeenCalledTimes(5) + }) + + it('reset gives a spent scheduler its budget back so the user can ask again', async () => { + vi.useFakeTimers() + const scheduler = new RemoteBrowserStreamRestartScheduler([10, 20]) + const run = vi.fn(async () => true) + + scheduler.schedule(run) + await vi.advanceTimersByTimeAsync(1_000) + expect(run).toHaveBeenCalledTimes(2) + + scheduler.reset() + scheduler.schedule(run) + await vi.advanceTimersByTimeAsync(1_000) + expect(run).toHaveBeenCalledTimes(4) + }) + + it('keeps retrying when an attempt rejects rather than dropping the cycle', async () => { + vi.useFakeTimers() + const onExhausted = vi.fn() + const scheduler = new RemoteBrowserStreamRestartScheduler([10, 20, 40], onExhausted) + const run = vi.fn(async () => { + throw new Error('start failed') + }) + + scheduler.schedule(run) + await vi.advanceTimersByTimeAsync(1_000) + + expect(run).toHaveBeenCalledTimes(3) + expect(onExhausted).toHaveBeenCalledTimes(1) + }) + + // Why: clearTimeout cannot recall an attempt already dispatched into an await. Pre-fix, a cancel() + // during that window was a no-op and the resolving attempt re-armed a "cancelled" scheduler. + it('does not re-arm when cancelled while an attempt is already in flight', async () => { + vi.useFakeTimers() + const scheduler = new RemoteBrowserStreamRestartScheduler() + let resolveAttempt: ((shouldRetry: boolean) => void) | undefined + const run = vi.fn( + () => + new Promise((resolve) => { + resolveAttempt = resolve + }) + ) + + scheduler.schedule(run) + await vi.advanceTimersByTimeAsync(500) + expect(run).toHaveBeenCalledTimes(1) + expect(scheduler.isScheduled).toBe(true) + + scheduler.cancel() + resolveAttempt?.(true) // the in-flight attempt reports a transient failure after the cancel + await vi.advanceTimersByTimeAsync(0) + + expect(scheduler.isScheduled).toBe(false) + await vi.advanceTimersByTimeAsync(60_000) + expect(run).toHaveBeenCalledTimes(1) + }) + + it('still retries normally when an in-flight attempt resolves without an intervening cancel', async () => { + vi.useFakeTimers() + const scheduler = new RemoteBrowserStreamRestartScheduler() + let resolveAttempt: ((shouldRetry: boolean) => void) | undefined + const run = vi.fn( + () => + new Promise((resolve) => { + resolveAttempt = resolve + }) + ) + + scheduler.schedule(run) + await vi.advanceTimersByTimeAsync(500) + resolveAttempt?.(true) + await vi.advanceTimersByTimeAsync(0) + expect(scheduler.isScheduled).toBe(true) + + await vi.advanceTimersByTimeAsync(1_000) + expect(run).toHaveBeenCalledTimes(2) + }) + + it('queues one replacement chain while the current attempt is in flight', async () => { + vi.useFakeTimers() + const scheduler = new RemoteBrowserStreamRestartScheduler([10, 20]) + let resolveAttempt: ((shouldRetry: boolean) => void) | undefined + const firstRun = vi.fn( + () => + new Promise((resolve) => { + resolveAttempt = resolve + }) + ) + const replacementRun = vi.fn(async () => false) + + scheduler.schedule(firstRun) + await vi.advanceTimersByTimeAsync(10) + scheduler.schedule(replacementRun) + + expect(scheduler.attemptCount).toBe(1) + resolveAttempt?.(false) + await vi.advanceTimersByTimeAsync(20) + + expect(firstRun).toHaveBeenCalledTimes(1) + expect(replacementRun).toHaveBeenCalledTimes(1) + expect(scheduler.isScheduled).toBe(false) + }) + + it('grows the delay per counted attempt, never by elapsed wall time, and then stops', async () => { + vi.useFakeTimers() + const scheduler = new RemoteBrowserStreamRestartScheduler() + const setTimeoutSpy = vi.spyOn(global, 'setTimeout') + const run = vi.fn(async () => true) + + scheduler.schedule(run) + await vi.advanceTimersByTimeAsync(60_000) + const observedDelays = setTimeoutSpy.mock.calls.map((call) => call[1]) + + // Literal on purpose: asserting against the constant under test makes the ladder self-certifying + // — verified, a change to 12 flat 1s attempts passed the whole suite. The budget's size and shape + // are user-visible (how long the pane waits before handing control back), so they get pinned here. + expect(observedDelays).toEqual([500, 1_000, 2_000, 4_000, 8_000]) + }) + + it('does not double-schedule while a restart is already pending', () => { + vi.useFakeTimers() + const scheduler = new RemoteBrowserStreamRestartScheduler() + const run = vi.fn(async () => true) + + scheduler.schedule(run) + expect(scheduler.attemptCount).toBe(1) + scheduler.schedule(run) // no-op: already scheduled + expect(scheduler.attemptCount).toBe(1) + expect(scheduler.isScheduled).toBe(true) + }) + + it('stops the retry chain and clears the timer when run resolves false (e.g. superseded or success)', async () => { + vi.useFakeTimers() + const scheduler = new RemoteBrowserStreamRestartScheduler() + const run = vi.fn(async () => false) + + scheduler.schedule(run) + await vi.advanceTimersByTimeAsync(500) + + expect(run).toHaveBeenCalledTimes(1) + expect(scheduler.isScheduled).toBe(false) + + await vi.advanceTimersByTimeAsync(60_000) + expect(run).toHaveBeenCalledTimes(1) + }) + + it('reset() forgets prior failures so the next drop backs off from the first delay again', async () => { + vi.useFakeTimers() + const scheduler = new RemoteBrowserStreamRestartScheduler() + const setTimeoutSpy = vi.spyOn(global, 'setTimeout') + const run = vi.fn().mockResolvedValueOnce(true).mockResolvedValue(false) + + scheduler.schedule(run) + await vi.advanceTimersByTimeAsync(1_500) + expect(scheduler.attemptCount).toBe(2) + + // A confirmed-live stream ("ready") resets the counter. + scheduler.reset() + expect(scheduler.attemptCount).toBe(0) + + scheduler.schedule(run) + const call = setTimeoutSpy.mock.calls.at(-1) + expect(call?.[1]).toBe(500) + }) + + it('cancel() clears a pending timer and resets the attempt count', async () => { + vi.useFakeTimers() + const scheduler = new RemoteBrowserStreamRestartScheduler() + const run = vi.fn(async () => true) + + scheduler.schedule(run) + expect(scheduler.isScheduled).toBe(true) + scheduler.cancel() + + expect(scheduler.isScheduled).toBe(false) + expect(scheduler.attemptCount).toBe(0) + + await vi.advanceTimersByTimeAsync(60_000) + expect(run).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/browser-pane/remote-browser-stream-restart-scheduler.ts b/src/renderer/src/components/browser-pane/remote-browser-stream-restart-scheduler.ts new file mode 100644 index 00000000000..9ef35acc8a6 --- /dev/null +++ b/src/renderer/src/components/browser-pane/remote-browser-stream-restart-scheduler.ts @@ -0,0 +1,109 @@ +// Why a retry budget rather than "retry until it works" or "give up after one try": both extremes +// fail the user. One attempt strands the pane on any blip with no way back. Unbounded retry hides a +// genuinely dead stream behind background work that never stops and an error that keeps reappearing. +// +// The budget covers the transient case invisibly, then hands control back: once it is spent the pane +// reports that it stopped and offers an explicit reconnect. That also removes the sharpest edge in +// this area — misjudging a failure as permanent is no longer unrecoverable, because the user always +// has a way to ask again. +// +// Counts attempts, not elapsed time. A host that refuses fast spends the budget in ~16s; one that +// accepts and then hangs spends it far more slowly, since each attempt carries its own timeout. +export const REMOTE_BROWSER_STREAM_RESTART_DELAYS_MS: readonly number[] = [ + 500, 1_000, 2_000, 4_000, 8_000 +] + +export type RemoteBrowserStreamRestartAttempt = () => Promise + +export class RemoteBrowserStreamRestartScheduler { + private attempt = 0 + private timer: ReturnType | null = null + private generation = 0 + private inFlightGeneration: number | null = null + private queuedRun: RemoteBrowserStreamRestartAttempt | null = null + + constructor( + private readonly delaysMs: readonly number[] = REMOTE_BROWSER_STREAM_RESTART_DELAYS_MS, + private readonly onBudgetExhausted: () => void = () => {} + ) {} + + // Kept for tests: reset()/cancel() restoring the budget is behaviour worth asserting directly, + // and the counter is the only honest way to observe it. + get attemptCount(): number { + return this.attempt + } + + get isScheduled(): boolean { + return ( + this.timer !== null || this.inFlightGeneration === this.generation || this.queuedRun !== null + ) + } + + get isBudgetExhausted(): boolean { + return this.attempt >= this.delaysMs.length + } + + // Why: run() resolves true to keep retrying (transient failure), false to stop (success or + // superseded/missing). Retries stop regardless once the budget is spent. + schedule(run: RemoteBrowserStreamRestartAttempt): void { + if (this.timer !== null) { + return + } + if (this.inFlightGeneration === this.generation) { + this.queuedRun = run + return + } + if (this.isBudgetExhausted) { + this.onBudgetExhausted() + return + } + const delayMs = this.delaysMs[this.attempt]! + this.attempt += 1 + const generation = this.generation + this.timer = setTimeout(() => { + this.timer = null + if (generation !== this.generation) { + return + } + this.inFlightGeneration = generation + void Promise.resolve() + .then(run) + .then( + (shouldRetry) => this.finishAttempt(generation, run, shouldRetry), + () => this.finishAttempt(generation, run, true) + ) + }, delayMs) + } + + // Why: a confirmed-live stream ('ready') forgets prior failures so the next drop backs off from + // scratch — and gets the whole budget again rather than the tail of an earlier one. + reset(): void { + this.attempt = 0 + } + + cancel(): void { + if (this.timer !== null) { + clearTimeout(this.timer) + this.timer = null + } + this.queuedRun = null + this.generation += 1 + this.attempt = 0 + } + + private finishAttempt( + generation: number, + run: RemoteBrowserStreamRestartAttempt, + shouldRetry: boolean + ): void { + if (generation !== this.generation) { + return + } + this.inFlightGeneration = null + const nextRun = this.queuedRun ?? (shouldRetry ? run : null) + this.queuedRun = null + if (nextRun) { + this.schedule(nextRun) + } + } +} diff --git a/src/renderer/src/components/browser-pane/remote-browser-stream-status.test.ts b/src/renderer/src/components/browser-pane/remote-browser-stream-status.test.ts new file mode 100644 index 00000000000..d86bb71486a --- /dev/null +++ b/src/renderer/src/components/browser-pane/remote-browser-stream-status.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest' +import { + canReconnectRemoteBrowserStream, + isRemoteBrowserStreamBusy, + REMOTE_BROWSER_STREAM_IDLE, + REMOTE_BROWSER_STREAM_LIVE, + REMOTE_BROWSER_STREAM_OPENING, + remoteBrowserStreamNotice, + remoteBrowserStreamRetrying, + remoteBrowserStreamStopped, + type RemoteBrowserStreamStatus +} from './remote-browser-stream-status' + +// These pin the derivation rules, which are what make the four defects three review rounds found +// unrepresentable. The type stops you writing an invalid state; these stop the derivations drifting +// so that a valid state renders as an invalid one. +const ALL: RemoteBrowserStreamStatus[] = [ + REMOTE_BROWSER_STREAM_IDLE, + REMOTE_BROWSER_STREAM_OPENING, + REMOTE_BROWSER_STREAM_LIVE, + remoteBrowserStreamRetrying('Lost connection to the remote server.'), + remoteBrowserStreamStopped('Lost connection to the remote server.') +] + +describe('remote browser stream status', () => { + // "stopped, but no message" made the reconnect control unrenderable, because it lives inside the + // notice. The constructor requires one, so this holds by construction — asserted anyway because + // the render coupling is the part no unit test can see. + it('always carries a notice in the states that report a failure', () => { + expect(remoteBrowserStreamNotice(remoteBrowserStreamStopped('gone'))).toBe('gone') + expect(remoteBrowserStreamNotice(remoteBrowserStreamRetrying('dropped'))).toBe('dropped') + }) + + // The budget exists to absorb a blip invisibly, so a retry that has not failed yet says nothing. + it('stays silent while retrying until an attempt has actually failed', () => { + expect(remoteBrowserStreamNotice(remoteBrowserStreamRetrying(null))).toBeNull() + expect(isRemoteBrowserStreamBusy(remoteBrowserStreamRetrying(null))).toBe(true) + expect(canReconnectRemoteBrowserStream(remoteBrowserStreamRetrying(null))).toBe(false) + }) + + it('reports no notice while nothing has failed', () => { + for (const status of [ + REMOTE_BROWSER_STREAM_IDLE, + REMOTE_BROWSER_STREAM_OPENING, + REMOTE_BROWSER_STREAM_LIVE + ]) { + expect(remoteBrowserStreamNotice(status)).toBeNull() + } + }) + + // "retrying, but the control is up" offered a manual retry ~500ms before the automatic one. + it('offers reconnect only once automatic recovery is over', () => { + expect(canReconnectRemoteBrowserStream(remoteBrowserStreamStopped('gone'))).toBe(true) + for (const status of ALL.filter((candidate) => candidate.kind !== 'stopped')) { + expect(canReconnectRemoteBrowserStream(status)).toBe(false) + } + }) + + // "stopped, but still busy" left a spinner over a frozen frame, which also blocked the pane's + // own input handlers. + it('is never busy in a state the user is expected to act on', () => { + expect(isRemoteBrowserStreamBusy(remoteBrowserStreamStopped('gone'))).toBe(false) + expect(isRemoteBrowserStreamBusy(REMOTE_BROWSER_STREAM_LIVE)).toBe(false) + expect(isRemoteBrowserStreamBusy(REMOTE_BROWSER_STREAM_IDLE)).toBe(false) + }) + + it('is busy exactly while recovery is in flight', () => { + expect(isRemoteBrowserStreamBusy(REMOTE_BROWSER_STREAM_OPENING)).toBe(true) + expect(isRemoteBrowserStreamBusy(remoteBrowserStreamRetrying('dropped'))).toBe(true) + }) + + // The combination that must never be derivable: something to act on, with no way to act. + it('never offers reconnect without a notice to render it in', () => { + for (const status of ALL) { + if (canReconnectRemoteBrowserStream(status)) { + expect(remoteBrowserStreamNotice(status)).not.toBeNull() + expect(isRemoteBrowserStreamBusy(status)).toBe(false) + } + } + }) +}) diff --git a/src/renderer/src/components/browser-pane/remote-browser-stream-status.ts b/src/renderer/src/components/browser-pane/remote-browser-stream-status.ts new file mode 100644 index 00000000000..5d1bd31744d --- /dev/null +++ b/src/renderer/src/components/browser-pane/remote-browser-stream-status.ts @@ -0,0 +1,95 @@ +import { translate } from '@/i18n/i18n' + +// Why one value instead of the three booleans/strings this replaces (error, reconnectAvailable, +// busy): they always described a single thing — what this stream is doing right now — but were +// written independently from ~15 sites, so nothing stopped them disagreeing. Every defect three +// review rounds found in this area was one such disagreement: +// +// stopped, but no affordance -> the pane stranded (the bug this work exists to fix) +// stopped, but no message -> the control renders inside the message, so it could not render +// retrying, but affordance shown -> offered a manual retry ~500ms before the automatic one +// stopped, but still busy -> a spinner over a frozen frame, blocking the pane's own input +// +// Expressed as one tagged value, none of those four is a state you can write down. `busy`, the +// message, and whether the reconnect control appears are all derived from it, so they cannot drift. +export type RemoteBrowserStreamStatus = + | { kind: 'idle' } + /** Establishing a stream: no stream yet, and no failure to report. */ + | { kind: 'opening' } + /** A confirmed-live stream; the host has sent 'ready'. */ + | { kind: 'live' } + // Automatic recovery is running. The notice is null until an attempt has actually failed, so a + // blip the budget absorbs in 500ms stays invisible — which is the whole point of having a budget. + | { kind: 'retrying'; notice: string | null } + /** Automatic recovery is over. This is the only state that offers a reconnect. */ + | { kind: 'stopped'; notice: string } + +export const REMOTE_BROWSER_STREAM_IDLE: RemoteBrowserStreamStatus = { kind: 'idle' } +export const REMOTE_BROWSER_STREAM_OPENING: RemoteBrowserStreamStatus = { kind: 'opening' } +export const REMOTE_BROWSER_STREAM_LIVE: RemoteBrowserStreamStatus = { kind: 'live' } + +export function remoteBrowserStreamRetrying(notice: string | null): RemoteBrowserStreamStatus { + return { kind: 'retrying', notice } +} + +export function remoteBrowserStreamStopped(notice: string): RemoteBrowserStreamStatus { + return { kind: 'stopped', notice } +} + +/** Why the pane must not paint a stale frame as interactive: nothing is arriving in these states. */ +export function isRemoteBrowserStreamBusy(status: RemoteBrowserStreamStatus): boolean { + return status.kind === 'opening' || status.kind === 'retrying' +} + +/** The stream's own message. Incidental notices (input failures, URL validation) are separate. */ +export function remoteBrowserStreamNotice(status: RemoteBrowserStreamStatus): string | null { + if (status.kind === 'stopped') { + return status.notice + } + return status.kind === 'retrying' ? status.notice : null +} + +// Why only 'stopped': while attempts remain, a manual control competes with the automatic recovery +// that is about to run anyway; and once live there is nothing to reconnect. +export function canReconnectRemoteBrowserStream(status: RemoteBrowserStreamStatus): boolean { + return status.kind === 'stopped' +} + +// Why the pane authors these rather than forwarding the failure's own message: those strings come +// from the transport layer and are written for logs (e.g. "Runtime environment is manually +// disconnected." — an RPC error with the code runtime_manually_disconnected). They name our +// internals rather than the user's situation and none of them change what the user can do, which +// the reconnect control already says. The raw error is still logged, so diagnosis keeps the detail +// the UI drops. A failure we classified ourselves keeps its own message: it is specific and true, +// and flattening "The selected runtime does not support remote browser streaming." into "lost +// connection" would be both vaguer and wrong. + +/** The stream was established and then died. */ +export function remoteBrowserStreamLostNotice(): string { + return translate( + 'auto.components.BrowserPane.streamConnectionLost', + 'Lost connection to the remote server.' + ) +} + +/** Nothing was ever established — saying "lost" would name something the user never had. */ +export function remoteBrowserStreamUnreachableNotice(): string { + return translate( + 'auto.components.BrowserPane.streamConnectionUnreachable', + 'Cannot reach the remote server.' + ) +} + +export function remoteBrowserStreamRestartFailedNotice(): string { + return translate( + 'auto.components.BrowserPane.streamRestartFailed', + 'Failed to restart remote browser stream.' + ) +} + +export function remoteBrowserStreamUnsupportedNotice(): string { + return translate( + 'auto.components.BrowserPane.streamCapabilityUnsupported', + 'The selected runtime does not support remote browser streaming.' + ) +} diff --git a/src/renderer/src/components/browser-pane/remote-browser-stream-tokens.ts b/src/renderer/src/components/browser-pane/remote-browser-stream-tokens.ts new file mode 100644 index 00000000000..0f33af80c1b --- /dev/null +++ b/src/renderer/src/components/browser-pane/remote-browser-stream-tokens.ts @@ -0,0 +1,146 @@ +export type RemoteBrowserViewportSize = { + width: number + height: number +} + +// Why: sub-pixel jitter from the resize observer must not count as a resize, or every layout +// settle would tear down a healthy stream. +export function areRemoteViewportSizesNear( + a: RemoteBrowserViewportSize | null, + b: RemoteBrowserViewportSize | null +): boolean { + if (!a || !b) { + return false + } + return Math.abs(a.width - b.width) <= 3 && Math.abs(a.height - b.height) <= 3 +} + +export type RemoteBrowserStreamToken = { + tabId: string + environmentId: string + remotePageId: string + generation: number + operationGeneration: number +} + +export type RemoteBrowserStreamSubscription = { + token: RemoteBrowserStreamToken + unsubscribe: () => void +} + +export type RemoteBrowserOperationToken = { + tabId: string + environmentId: string + remotePageId: string | null + generation: number +} + +// Why: pane identity lives in React refs; the lifecycle reads it through this port so the same +// staleness guards can run in a test with no component mounted. +export type RemoteBrowserPaneIdentity = { + isMounted(): boolean + isActive(): boolean + getTabId(): string + getEnvironmentId(): string | null + browserPageExists(tabId: string): boolean +} + +// Owns the two generation counters every async remote-browser operation is guarded by: the +// operation generation (a new pane/page/environment epoch) and the stream generation (one +// screencast subscription). Anything that supersedes work bumps one of them. +export class RemoteBrowserOperationTokens { + private operationGeneration = 0 + private streamGeneration = 0 + private activeStreamToken: RemoteBrowserStreamToken | null = null + private remotePageId: string | null = null + + constructor(private readonly identity: RemoteBrowserPaneIdentity) {} + + get remotePage(): string | null { + return this.remotePageId + } + + setRemotePage(remotePageId: string | null): void { + this.remotePageId = remotePageId + } + + createOperationToken(remotePageId: string | null = null): RemoteBrowserOperationToken | null { + const environmentId = this.identity.getEnvironmentId() + if (!environmentId) { + return null + } + return { + tabId: this.identity.getTabId(), + environmentId, + remotePageId, + generation: this.operationGeneration + } + } + + isCurrent(token: RemoteBrowserOperationToken): boolean { + return ( + this.identity.isMounted() && + this.identity.isActive() && + this.identity.browserPageExists(token.tabId) && + this.identity.getTabId() === token.tabId && + this.identity.getEnvironmentId() === token.environmentId && + this.operationGeneration === token.generation && + (token.remotePageId === null || this.remotePageId === token.remotePageId) + ) + } + + isCurrentStreamOperation(token: RemoteBrowserStreamToken): boolean { + return this.isCurrent(toOperationToken(token)) + } + + isCurrentStreamToken(token: RemoteBrowserStreamToken): boolean { + const active = this.activeStreamToken + return ( + active?.generation === token.generation && + active.operationGeneration === token.operationGeneration && + active.tabId === token.tabId && + active.environmentId === token.environmentId && + active.remotePageId === token.remotePageId && + this.isCurrentStreamOperation(token) + ) + } + + // Why: every supersession site bumps the operation generation so in-flight awaits fail their own + // token guard on resume — clearing a timer cannot recall work already dispatched into an await. + supersedeOperations(): void { + this.operationGeneration += 1 + } + + supersedeStream(): void { + this.streamGeneration += 1 + } + + claimStreamToken( + operationToken: RemoteBrowserOperationToken, + remotePageId: string + ): RemoteBrowserStreamToken { + const token: RemoteBrowserStreamToken = { + tabId: this.identity.getTabId(), + environmentId: operationToken.environmentId, + remotePageId, + generation: this.streamGeneration + 1, + operationGeneration: operationToken.generation + } + this.streamGeneration = token.generation + this.activeStreamToken = token + return token + } + + releaseStreamToken(): void { + this.activeStreamToken = null + } +} + +export function toOperationToken(token: RemoteBrowserStreamToken): RemoteBrowserOperationToken { + return { + tabId: token.tabId, + environmentId: token.environmentId, + remotePageId: token.remotePageId, + generation: token.operationGeneration + } +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 9cdfd6ecc18..8c8e9307ba8 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -13871,6 +13871,7 @@ "b472c5fe03": "Add browser annotation", "b5ba6085de": "Question", "143204e423": "Change", + "b71dc3d930": "Reconnect", "e7ca5a098c": "success", "d51ef37351": "Copy", "6f4ab3592b": "Copied", @@ -14804,6 +14805,12 @@ "pendingChip": "{{value0}} pending", "needsActionChip": "{{value0}} action required", "unresolvedChip": "{{value0}} unresolved" + }, + "BrowserPane": { + "streamConnectionLost": "Lost connection to the remote server.", + "streamConnectionUnreachable": "Cannot reach the remote server.", + "streamRestartFailed": "Failed to restart remote browser stream.", + "streamCapabilityUnsupported": "The selected runtime does not support remote browser streaming." } }, "i18n": { diff --git a/tests/e2e/paired-remote-browser-stream-reconnect.spec.ts b/tests/e2e/paired-remote-browser-stream-reconnect.spec.ts new file mode 100644 index 00000000000..d67f82d22dc --- /dev/null +++ b/tests/e2e/paired-remote-browser-stream-reconnect.spec.ts @@ -0,0 +1,266 @@ +import type { Page } from '@stablyai/playwright-test' +import { + launchHeadlessPairedRuntimeHost, + type HeadlessPairedRuntimeHost +} from './helpers/headless-paired-runtime-host' +import { expect, test } from './helpers/orca-app' +import { + launchPairedElectronClient, + type PairedElectronClient +} from './helpers/paired-electron-client' + +// What this proves: a paired client whose remote browser stream drops retries on a bounded budget, +// then surfaces a Reconnect control instead of either giving up silently or retrying forever. The +// failure is real, not injected — the runtime connection is dropped, so every restart genuinely +// fails while it is down. + +async function callEnvironment( + page: Page, + environmentId: string, + method: string, + params: unknown +): Promise { + return page.evaluate( + async ({ environmentId, method, params }) => { + const response = await window.api.runtimeEnvironments.call({ + selector: environmentId, + method, + params + }) + if (!response.ok) { + throw new Error(`${response.error.code}: ${response.error.message}`) + } + return response.result + }, + { environmentId, method, params } + ) as Promise +} + +/** Mirrors what the client does when the user opens a browser tab on a paired host. */ +async function attachRemoteBrowserPane( + page: Page, + environmentId: string, + worktreeId: string, + remotePageId: string +): Promise { + await page.evaluate( + ({ environmentId, worktreeId, remotePageId }) => { + const state = window.__store?.getState() + if (!state) { + throw new Error('client store unavailable') + } + const browserTab = state.createBrowserTab(worktreeId, 'about:blank', { + title: 'New Browser Tab', + browserRuntimeEnvironmentId: environmentId + }) + const pageId = browserTab.activePageId ?? browserTab.pageIds?.[0] ?? null + if (!pageId) { + throw new Error('client did not allocate a browser page id') + } + state.setRemoteBrowserPageHandle(pageId, { environmentId, remotePageId }) + state.setActiveWorktree(worktreeId) + state.focusBrowserTabInWorktree(worktreeId, browserTab.id, { surfacePane: true }) + }, + { environmentId, worktreeId, remotePageId } + ) +} + +test('bounds remote browser stream retries, then offers reconnect', async ({ + testRepoPath +}, testInfo) => { + test.setTimeout(300_000) + const host: HeadlessPairedRuntimeHost = await launchHeadlessPairedRuntimeHost() + let client: PairedElectronClient | null = null + + try { + await host.client.call('repo.add', { path: testRepoPath, kind: 'git' }) + client = await launchPairedElectronClient(host.offer, testInfo, 'Remote browser reconnect') + const page = client.page + + await expect + .poll(() => page.evaluate(() => window.__store?.getState().allWorktrees().length ?? 0), { + timeout: 60_000, + message: 'paired client never saw a host worktree' + }) + .toBeGreaterThan(0) + const worktreeId = await page.evaluate( + () => window.__store?.getState().allWorktrees()[0]?.id ?? null + ) + if (!worktreeId) { + throw new Error('paired client did not receive the host worktree') + } + + const created = await callEnvironment<{ browserPageId: string }>( + page, + client.environmentId, + 'browser.tabCreate', + { worktree: `id:${worktreeId}`, url: 'about:blank', activate: true } + ) + await attachRemoteBrowserPane(page, client.environmentId, worktreeId, created.browserPageId) + + // The stream is live once the pane paints a remote frame. + const remoteFrame = page.getByTestId('remote-browser-frame').first() + await expect(remoteFrame).toBeVisible({ timeout: 60_000 }) + + const errorToast = page.getByTestId('remote-browser-stream-error') + const reconnectButton = page.getByRole('button', { name: 'Reconnect' }) + await expect(reconnectButton).toHaveCount(0) + + // Leave a pane-owned notice on screen before the drop. It outranks the stream's own message by + // design (it is the only response to what the user typed), so if nothing clears it on a status + // change, the stranded pane below reports a stale URL complaint next to its Reconnect button and + // never says what actually happened. The 'Lost connection' assertion after the drop is what + // catches that. + // Scoped to the pane holding the remote frame: a workspace can hold more than one browser pane. + const remotePane = page + .getByTestId('remote-browser-pane') + .filter({ has: page.getByTestId('remote-browser-frame') }) + const addressBar = remotePane.locator('[data-orca-browser-address-bar="true"]') + await addressBar.click() + await addressBar.fill('about:config') + await addressBar.press('Enter') + await expect(errorToast).toContainText('Enter a valid http(s) or localhost URL.') + + // Drop the runtime connection: the stream closes and every restart attempt now genuinely fails. + await page.evaluate(async (selector) => { + await window.api.runtimeEnvironments.disconnect({ selector }) + }, client.environmentId) + + // Mid-budget: the pane is retrying, so it must NOT be offering reconnect yet. + await expect(errorToast).toBeVisible({ timeout: 60_000 }) + // The pane must speak for itself here, not echo the transport's log string. + await expect(errorToast).toContainText('Lost connection to the remote server.') + await expect(errorToast).not.toContainText('Runtime environment') + await expect(reconnectButton).toHaveCount(0) + + // Budget spans 500ms+1s+2s+4s+8s; once spent the control appears and retrying stops. + await expect(reconnectButton).toBeVisible({ timeout: 90_000 }) + await page.screenshot({ + path: testInfo.outputPath('remote-browser-reconnect.png'), + fullPage: false + }) + await testInfo.attach('reconnect-ui', { + path: testInfo.outputPath('remote-browser-reconnect.png'), + contentType: 'image/png' + }) + + // It must stay put rather than quietly resuming a retry loop. + await page.waitForTimeout(15_000) + await expect(reconnectButton).toBeVisible() + + // Clicking the frozen frame is the natural reaction to a stuck pane, and the input handlers + // clear the error optimistically — so without a guard this exact gesture deletes the user's only + // way back, then repaints the raw transport string once the queued RPC fails. + await remoteFrame.click({ position: { x: 40, y: 40 }, force: true }) + await page.waitForTimeout(1_000) + await expect(reconnectButton).toBeVisible() + await expect(errorToast).not.toContainText('Runtime environment') + + await expect + .poll( + () => + page.evaluate(async (selector) => { + const response = await window.api.runtimeEnvironments.connect({ selector }) + return response.ok + }, client!.environmentId), + { timeout: 60_000, message: 'paired client never reconnected to the host runtime' } + ) + .toBe(true) + + // Why the frame src and not just visibility: the pane deliberately keeps the last frame painted + // during a restart, so "an image is showing" is also true of a frozen, dead stream. A changed + // object URL means a NEW frame actually arrived, which only a live subscription can produce. + const frozenFrameSrc = await remoteFrame.getAttribute('src') + await reconnectButton.click() + await expect(errorToast).toHaveCount(0, { timeout: 60_000 }) + await expect(remoteFrame).toBeVisible({ timeout: 60_000 }) + await expect + .poll(() => remoteFrame.getAttribute('src'), { + timeout: 60_000, + message: 'remote stream never delivered a new frame after reconnect' + }) + .not.toBe(frozenFrameSrc) + } finally { + await client?.dispose() + await host.dispose() + } +}) + +// The other door into the same failure: the pane never had a stream to lose. A drop-only fix leaves +// this one stranded on a bare error, which is the original bug wearing a different hat. +test('offers reconnect when the remote browser never opens at all', async ({ + testRepoPath +}, testInfo) => { + test.setTimeout(300_000) + const host: HeadlessPairedRuntimeHost = await launchHeadlessPairedRuntimeHost() + let client: PairedElectronClient | null = null + + try { + await host.client.call('repo.add', { path: testRepoPath, kind: 'git' }) + client = await launchPairedElectronClient(host.offer, testInfo, 'Remote browser cold failure') + const page = client.page + + await expect + .poll(() => page.evaluate(() => window.__store?.getState().allWorktrees().length ?? 0), { + timeout: 60_000, + message: 'paired client never saw a host worktree' + }) + .toBeGreaterThan(0) + const worktreeId = await page.evaluate( + () => window.__store?.getState().allWorktrees()[0]?.id ?? null + ) + if (!worktreeId) { + throw new Error('paired client did not receive the host worktree') + } + + const created = await callEnvironment<{ browserPageId: string }>( + page, + client.environmentId, + 'browser.tabCreate', + { worktree: `id:${worktreeId}`, url: 'about:blank', activate: true } + ) + + // Drop the connection BEFORE the pane mounts, so the very first open fails and no stream ever + // exists. Nothing here can be recovered by restarting a subscription. + await page.evaluate(async (selector) => { + await window.api.runtimeEnvironments.disconnect({ selector }) + }, client.environmentId) + await attachRemoteBrowserPane(page, client.environmentId, worktreeId, created.browserPageId) + + const errorToast = page.getByTestId('remote-browser-stream-error') + const reconnectButton = page.getByRole('button', { name: 'Reconnect' }) + await expect(errorToast).toBeVisible({ timeout: 90_000 }) + await expect(errorToast).toContainText('Cannot reach the remote server.') + await expect(errorToast).not.toContainText('Runtime environment') + await expect(reconnectButton).toBeVisible({ timeout: 90_000 }) + await page.screenshot({ + path: testInfo.outputPath('remote-browser-cold-failure.png'), + fullPage: false + }) + await testInfo.attach('reconnect-ui-cold-failure', { + path: testInfo.outputPath('remote-browser-cold-failure.png'), + contentType: 'image/png' + }) + + await expect + .poll( + () => + page.evaluate(async (selector) => { + const response = await window.api.runtimeEnvironments.connect({ selector }) + return response.ok + }, client!.environmentId), + { timeout: 60_000, message: 'paired client never reconnected to the host runtime' } + ) + .toBe(true) + + // Reconnect must build the stream from nothing, not resume something that never existed. + await reconnectButton.click() + await expect(errorToast).toHaveCount(0, { timeout: 60_000 }) + await expect(page.getByTestId('remote-browser-frame').first()).toBeVisible({ + timeout: 60_000 + }) + } finally { + await client?.dispose() + await host.dispose() + } +}) From f8786d5224f5b5593e9c577045c7b9b20eec38a7 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:06:37 -0700 Subject: [PATCH 026/289] fix(agent-history): fold trailing-slash, NFD/NFC, and project-fallback folder group keys (#12458) Co-authored-by: Orca --- .../ai-vault-session-filters.test.ts | 4 +- .../ai-vault-session-projects.test.ts | 26 ++++++ .../ai-vault-session-projects.ts | 22 ++--- src/shared/ai-vault-session-filters.test.ts | 84 +++++++++++++++++++ src/shared/ai-vault-session-filters.ts | 30 +++++-- 5 files changed, 143 insertions(+), 23 deletions(-) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-filters.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-filters.test.ts index 63139bdcd71..73e28674b85 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-filters.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-filters.test.ts @@ -664,7 +664,7 @@ describe('groupAiVaultSessions', () => { ] expect(groupAiVaultSessions(sessions, 'folder')).toEqual([ - { key: '/users/ada/repo/app', label: 'repo/app', sessions } + { key: 'folder:/Users/ada/repo/app', label: 'repo/app', sessions } ]) expect(groupAiVaultSessions(sessions, 'agent').map((group) => group.label)).toEqual([ 'Claude', @@ -695,7 +695,7 @@ describe('groupAiVaultSessions', () => { it('falls back to folder grouping when project metadata is unavailable', () => { expect(groupAiVaultSessions([baseSession], 'project')).toEqual([ - { key: '/users/ada/repo/app', label: 'repo/app', sessions: [baseSession] } + { key: 'folder:/Users/ada/repo/app', label: 'repo/app', sessions: [baseSession] } ]) }) }) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts index e9d693d7dc0..6c46a144f86 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts @@ -7,6 +7,7 @@ import { buildAiVaultSessionProjectById, toAiVaultProjectKey } from './ai-vault-session-projects' +import { groupAiVaultSessions } from '../../../../shared/ai-vault-session-filters' const baseSession: AiVaultSession = { id: 'claude:1', @@ -383,6 +384,31 @@ describe('buildAiVaultProjectContext', () => { }) }) + it('emits a folder project key the shared folder grouping falls back to', () => { + const repo = makeRepo({ id: 'repo-1', displayName: 'Repo', path: '/repo' }) + const resolved = makeSession({ id: 'claude:resolved', cwd: '/outside/path' }) + const unresolved = makeSession({ id: 'claude:unresolved', cwd: '/outside/path/' }) + + const context = buildAiVaultProjectContext({ + repos: [repo], + worktrees: [], + projectHostSetupProjection: makeProjection({ projects: [], setups: [] }), + activeRepo: repo, + activeWorktree: null, + sessions: [resolved] + }) + + // Both key builders must agree, or one folder shows two identically labelled headers. + const groups = groupAiVaultSessions([resolved, unresolved], 'project', { + sessionProjectById: context.sessionProjectById + }) + expect(groups).toHaveLength(1) + expect(groups[0].sessions.map((session) => session.id)).toEqual([ + 'claude:resolved', + 'claude:unresolved' + ]) + }) + it('ignores blank setup paths instead of treating them as catch-all candidates', () => { const repo = makeRepo({ id: 'repo-1', displayName: 'Repo', path: '/repo' }) const session = makeSession({ id: 'claude:outside', cwd: '/outside/path' }) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-projects.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-projects.ts index be78edf4af2..80ed7f3ced7 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-projects.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-projects.ts @@ -9,10 +9,13 @@ import type { AiVaultSession } from '../../../../shared/ai-vault-types' import type { ProjectHostSetup, Repo, Worktree } from '../../../../shared/types' import { createNormalizedPathInsideOrEqualMatcher, - normalizeRuntimePathForComparison, - normalizeRuntimePathSeparators + normalizeRuntimePathForComparison } from '../../../../shared/cross-platform-path' -import type { AiVaultSessionProject } from '../../../../shared/ai-vault-session-filters' +import { + folderGroupKey, + folderLabel, + type AiVaultSessionProject +} from '../../../../shared/ai-vault-session-filters' // Why: the plain project descriptor moved to /shared (so the lifted filter core // stays renderer-free). Re-export it here for renderer import parity. @@ -308,22 +311,13 @@ function compareCandidates(left: SessionProjectCandidate, right: SessionProjectC } function folderProject(cwd: string): AiVaultSessionProject { - const normalizedPath = normalizeRuntimePathForComparison(cwd) return { kind: 'folder', - key: `folder:${normalizedPath}`, - label: compactFolderLabel(cwd) + key: folderGroupKey(cwd), + label: folderLabel(cwd) } } -function compactFolderLabel(pathValue: string): string { - const parts = normalizeRuntimePathSeparators(pathValue).split('/').filter(Boolean) - if (parts.length >= 2) { - return parts.slice(-2).join('/') - } - return parts[0] ?? pathValue -} - function resolveActiveProjectKey( activeRepo: Repo | null, activeWorktree: Worktree | null, diff --git a/src/shared/ai-vault-session-filters.test.ts b/src/shared/ai-vault-session-filters.test.ts index 47a1e10b64d..746c18010ec 100644 --- a/src/shared/ai-vault-session-filters.test.ts +++ b/src/shared/ai-vault-session-filters.test.ts @@ -3,6 +3,7 @@ import type { AiVaultSession } from './ai-vault-types' import { agentLabel, filterAiVaultSessions, + folderGroupKey, folderLabel, groupAiVaultSessions, parseVaultQuery @@ -89,6 +90,89 @@ describe('/shared ai-vault-session-filters (lifted core)', () => { expect(groups.map((group) => group.label).sort()).toEqual(['packages/ui', 'repo/app']) }) + it('folds trailing-slash and repeated-slash cwd spellings into one folder group', () => { + const groups = groupAiVaultSessions( + [ + { ...baseSession, cwd: '/Users/ada/repo/app' }, + { ...baseSession, id: 'claude:2', cwd: '/Users/ada/repo/app/' }, + { ...baseSession, id: 'claude:3', cwd: '/Users/ada//repo/app//' } + ], + 'folder' + ) + expect(groups).toHaveLength(1) + expect(groups[0].sessions).toHaveLength(3) + expect(groups[0].label).toBe('repo/app') + }) + + it('folds NFD and NFC cwd spellings into one folder group with an NFC label', () => { + const groups = groupAiVaultSessions( + [ + { ...baseSession, cwd: '/Users/ada/Café/app'.normalize('NFD') }, + { ...baseSession, id: 'claude:2', cwd: '/Users/ada/Café/app'.normalize('NFC') } + ], + 'folder' + ) + expect(groups).toHaveLength(1) + expect(groups[0].sessions).toHaveLength(2) + expect(groups[0].label).toBe('Café/app'.normalize('NFC')) + }) + + it('folds separator and case variants of one Windows folder', () => { + const groups = groupAiVaultSessions( + [ + { ...baseSession, cwd: 'C:\\Users\\Ada\\repo\\app' }, + { ...baseSession, id: 'claude:2', cwd: 'c:/users/ada/repo/app/' } + ], + 'folder' + ) + expect(groups).toHaveLength(1) + expect(groups[0].sessions).toHaveLength(2) + }) + + it('folds the two WSL UNC aliases of one folder into a single group', () => { + const groups = groupAiVaultSessions( + [ + { ...baseSession, cwd: '//wsl.localhost/Ubuntu/home/ada/repo/app' }, + { ...baseSession, id: 'claude:2', cwd: '//wsl$/Ubuntu/home/ada/repo/app' } + ], + 'folder' + ) + expect(groups).toHaveLength(1) + expect(groups[0].sessions.map((session) => session.id)).toEqual(['claude:1', 'claude:2']) + }) + + it('keeps case-distinct POSIX folders in separate groups', () => { + const groups = groupAiVaultSessions( + [ + { ...baseSession, cwd: '/home/ada/Foo' }, + { ...baseSession, id: 'claude:2', cwd: '/home/ada/foo' } + ], + 'folder' + ) + expect(groups).toHaveLength(2) + }) + + it('folds the project-grouping fallback onto the resolved folder project key', () => { + const resolved = { ...baseSession, cwd: '/Users/ada/repo/app' } + const unresolved = { ...baseSession, id: 'claude:2', cwd: '/Users/ada/repo/app/' } + // Key literal, not folderGroupKey(), so the test still fails if both builders drift together. + const groups = groupAiVaultSessions([resolved, unresolved], 'project', { + sessionProjectById: new Map([ + [ + resolved.id, + { kind: 'folder' as const, key: 'folder:/Users/ada/repo/app', label: 'repo/app' } + ] + ]) + }) + expect(groups).toHaveLength(1) + expect(groups[0].sessions).toHaveLength(2) + }) + + it('keys unknown cwd separately from real folders', () => { + expect(folderGroupKey(null)).toBe('unknown') + expect(folderGroupKey('/Users/ada/repo/app')).toBe('folder:/Users/ada/repo/app') + }) + it('parses repo: and path: operators from the query', () => { expect(parseVaultQuery('hello repo:orca path:/tmp world')).toEqual({ terms: ['hello', 'world'], diff --git a/src/shared/ai-vault-session-filters.ts b/src/shared/ai-vault-session-filters.ts index c943dcf6837..46fafbc8efd 100644 --- a/src/shared/ai-vault-session-filters.ts +++ b/src/shared/ai-vault-session-filters.ts @@ -2,7 +2,11 @@ // It lives in /shared (not renderer) so the mobile package can reuse it — // Metro only watches mobile/ + repo-root src/shared, never src/renderer. // INVARIANT: /shared is a leaf — this module must NOT import from src/renderer. -import { isPathInsideOrEqual, normalizeRuntimePathSeparators } from './cross-platform-path' +import { + isPathInsideOrEqual, + normalizeRuntimePathForComparison, + normalizeRuntimePathSeparators +} from './cross-platform-path' import { isClipboardTextByteLengthOverLimit } from './clipboard-text' import { parseWslUncPath } from './wsl-paths' import type { @@ -143,13 +147,29 @@ export function folderLabel(pathValue: string | null): string { if (!pathValue) { return 'Unknown location' } - const parts = normalizeRuntimePathSeparators(pathValue).split('/').filter(Boolean) + // NFC so one folder renders the same header whichever spelling (macOS NFD vs + // agent-recorded NFC) reaches the group first. + const parts = normalizeRuntimePathSeparators(pathValue.normalize('NFC')) + .split('/') + .filter(Boolean) if (parts.length >= 2) { return parts.slice(-2).join('/') } return parts[0] ?? pathValue } +/** + * Why comparison-normalized: cwd is copied verbatim out of agent transcripts, so + * one folder arrives with and without a trailing slash and in both NFD/NFC — each + * spelling otherwise became its own group under an identical `folderLabel`. Also + * avoids blanket lowercasing, which merged distinct case-sensitive POSIX folders. + * The `folder:` prefix matches the folder project key so project grouping and its + * fallback agree. + */ +export function folderGroupKey(pathValue: string | null): string { + return pathValue ? `folder:${normalizeRuntimePathForComparison(pathValue)}` : 'unknown' +} + export function agentLabel(agent: AiVaultAgent): string { return aiVaultAgentLabel(agent) } @@ -253,11 +273,7 @@ function getGroupIdentity( } } } - return { key: getFolderGroupKey(session.cwd), label: folderLabel(session.cwd) } -} - -function getFolderGroupKey(pathValue: string | null): string { - return pathValue ? normalizeRuntimePathSeparators(pathValue).toLowerCase() : 'unknown' + return { key: folderGroupKey(session.cwd), label: folderLabel(session.cwd) } } function isAiVaultSessionInWorkspacePath(workspacePath: string, sessionCwd: string): boolean { From 2a93cae293d99f85127da8757722e0ff8316d250 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:51:24 -0700 Subject: [PATCH 027/289] fix(crash-reporting): stop filing Windows OS shutdown as renderer crashes (#12938) --- .../expected-teardown-state.test.ts | 193 ++++++++++++++++++ .../expected-teardown-state.ts | 58 ++++++ .../process-gone-killed-one-ordering.test.ts | 149 ++++++++++++++ src/main/index.ts | 22 +- .../startup/desktop-startup-ordering.test.ts | 27 +++ src/main/window/createMainWindow.test.ts | 94 +++++++++ src/main/window/createMainWindow.ts | 6 + 7 files changed, 540 insertions(+), 9 deletions(-) create mode 100644 src/main/crash-reporting/expected-teardown-state.test.ts create mode 100644 src/main/crash-reporting/expected-teardown-state.ts create mode 100644 src/main/crash-reporting/process-gone-killed-one-ordering.test.ts diff --git a/src/main/crash-reporting/expected-teardown-state.test.ts b/src/main/crash-reporting/expected-teardown-state.test.ts new file mode 100644 index 00000000000..17a296bb582 --- /dev/null +++ b/src/main/crash-reporting/expected-teardown-state.test.ts @@ -0,0 +1,193 @@ +import { performance } from 'node:perf_hooks' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + shouldRecordProcessGoneCrash, + shouldRecoverRendererAfterProcessGone +} from './process-gone-classification' +import { + markSystemSessionEnding, + resetExpectedTeardownStateForTest, + resolveExpectedTeardownScope, + WINDOWS_SESSION_END_CRASH_SUPPRESSION_WINDOW_MS +} from './expected-teardown-state' + +function shouldRecordKilledRenderer(expectedTeardown: 'none' | 'renderer-reload' | 'app-shutdown') { + return shouldRecordProcessGoneCrash({ + source: 'renderer', + processType: 'renderer', + reason: 'killed', + exitCode: 1, + expectedTeardown + }) +} + +let now: number + +beforeEach(() => { + now = 1_000 + resetExpectedTeardownStateForTest(() => now) +}) + +afterEach(() => { + vi.restoreAllMocks() + resetExpectedTeardownStateForTest() +}) + +describe('expected teardown state', () => { + it('uses a product-chosen five-second harm bound, not a Windows lifetime guarantee', () => { + // Restart Manager may wait 30s; tree-kills after this bound remain reportable by design. + expect(WINDOWS_SESSION_END_CRASH_SUPPRESSION_WINDOW_MS).toBe(5_000) + }) + + it('uses the production monotonic clock by default', () => { + vi.spyOn(performance, 'now').mockReturnValueOnce(1_000).mockReturnValue(1_001) + vi.spyOn(Date, 'now').mockReturnValueOnce(10_000).mockReturnValue(15_000) + resetExpectedTeardownStateForTest() + + markSystemSessionEnding() + + expect( + resolveExpectedTeardownScope({ + isQuitting: false, + isQuittingForUpdate: false, + isExpectedRendererReload: false + }) + ).toBe('app-shutdown') + }) + + it('does not resurrect session-end after an injected clock rollback and catch-up', () => { + now = 3_600_000 + markSystemSessionEnding() + now = 0 + const rollbackScope = resolveExpectedTeardownScope({ + isQuitting: false, + isQuittingForUpdate: false, + isExpectedRendererReload: false + }) + now = 3_600_001 + const catchUpScope = resolveExpectedTeardownScope({ + isQuitting: false, + isQuittingForUpdate: false, + isExpectedRendererReload: false + }) + + expect(rollbackScope).toBe('none') + expect(catchUpScope).toBe('none') + }) + + it('does not resurrect session-end after expiry and an injected backtrack', () => { + markSystemSessionEnding() + now += WINDOWS_SESSION_END_CRASH_SUPPRESSION_WINDOW_MS + const expiredScope = resolveExpectedTeardownScope({ + isQuitting: false, + isQuittingForUpdate: false, + isExpectedRendererReload: false + }) + now -= 1 + const backtrackScope = resolveExpectedTeardownScope({ + isQuitting: false, + isQuittingForUpdate: false, + isExpectedRendererReload: false + }) + + expect(expiredScope).toBe('none') + expect(backtrackScope).toBe('none') + }) + + it('re-arms the suppression window on repeated session-end events', () => { + markSystemSessionEnding() + now += WINDOWS_SESSION_END_CRASH_SUPPRESSION_WINDOW_MS - 1 + markSystemSessionEnding() + now += WINDOWS_SESSION_END_CRASH_SUPPRESSION_WINDOW_MS - 1 + + expect( + resolveExpectedTeardownScope({ + isQuitting: false, + isQuittingForUpdate: false, + isExpectedRendererReload: false + }) + ).toBe('app-shutdown') + }) + + it('classifies killed/1 just inside the session-end window as app shutdown', () => { + markSystemSessionEnding() + now += WINDOWS_SESSION_END_CRASH_SUPPRESSION_WINDOW_MS - 1 + const scope = resolveExpectedTeardownScope({ + isQuitting: false, + isQuittingForUpdate: false, + isExpectedRendererReload: false + }) + + expect(scope).toBe('app-shutdown') + expect(shouldRecordKilledRenderer(scope)).toBe(false) + }) + + it('keeps killed/1 reportable at and just outside the session-end boundary', () => { + markSystemSessionEnding() + now += WINDOWS_SESSION_END_CRASH_SUPPRESSION_WINDOW_MS + const boundaryScope = resolveExpectedTeardownScope({ + isQuitting: false, + isQuittingForUpdate: false, + isExpectedRendererReload: false + }) + now += 1 + const outsideScope = resolveExpectedTeardownScope({ + isQuitting: false, + isQuittingForUpdate: false, + isExpectedRendererReload: false + }) + + expect(boundaryScope).toBe('none') + expect(outsideScope).toBe('none') + expect(shouldRecordKilledRenderer(outsideScope)).toBe(true) + }) + + it('excludes session-end from recovery while preserving in-app quit suppression', () => { + markSystemSessionEnding() + const sessionEndScope = resolveExpectedTeardownScope({ + isQuitting: false, + isQuittingForUpdate: false, + isExpectedRendererReload: false, + includeSystemSessionEnd: false + }) + const inAppQuitScope = resolveExpectedTeardownScope({ + isQuitting: true, + isQuittingForUpdate: false, + isExpectedRendererReload: false, + includeSystemSessionEnd: false + }) + + expect(sessionEndScope).toBe('none') + expect( + shouldRecoverRendererAfterProcessGone({ + reason: 'killed', + expectedTeardown: sessionEndScope + }) + ).toBe(true) + expect(inAppQuitScope).toBe('app-shutdown') + expect( + shouldRecoverRendererAfterProcessGone({ + reason: 'killed', + expectedTeardown: inAppQuitScope + }) + ).toBe(false) + }) + + it('preserves existing update and renderer-reload scopes', () => { + expect( + resolveExpectedTeardownScope({ + isQuitting: false, + isQuittingForUpdate: true, + isExpectedRendererReload: false + }) + ).toBe('app-shutdown') + expect( + resolveExpectedTeardownScope({ + isQuitting: false, + isQuittingForUpdate: false, + isExpectedRendererReload: true, + includeSystemSessionEnd: false + }) + ).toBe('renderer-reload') + }) +}) diff --git a/src/main/crash-reporting/expected-teardown-state.ts b/src/main/crash-reporting/expected-teardown-state.ts new file mode 100644 index 00000000000..1480ecbbfe2 --- /dev/null +++ b/src/main/crash-reporting/expected-teardown-state.ts @@ -0,0 +1,58 @@ +import { performance } from 'node:perf_hooks' +import type { ExpectedTeardownScope } from './process-gone-classification' + +// Why: 5s bounds harm; Restart Manager may wait 30s, so later kills remain reportable. +export const WINDOWS_SESSION_END_CRASH_SUPPRESSION_WINDOW_MS = 5_000 + +type Clock = () => number + +// Windows performance.now is monotonic and includes time spent suspended. +const monotonicNow = (): number => performance.now() +let now: Clock = monotonicNow +let systemSessionEndedAt: number | null = null + +export function markSystemSessionEnding(): void { + systemSessionEndedAt = now() +} + +function isRecentSystemSessionEnd(): boolean { + if (systemSessionEndedAt === null) { + return false + } + const elapsed = now() - systemSessionEndedAt + if (elapsed < 0) { + systemSessionEndedAt = null + return false + } + if (elapsed >= WINDOWS_SESSION_END_CRASH_SUPPRESSION_WINDOW_MS) { + systemSessionEndedAt = null + return false + } + return true +} + +export function resolveExpectedTeardownScope({ + isQuitting, + isQuittingForUpdate, + isExpectedRendererReload, + includeSystemSessionEnd = true +}: { + isQuitting: boolean + isQuittingForUpdate: boolean + isExpectedRendererReload: boolean + includeSystemSessionEnd?: boolean +}): ExpectedTeardownScope { + if ( + isQuitting || + isQuittingForUpdate || + (includeSystemSessionEnd && isRecentSystemSessionEnd()) + ) { + return 'app-shutdown' + } + return isExpectedRendererReload ? 'renderer-reload' : 'none' +} + +export function resetExpectedTeardownStateForTest(clock: Clock = monotonicNow): void { + now = clock + systemSessionEndedAt = null +} diff --git a/src/main/crash-reporting/process-gone-killed-one-ordering.test.ts b/src/main/crash-reporting/process-gone-killed-one-ordering.test.ts new file mode 100644 index 00000000000..7f47e794252 --- /dev/null +++ b/src/main/crash-reporting/process-gone-killed-one-ordering.test.ts @@ -0,0 +1,149 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ + app: { + getVersion: () => '1.2.3-test', + getAppMetrics: () => [] + } +})) + +import { clearCrashBreadcrumbsForTest } from './crash-breadcrumb-store' +import { + markSystemSessionEnding, + resetExpectedTeardownStateForTest, + resolveExpectedTeardownScope, + WINDOWS_SESSION_END_CRASH_SUPPRESSION_WINDOW_MS +} from './expected-teardown-state' +import { ProcessGoneDedupe } from './process-gone-dedupe' +import { recordProcessGoneCrash, type ProcessGoneCrashEvent } from './process-gone-recorder' + +function event(overrides: Partial = {}): ProcessGoneCrashEvent { + return { + source: 'renderer', + processType: 'renderer', + reason: 'killed', + exitCode: 1, + expectedTeardown: 'none', + details: { processType: 'renderer' }, + ...overrides + } +} + +const gpuKill = event({ + source: 'child', + processType: 'GPU', + details: { serviceName: 'GPU', type: 'GPU' } +}) +const networkServiceKill = event({ + source: 'child', + processType: 'Utility', + details: { + name: 'Network Service', + serviceName: 'network.mojom.NetworkService', + type: 'Utility' + } +}) +const rendererKill = event() + +function currentTeardownScope() { + return resolveExpectedTeardownScope({ + isQuitting: false, + isQuittingForUpdate: false, + isExpectedRendererReload: false + }) +} + +let now: number + +beforeEach(() => { + now = 1_000 + resetExpectedTeardownStateForTest(() => now) + clearCrashBreadcrumbsForTest() +}) + +afterEach(() => { + vi.restoreAllMocks() + resetExpectedTeardownStateForTest() + clearCrashBreadcrumbsForTest() +}) + +describe('recordProcessGoneCrash killed/1 ordering', () => { + it('reports a genuine lone renderer killed/1 without teardown intent', () => { + const record = vi.fn().mockResolvedValue({ id: 'report-1' }) + + recordProcessGoneCrash({ record } as never, rendererKill, new ProcessGoneDedupe()) + + expect(record).toHaveBeenCalledOnce() + }) + + it('reports R3 after matching recoverable sibling churn', () => { + const record = vi.fn().mockResolvedValue({ id: 'report-1' }) + const dedupe = new ProcessGoneDedupe() + + recordProcessGoneCrash({ record } as never, gpuKill, dedupe) + recordProcessGoneCrash({ record } as never, networkServiceKill, dedupe) + recordProcessGoneCrash({ record } as never, rendererKill, dedupe) + + expect(record).toHaveBeenCalledOnce() + }) + + it('suppresses the fleet sequence only after independent session-end intent', () => { + const record = vi.fn().mockResolvedValue({ id: 'report-1' }) + const dedupe = new ProcessGoneDedupe() + markSystemSessionEnding() + const expectedTeardown = currentTeardownScope() + + recordProcessGoneCrash({ record } as never, event({ ...gpuKill, expectedTeardown }), dedupe) + recordProcessGoneCrash( + { record } as never, + event({ ...networkServiceKill, expectedTeardown }), + dedupe + ) + recordProcessGoneCrash( + { record } as never, + event({ ...rendererKill, expectedTeardown }), + dedupe + ) + + expect(expectedTeardown).toBe('app-shutdown') + expect(record).not.toHaveBeenCalled() + }) + + it('durably reports killed/1 after the session-end window expires', () => { + const record = vi.fn().mockResolvedValue({ id: 'report-1' }) + markSystemSessionEnding() + now += WINDOWS_SESSION_END_CRASH_SUPPRESSION_WINDOW_MS + const expectedTeardown = currentTeardownScope() + + recordProcessGoneCrash( + { record } as never, + event({ ...rendererKill, expectedTeardown }), + new ProcessGoneDedupe() + ) + + expect(expectedTeardown).toBe('none') + expect(record).toHaveBeenCalledOnce() + }) + + it('keeps a renderer report filed when session-end intent arrives later', () => { + const record = vi.fn().mockResolvedValue({ id: 'report-1' }) + const scopeBeforeSessionEnd = currentTeardownScope() + + recordProcessGoneCrash( + { record } as never, + event({ ...rendererKill, expectedTeardown: scopeBeforeSessionEnd }), + new ProcessGoneDedupe() + ) + markSystemSessionEnding() + const scopeAfterSessionEnd = currentTeardownScope() + recordProcessGoneCrash( + { record } as never, + event({ ...rendererKill, expectedTeardown: scopeAfterSessionEnd }), + new ProcessGoneDedupe() + ) + + expect(scopeBeforeSessionEnd).toBe('none') + expect(scopeAfterSessionEnd).toBe('app-shutdown') + expect(record).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/index.ts b/src/main/index.ts index 912941275d5..e5a0050a781 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -298,6 +298,7 @@ import { type ExpectedTeardownScope } from './crash-reporting/process-gone-classification' import { recordProcessGoneCrash as recordProcessGoneCrashEvent } from './crash-reporting/process-gone-recorder' +import { resolveExpectedTeardownScope } from './crash-reporting/expected-teardown-state' import { advanceSyntheticTitleSpinnerEntries, type SyntheticTitleSpinnerEntry @@ -697,14 +698,17 @@ function clearExpectedRendererReload(webContentsId?: number): void { expectedRendererReload.clear(webContentsId) } -function getExpectedTeardownScope(webContentsId?: number): ExpectedTeardownScope { - if (isQuitting || isQuittingForUpdate()) { - return 'app-shutdown' - } - if (webContentsId === undefined) { - return 'none' - } - return expectedRendererReload.matches(webContentsId) ? 'renderer-reload' : 'none' +function getExpectedTeardownScope( + webContentsId?: number, + includeSystemSessionEnd = true +): ExpectedTeardownScope { + return resolveExpectedTeardownScope({ + isQuitting, + isQuittingForUpdate: isQuittingForUpdate(), + isExpectedRendererReload: + webContentsId !== undefined && expectedRendererReload.matches(webContentsId), + includeSystemSessionEnd + }) } function markRecoveryReloadInFlight(webContentsId: number, durationMs = 10_000): void { @@ -1263,7 +1267,7 @@ function openMainWindow(): BrowserWindow { shouldRecoverRenderer: (details, webContentsId) => shouldRecoverRendererAfterProcessGone({ reason: details.reason, - expectedTeardown: getExpectedTeardownScope(webContentsId) + expectedTeardown: getExpectedTeardownScope(webContentsId, false) }), onRendererRecoveryExhausted: ({ details, recentRecoveryCount }) => { recordDurableCrashBreadcrumb('renderer_recovery_circuit_breaker_open', { diff --git a/src/main/startup/desktop-startup-ordering.test.ts b/src/main/startup/desktop-startup-ordering.test.ts index 7dc2762195e..001bc5252d1 100644 --- a/src/main/startup/desktop-startup-ordering.test.ts +++ b/src/main/startup/desktop-startup-ordering.test.ts @@ -156,6 +156,33 @@ describe('startup ordering', () => { expect(startIndex).toBeGreaterThan(attachIndex) }) + it('wires bounded teardown state to reporting but not recovery or close behavior', () => { + const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + const scopeStart = source.indexOf('function getExpectedTeardownScope(') + const scopeEnd = source.indexOf('function markRecoveryReloadInFlight(', scopeStart) + const scope = source.slice(scopeStart, scopeEnd) + const windowStart = source.indexOf('const window = createMainWindow(store, {') + const windowEnd = source.indexOf('onRendererRecoveryExhausted:', windowStart) + const windowOptions = source.slice(windowStart, windowEnd) + const recorderStart = source.indexOf('function recordProcessGoneCrash(') + const recorderEnd = source.indexOf('function shutdownWatchersOnce(', recorderStart) + const recorder = source.slice(recorderStart, recorderEnd) + + expect(scopeStart).toBeGreaterThanOrEqual(0) + expect(scopeEnd).toBeGreaterThan(scopeStart) + expect(scope).toContain('resolveExpectedTeardownScope({') + expect(scope).toContain('includeSystemSessionEnd') + expect(windowStart).toBeGreaterThanOrEqual(0) + expect(windowEnd).toBeGreaterThan(windowStart) + expect(windowOptions).toContain('getIsQuitting: () => isQuitting') + expect(windowOptions).toContain( + 'expectedTeardown: getExpectedTeardownScope(webContentsId, false)' + ) + expect(recorderStart).toBeGreaterThanOrEqual(0) + expect(recorderEnd).toBeGreaterThan(recorderStart) + expect(recorder).toContain('expectedTeardown: getExpectedTeardownScope(webContentsId)') + }) + it('attaches renderer services before starting the TCC prompt watcher', () => { const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') const attachIndex = source.indexOf('attachMainWindowServices(') diff --git a/src/main/window/createMainWindow.test.ts b/src/main/window/createMainWindow.test.ts index bc595f10b20..bbff3b994c3 100644 --- a/src/main/window/createMainWindow.test.ts +++ b/src/main/window/createMainWindow.test.ts @@ -74,6 +74,11 @@ import { } from './createMainWindow' import { ipcMain } from 'electron' import { shouldRecoverRendererAfterProcessGone } from '../crash-reporting/process-gone-classification' +import { + resetExpectedTeardownStateForTest, + resolveExpectedTeardownScope, + WINDOWS_SESSION_END_CRASH_SUPPRESSION_WINDOW_MS +} from '../crash-reporting/expected-teardown-state' function withPlatform(platform: NodeJS.Platform, run: () => T): T { const original = process.platform @@ -102,6 +107,7 @@ describe('createMainWindow', () => { vi.mocked(ipcMain.removeListener).mockReset() vi.mocked(ipcMain.handle).mockReset() vi.mocked(ipcMain.removeHandler).mockReset() + resetExpectedTeardownStateForTest() vi.useRealTimers() }) @@ -3143,6 +3149,40 @@ describe('createMainWindow', () => { consoleError.mockRestore() }) + it('still preserves PTYs and reloads after Windows session-end', () => { + vi.useFakeTimers() + + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + const { browserWindowInstance, windowHandlers } = createRendererRecoveryWindowHarness() + const onBeforeRecoveryReload = vi.fn() + + withPlatform('win32', () => { + createMainWindow(null, { + onBeforeRecoveryReload, + shouldRecoverRenderer: (details) => + shouldRecoverRendererAfterProcessGone({ + reason: details.reason, + expectedTeardown: resolveExpectedTeardownScope({ + isQuitting: false, + isQuittingForUpdate: false, + isExpectedRendererReload: false, + includeSystemSessionEnd: false + }) + }) + }) + }) + windowHandlers['session-end']?.({} as never) + windowHandlers['render-process-gone']?.( + {} as never, + { reason: 'killed', exitCode: 1 } as Electron.RenderProcessGoneDetails + ) + vi.runAllTimers() + + expect(onBeforeRecoveryReload).toHaveBeenCalledWith(143) + expect(browserWindowInstance.loadFile).toHaveBeenCalledTimes(2) + consoleError.mockRestore() + }) + it('does not reload after renderer loss when recovery is disabled', () => { vi.useFakeTimers() @@ -3730,6 +3770,60 @@ describe('createMainWindow', () => { setPlatform(originalPlatform) }) + it('marks production teardown state on irrevocable Windows session end', () => { + setPlatform('win32') + resetExpectedTeardownStateForTest(() => 1_000) + const { windowHandlers } = setupCloseWindow() + + createMainWindow(null) + windowHandlers['session-end']?.({} as never) + + expect( + resolveExpectedTeardownScope({ + isQuitting: false, + isQuittingForUpdate: false, + isExpectedRendererReload: false + }) + ).toBe('app-shutdown') + }) + + it.each(['darwin', 'linux'] as const)( + 'does not mark session teardown state on %s', + (platform) => { + setPlatform(platform) + const { windowHandlers } = setupCloseWindow() + + createMainWindow(null) + + expect(windowHandlers['session-end']).toBeUndefined() + expect( + resolveExpectedTeardownScope({ + isQuitting: false, + isQuittingForUpdate: false, + isExpectedRendererReload: false + }) + ).toBe('none') + } + ) + + it('still minimizes to tray after the session-end reporting window expires', () => { + setPlatform('win32') + let now = 1_000 + resetExpectedTeardownStateForTest(() => now) + const { windowHandlers, webContents, instance } = setupCloseWindow() + const store = makeStore(true, true) + + createMainWindow(store as never) + windowHandlers['session-end']?.({} as never) + now += WINDOWS_SESSION_END_CRASH_SUPPRESSION_WINDOW_MS + const preventDefault = vi.fn() + windowHandlers.close({ preventDefault } as never) + + expect(preventDefault).toHaveBeenCalledOnce() + expect(instance.hide).toHaveBeenCalledOnce() + expect(webContents.send).not.toHaveBeenCalledWith('window:close-requested', expect.anything()) + }) + it('hides to the tray instead of closing when the setting is on', () => { setPlatform('win32') const { windowHandlers, webContents, instance } = setupCloseWindow() diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index 327772164f4..f04cfb72430 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -19,6 +19,7 @@ import { translateMain } from '../i18n/main-i18n' import { normalizeBrowserNavigationUrl } from '../../shared/browser-url' import { ORCA_BROWSER_GUEST_WEB_PREFERENCES } from '../../shared/browser-guest-web-preferences' import { isCrashReportReason } from '../../shared/crash-reporting' +import { markSystemSessionEnding } from '../crash-reporting/expected-teardown-state' import { DEFAULT_RENDERER_RECOVERY_MAX_RECOVERIES, DEFAULT_RENDERER_RECOVERY_WINDOW_MS, @@ -304,6 +305,11 @@ export function createMainWindow( // Why: native paste fallback is privileged IPC; only the top-level renderer may request it. setTrustedUIRendererWebContentsId(rendererWebContentsId) + // Unlike query-session-end, session-end cannot be canceled before this signal is recorded. + if (process.platform === 'win32') { + mainWindow.on('session-end', markSystemSessionEnding) + } + if (process.platform === 'darwin') { // Why: preserve hidden-window power savings; stable native sizing and frame-only invalidation // make wake recovery independent of the throttled viewport. From 9b70ce0424dc646a66916a6fb24d4d18f9b58275 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:59:00 -0700 Subject: [PATCH 028/289] fix(computer-use): settle macOS permission checks (#13025) --- .../Sources/OrcaComputerUseMacOS/main.swift | 189 ++++++++++++++---- .../PermissionStatusSnapshot.swift | 116 +++++++++++ .../PermissionTrustSettling.swift | 73 +++++++ .../ScreenCaptureProbeWindowSelection.swift | 22 ++ .../PermissionStatusSnapshotTests.swift | 126 ++++++++++++ .../PermissionTrustSettlingTests.swift | 59 ++++++ ...ScreenCapturePermissionSettlingTests.swift | 79 ++++++++ ...reenCaptureProbeWindowSelectionTests.swift | 44 ++++ 8 files changed, 664 insertions(+), 44 deletions(-) create mode 100644 native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/PermissionStatusSnapshot.swift create mode 100644 native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/PermissionTrustSettling.swift create mode 100644 native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/ScreenCaptureProbeWindowSelection.swift create mode 100644 native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/PermissionStatusSnapshotTests.swift create mode 100644 native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/PermissionTrustSettlingTests.swift create mode 100644 native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/ScreenCapturePermissionSettlingTests.swift create mode 100644 native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/ScreenCaptureProbeWindowSelectionTests.swift diff --git a/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift b/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift index 7ecbdbf004a..61ee277156d 100644 --- a/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift +++ b/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift @@ -601,7 +601,7 @@ final class Provider { windowIndex: Int?, restoreWindow: Bool ) throws -> Snapshot { - guard accessibilityTrusted() else { + guard accessibilityTrustedSettled() else { // Why: agents retry failed observations. Only the explicit setup flow // should open macOS privacy prompts/settings; runtime calls stay quiet. throw ProviderError.coded( @@ -619,7 +619,7 @@ final class Provider { allowRecovery: restoreWindow ) let focusedTitle = stringAttribute(focused, kAXTitleAttribute as String) ?? app.name - let canCaptureScreenshot = includeScreenshot && screenCaptureTrusted() + let canCaptureScreenshot = includeScreenshot && screenCaptureTrustedSettled() guard let capture = WindowCapture.resolve( candidates: windowCandidates, titleHint: focusedTitle, @@ -1034,10 +1034,64 @@ private func accessibilityTrusted() -> Bool { AXIsProcessTrusted() } +private func accessibilityTrustedSettled() -> Bool { + // Fresh helper processes can receive transient TCC preflight denials before the real grant settles. + PermissionTrustSettling.settle(probe: accessibilityTrusted).settled +} + private func screenCaptureTrusted() -> Bool { CGPreflightScreenCaptureAccess() } +private func screenCaptureTrustedSettled() -> Bool { + PermissionTrustSettling.settleWithFallback( + finalTimeoutMs: 500, + probe: screenCaptureTrusted, + fallbackProbe: screenCaptureTrustedByCaptureProbe + ) +} + +private func permissionStatusSnapshotSettled() -> PermissionStatusSnapshot { + PermissionStatusSnapshotProbe.capture( + accessibilityProbe: accessibilityTrustedSettled, + screenshotsProbe: screenCaptureTrustedSettled + ) +} + +private func screenCaptureTrustedByCaptureProbe() -> Bool { + guard let infos = CGWindowListCopyWindowInfo( + [.optionOnScreenOnly], + kCGNullWindowID + ) as? [[String: Any]] else { + return false + } + let windows = infos.compactMap { info -> ScreenCaptureProbeWindow? in + guard let layer = info[kCGWindowLayer as String] as? Int, + let ownerPid = info[kCGWindowOwnerPID as String] as? NSNumber, + let number = info[kCGWindowNumber as String] as? NSNumber + else { + return nil + } + return ScreenCaptureProbeWindow( + layer: layer, + ownerPid: ownerPid.int32Value, + windowId: number.uint32Value + ) + } + guard let windowId = ScreenCaptureProbeWindowSelection.firstCrossProcessNormalWindow( + ownPid: ProcessInfo.processInfo.processIdentifier, + windows: windows + ) else { + return false + } + return CGWindowListCreateImage( + .null, + [.optionIncludingWindow], + CGWindowID(windowId), + [.boundsIgnoreFraming] + ) != nil +} + private func requestScreenCaptureAccess() -> Bool { CGRequestScreenCaptureAccess() } @@ -1065,6 +1119,38 @@ private func enableManualAccessibilityIfNeeded(_ appElement: AXUIElement, app: A private func focusedWindow(appElement: AXUIElement, app: AppDescriptor, visibleWindowCount: Int, allowRecovery: Bool) throws -> AXUIElement { let systemWide = AXUIElementCreateSystemWide() + if let window = lookupUsableWindow(systemWide: systemWide, appElement: appElement, app: app) { + return window + } + if allowRecovery { + recoverWindow(app) + if let window = lookupUsableWindow(systemWide: systemWide, appElement: appElement, app: app) { + return window + } + } + if visibleWindowCount > 0 { + var settledWindow: AXUIElement? + let outcome = PermissionTrustSettling.settle { + settledWindow = lookupUsableWindow( + systemWide: systemWide, + appElement: appElement, + app: app + ) + return settledWindow != nil + } + if let window = settledWindow, outcome.settled { + return window + } + throw ProviderError.coded("permission_denied", "app '\(app.name)' has visible windows but no accessibility window (AX reads stayed blocked for \(outcome.waitedMs)ms after retries). macOS Accessibility may need Orca Computer Use toggled off and on again in System Settings.") + } + throw ProviderError.coded("window_not_found", "app '\(app.name)' has no accessibility window; make sure the app has a visible window, then retry with --restore-window.") +} + +private func lookupUsableWindow( + systemWide: AXUIElement, + appElement: AXUIElement, + app: AppDescriptor +) -> AXUIElement? { if let window = focusedSystemWindow(systemWide: systemWide, app: app) { return window } @@ -1072,31 +1158,9 @@ private func focusedWindow(appElement: AXUIElement, app: AppDescriptor, visibleW return window } if let windows = copyArray(appElement, kAXWindowsAttribute as String) { - if let window = windows.first(where: usableWindow) { - return window - } + return windows.first(where: usableWindow) } - if allowRecovery { - recoverWindow(app) - if let window = focusedSystemWindow(systemWide: systemWide, app: app) { - return window - } - if let window = copyElement(appElement, kAXFocusedWindowAttribute as String), usableWindow(window) { - return window - } - if let windows = copyArray(appElement, kAXWindowsAttribute as String) { - if let window = windows.first(where: usableWindow) { - return window - } - } - } - let permissionHint = visibleWindowCount > 0 - ? " The app has visible windows, so macOS Accessibility may need Orca Computer Use toggled off and on again in System Settings." - : "" - if visibleWindowCount > 0 { - throw ProviderError.coded("permission_denied", "app '\(app.name)' has visible windows but no accessibility window.\(permissionHint)") - } - throw ProviderError.coded("window_not_found", "app '\(app.name)' has no accessibility window; make sure the app has a visible window, then retry with --restore-window.") + return nil } private func focusedSystemWindow(systemWide: AXUIElement, app: AppDescriptor) -> AXUIElement? { @@ -2874,6 +2938,7 @@ private final class PermissionRuntime: NSObject, NSApplicationDelegate { windowController?.window?.makeKeyAndOrderFront(nil) NSApp.activate(ignoringOtherApps: true) } + windowController?.refreshPermissions() } func applicationDidBecomeActive(_ notification: Notification) { @@ -2890,6 +2955,7 @@ private final class PermissionWindowController: NSWindowController { private var dragAssistantPermission: PermissionKind? private let initialPermission: PermissionKind? private let terminateWhenDragAssistantCloses: Bool + private var permissionStatusRefresh: PermissionStatusRefreshCoordinator? convenience init(initialPermission: PermissionKind? = nil, terminateWhenDragAssistantCloses: Bool = false) { let window = NSWindow( @@ -2920,6 +2986,14 @@ private final class PermissionWindowController: NSWindowController { self.initialPermission = initialPermission self.terminateWhenDragAssistantCloses = terminateWhenDragAssistantCloses super.init(window: window) + permissionStatusRefresh = PermissionStatusRefreshCoordinator( + probe: permissionStatusSnapshotSettled, + handler: { [weak self] snapshot in + Task { @MainActor in + self?.applyPermissionStatus(snapshot) + } + } + ) } required init?(coder: NSCoder) { @@ -2976,21 +3050,25 @@ private final class PermissionWindowController: NSWindowController { } func refreshPermissions() { - if let initialPermission, initialPermission.isGranted { + permissionStatusRefresh?.refresh() + } + + private func applyPermissionStatus(_ snapshot: PermissionStatusSnapshot) { + if let initialPermission, initialPermission.isGranted(in: snapshot) { // Why: targeted permission helpers should finish once the requested // grant lands, even if other Computer Use permissions remain unset. completeDragAssistant() return } - if dragAssistantPermission?.isGranted == true { + if dragAssistantPermission?.isGranted(in: snapshot) == true { // Why: after one grant in full setup, the remaining missing permission // needs fresh guidance instead of the old assistant's instructions. closeDragAssistant() } - if PermissionKind.allCases.allSatisfy(\.isGranted) { + if PermissionKind.allCases.allSatisfy({ $0.isGranted(in: snapshot) }) { closeDragAssistant() } - (window?.contentView as? PermissionView)?.refreshPermissions() + (window?.contentView as? PermissionView)?.refreshPermissions(snapshot) } } @@ -3045,12 +3123,12 @@ private enum PermissionKind: CaseIterable { } } - var isGranted: Bool { + func isGranted(in snapshot: PermissionStatusSnapshot) -> Bool { switch self { case .accessibility: - accessibilityTrusted() + snapshot.accessibilityGranted case .screenshots: - screenCaptureTrusted() + snapshot.screenshotsGranted } } @@ -3071,6 +3149,7 @@ private final class PermissionView: NSView { private let close: () -> Void private var contentStack: NSStackView? private var contentConstraints: [NSLayoutConstraint] = [] + private var permissionStatus: PermissionStatusSnapshot? init(frame frameRect: NSRect, showDragAssistant: @escaping (PermissionKind) -> Void, close: @escaping () -> Void) { self.showDragAssistant = showDragAssistant @@ -3107,12 +3186,22 @@ private final class PermissionView: NSView { icon.heightAnchor.constraint(equalToConstant: 58) ]) - let missingPermissions = PermissionKind.allCases.filter { !$0.isGranted } - let ready = missingPermissions.isEmpty + let missingPermissions = permissionStatus.map { snapshot in + PermissionKind.allCases.filter { !$0.isGranted(in: snapshot) } + } ?? [] + let checking = permissionStatus == nil + let ready = !checking && missingPermissions.isEmpty - let title = label(ready ? "Computer Use is Ready" : "Enable Orca Computer Use", size: 22, weight: .bold) + let titleText = checking + ? "Checking Computer Use" + : (ready ? "Computer Use is Ready" : "Enable Orca Computer Use") + let title = label(titleText, size: 22, weight: .bold) let subtitle = label( - ready ? "Orca can use local apps when you ask." : "Grant permissions so Orca can use apps when you ask.", + checking + ? "Checking Accessibility and Screenshots." + : (ready + ? "Orca can use local apps when you ask." + : "Grant permissions so Orca can use apps when you ask."), size: 12, weight: .regular ) @@ -3129,7 +3218,16 @@ private final class PermissionView: NSView { header.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true subtitle.widthAnchor.constraint(equalTo: stack.widthAnchor, constant: -10).isActive = true - if ready { + if checking { + let progress = NSProgressIndicator() + progress.style = .spinning + progress.controlSize = .small + progress.translatesAutoresizingMaskIntoConstraints = false + progress.startAnimation(nil) + stack.addArrangedSubview(progress) + progress.setContentHuggingPriority(.required, for: .horizontal) + progress.centerXAnchor.constraint(equalTo: stack.centerXAnchor).isActive = true + } else if ready { stack.addArrangedSubview(doneButton()) } else { for permission in missingPermissions { @@ -3149,8 +3247,9 @@ private final class PermissionView: NSView { NSLayoutConstraint.activate(contentConstraints) } - func refreshPermissions() { - // Why: TCC grants can change in System Settings while this window stays open. + func refreshPermissions(_ snapshot: PermissionStatusSnapshot) { + guard permissionStatus != snapshot else { return } + permissionStatus = snapshot build() } @@ -4039,14 +4138,16 @@ private func runPermissionCheck(initialPermission: PermissionKind? = nil) { } private func printPermissionStatus() { - let accessibility = accessibilityTrusted() ? "granted" : "not-granted" - let screenshots = screenCaptureTrusted() ? "granted" : "not-granted" + let snapshot = permissionStatusSnapshotSettled() + let accessibility = snapshot.accessibilityGranted ? "granted" : "not-granted" + let screenshots = snapshot.screenshotsGranted ? "granted" : "not-granted" print(#"{"accessibility":"\#(accessibility)","screenshots":"\#(screenshots)"}"#) } private func writePermissionStatus(to path: String) { - let accessibility = accessibilityTrusted() ? "granted" : "not-granted" - let screenshots = screenCaptureTrusted() ? "granted" : "not-granted" + let snapshot = permissionStatusSnapshotSettled() + let accessibility = snapshot.accessibilityGranted ? "granted" : "not-granted" + let screenshots = snapshot.screenshotsGranted ? "granted" : "not-granted" let text = #"{"accessibility":"\#(accessibility)","screenshots":"\#(screenshots)"}"# do { try text.write(toFile: path, atomically: true, encoding: .utf8) diff --git a/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/PermissionStatusSnapshot.swift b/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/PermissionStatusSnapshot.swift new file mode 100644 index 00000000000..a1e71edebb4 --- /dev/null +++ b/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/PermissionStatusSnapshot.swift @@ -0,0 +1,116 @@ +import Dispatch +import Foundation + +public struct PermissionStatusSnapshot: Equatable, Sendable { + public let accessibilityGranted: Bool + public let screenshotsGranted: Bool + + public init(accessibilityGranted: Bool, screenshotsGranted: Bool) { + self.accessibilityGranted = accessibilityGranted + self.screenshotsGranted = screenshotsGranted + } +} + +public enum PermissionStatusSnapshotProbe { + public typealias Probe = @Sendable () -> Bool + + private static let probeQueue = DispatchQueue( + label: "com.stablyai.orca.computer-use-permission-status", + attributes: .concurrent + ) + + public static func capture( + accessibilityProbe: @escaping Probe, + screenshotsProbe: @escaping Probe + ) -> PermissionStatusSnapshot { + let result = PermissionStatusSnapshotResult() + let group = DispatchGroup() + + group.enter() + probeQueue.async { + result.recordAccessibility(accessibilityProbe()) + group.leave() + } + group.enter() + probeQueue.async { + result.recordScreenshots(screenshotsProbe()) + group.leave() + } + group.wait() + return result.snapshot + } +} + +public final class PermissionStatusRefreshCoordinator: @unchecked Sendable { + public typealias SnapshotProbe = @Sendable () -> PermissionStatusSnapshot + public typealias SnapshotHandler = @Sendable (PermissionStatusSnapshot) -> Void + + private let callbackQueue: DispatchQueue + private let handler: SnapshotHandler + private let probe: SnapshotProbe + private let stateLock = NSLock() + private let workerQueue: DispatchQueue + private var refreshInFlight = false + + public init( + workerQueue: DispatchQueue = DispatchQueue( + label: "com.stablyai.orca.computer-use-permission-refresh", + qos: .userInitiated + ), + callbackQueue: DispatchQueue = .main, + probe: @escaping SnapshotProbe, + handler: @escaping SnapshotHandler + ) { + self.workerQueue = workerQueue + self.callbackQueue = callbackQueue + self.probe = probe + self.handler = handler + } + + public func refresh() { + stateLock.lock() + guard !refreshInFlight else { + stateLock.unlock() + return + } + refreshInFlight = true + stateLock.unlock() + + workerQueue.async { [self] in + let snapshot = probe() + callbackQueue.async { [self] in + stateLock.lock() + refreshInFlight = false + stateLock.unlock() + handler(snapshot) + } + } + } +} + +private final class PermissionStatusSnapshotResult: @unchecked Sendable { + private let lock = NSLock() + private var accessibilityGranted = false + private var screenshotsGranted = false + + var snapshot: PermissionStatusSnapshot { + lock.lock() + defer { lock.unlock() } + return PermissionStatusSnapshot( + accessibilityGranted: accessibilityGranted, + screenshotsGranted: screenshotsGranted + ) + } + + func recordAccessibility(_ granted: Bool) { + lock.lock() + accessibilityGranted = granted + lock.unlock() + } + + func recordScreenshots(_ granted: Bool) { + lock.lock() + screenshotsGranted = granted + lock.unlock() + } +} diff --git a/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/PermissionTrustSettling.swift b/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/PermissionTrustSettling.swift new file mode 100644 index 00000000000..80612664454 --- /dev/null +++ b/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/PermissionTrustSettling.swift @@ -0,0 +1,73 @@ +import Foundation + +public enum PermissionTrustSettling { + public struct Outcome: Equatable { + public let settled: Bool + public let attempts: Int + public let waitedMs: Int + + public init(settled: Bool, attempts: Int, waitedMs: Int) { + self.settled = settled + self.attempts = attempts + self.waitedMs = waitedMs + } + } + + public static let defaultTimeoutMs = 1_500 + public static let defaultIntervalMs = 100 + + public static func settle( + timeoutMs: Int = defaultTimeoutMs, + intervalMs: Int = defaultIntervalMs, + sleepMs: (Int) -> Void = { interval in + Thread.sleep(forTimeInterval: TimeInterval(interval) / 1_000) + }, + probe: () -> Bool + ) -> Outcome { + precondition(timeoutMs >= 0, "timeoutMs must not be negative") + precondition(intervalMs > 0, "intervalMs must be positive") + var attempts = 0 + var waitedMs = 0 + while true { + attempts += 1 + if probe() { + return Outcome(settled: true, attempts: attempts, waitedMs: waitedMs) + } + if waitedMs >= timeoutMs { + return Outcome(settled: false, attempts: attempts, waitedMs: waitedMs) + } + let interval = min(intervalMs, timeoutMs - waitedMs) + sleepMs(interval) + waitedMs += interval + } + } + + public static func settleWithFallback( + initialTimeoutMs: Int = defaultTimeoutMs, + finalTimeoutMs: Int, + intervalMs: Int = defaultIntervalMs, + sleepMs: (Int) -> Void = { interval in + Thread.sleep(forTimeInterval: TimeInterval(interval) / 1_000) + }, + probe: () -> Bool, + fallbackProbe: () -> Bool + ) -> Bool { + if settle( + timeoutMs: initialTimeoutMs, + intervalMs: intervalMs, + sleepMs: sleepMs, + probe: probe + ).settled { + return true + } + if fallbackProbe() { + return true + } + return settle( + timeoutMs: finalTimeoutMs, + intervalMs: intervalMs, + sleepMs: sleepMs, + probe: probe + ).settled + } +} diff --git a/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/ScreenCaptureProbeWindowSelection.swift b/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/ScreenCaptureProbeWindowSelection.swift new file mode 100644 index 00000000000..36b4346a8bc --- /dev/null +++ b/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/ScreenCaptureProbeWindowSelection.swift @@ -0,0 +1,22 @@ +public struct ScreenCaptureProbeWindow: Equatable { + public let layer: Int + public let ownerPid: Int32 + public let windowId: UInt32 + + public init(layer: Int, ownerPid: Int32, windowId: UInt32) { + self.layer = layer + self.ownerPid = ownerPid + self.windowId = windowId + } +} + +public enum ScreenCaptureProbeWindowSelection { + public static func firstCrossProcessNormalWindow( + ownPid: Int32, + windows: [ScreenCaptureProbeWindow] + ) -> UInt32? { + windows.first { window in + window.layer == 0 && window.ownerPid != ownPid + }?.windowId + } +} diff --git a/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/PermissionStatusSnapshotTests.swift b/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/PermissionStatusSnapshotTests.swift new file mode 100644 index 00000000000..7aff74ee196 --- /dev/null +++ b/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/PermissionStatusSnapshotTests.swift @@ -0,0 +1,126 @@ +import Dispatch +@testable import OrcaComputerUseMacOSCore +import XCTest + +final class PermissionStatusSnapshotTests: XCTestCase { + func testCapturesBothPermissionResults() { + let snapshot = PermissionStatusSnapshotProbe.capture( + accessibilityProbe: { true }, + screenshotsProbe: { false } + ) + + XCTAssertEqual( + snapshot, + PermissionStatusSnapshot(accessibilityGranted: true, screenshotsGranted: false) + ) + } + + func testRunsPermissionProbesConcurrently() { + let accessibilityStarted = DispatchSemaphore(value: 0) + let screenshotsStarted = DispatchSemaphore(value: 0) + let releaseProbes = DispatchSemaphore(value: 0) + let finished = expectation(description: "permission snapshot") + + DispatchQueue.global().async { + _ = PermissionStatusSnapshotProbe.capture( + accessibilityProbe: { + accessibilityStarted.signal() + releaseProbes.wait() + return true + }, + screenshotsProbe: { + screenshotsStarted.signal() + releaseProbes.wait() + return true + } + ) + finished.fulfill() + } + + XCTAssertEqual(accessibilityStarted.wait(timeout: .now() + 1), .success) + XCTAssertEqual(screenshotsStarted.wait(timeout: .now() + 1), .success) + releaseProbes.signal() + releaseProbes.signal() + wait(for: [finished], timeout: 1) + } + + func testCoalescesRefreshesWhileProbeIsRunning() { + let callbackQueue = DispatchQueue(label: "permission-status-test-callback") + let probeStarted = DispatchSemaphore(value: 0) + let releaseProbe = DispatchSemaphore(value: 0) + let handled = expectation(description: "snapshot handled") + let probeCount = LockedCounter() + let coordinator = PermissionStatusRefreshCoordinator( + callbackQueue: callbackQueue, + probe: { + probeCount.increment() + probeStarted.signal() + releaseProbe.wait() + return PermissionStatusSnapshot( + accessibilityGranted: true, + screenshotsGranted: true + ) + }, + handler: { _ in handled.fulfill() } + ) + + coordinator.refresh() + XCTAssertEqual(probeStarted.wait(timeout: .now() + 1), .success) + coordinator.refresh() + coordinator.refresh() + releaseProbe.signal() + + wait(for: [handled], timeout: 1) + XCTAssertEqual(probeCount.value, 1) + } + + func testAllowsRefreshAfterPreviousSnapshotWasHandled() { + let callbackQueue = DispatchQueue(label: "permission-status-test-callback") + let firstHandled = expectation(description: "first snapshot handled") + let secondHandled = expectation(description: "second snapshot handled") + let probeCount = LockedCounter() + let coordinator = PermissionStatusRefreshCoordinator( + callbackQueue: callbackQueue, + probe: { + let count = probeCount.increment() + return PermissionStatusSnapshot( + accessibilityGranted: count == 2, + screenshotsGranted: false + ) + }, + handler: { snapshot in + if snapshot.accessibilityGranted { + secondHandled.fulfill() + } else { + firstHandled.fulfill() + } + } + ) + + coordinator.refresh() + wait(for: [firstHandled], timeout: 1) + coordinator.refresh() + + wait(for: [secondHandled], timeout: 1) + XCTAssertEqual(probeCount.value, 2) + } +} + +private final class LockedCounter: @unchecked Sendable { + private let lock = NSLock() + private var count = 0 + + var value: Int { + lock.lock() + defer { lock.unlock() } + return count + } + + @discardableResult + func increment() -> Int { + lock.lock() + defer { lock.unlock() } + count += 1 + return count + } +} diff --git a/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/PermissionTrustSettlingTests.swift b/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/PermissionTrustSettlingTests.swift new file mode 100644 index 00000000000..c3f3956acd6 --- /dev/null +++ b/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/PermissionTrustSettlingTests.swift @@ -0,0 +1,59 @@ +import Testing +@testable import OrcaComputerUseMacOSCore + +@Suite("PermissionTrustSettling") +struct PermissionTrustSettlingTests { + @Test("already-trusted probe returns immediately") + func immediateSuccess() { + var sleeps: [Int] = [] + let outcome = PermissionTrustSettling.settle( + sleepMs: { sleeps.append($0) }, + probe: { true } + ) + + #expect(outcome == .init(settled: true, attempts: 1, waitedMs: 0)) + #expect(sleeps.isEmpty) + } + + @Test("transient denial settles after retries") + func lateGrant() { + var calls = 0 + var sleeps: [Int] = [] + let outcome = PermissionTrustSettling.settle( + sleepMs: { sleeps.append($0) }, + probe: { + calls += 1 + return calls >= 4 + } + ) + + #expect(outcome == .init(settled: true, attempts: 4, waitedMs: 300)) + #expect(sleeps == [100, 100, 100]) + } + + @Test("persistent denial stops at the timeout") + func neverGranted() { + let outcome = PermissionTrustSettling.settle( + timeoutMs: 1_000, + intervalMs: 300, + sleepMs: { _ in }, + probe: { false } + ) + + #expect(outcome == .init(settled: false, attempts: 5, waitedMs: 1_000)) + } + + @Test("final interval is clamped to the timeout") + func clampsFinalInterval() { + var sleeps: [Int] = [] + let outcome = PermissionTrustSettling.settle( + timeoutMs: 250, + intervalMs: 100, + sleepMs: { sleeps.append($0) }, + probe: { false } + ) + + #expect(sleeps == [100, 100, 50]) + #expect(outcome == .init(settled: false, attempts: 4, waitedMs: 250)) + } +} diff --git a/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/ScreenCapturePermissionSettlingTests.swift b/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/ScreenCapturePermissionSettlingTests.swift new file mode 100644 index 00000000000..d53991d36d0 --- /dev/null +++ b/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/ScreenCapturePermissionSettlingTests.swift @@ -0,0 +1,79 @@ +import Testing +@testable import OrcaComputerUseMacOSCore + +@Suite("Screen Capture permission settling") +struct ScreenCapturePermissionSettlingTests { + @Test("initial preflight success skips fallback") + func initialSuccess() { + var fallbackCalls = 0 + let trusted = PermissionTrustSettling.settleWithFallback( + finalTimeoutMs: 500, + sleepMs: { _ in }, + probe: { true }, + fallbackProbe: { + fallbackCalls += 1 + return true + } + ) + + #expect(trusted) + #expect(fallbackCalls == 0) + } + + @Test("capture fallback can establish trust") + func fallbackSuccess() { + var fallbackCalls = 0 + let trusted = PermissionTrustSettling.settleWithFallback( + initialTimeoutMs: 200, + finalTimeoutMs: 200, + intervalMs: 100, + sleepMs: { _ in }, + probe: { false }, + fallbackProbe: { + fallbackCalls += 1 + return true + } + ) + + #expect(trusted) + #expect(fallbackCalls == 1) + } + + @Test("preflight is retried after fallback failure") + func finalPreflightSuccess() { + var probeCalls = 0 + let trusted = PermissionTrustSettling.settleWithFallback( + initialTimeoutMs: 100, + finalTimeoutMs: 200, + intervalMs: 100, + sleepMs: { _ in }, + probe: { + probeCalls += 1 + return probeCalls == 4 + }, + fallbackProbe: { false } + ) + + #expect(trusted) + #expect(probeCalls == 4) + } + + @Test("persistent denial remains denied") + func persistentDenial() { + var fallbackCalls = 0 + let trusted = PermissionTrustSettling.settleWithFallback( + initialTimeoutMs: 100, + finalTimeoutMs: 100, + intervalMs: 100, + sleepMs: { _ in }, + probe: { false }, + fallbackProbe: { + fallbackCalls += 1 + return false + } + ) + + #expect(!trusted) + #expect(fallbackCalls == 1) + } +} diff --git a/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/ScreenCaptureProbeWindowSelectionTests.swift b/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/ScreenCaptureProbeWindowSelectionTests.swift new file mode 100644 index 00000000000..96afd6fbccb --- /dev/null +++ b/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/ScreenCaptureProbeWindowSelectionTests.swift @@ -0,0 +1,44 @@ +import Testing +@testable import OrcaComputerUseMacOSCore + +@Suite("Screen capture probe window selection") +struct ScreenCaptureProbeWindowSelectionTests { + @Test("own process windows cannot prove cross-app capture permission") + func excludesOwnWindows() { + let selected = ScreenCaptureProbeWindowSelection.firstCrossProcessNormalWindow( + ownPid: 42, + windows: [ + .init(layer: 0, ownerPid: 42, windowId: 1), + .init(layer: 0, ownerPid: 84, windowId: 2) + ] + ) + + #expect(selected == 2) + } + + @Test("non-normal windows are skipped") + func excludesNonNormalWindows() { + let selected = ScreenCaptureProbeWindowSelection.firstCrossProcessNormalWindow( + ownPid: 42, + windows: [ + .init(layer: 1, ownerPid: 84, windowId: 1), + .init(layer: 0, ownerPid: 84, windowId: 2) + ] + ) + + #expect(selected == 2) + } + + @Test("no cross-process normal window returns nil") + func noCandidate() { + let selected = ScreenCaptureProbeWindowSelection.firstCrossProcessNormalWindow( + ownPid: 42, + windows: [ + .init(layer: 0, ownerPid: 42, windowId: 1), + .init(layer: 1, ownerPid: 84, windowId: 2) + ] + ) + + #expect(selected == nil) + } +} From 7a867f12aacbbacaf07cac55fac1d430e0b2cafd Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:09:53 -0700 Subject: [PATCH 029/289] Revert "Fix grok stale pane width (#13060)" (#13098) This reverts commit 58531e4caa7a8ed48c1dede4e00e87369ac8a95f. --- ...terminals-hydration-canonical-rows.test.ts | 130 ------------------ src/renderer/src/store/slices/terminals.ts | 30 +--- 2 files changed, 1 insertion(+), 159 deletions(-) delete mode 100644 src/renderer/src/store/slices/terminals-hydration-canonical-rows.test.ts diff --git a/src/renderer/src/store/slices/terminals-hydration-canonical-rows.test.ts b/src/renderer/src/store/slices/terminals-hydration-canonical-rows.test.ts deleted file mode 100644 index 87563472922..00000000000 --- a/src/renderer/src/store/slices/terminals-hydration-canonical-rows.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { WorkspaceSessionState } from '../../../../shared/types' -import { getDefaultWorkspaceSession } from '../../../../shared/constants' -import { buildWorkspaceSessionPayload } from '@/lib/workspace-session' -import { createTestStore, makeLayout, makeTab, makeWorktree, seedStore } from './store-test-helpers' - -vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) -vi.mock('@/runtime/sync-runtime-graph', () => ({ - scheduleRuntimeGraphSync: vi.fn() -})) -vi.mock('@/components/terminal-pane/pty-transport', () => ({ - registerEagerPtyBuffer: vi.fn(), - ensurePtyDispatcher: vi.fn() -})) - -const apiProxy = (): unknown => - new Proxy(() => undefined, { - get: (_target, prop) => (prop === 'then' ? undefined : apiProxy()), - apply: () => Promise.resolve(null) - }) - -// @ts-expect-error -- mocked browser preload API -globalThis.window = { api: apiProxy() } - -describe('hydrateWorkspaceSession canonical terminal rows', () => { - it('drops only legacy rows that duplicate canonical PTY ownership', () => { - const store = createTestStore() - const worktreeId = 'repo1::/wt-1' - const sharedPtyId = 'daemon-session-1' - const recoveryPtyId = 'daemon-session-2' - seedStore(store, { - worktreesByRepo: { - repo1: [makeWorktree({ id: worktreeId, repoId: 'repo1', path: '/wt-1' })] - } - }) - - const session: WorkspaceSessionState = { - ...getDefaultWorkspaceSession(), - activeRepoId: 'repo1', - activeWorktreeId: worktreeId, - activeTabId: 'stale-tab', - activeWorktreeIdsOnShutdown: [worktreeId], - activeTabIdByWorktree: { [worktreeId]: 'stale-tab' }, - tabsByWorktree: { - [worktreeId]: [ - makeTab({ id: 'canonical-tab', worktreeId, ptyId: sharedPtyId }), - makeTab({ id: 'stale-tab', worktreeId, ptyId: sharedPtyId }), - makeTab({ id: 'recovery-tab', worktreeId, ptyId: recoveryPtyId }) - ] - }, - terminalLayoutsByTabId: { - 'canonical-tab': { - ...makeLayout(), - ptyIdsByLeafId: { 'canonical-leaf': sharedPtyId } - }, - 'stale-tab': { - ...makeLayout(), - ptyIdsByLeafId: { 'stale-leaf': sharedPtyId } - }, - 'recovery-tab': { - ...makeLayout(), - ptyIdsByLeafId: { 'recovery-leaf': recoveryPtyId } - } - }, - remoteSessionIdsByTabId: { - 'canonical-tab': sharedPtyId, - 'stale-tab': sharedPtyId, - 'recovery-tab': recoveryPtyId - }, - unifiedTabs: { - [worktreeId]: [ - { - id: 'canonical-unified-tab', - entityId: 'canonical-tab', - groupId: 'group-1', - worktreeId, - contentType: 'terminal', - label: 'Grok', - customLabel: null, - color: null, - sortOrder: 0, - createdAt: 1 - } - ] - }, - tabGroups: { - [worktreeId]: [ - { - id: 'group-1', - worktreeId, - activeTabId: 'canonical-unified-tab', - tabOrder: ['canonical-unified-tab'] - } - ] - } - } - - store.getState().hydrateWorkspaceSession(session) - store.getState().hydrateTabsSession(session) - const reconciliation = store.getState().reconcileWorktreeTabModel(worktreeId) - const state = store.getState() - const persisted = buildWorkspaceSessionPayload(state) - - expect(reconciliation.renderableTabCount).toBe(2) - expect(state.unifiedTabsByWorktree[worktreeId]?.map((tab) => tab.entityId)).toEqual([ - 'canonical-tab', - 'recovery-tab' - ]) - expect(state.tabsByWorktree[worktreeId]?.map((tab) => tab.id)).toEqual([ - 'canonical-tab', - 'recovery-tab' - ]) - expect(state.terminalLayoutsByTabId['stale-tab']).toBeUndefined() - expect(persisted.tabsByWorktree[worktreeId]?.map((tab) => tab.id)).toEqual([ - 'canonical-tab', - 'recovery-tab' - ]) - expect(persisted.terminalLayoutsByTabId['stale-tab']).toBeUndefined() - expect(state.pendingReconnectTabByWorktree[worktreeId]).toEqual([ - 'canonical-tab', - 'recovery-tab' - ]) - expect(state.pendingReconnectPtyIdByTabId).toEqual({ - 'canonical-tab': sharedPtyId, - 'recovery-tab': recoveryPtyId - }) - expect(state.activeTabId).toBeNull() - expect(state.activeTabIdByWorktree).toEqual({}) - }) -}) diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 87729c71ddb..7167e1cfc4d 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -492,17 +492,6 @@ function uniquePtyIds(ptyIds: readonly (string | null | undefined)[]): string[] return [...new Set(ptyIds.filter((ptyId): ptyId is string => Boolean(ptyId)))] } -function collectPersistedTerminalPtyIds( - session: WorkspaceSessionState, - tab: TerminalTab -): string[] { - return uniquePtyIds([ - tab.ptyId, - session.remoteSessionIdsByTabId?.[tab.id], - ...Object.values(session.terminalLayoutsByTabId[tab.id]?.ptyIdsByLeafId ?? {}) - ]) -} - function resolvePrimaryLayoutPtyId(layout: TerminalLayoutSnapshot): string | null { const ptyIdsByLeafId = layout.ptyIdsByLeafId ?? {} const activePtyId = layout.activeLeafId ? ptyIdsByLeafId[layout.activeLeafId] : undefined @@ -3954,16 +3943,6 @@ export const createTerminalSlice: StateCreator Object.entries(session.tabsByWorktree) .filter(([worktreeId]) => validWorktreeIds.has(worktreeId)) .map(([worktreeId, tabs]) => { - const canonicalTerminalIds = new Set( - (session.unifiedTabs?.[worktreeId] ?? []).flatMap((tab) => - tab.contentType === 'terminal' ? [tab.entityId] : [] - ) - ) - const canonicalPtyIds = new Set( - tabs - .filter((tab) => canonicalTerminalIds.has(tab.id)) - .flatMap((tab) => collectPersistedTerminalPtyIds(session, tab)) - ) const quickCommandLabelByTerminalId = new Map( (session.unifiedTabs?.[worktreeId] ?? []) .filter((tab) => tab.contentType === 'terminal' && tab.quickCommandLabel?.trim()) @@ -3978,15 +3957,8 @@ export const createTerminalSlice: StateCreator worktreeId, [...tabs] .filter((tab) => { - // Why: canonical mounts win PTY ownership over stale legacy duplicates. // Why: old web-client mirrors could persist host surface ids with "::"; makePaneKey reserves ":" as its separator. - return ( - (canonicalTerminalIds.has(tab.id) || - !collectPersistedTerminalPtyIds(session, tab).some((ptyId) => - canonicalPtyIds.has(ptyId) - )) && - isValidTerminalTabId(tab.id) - ) + return isValidTerminalTabId(tab.id) }) .sort((a, b) => a.sortOrder - b.sortOrder || a.createdAt - b.createdAt) .map((tab, index) => { From 20aeb0cb990a17f328b673f51717824d2f9c10ea Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:52:37 -0700 Subject: [PATCH 030/289] Prepare mobile 0.0.42 and fix TestFlight CI hang (#12961) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Bump mobile app.json to 0.0.42 * fix(mobile-ios): stop TestFlight CI from waiting on ASC processing 0.0.42 builds 1–2 uploaded successfully then hung for hours polling processing with no Ready build and no Apple email. Exit after upload and cap the job at 90m so the next cut does not repeat that hang. * fix(mobile-ios): fully skip Pilot wait (no changelog) Pilot only returns immediately after upload when changelog is nil; passing notes re-enters the ASC build-list poll. --- .github/workflows/mobile-ios-release.yml | 3 +++ mobile/app.json | 2 +- mobile/fastlane/Fastfile | 25 ++++++++---------------- 3 files changed, 12 insertions(+), 18 deletions(-) diff --git a/.github/workflows/mobile-ios-release.yml b/.github/workflows/mobile-ios-release.yml index f759bd3efee..9e31a0b94f4 100644 --- a/.github/workflows/mobile-ios-release.yml +++ b/.github/workflows/mobile-ios-release.yml @@ -30,6 +30,9 @@ jobs: # (@MainActor isolation). Xcode 16.x (macos-15) can't even parse it # ("unknown attribute 'MainActor'"), so we need Xcode 26.x → macos-26. runs-on: macos-26 + # Archive + upload is ~30–40m when healthy; 90m leaves margin without + # sitting multi-hour on ASC processing polls (see Fastfile pilot wait). + timeout-minutes: 90 defaults: run: diff --git a/mobile/app.json b/mobile/app.json index 87b50446486..e1749d34b7d 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Orca", "slug": "orca-mobile", - "version": "0.0.41", + "version": "0.0.42", "orientation": "default", "icon": "./assets/icon.png", "userInterfaceStyle": "automatic", diff --git a/mobile/fastlane/Fastfile b/mobile/fastlane/Fastfile index d08ce9462f1..45819c55ffd 100644 --- a/mobile/fastlane/Fastfile +++ b/mobile/fastlane/Fastfile @@ -28,8 +28,6 @@ WORKSPACE = File.join(MOBILE_ROOT, "ios", "Orca.xcworkspace") BUILD_OUTPUT_DIRECTORY = File.join(MOBILE_ROOT, "build") SCHEME = "Orca" BUNDLE_ID = "com.stably.orca.mobile" -TESTFLIGHT_GROUPS = ["peeps"].freeze -DEFAULT_TESTFLIGHT_CHANGELOG = "Latest Orca Mobile updates and fixes.".freeze # App Store version states in which the version "train" is terminally closed to # new TestFlight build uploads (altool rejects with 90186 "train ... is @@ -112,11 +110,6 @@ rescue StandardError => error nil end -def testflight_changelog - changelog = ENV.fetch("TESTFLIGHT_CHANGELOG", "").strip - changelog.empty? ? DEFAULT_TESTFLIGHT_CHANGELOG : changelog -end - platform :ios do desc "Resolve the iOS release version and next TestFlight build number" lane :prepare_release_version do |options| @@ -178,7 +171,7 @@ platform :ios do UI.message("Prepared Orca Mobile iOS #{version} (#{build_number})") end - desc "Build, sign, upload, and share Orca Mobile on TestFlight" + desc "Build, sign, and upload Orca Mobile to App Store Connect / TestFlight" lane :release do api_key = app_store_connect_api_key_from_env @@ -221,15 +214,13 @@ platform :ios do upload_to_testflight( api_key: api_key, - # Why: fastlane can only assign external TestFlight groups after Apple - # finishes processing the uploaded build. - skip_waiting_for_build_processing: false, - distribute_external: true, - groups: TESTFLIGHT_GROUPS, - notify_external_testers: true, - # Why: fastlane requires release notes when distributing to external - # TestFlight testers, even for CI-triggered smoke releases. - changelog: testflight_changelog, + # Why: ASC processing wait hung for hours (0.0.42 builds 1–2) with no Ready + # build and no email. Exit after upload acceptance. Do not pass changelog — + # Pilot only fully skips waiting when changelog is nil (otherwise it still + # polls until the build appears). External distribute (e.g. peeps) from ASC + # or a follow-up lane once Ready. + skip_waiting_for_build_processing: true, + distribute_external: false, ) end end From 38275c2aa2d4fa2bb45938f63f3bfb4e8948268b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:53:41 -0700 Subject: [PATCH 031/289] fix(codex): publish Windows system-default sessions (#12611) * fix(codex): publish Windows system-default sessions * fix(codex): close launch-scheduling races in session migration scheduler * fix(codex): bound repeated session migration audits * fix(codex): preserve delayed session publication passes * fix(codex): bound failed session audit events * fix(codex): fence stale session migration markers * chore: preserve main formatting after merge * perf(codex): bound launch session migration scans * perf(codex): preserve coalesced migration scope * fix(codex): preserve scheduled migration recovery * fix(codex): preserve session migration recovery * fix(codex): close session migration launch races * fix(codex): harden session migration completion --- .../runtime-home-service.test.ts | 71 ++- .../codex-accounts/runtime-home-service.ts | 81 +++- ...ime-home-windows-profile-ownership.test.ts | 1 + .../codex-session-backfill-audit-pass.ts | 118 +++++ .../codex/codex-session-backfill-audit.ts | 137 +++++- src/main/codex/codex-session-backfill-date.ts | 65 +++ .../codex/codex-session-backfill-marker.ts | 39 +- .../codex/codex-session-backfill-types.ts | 12 + src/main/codex/codex-session-backfill.test.ts | 428 +++++++++++++++-- src/main/codex/codex-session-backfill.ts | 178 +++---- .../codex/codex-session-index-heal-state.ts | 28 +- .../codex/codex-session-index-heal.test.ts | 31 +- .../codex-session-migration-scheduler.test.ts | 450 +++++++++++++++++- .../codex-session-migration-scheduler.ts | 252 +++++++++- src/main/index.ts | 48 +- src/main/ipc/pty.test.ts | 86 +++- src/main/ipc/pty.ts | 71 ++- .../window/attach-main-window-services.ts | 7 +- src/types/psl.ts | 17 + 19 files changed, 1890 insertions(+), 230 deletions(-) create mode 100644 src/main/codex/codex-session-backfill-audit-pass.ts create mode 100644 src/main/codex/codex-session-backfill-date.ts create mode 100644 src/types/psl.ts diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 8d3a52fa823..e07496b9e07 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -1004,10 +1004,62 @@ describe('CodexRuntimeHomeService', () => { const store = createStore(createSettings()) const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) - expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath()) - // Why: a mirror launch never leaves the real home, so its backfill stays valid. - expect(existsSync(markerPath)).toBe(true) + expect(existsSync(markerPath)).toBe(false) + service.finishHostSystemDefaultSessionMigrationPass() + expect(service.beginHostSystemDefaultSessionMigrationLaunch(getRuntimeCodexHomePath())).toBe( + true + ) + service.finishHostSystemDefaultSessionMigrationPass() + writeFileSync( + markerPath, + `${JSON.stringify({ + version: 3, + systemSessionsRoot: join(getSystemCodexHomePath(), 'sessions'), + summary: { scannedFiles: 1 } + })}\n`, + 'utf-8' + ) + service.prepareForCodexLaunch() + writeFileSync( + markerPath, + `${JSON.stringify({ + version: 3, + systemSessionsRoot: join(getSystemCodexHomePath(), 'sessions'), + summary: { scannedFiles: 1 } + })}\n`, + 'utf-8' + ) + expect(service.beginHostSystemDefaultSessionMigrationLaunch(getRuntimeCodexHomePath())).toBe( + false + ) + expect(existsSync(markerPath)).toBe(false) + service.prepareForCodexLaunch() + expect(service.beginHostSystemDefaultSessionMigrationLaunch(getRuntimeCodexHomePath())).toBe( + false + ) + expect(service.beginHostSystemDefaultSessionMigrationLaunch(null)).toBeNull() + service.finishHostSystemDefaultSessionMigrationPass() + writeFileSync( + markerPath, + `${JSON.stringify({ + version: 3, + systemSessionsRoot: join(getSystemCodexHomePath(), 'sessions'), + summary: { scannedFiles: 1 } + })}\n`, + 'utf-8' + ) + expect(service.beginHostSystemDefaultSessionMigrationLaunch(null, { reattached: true })).toBe( + false + ) + expect(existsSync(markerPath)).toBe(false) + store.updateSettings({ + codexSessionSourceHome: { host: join(testState.fakeHomeDir, 'moved-history'), wsl: {} } + }) + service.prepareForCodexLaunch() + expect(service.beginHostSystemDefaultSessionMigrationLaunch(getRuntimeCodexHomePath())).toBe( + true + ) expect(service.prepareForRateLimitFetch()).toBe(getRuntimeCodexHomePath()) expect(service.getHostCodexHomePathsForSessionDiscovery()).toEqual([getRuntimeCodexHomePath()]) expect(existsSync(getRuntimeCodexHomePath())).toBe(true) @@ -1037,6 +1089,10 @@ describe('CodexRuntimeHomeService', () => { writeFileSync(markerPath, '{}\n', 'utf-8') expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath()) expect(existsSync(markerPath)).toBe(false) + expect(service.beginHostSystemDefaultSessionMigrationLaunch(getRuntimeCodexHomePath())).toBe( + true + ) + service.finishHostSystemDefaultSessionMigrationPass() service.setRealHomeLaneGate(() => true) const perSpawnCustomHome = join(testState.fakeHomeDir, 'per-spawn-custom-codex-home') writeFileSync(markerPath, '{}\n', 'utf-8') @@ -1045,6 +1101,11 @@ describe('CodexRuntimeHomeService', () => { getRuntimeCodexHomePath() ) expect(existsSync(markerPath)).toBe(true) + expect( + service.beginHostSystemDefaultSessionMigrationLaunch(getRuntimeCodexHomePath(), { + launchEnv: { CODEX_HOME: perSpawnCustomHome } + }) + ).toBeNull() if (process.platform !== 'win32') { // Why: shell startup CODEX_HOME discovery is a POSIX-shell lane; Windows // must not invoke an ambient WSL bash while evaluating this contract. @@ -1719,7 +1780,11 @@ describe('CodexRuntimeHomeService', () => { // A host managed account's own home is its CODEX_HOME. expect(service.isHostSystemDefaultRealHome()).toBe(false) + expect(service.isHostSystemDefaultSessionMigrationEligible()).toBe(false) expect(service.prepareForCodexLaunch()).toBe(managedHomePath) + expect( + service.beginHostSystemDefaultSessionMigrationLaunch(getRuntimeCodexHomePath()) + ).toBeNull() // The per-account home keeps its own auth in place; the shared mirror's // auth.json is never hot-swapped, so two accounts cannot race one file. expect(readFileSync(join(managedHomePath, 'auth.json'), 'utf-8')).toBe( diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index c2b99b52c6d..68d054f3571 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -41,7 +41,6 @@ import { import { getOrcaManagedCodexHomePath, getOrcaUserDataPath, - getCodexSessionBackfillStateDirPath, getSystemCodexHomePath, resolveOrcaManagedCodexHomePath, syncCodexGlobalInstructionsIntoManagedHome, @@ -68,7 +67,11 @@ import { } from './runtime-selection' import { getDefaultWslDistro, getWslHome } from '../wsl' import { hasCustomCodexHomeOverrideForLaunch } from '../codex/codex-real-home-path' -import { invalidateCodexSessionBackfillMarker } from '../codex/codex-session-backfill-marker' +import { + hasCompletedCodexSessionBackfillMarker, + invalidateCodexSessionBackfillMarker +} from '../codex/codex-session-backfill-marker' +import { resolveCodexSessionBackfillPaths } from '../codex/codex-session-backfill' import { assertOwnedHostCodexManagedHomePath } from './host-codex-managed-home-ownership' import { codexAuthCouldBelongToManagedAccount, @@ -181,6 +184,9 @@ export class CodexRuntimeHomeService { private sharedAuthRefreshBlockedByManagedTransition = false // Why: transient auth.json read/parse failures must not deselect an account. private readonly credentialAbsenceGrace = new CodexCredentialAbsenceGrace() + private hostSystemDefaultSessionMigrationPending = false + private pendingHostSystemDefaultSessionMigrationNeedsFullScan = false + private pendingHostSystemDefaultSessionMigrationTarget: string | null = null constructor(private readonly store: Store) { this.safeRecoverInterruptedRuntimeAuthOperation() @@ -253,6 +259,53 @@ export class CodexRuntimeHomeService { return this.getRuntimeHomePath() } + beginHostSystemDefaultSessionMigrationLaunch( + codexHomePath: string | null, + options: { reattached?: boolean; launchEnv?: NodeJS.ProcessEnv } = {} + ): boolean | null { + if ( + !this.isHostSystemDefaultSessionMigrationEligible() || + (!codexHomePath && !options.reattached) || + (codexHomePath && + normalizeRuntimePathForComparison(codexHomePath) !== + normalizeRuntimePathForComparison(this.getRuntimeHomePath())) + ) { + return null + } + // Why: an older pass can clear launch preparation while PTY spawn awaits recovery. + return this.invalidateBackfillAfterManagedSystemDefaultLaunch( + options.reattached && !codexHomePath ? undefined : options.launchEnv + ) + } + + isHostSystemDefaultSessionMigrationEligible(): boolean { + return ( + normalizeCodexRuntimeSelection(this.store.getSettings()).host === null && + !hasCustomCodexHomeOverrideForLaunch() + ) + } + + prepareHostSystemDefaultSessionMigrationPass(): boolean { + const paths = resolveCodexSessionBackfillPaths( + resolveHostCodexSessionSourceHome(this.store.getSettings()) + ) + if ( + this.hostSystemDefaultSessionMigrationPending && + this.pendingHostSystemDefaultSessionMigrationTarget !== paths.systemSessionsRoot + ) { + this.pendingHostSystemDefaultSessionMigrationNeedsFullScan = true + this.pendingHostSystemDefaultSessionMigrationTarget = paths.systemSessionsRoot + } + invalidateCodexSessionBackfillMarker(paths.markerPath) + return this.pendingHostSystemDefaultSessionMigrationNeedsFullScan + } + + finishHostSystemDefaultSessionMigrationPass(): void { + this.hostSystemDefaultSessionMigrationPending = false + this.pendingHostSystemDefaultSessionMigrationNeedsFullScan = false + this.pendingHostSystemDefaultSessionMigrationTarget = null + } + // Why: a managed HOST account runs against its own self-contained CODEX_HOME // (codex-accounts//home) rather than the shared runtime mirror. Its // auth.json lives there and codex refreshes it in place, so two accounts never @@ -404,18 +457,26 @@ export class CodexRuntimeHomeService { this.lastHostAccountUsedSelfContainedHome = false } - private invalidateBackfillAfterManagedSystemDefaultLaunch(launchEnv?: NodeJS.ProcessEnv): void { + private invalidateBackfillAfterManagedSystemDefaultLaunch( + launchEnv?: NodeJS.ProcessEnv + ): boolean | null { const settings = this.store.getSettings() - if (normalizeCodexRuntimeSelection(settings).host !== null) { - return + if ( + normalizeCodexRuntimeSelection(settings).host !== null || + hasCustomCodexHomeOverrideForLaunch(launchEnv) + ) { + return null } - // Why: reached only when the real-home lane is selected but its gate is off, - // so the launch runs on the mirror and the backfill marker is stale. - if (this.isHostSystemDefaultRealHomeSelected(launchEnv)) { - invalidateCodexSessionBackfillMarker( - join(getCodexSessionBackfillStateDirPath(), 'backfill-complete.json') + if (!this.hostSystemDefaultSessionMigrationPending) { + const paths = resolveCodexSessionBackfillPaths( + resolveHostCodexSessionSourceHome(this.store.getSettings()) ) + this.pendingHostSystemDefaultSessionMigrationNeedsFullScan = + !hasCompletedCodexSessionBackfillMarker(paths.markerPath, paths.systemSessionsRoot) + this.pendingHostSystemDefaultSessionMigrationTarget = paths.systemSessionsRoot + this.hostSystemDefaultSessionMigrationPending = true } + return this.prepareHostSystemDefaultSessionMigrationPass() } private startWslSessionBridgeForLaunch( diff --git a/src/main/codex-accounts/runtime-home-windows-profile-ownership.test.ts b/src/main/codex-accounts/runtime-home-windows-profile-ownership.test.ts index 54ab5d5f095..337aaaf5375 100644 --- a/src/main/codex-accounts/runtime-home-windows-profile-ownership.test.ts +++ b/src/main/codex-accounts/runtime-home-windows-profile-ownership.test.ts @@ -29,6 +29,7 @@ describe('Windows System Default Codex home ownership', () => { SHELL: 'powershell.exe' }) ).toBe(false) + expect(service.isHostSystemDefaultSessionMigrationEligible()).toBe(true) }) }) diff --git a/src/main/codex/codex-session-backfill-audit-pass.ts b/src/main/codex/codex-session-backfill-audit-pass.ts new file mode 100644 index 00000000000..8958cf689b6 --- /dev/null +++ b/src/main/codex/codex-session-backfill-audit-pass.ts @@ -0,0 +1,118 @@ +import type { Stats } from 'node:fs' +import { lstat } from 'node:fs/promises' +import { + appendCodexSessionHealAuditRecord, + createCodexSessionBackfillAuditWriter, + createCodexSessionBackfillDiagnosticEventId, + createCodexSessionBackfillFileEventId, + readCodexSessionBackfillAuditCoverage, + recordExistingCodexSessionForHeal, + type CodexSessionBackfillAuditWriter +} from './codex-session-backfill-audit' +import type { CodexSessionBackfillSummary } from './codex-session-backfill-types' + +export type CodexSessionBackfillAuditPass = { + appendRecord: CodexSessionBackfillAuditWriter + recordExisting( + summary: CodexSessionBackfillSummary, + source: string, + target: string, + targetStat: Stats | null + ): Promise + recordPublished( + summary: CodexSessionBackfillSummary, + action: 'hardlink' | 'copy', + source: string, + target: string + ): Promise + recordDiagnostic( + record: { + action: 'copy-unsupported' | 'failed' + source: string + target: string + error?: string + linkError?: string + errorCode?: string + linkErrorCode?: string + }, + sourceStat: Stats | null + ): Promise + finish(summary: CodexSessionBackfillSummary): Promise +} + +export async function createCodexSessionBackfillAuditPass( + auditLogPath: string +): Promise { + const coverage = await readCodexSessionBackfillAuditCoverage(auditLogPath) + const writeAuditRecord = createCodexSessionBackfillAuditWriter(auditLogPath) + let auditChanged = false + const appendRecord: CodexSessionBackfillAuditWriter = async (record) => { + const appended = await writeAuditRecord(record) + auditChanged ||= appended + return appended + } + + return { + appendRecord, + async recordExisting(summary, source, target, targetStat): Promise { + const fileEventId = targetStat + ? createCodexSessionBackfillFileEventId(target, targetStat) + : undefined + if (fileEventId && coverage.fileEventIds.has(fileEventId)) { + summary.skippedExistingFiles += 1 + return + } + if ( + await recordExistingCodexSessionForHeal(appendRecord, summary, source, target, fileEventId) + ) { + if (fileEventId) { + coverage.fileEventIds.add(fileEventId) + } + } + }, + async recordPublished(summary, action, source, target): Promise { + const targetStat = await readCodexSessionTargetStat(target) + const fileEventId = targetStat + ? createCodexSessionBackfillFileEventId(target, targetStat) + : undefined + if ( + await appendCodexSessionHealAuditRecord(appendRecord, summary, { + action, + source, + target, + ...(fileEventId ? { fileEventId } : {}) + }) + ) { + if (fileEventId) { + coverage.fileEventIds.add(fileEventId) + } + } + }, + async recordDiagnostic(record, sourceStat): Promise { + const diagnosticEventId = createCodexSessionBackfillDiagnosticEventId({ + ...record, + sourceStat + }) + if (coverage.diagnosticEventIds.has(diagnosticEventId)) { + return + } + if (await appendRecord({ ...record, diagnosticEventId })) { + coverage.diagnosticEventIds.add(diagnosticEventId) + } + }, + async finish(summary): Promise { + if (auditChanged || !coverage.hasRunSummary) { + await appendRecord({ action: 'run-summary', ...summary }) + } + } + } +} + +/** A broken symlink at the target still counts as taken. */ +export async function readCodexSessionTargetStat(entryPath: string): Promise { + try { + return await lstat(entryPath) + } catch { + return null + } +} diff --git a/src/main/codex/codex-session-backfill-audit.ts b/src/main/codex/codex-session-backfill-audit.ts index c4740b95572..1bec593821a 100644 --- a/src/main/codex/codex-session-backfill-audit.ts +++ b/src/main/codex/codex-session-backfill-audit.ts @@ -1,10 +1,22 @@ -import { randomUUID } from 'node:crypto' +import { createHash, randomUUID } from 'node:crypto' +import { createReadStream, type Stats } from 'node:fs' import { appendFile, mkdir } from 'node:fs/promises' import { dirname } from 'node:path' +import { createInterface } from 'node:readline' +import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' import type { CodexSessionBackfillSummary } from './codex-session-backfill-types' export type CodexSessionBackfillAuditWriter = (record: Record) => Promise +export type CodexSessionBackfillAuditCoverage = { + fileEventIds: Set + diagnosticEventIds: Set + hasRunSummary: boolean +} + +const HEAL_AUDIT_ACTIONS = new Set(['hardlink', 'copy', 'existing']) +const DIAGNOSTIC_AUDIT_ACTIONS = new Set(['copy-unsupported', 'failed']) + export function createCodexSessionBackfillAuditWriter( auditLogPath: string ): CodexSessionBackfillAuditWriter { @@ -48,28 +60,139 @@ export function createCodexSessionBackfillAuditWriter( } } +export async function readCodexSessionBackfillAuditCoverage( + auditLogPath: string +): Promise { + const coverage: CodexSessionBackfillAuditCoverage = { + fileEventIds: new Set(), + diagnosticEventIds: new Set(), + hasRunSummary: false + } + const input = createReadStream(auditLogPath, { encoding: 'utf-8' }) + const lines = createInterface({ input, crlfDelay: Infinity }) + try { + for await (const raw of lines) { + try { + const parsed: unknown = JSON.parse(raw) + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + continue + } + const record = parsed as Record + coverage.hasRunSummary ||= record.action === 'run-summary' + if ( + typeof record.action === 'string' && + HEAL_AUDIT_ACTIONS.has(record.action) && + typeof record.fileEventId === 'string' + ) { + coverage.fileEventIds.add(record.fileEventId) + } + if ( + typeof record.action === 'string' && + DIAGNOSTIC_AUDIT_ACTIONS.has(record.action) && + typeof record.diagnosticEventId === 'string' + ) { + coverage.diagnosticEventIds.add(record.diagnosticEventId) + } + } catch { + // Torn audit tails are quarantined by the writer's leading newline. + } + } + } catch (error) { + if (!isNotFoundError(error)) { + throw error + } + } + return coverage +} + +export function createCodexSessionBackfillFileEventId(targetPath: string, stat: Stats): string { + const fileIdentity = [ + stat.dev, + stat.ino, + stat.birthtimeMs, + stat.size, + stat.mtimeMs, + stat.ctimeMs + ].join('\0') + return createHash('sha256') + .update(normalizeRuntimePathForComparison(targetPath)) + .update('\0') + .update(fileIdentity) + .digest('hex') +} + +export function createCodexSessionBackfillDiagnosticEventId(args: { + action: 'copy-unsupported' | 'failed' + source: string + target: string + sourceStat: Stats | null + errorCode?: string + linkErrorCode?: string +}): string { + const sourceIdentity = args.sourceStat + ? [ + args.sourceStat.dev, + args.sourceStat.ino, + args.sourceStat.birthtimeMs, + args.sourceStat.size, + args.sourceStat.mtimeMs, + args.sourceStat.ctimeMs + ].join('\0') + : 'unavailable' + return createHash('sha256') + .update(args.action) + .update('\0') + .update(normalizeRuntimePathForComparison(args.source)) + .update('\0') + .update(normalizeRuntimePathForComparison(args.target)) + .update('\0') + .update(sourceIdentity) + .update('\0') + .update(args.errorCode ?? '') + .update('\0') + .update(args.linkErrorCode ?? '') + .digest('hex') +} + +export function describeCodexSessionBackfillErrorCode(error: unknown): string { + const code = (error as NodeJS.ErrnoException | null)?.code + return typeof code === 'string' && code + ? code + : error instanceof Error + ? error.name + : typeof error +} + export async function appendCodexSessionHealAuditRecord( writer: CodexSessionBackfillAuditWriter, summary: CodexSessionBackfillSummary, record: Record -): Promise { - if (!(await writer(record))) { +): Promise { + const appended = await writer(record) + if (!appended) { summary.failedHealAuditRecords += 1 } + return appended } export async function recordExistingCodexSessionForHeal( writer: CodexSessionBackfillAuditWriter, summary: CodexSessionBackfillSummary, source: string, - target: string -): Promise { + target: string, + fileEventId?: string +): Promise { summary.skippedExistingFiles += 1 // Why: this also recovers a rollout installed before a crash or audit // failure; thread/read is idempotent for a pre-existing real-home file. - await appendCodexSessionHealAuditRecord(writer, summary, { + return appendCodexSessionHealAuditRecord(writer, summary, { action: 'existing', source, - target + target, + ...(fileEventId ? { fileEventId } : {}) }) } + +function isNotFoundError(error: unknown): boolean { + return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT' +} diff --git a/src/main/codex/codex-session-backfill-date.ts b/src/main/codex/codex-session-backfill-date.ts new file mode 100644 index 00000000000..8c2157d9d61 --- /dev/null +++ b/src/main/codex/codex-session-backfill-date.ts @@ -0,0 +1,65 @@ +import { join, relative, sep } from 'node:path' +import { listCodexSessionJsonlFilesIncrementally } from './codex-session-file-listing' +import type { + CodexSessionBackfillDate, + CodexSessionBackfillOptions +} from './codex-session-backfill-types' + +export function getCodexSessionBackfillDate(date = new Date()): CodexSessionBackfillDate { + return [ + String(date.getUTCFullYear()).padStart(4, '0'), + String(date.getUTCMonth() + 1).padStart(2, '0'), + String(date.getUTCDate()).padStart(2, '0') + ] +} + +export function isCodexSessionRolloutPath(sessionsRoot: string, filePath: string): boolean { + const pathParts = relative(sessionsRoot, filePath).split(sep) + if (pathParts.length !== 4) { + return false + } + const [year, month, day, fileName] = pathParts + return ( + /^\d{4}$/.test(year) && + /^\d{2}$/.test(month) && + /^\d{2}$/.test(day) && + /^rollout-.+\.jsonl$/.test(fileName) + ) +} + +export async function* listCodexSessionBackfillFilesForDates( + sessionsRoot: string, + options: CodexSessionBackfillOptions, + onDirectoryError: (directoryPath: string, error: unknown) => void | Promise +): AsyncGenerator { + const scanRoots = resolveCodexSessionBackfillDateRoots(sessionsRoot, options.scanDates) + for (const scanRoot of scanRoots) { + yield* listCodexSessionJsonlFilesIncrementally( + scanRoot, + options, + async (directoryPath, error) => { + if (directoryPath !== scanRoot || !isNotFoundError(error)) { + await onDirectoryError(directoryPath, error) + } + } + ) + } +} + +function resolveCodexSessionBackfillDateRoots( + sessionsRoot: string, + scanDates: readonly CodexSessionBackfillDate[] | undefined +): string[] { + if (!scanDates?.length) { + return [sessionsRoot] + } + return scanDates + .filter( + ([year, month, day]) => /^\d{4}$/.test(year) && /^\d{2}$/.test(month) && /^\d{2}$/.test(day) + ) + .map(([year, month, day]) => join(sessionsRoot, year, month, day)) +} + +function isNotFoundError(error: unknown): boolean { + return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT' +} diff --git a/src/main/codex/codex-session-backfill-marker.ts b/src/main/codex/codex-session-backfill-marker.ts index d3d5a07e5f7..8c2c9fb9c50 100644 --- a/src/main/codex/codex-session-backfill-marker.ts +++ b/src/main/codex/codex-session-backfill-marker.ts @@ -6,6 +6,11 @@ import type { CodexSessionBackfillSummary } from './codex-session-backfill-types // Why: bump to re-run the backfill for every host after a layout or semantics // change; the run itself stays skip-existing so re-runs never overwrite. const CODEX_SESSION_BACKFILL_MARKER_VERSION = 3 +let markerInvalidationGeneration = 0 + +export function captureCodexSessionBackfillMarkerGeneration(): number { + return markerInvalidationGeneration +} export function hasCompletedCodexSessionBackfillMarker( markerPath: string, @@ -16,13 +21,22 @@ export function hasCompletedCodexSessionBackfillMarker( if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { return false } - const marker = parsed as { version?: unknown; systemSessionsRoot?: unknown } + const marker = parsed as { + version?: unknown + systemSessionsRoot?: unknown + summary?: { scannedFiles?: unknown } + } // Why: changing the configured real Codex home must backfill the new // target instead of honoring a marker written for a different history. - return ( + const markerMatchesTarget = marker.version === CODEX_SESSION_BACKFILL_MARKER_VERSION && marker.systemSessionsRoot === systemSessionsRoot - ) + if (!markerMatchesTarget) { + return false + } + // Why: an empty source can become populated after an early migration run; + // let the incremental async walk verify it without blocking the main thread. + return marker.summary?.scannedFiles !== 0 } catch { return false } @@ -31,8 +45,13 @@ export function hasCompletedCodexSessionBackfillMarker( export function writeCodexSessionBackfillMarker( markerPath: string, systemSessionsRoot: string, - summary: CodexSessionBackfillSummary + summary: CodexSessionBackfillSummary, + expectedGeneration: number ): void { + // Why: a launch can invalidate this pass before its delayed replacement begins. + if (expectedGeneration !== markerInvalidationGeneration) { + return + } mkdirSync(dirname(markerPath), { recursive: true }) writeFileAtomically( markerPath, @@ -50,11 +69,23 @@ export function writeCodexSessionBackfillMarker( } export function invalidateCodexSessionBackfillMarker(markerPath: string): void { + markerInvalidationGeneration += 1 try { // Why: a managed-lane system-default launch can create new source // rollouts, so a prior one-time marker must not suppress the next opt-in. rmSync(markerPath, { force: true }) } catch (error) { console.warn('[codex-session-backfill] Failed to invalidate completion marker:', error) + try { + writeFileAtomically( + markerPath, + `${JSON.stringify({ version: 0, invalidatedAt: Date.now() })}\n` + ) + } catch (fallbackError) { + throw new AggregateError( + [error, fallbackError], + 'Failed to invalidate Codex session backfill marker' + ) + } } } diff --git a/src/main/codex/codex-session-backfill-types.ts b/src/main/codex/codex-session-backfill-types.ts index d3f25b37bbf..92891beaaff 100644 --- a/src/main/codex/codex-session-backfill-types.ts +++ b/src/main/codex/codex-session-backfill-types.ts @@ -24,4 +24,16 @@ export type CodexSessionBackfillPaths = { export type CodexSessionBackfillOptions = CodexSessionBridgeIncrementalOptions & { /** Polled before each target mutation; true stops with progress preserved. */ shouldStop?: () => boolean + /** Limits a launch-triggered pass to the date directories that can contain its rollouts. */ + scanDates?: readonly CodexSessionBackfillDate[] + /** A scheduled launch pass must not be suppressed by a marker it just invalidated. */ + ignoreCompletionMarker?: boolean + /** Active launch passes defer global completion until their final exit scan. */ + writeCompletionMarker?: boolean + /** Final launch scans can extend a previously certified full-tree baseline. */ + writeBoundedCompletionMarker?: boolean + /** Rechecks launch scheduling state immediately before marker publication. */ + canWriteCompletionMarker?: () => boolean } + +export type CodexSessionBackfillDate = readonly [year: string, month: string, day: string] diff --git a/src/main/codex/codex-session-backfill.test.ts b/src/main/codex/codex-session-backfill.test.ts index bd5e0bca680..418ef0d8381 100644 --- a/src/main/codex/codex-session-backfill.test.ts +++ b/src/main/codex/codex-session-backfill.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { + appendFileSync, existsSync, lstatSync, mkdirSync, @@ -23,12 +24,14 @@ const { homedirMock } = vi.hoisted(() => ({ const { fsMockState } = vi.hoisted(() => ({ fsMockState: { failLink: false, - failInstallLink: false, - failInstallLinkTransiently: false, + failLinkTransiently: false, + failLinkPermission: false, raceTargetIntoExistence: false, - failCopy: false, + failMarkerRm: false, + failMarkerReplacement: false, failAuditMkdirOnce: false, failAuditWrites: false, + failMkdirPath: null as string | null, failDirectoryPath: null as string | null, failLstatPath: null as string | null } @@ -43,6 +46,30 @@ vi.mock('node:fs', async () => { return false } return actual.existsSync(...args) + }, + rmSync: (...args: Parameters) => { + if ( + fsMockState.failMarkerRm && + String(args[0]).includes('codex-session-backfill') && + String(args[0]).endsWith('backfill-complete.json') + ) { + const error = new Error('EACCES: marker removal failed') as NodeJS.ErrnoException + error.code = 'EACCES' + throw error + } + return actual.rmSync(...args) + }, + renameSync: (...args: Parameters) => { + if ( + fsMockState.failMarkerReplacement && + String(args[1]).includes('codex-session-backfill') && + String(args[1]).endsWith('backfill-complete.json') + ) { + const error = new Error('EACCES: marker replacement failed') as NodeJS.ErrnoException + error.code = 'EACCES' + throw error + } + return actual.renameSync(...args) } } }) @@ -52,6 +79,11 @@ vi.mock('node:fs/promises', async () => { return { ...actual, mkdir: (...args: Parameters) => { + if (args[0] === fsMockState.failMkdirPath) { + const error = new Error('EACCES: target directory inaccessible') as NodeJS.ErrnoException + error.code = 'EACCES' + throw error + } if (fsMockState.failAuditMkdirOnce && String(args[0]).includes('codex-session-backfill')) { fsMockState.failAuditMkdirOnce = false const error = new Error( @@ -91,30 +123,17 @@ vi.mock('node:fs/promises', async () => { error.code = 'EXDEV' throw error } - // Simulate a target filesystem with no hardlink support: even the - // same-volume staged-copy install link (.orca-backfill-*.tmp) fails. - if (fsMockState.failInstallLink && String(args[0]).includes('.orca-backfill-')) { - const error = new Error('EPERM: hardlinks unsupported') as NodeJS.ErrnoException - error.code = 'EPERM' - throw error - } - if (fsMockState.failInstallLinkTransiently && String(args[0]).includes('.orca-backfill-')) { - const error = new Error('EIO: transient install failure') as NodeJS.ErrnoException + if (fsMockState.failLinkTransiently && String(args[0]).includes('codex-runtime-home')) { + const error = new Error('EIO: transient hardlink failure') as NodeJS.ErrnoException error.code = 'EIO' throw error } - return actual.link(...args) - }, - copyFile: async (...args: Parameters) => { - if (fsMockState.failCopy) { - // Simulate a copy that fails after opening its destination, which is - // the dangerous case for resumability rather than a preflight error. - await actual.writeFile(args[1], 'partial copy\n', 'utf-8') - const error = new Error('EACCES: copy disabled for test') as NodeJS.ErrnoException + if (fsMockState.failLinkPermission && String(args[0]).includes('codex-runtime-home')) { + const error = new Error('EACCES: hardlink permission denied') as NodeJS.ErrnoException error.code = 'EACCES' throw error } - return actual.copyFile(...args) + return actual.link(...args) }, opendir: (...args: Parameters) => { if (args[0] === fsMockState.failDirectoryPath) { @@ -140,6 +159,7 @@ import { resolveCodexSessionBackfillPaths, startCodexSessionBackfillInBackground } from './codex-session-backfill' +import { invalidateCodexSessionBackfillMarker } from './codex-session-backfill-marker' let fakeHomeDir: string let userDataDir: string @@ -168,27 +188,40 @@ function writeManagedSession(relativePath: string, contents: string): string { return filePath } -function readAuditActions(): string[] { +type BackfillAuditRecord = { + action: string + target?: string + fileEventId?: string + diagnosticEventId?: string +} + +function readBackfillAuditRecords(): BackfillAuditRecord[] { return readFileSync(getAuditLogPath(), 'utf-8') .split('\n') .filter(Boolean) .flatMap((line) => { try { - return [(JSON.parse(line) as { action: string }).action] + return [JSON.parse(line) as BackfillAuditRecord] } catch { return [] } }) } +function readAuditActions(): string[] { + return readBackfillAuditRecords().map((record) => record.action) +} + beforeEach(() => { fsMockState.failLink = false - fsMockState.failInstallLink = false - fsMockState.failInstallLinkTransiently = false + fsMockState.failLinkTransiently = false + fsMockState.failLinkPermission = false fsMockState.raceTargetIntoExistence = false - fsMockState.failCopy = false + fsMockState.failMarkerRm = false + fsMockState.failMarkerReplacement = false fsMockState.failAuditMkdirOnce = false fsMockState.failAuditWrites = false + fsMockState.failMkdirPath = null fsMockState.failDirectoryPath = null fsMockState.failLstatPath = null fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-codex-backfill-home-')) @@ -367,27 +400,22 @@ describe('backfillManagedCodexSessionsIntoSystemHome', () => { expect(readAuditActions()).toEqual(['hardlink', 'run-summary']) }) - it('falls back to copy when hardlinking fails across volumes', async () => { + it('skips cross-volume rollouts instead of freezing a mutable snapshot', async () => { fsMockState.failLink = true - const managedPath = writeManagedSession( - join('2026', '05', '26', 'rollout-a ü.jsonl'), - '{"id":"a"}\n' - ) + const relativePath = join('2026', '05', '26', 'rollout-a ü.jsonl') + writeManagedSession(relativePath, '{"id":"a"}\n') const summary = await backfillManagedCodexSessionsIntoSystemHome( resolveCodexSessionBackfillPaths() ) - expect(summary).toMatchObject({ linkedFiles: 0, copiedFiles: 1, failedFiles: 0 }) - const targetPath = join(getSystemSessionsRoot(), '2026', '05', '26', 'rollout-a ü.jsonl') - expect(readFileSync(targetPath, 'utf-8')).toBe(readFileSync(managedPath, 'utf-8')) - expect(lstatSync(targetPath).ino).not.toBe(lstatSync(managedPath).ino) - expect(readAuditActions()).toEqual(['copy', 'run-summary']) + expect(summary).toMatchObject({ copiedFiles: 0, skippedUnsupportedFilesystemFiles: 1 }) + expect(existsSync(join(getSystemSessionsRoot(), relativePath))).toBe(false) + expect(readAuditActions()).toEqual(['copy-unsupported', 'run-summary']) }) it('fails closed when the target filesystem cannot install without overwrite', async () => { fsMockState.failLink = true - fsMockState.failInstallLink = true writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') const summary = await backfillManagedCodexSessionsIntoSystemHome( @@ -406,9 +434,8 @@ describe('backfillManagedCodexSessionsIntoSystemHome', () => { expect(readAuditActions()).toEqual(['copy-unsupported', 'run-summary']) }) - it('keeps transient install failures retryable', async () => { - fsMockState.failLink = true - fsMockState.failInstallLinkTransiently = true + it('keeps transient hardlink failures retryable', async () => { + fsMockState.failLinkTransiently = true writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') const summary = await backfillManagedCodexSessionsIntoSystemHome( @@ -422,9 +449,33 @@ describe('backfillManagedCodexSessionsIntoSystemHome', () => { expect(readAuditActions()).toEqual(['failed', 'run-summary']) }) + it('keeps target directory permission failures retryable', async () => { + const relativePath = join('2026', '05', '26', 'rollout-a.jsonl') + writeManagedSession(relativePath, '{"id":"a"}\n') + fsMockState.failMkdirPath = dirname(join(getSystemSessionsRoot(), relativePath)) + + const summary = await startCodexSessionBackfillInBackground() + + expect(summary).toMatchObject({ failedFiles: 1, skippedUnsupportedFilesystemFiles: 0 }) + expect(existsSync(join(getSystemSessionsRoot(), relativePath))).toBe(false) + expect(existsSync(getMarkerPath())).toBe(false) + expect(readAuditActions()).toEqual(['failed', 'run-summary']) + }) + + it('keeps hardlink permission failures retryable', async () => { + fsMockState.failLinkPermission = true + const relativePath = join('2026', '05', '26', 'rollout-a.jsonl') + writeManagedSession(relativePath, '{"id":"a"}\n') + + const summary = await startCodexSessionBackfillInBackground() + + expect(summary).toMatchObject({ failedFiles: 1, skippedUnsupportedFilesystemFiles: 0 }) + expect(existsSync(join(getSystemSessionsRoot(), relativePath))).toBe(false) + expect(existsSync(getMarkerPath())).toBe(false) + }) + it('records per-file failures without aborting the run', async () => { - fsMockState.failLink = true - fsMockState.failCopy = true + fsMockState.failLinkTransiently = true writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') const summary = await backfillManagedCodexSessionsIntoSystemHome( @@ -446,6 +497,33 @@ describe('backfillManagedCodexSessionsIntoSystemHome', () => { expect(summary).toMatchObject({ scannedFiles: 0 }) expect(existsSync(getSystemSessionsRoot())).toBe(false) }) + + it('bounds a launch pass to its rollout date directories', async () => { + const oldRelativePath = join('2025', '12', '31', 'rollout-old.jsonl') + const launchRelativePath = join('2026', '08', '05', 'rollout-launch.jsonl') + writeManagedSession(oldRelativePath, 'old\n') + writeManagedSession(launchRelativePath, 'launch\n') + + const summary = await backfillManagedCodexSessionsIntoSystemHome( + resolveCodexSessionBackfillPaths(), + { scanDates: [['2026', '08', '05']] } + ) + + expect(summary).toMatchObject({ scannedFiles: 1, linkedFiles: 1, failedDirectories: 0 }) + expect(existsSync(join(getSystemSessionsRoot(), launchRelativePath))).toBe(true) + expect(existsSync(join(getSystemSessionsRoot(), oldRelativePath))).toBe(false) + }) + + it('treats a not-yet-created launch date as an empty bounded pass', async () => { + writeManagedSession(join('2025', '12', '31', 'rollout-old.jsonl'), 'old\n') + + const summary = await backfillManagedCodexSessionsIntoSystemHome( + resolveCodexSessionBackfillPaths(), + { scanDates: [['2026', '08', '05']] } + ) + + expect(summary).toMatchObject({ scannedFiles: 0, linkedFiles: 0, failedDirectories: 0 }) + }) }) describe('startCodexSessionBackfillInBackground', () => { @@ -479,6 +557,81 @@ describe('startCodexSessionBackfillInBackground', () => { expect(existsSync(getMarkerPath())).toBe(false) }) + it('defers completion while a launch lease is active', async () => { + writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') + + const active = await startCodexSessionBackfillInBackground({ + writeCompletionMarker: false + }) + expect(active).toMatchObject({ linkedFiles: 1 }) + expect(existsSync(getMarkerPath())).toBe(false) + + const completed = await startCodexSessionBackfillInBackground() + expect(completed).toMatchObject({ skippedExistingFiles: 1 }) + expect(existsSync(getMarkerPath())).toBe(true) + }) + + it('rechecks launch state before publishing completion', async () => { + writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') + + const summary = await startCodexSessionBackfillInBackground({ + canWriteCompletionMarker: () => false + }) + + expect(summary).toMatchObject({ linkedFiles: 1 }) + expect(existsSync(getMarkerPath())).toBe(false) + }) + + it('keeps an invalidated active pass from recreating the completion marker', async () => { + writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') + let invalidated = false + + const raced = await startCodexSessionBackfillInBackground({ + shouldStop: () => { + if (!invalidated) { + invalidated = true + invalidateCodexSessionBackfillMarker(getMarkerPath()) + } + return false + } + }) + + expect(raced).toMatchObject({ linkedFiles: 1, stopped: false }) + expect(existsSync(getMarkerPath())).toBe(false) + const racedAudit = readFileSync(getAuditLogPath(), 'utf-8') + + const recovered = await startCodexSessionBackfillInBackground() + + expect(recovered).toMatchObject({ skippedExistingFiles: 1, failedHealAuditRecords: 0 }) + expect(existsSync(getMarkerPath())).toBe(true) + expect(readFileSync(getAuditLogPath(), 'utf-8')).toBe(racedAudit) + }) + + it('replaces a stale marker when direct removal fails', async () => { + writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') + await startCodexSessionBackfillInBackground() + fsMockState.failMarkerRm = true + + invalidateCodexSessionBackfillMarker(getMarkerPath()) + + expect(JSON.parse(readFileSync(getMarkerPath(), 'utf-8'))).toMatchObject({ version: 0 }) + const recovered = await startCodexSessionBackfillInBackground() + expect(recovered).toMatchObject({ skippedExistingFiles: 1 }) + expect(JSON.parse(readFileSync(getMarkerPath(), 'utf-8'))).toMatchObject({ version: 3 }) + }) + + it('fails launch preparation when a stale marker cannot be invalidated', async () => { + writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') + await startCodexSessionBackfillInBackground() + fsMockState.failMarkerRm = true + fsMockState.failMarkerReplacement = true + + expect(() => invalidateCodexSessionBackfillMarker(getMarkerPath())).toThrow( + 'Failed to invalidate Codex session backfill marker' + ) + expect(JSON.parse(readFileSync(getMarkerPath(), 'utf-8'))).toMatchObject({ version: 3 }) + }) + it('writes a completion marker and skips the walk on later runs', async () => { writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') @@ -487,13 +640,160 @@ describe('startCodexSessionBackfillInBackground', () => { expect(existsSync(getMarkerPath())).toBe(true) expect(JSON.parse(readFileSync(getMarkerPath(), 'utf-8'))).toMatchObject({ version: 3 }) - // A file appearing after the marker must not be backfilled again. + // An ordinary call remains a no-op; only a launch-scheduled pass bypasses the marker. writeManagedSession(join('2026', '07', '01', 'rollout-later.jsonl'), '{"id":"later"}\n') const second = await startCodexSessionBackfillInBackground() expect(second).toBeNull() expect( existsSync(join(getSystemSessionsRoot(), '2026', '07', '01', 'rollout-later.jsonl')) ).toBe(false) + + const scheduled = await startCodexSessionBackfillInBackground({ + ignoreCompletionMarker: true, + scanDates: [['2026', '07', '01']] + }) + expect(scheduled).toMatchObject({ scannedFiles: 1, linkedFiles: 1 }) + }) + + it('does not let a bounded pass certify older unscanned history', async () => { + writeManagedSession(join('2026', '05', '26', 'rollout-baseline.jsonl'), 'baseline\n') + await startCodexSessionBackfillInBackground() + + invalidateCodexSessionBackfillMarker(getMarkerPath()) + const missedRelativePath = join('2026', '06', '01', 'rollout-missed.jsonl') + writeManagedSession(missedRelativePath, 'missed\n') + writeManagedSession(join('2026', '08', '05', 'rollout-launch.jsonl'), 'launch\n') + + const bounded = await startCodexSessionBackfillInBackground({ + ignoreCompletionMarker: true, + scanDates: [['2026', '08', '05']] + }) + expect(bounded).toMatchObject({ scannedFiles: 1, linkedFiles: 1 }) + expect(existsSync(getMarkerPath())).toBe(false) + + const recovered = await startCodexSessionBackfillInBackground() + expect(recovered).toMatchObject({ scannedFiles: 3, linkedFiles: 1 }) + expect(existsSync(join(getSystemSessionsRoot(), missedRelativePath))).toBe(true) + expect(existsSync(getMarkerPath())).toBe(true) + }) + + it('lets an explicit bounded final pass restore a certified baseline', async () => { + writeManagedSession(join('2026', '05', '26', 'rollout-baseline.jsonl'), 'baseline\n') + await startCodexSessionBackfillInBackground() + + invalidateCodexSessionBackfillMarker(getMarkerPath()) + writeManagedSession(join('2026', '08', '05', 'rollout-launch.jsonl'), 'launch\n') + + const bounded = await startCodexSessionBackfillInBackground({ + ignoreCompletionMarker: true, + scanDates: [['2026', '08', '05']], + writeBoundedCompletionMarker: true + }) + + expect(bounded).toMatchObject({ scannedFiles: 1, linkedFiles: 1 }) + expect(existsSync(getMarkerPath())).toBe(true) + }) + + it('records a new heal event when a linked rollout grows in place', async () => { + const relativePath = join('2026', '05', '26', 'rollout-growing.jsonl') + const managedPath = writeManagedSession(relativePath, '{"id":"a"}\n') + await startCodexSessionBackfillInBackground() + + const firstRecord = readBackfillAuditRecords().find((record) => record.action === 'hardlink') + invalidateCodexSessionBackfillMarker(getMarkerPath()) + appendFileSync(managedPath, '{"event":"later"}\n', 'utf-8') + await startCodexSessionBackfillInBackground() + + const fileRecords = readBackfillAuditRecords().filter((record) => + ['hardlink', 'existing'].includes(record.action) + ) + expect(fileRecords).toHaveLength(2) + expect(fileRecords[1]).toMatchObject({ action: 'existing' }) + expect(fileRecords[1]?.fileEventId).not.toBe(firstRecord?.fileEventId) + }) + + it('keeps repeated launch invalidations audit-stable', async () => { + writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') + + const first = await startCodexSessionBackfillInBackground() + expect(first).toMatchObject({ linkedFiles: 1, failedHealAuditRecords: 0 }) + const firstAudit = readFileSync(getAuditLogPath(), 'utf-8') + + for (let pass = 0; pass < 2; pass += 1) { + invalidateCodexSessionBackfillMarker(getMarkerPath()) + const repeated = await startCodexSessionBackfillInBackground() + expect(repeated).toMatchObject({ + linkedFiles: 0, + copiedFiles: 0, + skippedExistingFiles: 1, + failedHealAuditRecords: 0 + }) + expect(readFileSync(getAuditLogPath(), 'utf-8')).toBe(firstAudit) + } + + const fileRecords = readBackfillAuditRecords().filter((record) => + ['hardlink', 'copy', 'existing'].includes(record.action) + ) + expect(fileRecords).toEqual([ + expect.objectContaining({ action: 'hardlink', fileEventId: expect.any(String) }) + ]) + }) + + it('recovers a post-install audit interruption without duplicating prior events', async () => { + const firstRelativePath = join('2026', '05', '26', 'rollout-a.jsonl') + const secondRelativePath = join('2026', '05', '26', 'rollout-b.jsonl') + writeManagedSession(firstRelativePath, '{"id":"a"}\n') + await startCodexSessionBackfillInBackground() + + invalidateCodexSessionBackfillMarker(getMarkerPath()) + writeManagedSession(secondRelativePath, '{"id":"b"}\n') + fsMockState.failAuditWrites = true + + const interrupted = await startCodexSessionBackfillInBackground() + + expect(interrupted).toMatchObject({ linkedFiles: 1, failedHealAuditRecords: 1 }) + expect(existsSync(getMarkerPath())).toBe(false) + expect( + readBackfillAuditRecords().filter((record) => + ['hardlink', 'copy', 'existing'].includes(record.action) + ) + ).toHaveLength(1) + + fsMockState.failAuditWrites = false + const recovered = await startCodexSessionBackfillInBackground() + + expect(recovered).toMatchObject({ + linkedFiles: 0, + skippedExistingFiles: 2, + failedHealAuditRecords: 0 + }) + expect(existsSync(getMarkerPath())).toBe(true) + const recoveredFileRecords = readBackfillAuditRecords().filter((record) => + ['hardlink', 'copy', 'existing'].includes(record.action) + ) + expect(recoveredFileRecords).toHaveLength(2) + expect(new Set(recoveredFileRecords.map((record) => record.target))).toEqual( + new Set([ + join(getSystemSessionsRoot(), firstRelativePath), + join(getSystemSessionsRoot(), secondRelativePath) + ]) + ) + }) + + it('self-heals a zero-file marker when managed rollouts appear later', async () => { + const empty = await startCodexSessionBackfillInBackground() + expect(empty).toMatchObject({ scannedFiles: 0, linkedFiles: 0 }) + expect(JSON.parse(readFileSync(getMarkerPath(), 'utf-8'))).toMatchObject({ + summary: { scannedFiles: 0 } + }) + + writeManagedSession(join('2026', '07', '28', 'rollout-later.jsonl'), '{"id":"later"}\n') + const healed = await startCodexSessionBackfillInBackground() + + expect(healed).toMatchObject({ scannedFiles: 1, linkedFiles: 1 }) + expect( + existsSync(join(getSystemSessionsRoot(), '2026', '07', '28', 'rollout-later.jsonl')) + ).toBe(true) }) it('recovers an installed rollout after the completion marker write fails', async () => { @@ -534,19 +834,52 @@ describe('startCodexSessionBackfillInBackground', () => { it('does not retry a stable hardlink-less filesystem limitation', async () => { fsMockState.failLink = true - fsMockState.failInstallLink = true writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') const first = await startCodexSessionBackfillInBackground() expect(first).toMatchObject({ skippedUnsupportedFilesystemFiles: 1, failedFiles: 0 }) expect(existsSync(getMarkerPath())).toBe(true) + const firstAudit = readFileSync(getAuditLogPath(), 'utf-8') + invalidateCodexSessionBackfillMarker(getMarkerPath()) + const repeated = await startCodexSessionBackfillInBackground() + + expect(repeated).toMatchObject({ skippedUnsupportedFilesystemFiles: 1, failedFiles: 0 }) + expect(readFileSync(getAuditLogPath(), 'utf-8')).toBe(firstAudit) + expect(await startCodexSessionBackfillInBackground()).toBeNull() }) + it('keeps repeated unchanged per-file failures audit-stable', async () => { + fsMockState.failLinkTransiently = true + writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') + + const first = await startCodexSessionBackfillInBackground() + expect(first).toMatchObject({ failedFiles: 1 }) + const firstAudit = readFileSync(getAuditLogPath(), 'utf-8') + + const repeated = await startCodexSessionBackfillInBackground() + + expect(repeated).toMatchObject({ failedFiles: 1 }) + expect(readFileSync(getAuditLogPath(), 'utf-8')).toBe(firstAudit) + }) + + it('records a new failure event when the source file changes', async () => { + fsMockState.failLinkTransiently = true + const relativePath = join('2026', '05', '26', 'rollout-a.jsonl') + writeManagedSession(relativePath, '{"id":"a"}\n') + await startCodexSessionBackfillInBackground() + + writeManagedSession(relativePath, '{"id":"a","changed":true}\n') + await startCodexSessionBackfillInBackground() + + const failedRecords = readBackfillAuditRecords().filter((record) => record.action === 'failed') + expect(failedRecords).toHaveLength(2) + expect(new Set(failedRecords.map((record) => record.diagnosticEventId)).size).toBe(2) + }) + it('leaves the marker unset when any file fails so the next startup retries', async () => { - fsMockState.failLink = true - fsMockState.failCopy = true + fsMockState.failLinkTransiently = true writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') const first = await startCodexSessionBackfillInBackground() @@ -555,8 +888,7 @@ describe('startCodexSessionBackfillInBackground', () => { const targetPath = join(getSystemSessionsRoot(), '2026', '05', '26', 'rollout-a.jsonl') expect(existsSync(targetPath)).toBe(false) - fsMockState.failLink = false - fsMockState.failCopy = false + fsMockState.failLinkTransiently = false const second = await startCodexSessionBackfillInBackground() expect(second).toMatchObject({ linkedFiles: 1, failedFiles: 0 }) expect(readFileSync(targetPath, 'utf-8')).toBe('{"id":"a"}\n') diff --git a/src/main/codex/codex-session-backfill.ts b/src/main/codex/codex-session-backfill.ts index e0294a9a72a..720469cf2b0 100644 --- a/src/main/codex/codex-session-backfill.ts +++ b/src/main/codex/codex-session-backfill.ts @@ -1,24 +1,24 @@ import { link, lstat, mkdir } from 'node:fs/promises' -import { dirname, join, relative, sep } from 'node:path' +import { dirname, join, relative } from 'node:path' import { getCodexSessionBackfillStateDirPath, getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths' import { - appendCodexSessionHealAuditRecord, - createCodexSessionBackfillAuditWriter, - recordExistingCodexSessionForHeal, - type CodexSessionBackfillAuditWriter -} from './codex-session-backfill-audit' + createCodexSessionBackfillAuditPass, + readCodexSessionTargetStat, + type CodexSessionBackfillAuditPass +} from './codex-session-backfill-audit-pass' +import { describeCodexSessionBackfillErrorCode } from './codex-session-backfill-audit' import { - copySessionFileWithoutOverwrite, - isAtomicNoReplaceUnsupportedError -} from './codex-session-backfill-copy' -import { listCodexSessionJsonlFilesIncrementally } from './codex-session-file-listing' + isCodexSessionRolloutPath, + listCodexSessionBackfillFilesForDates +} from './codex-session-backfill-date' import { + captureCodexSessionBackfillMarkerGeneration, hasCompletedCodexSessionBackfillMarker, - writeCodexSessionBackfillMarker + writeCodexSessionBackfillMarker as writeBackfillMarker } from './codex-session-backfill-marker' import type { CodexSessionBackfillOptions, @@ -87,7 +87,11 @@ async function runCodexSessionBackfillOncePerHost( systemCodexHomePathOverride?: string ): Promise { const paths = resolveCodexSessionBackfillPaths(systemCodexHomePathOverride) - if (hasCompletedCodexSessionBackfillMarker(paths.markerPath, paths.systemSessionsRoot)) { + const markerGeneration = captureCodexSessionBackfillMarkerGeneration() + if ( + !options.ignoreCompletionMarker && + hasCompletedCodexSessionBackfillMarker(paths.markerPath, paths.systemSessionsRoot) + ) { return null } const summary = await backfillManagedCodexSessionsIntoSystemHome(paths, options) @@ -96,11 +100,14 @@ async function runCodexSessionBackfillOncePerHost( if ( !summary.stopped && options.shouldStop?.() !== true && + options.writeCompletionMarker !== false && + options.canWriteCompletionMarker?.() !== false && + (options.scanDates === undefined || options.writeBoundedCompletionMarker === true) && summary.failedFiles === 0 && summary.failedDirectories === 0 && summary.failedHealAuditRecords === 0 ) { - writeCodexSessionBackfillMarker(paths.markerPath, paths.systemSessionsRoot, summary) + writeBackfillMarker(paths.markerPath, paths.systemSessionsRoot, summary, markerGeneration) } return summary } @@ -109,8 +116,8 @@ async function runCodexSessionBackfillOncePerHost( * Backfills managed-home session rollout files into the real Codex home. * * Non-destructive by contract: existing target files are always skipped, and - * nothing in either home is deleted or moved. Hardlink first so resume sees - * one physical JSONL log; copy is the cross-volume fallback. + * nothing in either home is deleted or moved. A hardlink keeps mutable rollout + * contents coherent; cross-volume snapshots are skipped as unsupported. */ export async function backfillManagedCodexSessionsIntoSystemHome( paths: CodexSessionBackfillPaths, @@ -129,22 +136,18 @@ export async function backfillManagedCodexSessionsIntoSystemHome( failedFiles: 0, failedHealAuditRecords: 0 } - const appendAuditRecord = createCodexSessionBackfillAuditWriter(paths.auditLogPath) + const auditPass = await createCodexSessionBackfillAuditPass(paths.auditLogPath) const ensuredTargetDirectories = new Set() - const managedSessionsRootExists = await checkManagedSessionsRoot( - paths, - summary, - appendAuditRecord - ) + const managedSessionsRootExists = await checkManagedSessionsRoot(paths, summary, auditPass) if (managedSessionsRootExists) { - for await (const managedSessionFilePath of listCodexSessionJsonlFilesIncrementally( + for await (const managedSessionFilePath of listCodexSessionBackfillFilesForDates( paths.managedSessionsRoot, options, async (directoryPath, error) => { // Why: a partial walk must remain retryable; otherwise an unreadable // date directory would be silently omitted behind a completion marker. summary.failedDirectories += 1 - await appendAuditRecord({ + await auditPass.appendRecord({ action: 'scan-failed', source: directoryPath, error: describeError(error) @@ -158,7 +161,7 @@ export async function backfillManagedCodexSessionsIntoSystemHome( break } summary.scannedFiles += 1 - if (!isCodexRolloutPath(paths.managedSessionsRoot, managedSessionFilePath)) { + if (!isCodexSessionRolloutPath(paths.managedSessionsRoot, managedSessionFilePath)) { summary.skippedUnexpectedFiles += 1 continue } @@ -168,13 +171,13 @@ export async function backfillManagedCodexSessionsIntoSystemHome( paths, managedSessionFilePath, summary, - appendAuditRecord, - ensuredTargetDirectories + ensuredTargetDirectories, + auditPass ) } } summary.stopped ||= options.shouldStop?.() === true - await appendAuditRecord({ action: 'run-summary', ...summary }) + await auditPass.finish(summary) // Why: opt-out can land while the async summary append is pending; carry it // back to the marker gate so a managed launch cannot be hidden by stale completion. summary.stopped ||= options.shouldStop?.() === true @@ -184,7 +187,7 @@ export async function backfillManagedCodexSessionsIntoSystemHome( async function checkManagedSessionsRoot( paths: CodexSessionBackfillPaths, summary: CodexSessionBackfillSummary, - appendAuditRecord: CodexSessionBackfillAuditWriter + auditPass: CodexSessionBackfillAuditPass ): Promise { try { await lstat(paths.managedSessionsRoot) @@ -196,7 +199,7 @@ async function checkManagedSessionsRoot( // Why: existsSync collapses access failures into "missing," which could // permanently hide sessions behind an incorrect completion marker. summary.failedDirectories += 1 - await appendAuditRecord({ + await auditPass.appendRecord({ action: 'scan-failed', source: paths.managedSessionsRoot, error: describeError(error) @@ -205,26 +208,12 @@ async function checkManagedSessionsRoot( } } -function isCodexRolloutPath(sessionsRoot: string, filePath: string): boolean { - const pathParts = relative(sessionsRoot, filePath).split(sep) - if (pathParts.length !== 4) { - return false - } - const [year, month, day, fileName] = pathParts - return ( - /^\d{4}$/.test(year) && - /^\d{2}$/.test(month) && - /^\d{2}$/.test(day) && - /^rollout-.+\.jsonl$/.test(fileName) - ) -} - async function backfillOneManagedSessionFile( paths: CodexSessionBackfillPaths, managedSessionFilePath: string, summary: CodexSessionBackfillSummary, - appendAuditRecord: CodexSessionBackfillAuditWriter, - ensuredTargetDirectories: Set + ensuredTargetDirectories: Set, + auditPass: CodexSessionBackfillAuditPass ): Promise { if (await isSymbolicLink(managedSessionFilePath)) { // Why: bridge-created symlinks already point at a file in the user's own @@ -234,16 +223,18 @@ async function backfillOneManagedSessionFile( } const relativePath = relative(paths.managedSessionsRoot, managedSessionFilePath) const systemSessionFilePath = join(paths.systemSessionsRoot, relativePath) - if (await pathEntryExists(systemSessionFilePath)) { - await recordExistingCodexSessionForHeal( - appendAuditRecord, + const existingTargetStat = await readCodexSessionTargetStat(systemSessionFilePath) + if (existingTargetStat) { + await auditPass.recordExisting( summary, managedSessionFilePath, - systemSessionFilePath + systemSessionFilePath, + existingTargetStat ) return } + let linkAttempted = false try { const targetDirectory = dirname(systemSessionFilePath) if (!ensuredTargetDirectories.has(targetDirectory)) { @@ -252,66 +243,56 @@ async function backfillOneManagedSessionFile( await mkdir(targetDirectory, { recursive: true }) ensuredTargetDirectories.add(targetDirectory) } + linkAttempted = true await link(managedSessionFilePath, systemSessionFilePath) summary.linkedFiles += 1 - await appendCodexSessionHealAuditRecord(appendAuditRecord, summary, { - action: 'hardlink', - source: managedSessionFilePath, - target: systemSessionFilePath - }) + await auditPass.recordPublished( + summary, + 'hardlink', + managedSessionFilePath, + systemSessionFilePath + ) } catch (linkError) { - if (isExistsError(linkError)) { + if (linkAttempted && isExistsError(linkError)) { // Why: another window can publish the target after our existence probe; // enqueue it here too in case that writer died before its audit append. - await recordExistingCodexSessionForHeal( - appendAuditRecord, + await auditPass.recordExisting( summary, managedSessionFilePath, - systemSessionFilePath + systemSessionFilePath, + await readCodexSessionTargetStat(systemSessionFilePath) ) return } if (isNotFoundError(linkError)) { ensuredTargetDirectories.delete(dirname(systemSessionFilePath)) } - try { - // Why: cross-volume copies are staged so failures cannot strand a - // truncated rollout, then installed without overwriting collisions. - await copySessionFileWithoutOverwrite(managedSessionFilePath, systemSessionFilePath) - summary.copiedFiles += 1 - await appendCodexSessionHealAuditRecord(appendAuditRecord, summary, { - action: 'copy', - source: managedSessionFilePath, - target: systemSessionFilePath - }) - } catch (copyError) { - if (isExistsError(copyError)) { - await recordExistingCodexSessionForHeal( - appendAuditRecord, - summary, - managedSessionFilePath, - systemSessionFilePath - ) - return - } - if (isAtomicNoReplaceUnsupportedError(copyError)) { - summary.skippedUnsupportedFilesystemFiles += 1 - await appendAuditRecord({ + const sourceStat = await readCodexSessionTargetStat(managedSessionFilePath) + if (linkAttempted && isUnsupportedHardlinkError(linkError)) { + // Why: a mutable rollout cannot be kept coherent by a cross-volume snapshot. + summary.skippedUnsupportedFilesystemFiles += 1 + await auditPass.recordDiagnostic( + { action: 'copy-unsupported', source: managedSessionFilePath, - target: systemSessionFilePath - }) - return - } - summary.failedFiles += 1 - await appendAuditRecord({ + target: systemSessionFilePath, + linkErrorCode: describeCodexSessionBackfillErrorCode(linkError) + }, + sourceStat + ) + return + } + summary.failedFiles += 1 + await auditPass.recordDiagnostic( + { action: 'failed', source: managedSessionFilePath, target: systemSessionFilePath, - error: describeError(copyError), - linkError: describeError(linkError) - }) - } + linkError: describeError(linkError), + linkErrorCode: describeCodexSessionBackfillErrorCode(linkError) + }, + sourceStat + ) } } @@ -323,16 +304,6 @@ async function isSymbolicLink(filePath: string): Promise { } } -/** Existence via lstat so a broken symlink at the target still counts as taken. */ -async function pathEntryExists(entryPath: string): Promise { - try { - await lstat(entryPath) - return true - } catch { - return false - } -} - function isExistsError(error: unknown): boolean { return (error as NodeJS.ErrnoException | null)?.code === 'EEXIST' } @@ -341,6 +312,11 @@ function isNotFoundError(error: unknown): boolean { return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT' } +function isUnsupportedHardlinkError(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException | null)?.code + return code === 'EXDEV' || code === 'ENOTSUP' || code === 'EOPNOTSUPP' || code === 'ENOSYS' +} + function describeError(error: unknown): string { return error instanceof Error ? error.message : String(error) } diff --git a/src/main/codex/codex-session-index-heal-state.ts b/src/main/codex/codex-session-index-heal-state.ts index 4efc124316e..ec7b0caf9e0 100644 --- a/src/main/codex/codex-session-index-heal-state.ts +++ b/src/main/codex/codex-session-index-heal-state.ts @@ -72,10 +72,11 @@ export function collectPendingHealThreads(paths: CodexSessionIndexHealPaths): Pe const threadId = match[2].toLowerCase() const auditRecordId = typeof line.recordId === 'string' ? line.recordId : null if ( - processed.healedThreadIds.has(threadId) || - (auditRecordId - ? processed.missingAuditRecords.has(`${threadId}\0${auditRecordId}`) - : processed.legacyMissingThreadIds.has(threadId)) + auditRecordId + ? processed.healedAuditRecords.has(`${threadId}\0${auditRecordId}`) || + processed.missingAuditRecords.has(`${threadId}\0${auditRecordId}`) + : processed.legacyHealedThreadIds.has(threadId) || + processed.legacyMissingThreadIds.has(threadId) ) { // Why: only the newest publication event for a thread matters. A later // processed event must displace an older pending event from this scan. @@ -94,11 +95,13 @@ function lastPathSegment(filePath: string): string { } function readProcessedHealThreads(paths: CodexSessionIndexHealPaths): { - healedThreadIds: Set + healedAuditRecords: Set + legacyHealedThreadIds: Set missingAuditRecords: Set legacyMissingThreadIds: Set } { - const healedThreadIds = new Set() + const healedAuditRecords = new Set() + const legacyHealedThreadIds = new Set() const missingAuditRecords = new Set() const legacyMissingThreadIds = new Set() const expectedRoot = normalizeRuntimePathForComparison(paths.systemSessionsRoot) @@ -112,7 +115,11 @@ function readProcessedHealThreads(paths: CodexSessionIndexHealPaths): { ) { const threadId = line.threadId.toLowerCase() if (line.outcome === 'healed') { - healedThreadIds.add(threadId) + if (typeof line.auditRecordId === 'string') { + healedAuditRecords.add(`${threadId}\0${line.auditRecordId}`) + } else { + legacyHealedThreadIds.add(threadId) + } } else if (typeof line.auditRecordId === 'string') { missingAuditRecords.add(`${threadId}\0${line.auditRecordId}`) } else { @@ -120,7 +127,12 @@ function readProcessedHealThreads(paths: CodexSessionIndexHealPaths): { } } } - return { healedThreadIds, missingAuditRecords, legacyMissingThreadIds } + return { + healedAuditRecords, + legacyHealedThreadIds, + missingAuditRecords, + legacyMissingThreadIds + } } export function appendHealLedgerRecord( diff --git a/src/main/codex/codex-session-index-heal.test.ts b/src/main/codex/codex-session-index-heal.test.ts index 861c7f72b4e..a12dd7dc5bd 100644 --- a/src/main/codex/codex-session-index-heal.test.ts +++ b/src/main/codex/codex-session-index-heal.test.ts @@ -232,7 +232,7 @@ describe('runCodexSessionIndexHeal', () => { expect(marker.healedThreads).toBe(3) }) - it('is a no-op when the marker matches the audit ledger size', async () => { + it('keeps second and later passes cheap when the audit ledger is unchanged', async () => { const rig = createHealRig({ auditedThreads: [{ stamp: '2026-07-01T10-00-00', id: threadId('1') }] }) @@ -245,11 +245,38 @@ describe('runCodexSessionIndexHeal', () => { buildInvocation: rig.buildInvocation, interBatchDelayMs: 0 }) + const third = await runCodexSessionIndexHeal(rig.paths, { + buildInvocation: rig.buildInvocation, + interBatchDelayMs: 0 + }) expect(second.outcome).toBe('up-to-date') - // One spawn from the first run only — the no-op run must not hit the CLI. + expect(third.outcome).toBe('up-to-date') + // One spawn from the first run only — no-op runs must not hit the CLI. expect(rig.readLog().serverStarts).toBe(1) }) + it('re-reads a healed thread after a later publication event', async () => { + const id = threadId('1') + const stamp = '2026-07-01T10-00-00' + const rig = createHealRig({ auditedThreads: [{ stamp, id }] }) + await runCodexSessionIndexHeal(rig.paths, { + buildInvocation: rig.buildInvocation, + interBatchDelayMs: 0 + }) + + await createCodexSessionBackfillAuditWriter(rig.paths.auditLogPath)({ + action: 'existing', + target: rolloutTarget(rig.paths.systemSessionsRoot, stamp, id) + }) + const repeated = await runCodexSessionIndexHeal(rig.paths, { + buildInvocation: rig.buildInvocation, + interBatchDelayMs: 0 + }) + + expect(repeated).toMatchObject({ pendingThreads: 1, healedThreads: 1 }) + expect(rig.readLog().threadIds).toEqual([id, id]) + }) + it('resumes only unprocessed sessions when the audit ledger grows', async () => { const rig = createHealRig({ auditedThreads: [{ stamp: '2026-07-01T10-00-00', id: threadId('1') }] diff --git a/src/main/codex/codex-session-migration-scheduler.test.ts b/src/main/codex/codex-session-migration-scheduler.test.ts index 54094e022f1..9f9aed3173c 100644 --- a/src/main/codex/codex-session-migration-scheduler.test.ts +++ b/src/main/codex/codex-session-migration-scheduler.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { CodexSessionBackfillOptions } from './codex-session-backfill-types' import { createCodexSessionMigrationScheduler } from './codex-session-migration-scheduler' describe('createCodexSessionMigrationScheduler', () => { @@ -8,12 +9,14 @@ describe('createCodexSessionMigrationScheduler', () => { it('runs after a managed-account startup switches to host system default', async () => { let eligible = false - const startBackfill = vi.fn().mockResolvedValue(null) + const prepareScheduledRun = vi.fn() + const startBackfill = vi.fn().mockResolvedValue({ stopped: false }) const startIndexHeal = vi.fn().mockResolvedValue(null) const scheduler = createCodexSessionMigrationScheduler({ isEligible: () => eligible, isQuitting: () => false, resolveSystemCodexHomePathOverride: () => undefined, + prepareScheduledRun, startBackfill, startIndexHeal }) @@ -26,6 +29,222 @@ describe('createCodexSessionMigrationScheduler', () => { scheduler.requestRun() await vi.waitFor(() => expect(startIndexHeal).toHaveBeenCalledOnce()) expect(startBackfill).toHaveBeenCalledOnce() + expect(prepareScheduledRun).not.toHaveBeenCalled() + }) + + it('schedules a delayed rerun after a shared-home launch', async () => { + vi.setSystemTime(new Date(2026, 7, 5, 10, 0, 0)) + const prepareScheduledRun = vi.fn() + const finishScheduledRun = vi.fn() + const startBackfill = vi.fn().mockResolvedValue({ stopped: false }) + const startIndexHeal = vi.fn().mockResolvedValue(null) + const scheduler = createCodexSessionMigrationScheduler({ + isEligible: () => true, + isQuitting: () => false, + resolveSystemCodexHomePathOverride: () => undefined, + prepareScheduledRun, + finishScheduledRun, + startBackfill, + startIndexHeal, + initialDelayMs: 1_000 + }) + + scheduler.scheduleRun() + scheduler.scheduleRun() + await vi.advanceTimersByTimeAsync(999) + expect(startBackfill).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(1) + await vi.waitFor(() => expect(startIndexHeal).toHaveBeenCalledOnce()) + expect(startBackfill).toHaveBeenCalledOnce() + expect(startBackfill).toHaveBeenCalledWith( + expect.objectContaining({ + scanDates: [['2026', '08', '05']], + ignoreCompletionMarker: true + }), + undefined + ) + expect(prepareScheduledRun).toHaveBeenCalledOnce() + await vi.waitFor(() => expect(finishScheduledRun).toHaveBeenCalledOnce()) + }) + + it('covers both launch and run dates when a delayed pass crosses midnight', async () => { + vi.setSystemTime(new Date('2026-08-05T23:59:59.500Z')) + const startBackfill = vi.fn().mockResolvedValue({ stopped: false }) + const scheduler = createCodexSessionMigrationScheduler({ + isEligible: () => true, + isQuitting: () => false, + resolveSystemCodexHomePathOverride: () => undefined, + startBackfill, + startIndexHeal: vi.fn().mockResolvedValue(null), + initialDelayMs: 1_000 + }) + + scheduler.scheduleRun() + await vi.advanceTimersByTimeAsync(1_000) + + expect(startBackfill).toHaveBeenCalledWith( + expect.objectContaining({ + scanDates: [ + ['2026', '08', '05'], + ['2026', '08', '06'] + ] + }), + undefined + ) + }) + + it('keeps launch passes full when no completed baseline can cover older failures', async () => { + const startBackfill = vi.fn().mockResolvedValue({ stopped: false }) + const scheduler = createCodexSessionMigrationScheduler({ + isEligible: () => true, + isQuitting: () => false, + resolveSystemCodexHomePathOverride: () => undefined, + startBackfill, + startIndexHeal: vi.fn().mockResolvedValue(null), + initialDelayMs: 1_000 + }) + + scheduler.scheduleRun(true) + await vi.advanceTimersByTimeAsync(1_000) + + expect(startBackfill).toHaveBeenCalledWith( + expect.objectContaining({ scanDates: undefined }), + undefined + ) + }) + + it('upgrades a bounded pass when its target changed before the timer fired', async () => { + const startBackfill = vi.fn().mockResolvedValue({ stopped: false }) + const scheduler = createCodexSessionMigrationScheduler({ + isEligible: () => true, + isQuitting: () => false, + resolveSystemCodexHomePathOverride: () => '/moved-history', + prepareScheduledRun: () => true, + startBackfill, + startIndexHeal: vi.fn().mockResolvedValue(null), + initialDelayMs: 1_000 + }) + + scheduler.scheduleRun() + await vi.advanceTimersByTimeAsync(1_000) + + expect(startBackfill).toHaveBeenCalledWith( + expect.objectContaining({ scanDates: undefined }), + '/moved-history' + ) + }) + + it('delays the startup run from the latest shared-home launch', async () => { + const prepareScheduledRun = vi.fn() + const startBackfill = vi.fn().mockResolvedValue({ stopped: false }) + const startIndexHeal = vi.fn().mockResolvedValue(null) + const scheduler = createCodexSessionMigrationScheduler({ + isEligible: () => true, + isQuitting: () => false, + resolveSystemCodexHomePathOverride: () => undefined, + prepareScheduledRun, + startBackfill, + startIndexHeal, + initialDelayMs: 1_000 + }) + + scheduler.scheduleInitialRun() + await vi.advanceTimersByTimeAsync(999) + scheduler.scheduleRun() + + await vi.advanceTimersByTimeAsync(1) + expect(startBackfill).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(999) + await vi.waitFor(() => expect(startIndexHeal).toHaveBeenCalledOnce()) + expect(startBackfill).toHaveBeenCalledOnce() + expect(prepareScheduledRun).toHaveBeenCalledOnce() + }) + + it('preserves a delayed launch rerun while an earlier migration is active', async () => { + let releaseFirstIndexHeal: (() => void) | undefined + const prepareScheduledRun = vi.fn() + const startBackfill = vi.fn().mockResolvedValue({ stopped: false }) + const startIndexHeal = vi + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + releaseFirstIndexHeal = resolve + }) + ) + .mockResolvedValueOnce(null) + const scheduler = createCodexSessionMigrationScheduler({ + isEligible: () => true, + isQuitting: () => false, + resolveSystemCodexHomePathOverride: () => undefined, + prepareScheduledRun, + startBackfill, + startIndexHeal, + initialDelayMs: 1_000 + }) + + scheduler.requestRun() + await vi.waitFor(() => expect(startIndexHeal).toHaveBeenCalledOnce()) + + scheduler.scheduleRun() + await vi.advanceTimersByTimeAsync(1_000) + expect(startBackfill).toHaveBeenCalledOnce() + expect(prepareScheduledRun).not.toHaveBeenCalled() + + releaseFirstIndexHeal?.() + await vi.waitFor(() => expect(startBackfill).toHaveBeenCalledTimes(2)) + await vi.waitFor(() => expect(startIndexHeal).toHaveBeenCalledTimes(2)) + expect(prepareScheduledRun).toHaveBeenCalledOnce() + expect(prepareScheduledRun.mock.invocationCallOrder[0]).toBeLessThan( + startBackfill.mock.invocationCallOrder[1]! + ) + }) + + it('prepares a delayed launch pass after an earlier migration settles before the timer', async () => { + let releaseFirstBackfill: (() => void) | undefined + let markerPresent = false + const prepareScheduledRun = vi.fn(() => { + markerPresent = false + }) + const startBackfill = vi + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + releaseFirstBackfill = () => { + markerPresent = true + resolve() + } + }) + ) + .mockImplementationOnce(async () => { + expect(markerPresent).toBe(false) + }) + const startIndexHeal = vi.fn().mockResolvedValue(null) + const scheduler = createCodexSessionMigrationScheduler({ + isEligible: () => true, + isQuitting: () => false, + resolveSystemCodexHomePathOverride: () => undefined, + prepareScheduledRun, + startBackfill, + startIndexHeal, + initialDelayMs: 1_000 + }) + + scheduler.requestRun() + scheduler.scheduleRun() + releaseFirstBackfill?.() + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + expect(markerPresent).toBe(true) + expect(startIndexHeal).toHaveBeenCalledOnce() + + await vi.advanceTimersByTimeAsync(1_000) + await vi.waitFor(() => expect(startBackfill).toHaveBeenCalledTimes(2)) + expect(prepareScheduledRun).toHaveBeenCalledOnce() }) it('coalesces concurrent run requests and stops before index heal after opt-out', async () => { @@ -90,4 +309,233 @@ describe('createCodexSessionMigrationScheduler', () => { await vi.waitFor(() => expect(startBackfill).toHaveBeenCalledTimes(2)) await vi.waitFor(() => expect(startIndexHeal).toHaveBeenCalledOnce()) }) + + it('preserves scheduled identity and target recovery after a stopped pass', async () => { + let eligible = true + let target = '/old-history' + let releaseFirstBackfill: ((result: { stopped: boolean }) => void) | undefined + const prepareScheduledRun = vi.fn().mockReturnValueOnce(false).mockReturnValueOnce(true) + const finishScheduledRun = vi.fn() + const startBackfill = vi + .fn() + .mockImplementationOnce( + () => + new Promise<{ stopped: boolean }>((resolve) => { + releaseFirstBackfill = resolve + }) + ) + .mockResolvedValueOnce({ stopped: false }) + const scheduler = createCodexSessionMigrationScheduler({ + isEligible: () => eligible, + isQuitting: () => false, + resolveSystemCodexHomePathOverride: () => target, + prepareScheduledRun, + finishScheduledRun, + startBackfill, + startIndexHeal: vi.fn().mockResolvedValue(null), + initialDelayMs: 1_000 + }) + + scheduler.scheduleRun() + await vi.advanceTimersByTimeAsync(1_000) + const firstOptions = startBackfill.mock.calls[0]?.[0] + eligible = false + expect(firstOptions?.shouldStop()).toBe(true) + target = '/new-history' + releaseFirstBackfill?.({ stopped: true }) + await vi.waitFor(() => expect(prepareScheduledRun).toHaveBeenCalledOnce()) + expect(finishScheduledRun).not.toHaveBeenCalled() + + eligible = true + scheduler.requestRun() + await vi.waitFor(() => expect(startBackfill).toHaveBeenCalledTimes(2)) + expect(startBackfill).toHaveBeenLastCalledWith( + expect.objectContaining({ ignoreCompletionMarker: true, scanDates: undefined }), + '/new-history' + ) + await vi.waitFor(() => expect(finishScheduledRun).toHaveBeenCalledOnce()) + }) + + it('finishes a launch generation only after its PTY exits and every date is rescanned', async () => { + vi.setSystemTime(new Date('2026-08-05T23:59:59Z')) + const finishScheduledRun = vi.fn() + const startBackfill = vi.fn().mockResolvedValue({ stopped: false }) + const scheduler = createCodexSessionMigrationScheduler({ + isEligible: () => true, + isQuitting: () => false, + resolveSystemCodexHomePathOverride: () => undefined, + prepareScheduledRun: vi.fn(), + finishScheduledRun, + startBackfill, + startIndexHeal: vi.fn().mockResolvedValue(null), + initialDelayMs: 1_000 + }) + + scheduler.beginLaunch('pty-1') + await vi.advanceTimersByTimeAsync(1_000) + await vi.waitFor(() => expect(startBackfill).toHaveBeenCalledOnce()) + expect(startBackfill).toHaveBeenLastCalledWith( + expect.objectContaining({ writeCompletionMarker: false }), + undefined + ) + expect(finishScheduledRun).not.toHaveBeenCalled() + + vi.setSystemTime(new Date('2026-08-07T01:00:00Z')) + scheduler.finishLaunch('pty-1') + await vi.advanceTimersByTimeAsync(1_000) + await vi.waitFor(() => expect(startBackfill).toHaveBeenCalledTimes(2)) + expect(startBackfill).toHaveBeenLastCalledWith( + expect.objectContaining({ + scanDates: [ + ['2026', '08', '05'], + ['2026', '08', '06'], + ['2026', '08', '07'] + ], + ignoreCompletionMarker: true, + writeCompletionMarker: true, + writeBoundedCompletionMarker: true + }), + undefined + ) + await vi.waitFor(() => expect(finishScheduledRun).toHaveBeenCalledOnce()) + }) + + it('keeps a failed full scan required for the final launch pass', async () => { + const finishScheduledRun = vi.fn() + const startBackfill = vi + .fn() + .mockResolvedValueOnce({ stopped: false, failedFiles: 1 }) + .mockResolvedValueOnce({ stopped: false, failedFiles: 0 }) + const scheduler = createCodexSessionMigrationScheduler({ + isEligible: () => true, + isQuitting: () => false, + resolveSystemCodexHomePathOverride: () => undefined, + prepareScheduledRun: () => false, + finishScheduledRun, + startBackfill, + startIndexHeal: vi.fn().mockResolvedValue(null), + initialDelayMs: 1_000 + }) + + scheduler.beginLaunch('pty-1', true) + await vi.advanceTimersByTimeAsync(1_000) + await vi.waitFor(() => expect(startBackfill).toHaveBeenCalledOnce()) + expect(startBackfill).toHaveBeenLastCalledWith( + expect.objectContaining({ scanDates: undefined, writeBoundedCompletionMarker: false }), + undefined + ) + + scheduler.finishLaunch('pty-1') + await vi.advanceTimersByTimeAsync(1_000) + await vi.waitFor(() => expect(startBackfill).toHaveBeenCalledTimes(2)) + expect(startBackfill).toHaveBeenLastCalledWith( + expect.objectContaining({ scanDates: undefined, writeBoundedCompletionMarker: false }), + undefined + ) + await vi.waitFor(() => expect(finishScheduledRun).toHaveBeenCalledOnce()) + }) + + it('blocks marker publication when a newer launch pass is pending', async () => { + let releaseBackfill: ((result: { stopped: boolean }) => void) | undefined + const startBackfill = vi.fn( + (_options: CodexSessionBackfillOptions) => + new Promise<{ stopped: boolean }>((resolve) => { + releaseBackfill = resolve + }) + ) + const startIndexHeal = vi.fn().mockResolvedValue(null) + const scheduler = createCodexSessionMigrationScheduler({ + isEligible: () => true, + isQuitting: () => false, + resolveSystemCodexHomePathOverride: () => undefined, + startBackfill, + startIndexHeal, + initialDelayMs: 1_000 + }) + + scheduler.scheduleRun() + await vi.advanceTimersByTimeAsync(1_000) + const firstOptions = startBackfill.mock.calls[0]?.[0] + expect(firstOptions?.canWriteCompletionMarker?.()).toBe(true) + + scheduler.beginLaunch('pty-2') + expect(firstOptions?.canWriteCompletionMarker?.()).toBe(false) + scheduler.finishLaunch('pty-2') + expect(firstOptions?.canWriteCompletionMarker?.()).toBe(false) + + releaseBackfill?.({ stopped: false }) + await vi.waitFor(() => expect(startIndexHeal).toHaveBeenCalledOnce()) + }) + + it('turns an exit-before-begin race into a full recovery pass', async () => { + const finishScheduledRun = vi.fn() + const startBackfill = vi.fn().mockResolvedValue({ stopped: false }) + const scheduler = createCodexSessionMigrationScheduler({ + isEligible: () => true, + isQuitting: () => false, + resolveSystemCodexHomePathOverride: () => undefined, + finishScheduledRun, + startBackfill, + startIndexHeal: vi.fn().mockResolvedValue(null), + initialDelayMs: 1_000 + }) + + scheduler.finishLaunch('pty-fast-exit', 2) + scheduler.beginLaunch('pty-fast-exit', false, new Date(), 1) + await vi.advanceTimersByTimeAsync(1_000) + + expect(startBackfill).toHaveBeenCalledWith( + expect.objectContaining({ scanDates: undefined, writeBoundedCompletionMarker: false }), + undefined + ) + await vi.waitFor(() => expect(finishScheduledRun).toHaveBeenCalledOnce()) + }) + + it('does not consume an exit from an earlier stable-id incarnation', async () => { + const startBackfill = vi.fn().mockResolvedValue({ stopped: false }) + const scheduler = createCodexSessionMigrationScheduler({ + isEligible: () => true, + isQuitting: () => false, + resolveSystemCodexHomePathOverride: () => undefined, + startBackfill, + startIndexHeal: vi.fn().mockResolvedValue(null), + initialDelayMs: 1_000 + }) + + scheduler.finishLaunch('stable-pty', 1) + scheduler.beginLaunch('stable-pty', false, new Date(), 2) + await vi.advanceTimersByTimeAsync(1_000) + + expect(startBackfill).toHaveBeenCalledWith( + expect.objectContaining({ writeCompletionMarker: false }), + undefined + ) + }) + + it('preserves the pre-spawn UTC date when launch setup crosses midnight', async () => { + vi.setSystemTime(new Date('2026-08-06T00:00:01Z')) + const startBackfill = vi.fn().mockResolvedValue({ stopped: false }) + const scheduler = createCodexSessionMigrationScheduler({ + isEligible: () => true, + isQuitting: () => false, + resolveSystemCodexHomePathOverride: () => undefined, + startBackfill, + startIndexHeal: vi.fn().mockResolvedValue(null), + initialDelayMs: 1_000 + }) + + scheduler.beginLaunch('pty-midnight', false, new Date('2026-08-05T23:59:59Z')) + scheduler.finishLaunch('pty-midnight') + await vi.advanceTimersByTimeAsync(1_000) + + expect(startBackfill).toHaveBeenCalledWith( + expect.objectContaining({ + scanDates: [ + ['2026', '08', '05'], + ['2026', '08', '06'] + ] + }), + undefined + ) + }) }) diff --git a/src/main/codex/codex-session-migration-scheduler.ts b/src/main/codex/codex-session-migration-scheduler.ts index 908f77a6a83..28ce4b991da 100644 --- a/src/main/codex/codex-session-migration-scheduler.ts +++ b/src/main/codex/codex-session-migration-scheduler.ts @@ -1,12 +1,29 @@ -import type { CodexSessionBackfillOptions } from './codex-session-backfill-types' +import { getCodexSessionBackfillDate } from './codex-session-backfill-date' +import type { + CodexSessionBackfillDate, + CodexSessionBackfillOptions +} from './codex-session-backfill-types' type MigrationRun = ( options: CodexSessionBackfillOptions, systemCodexHomePathOverride?: string ) => Promise +const EARLY_PTY_EXIT_RETENTION_MS = 60_000 +const MAX_EARLY_PTY_EXITS = 256 + +type EarlyPtyExit = { sequence: number; recordedAt: number } + export type CodexSessionMigrationScheduler = { + beginLaunch( + leaseId: string, + fullScanRequired?: boolean, + startedAt?: Date, + startedSequence?: number + ): void + finishLaunch(leaseId: string, exitSequence?: number): void scheduleInitialRun(): void + scheduleRun(fullScanRequired?: boolean): void requestRun(): void } @@ -14,24 +31,64 @@ export function createCodexSessionMigrationScheduler(args: { isEligible: () => boolean isQuitting: () => boolean resolveSystemCodexHomePathOverride: () => string | undefined + prepareScheduledRun?: () => boolean | void + finishScheduledRun?: () => void startBackfill: MigrationRun startIndexHeal: MigrationRun initialDelayMs?: number }): CodexSessionMigrationScheduler { - let initialTimer: ReturnType | null = null + let scheduledTimer: ReturnType | null = null + let scheduledRunGeneration = 0 + let pendingScheduledRunGeneration: number | null = null + const scheduledScanDates = new Map() + const pendingScanDates = new Map() + let scheduledFullScan = false + let pendingFullScan = false let migrationTask: Promise | null = null + const activeLaunches = new Map() + const earlyPtyExits = new Map() let activeRunStopObserved = false let rerunRequested = false - const requestRun = (): void => { + const requestRun = ( + rerunIfActive = false, + requestedGeneration?: number, + requestedScanDates: readonly CodexSessionBackfillDate[] = [], + requestedFullScan = false + ): void => { + if (requestedGeneration !== undefined) { + pendingScheduledRunGeneration = Math.max( + requestedGeneration, + pendingScheduledRunGeneration ?? requestedGeneration + ) + for (const scanDate of requestedScanDates) { + pendingScanDates.set(scanDate.join('-'), scanDate) + } + pendingFullScan ||= requestedFullScan + } if (args.isQuitting() || !args.isEligible()) { return } if (migrationTask) { - // Why: an account transition can re-enable migration while the prior run is still stopping. - rerunRequested ||= activeRunStopObserved + // Why: delayed launches and resumed account transitions must survive an older active pass. + rerunRequested ||= rerunIfActive || activeRunStopObserved return } + const isScheduledRun = pendingScheduledRunGeneration !== null + const activeScheduledRunGeneration = pendingScheduledRunGeneration + let preparationNeedsFullScan = false + if (isScheduledRun) { + pendingScheduledRunGeneration = null + // Why: an older active pass can rewrite the marker after launch invalidates it. + preparationNeedsFullScan = args.prepareScheduledRun?.() === true + } + const fullScanRequired = pendingFullScan || preparationNeedsFullScan + const scanDates = + !fullScanRequired && pendingScanDates.size > 0 + ? [...pendingScanDates.values()].sort(compareBackfillDates) + : undefined + pendingScanDates.clear() + pendingFullScan = false activeRunStopObserved = false rerunRequested = false const shouldStop = (): boolean => { @@ -41,10 +98,27 @@ export function createCodexSessionMigrationScheduler(args: { } const systemCodexHomePathOverride = args.resolveSystemCodexHomePathOverride() let stoppedBackfill = false + let incompleteBackfill = true const task = args - .startBackfill({ shouldStop }, systemCodexHomePathOverride) + .startBackfill( + { + shouldStop, + scanDates, + ignoreCompletionMarker: isScheduledRun, + writeCompletionMarker: activeLaunches.size === 0, + writeBoundedCompletionMarker: + isScheduledRun && activeLaunches.size === 0 && !fullScanRequired, + canWriteCompletionMarker: () => + activeLaunches.size === 0 && + scheduledTimer === null && + pendingScheduledRunGeneration === null && + (!isScheduledRun || activeScheduledRunGeneration === scheduledRunGeneration) + }, + systemCodexHomePathOverride + ) .then((result) => { stoppedBackfill = isStoppedMigrationResult(result) + incompleteBackfill = isIncompleteBackfillResult(result) if (stoppedBackfill || shouldStop()) { return } @@ -58,9 +132,29 @@ export function createCodexSessionMigrationScheduler(args: { void task.finally(() => { if (migrationTask === task) { migrationTask = null - const shouldRerun = rerunRequested || stoppedBackfill + const scheduledRunIncomplete = stoppedBackfill || activeRunStopObserved + const shouldRerun = rerunRequested || scheduledRunIncomplete rerunRequested = false activeRunStopObserved = false + if ((shouldRerun || incompleteBackfill) && isScheduledRun) { + pendingScheduledRunGeneration = Math.max( + activeScheduledRunGeneration!, + pendingScheduledRunGeneration ?? activeScheduledRunGeneration! + ) + pendingFullScan ||= fullScanRequired + for (const scanDate of scanDates ?? []) { + pendingScanDates.set(scanDate.join('-'), scanDate) + } + } + if ( + isScheduledRun && + !incompleteBackfill && + activeLaunches.size === 0 && + scheduledTimer === null && + pendingScheduledRunGeneration === null + ) { + args.finishScheduledRun?.() + } if (shouldRerun) { requestRun() } @@ -68,20 +162,150 @@ export function createCodexSessionMigrationScheduler(args: { }) } + const armScheduledRun = (generation?: number): void => { + scheduledTimer = setTimeout(() => { + scheduledTimer = null + if (generation !== undefined) { + const currentDate = getCodexSessionBackfillDate() + scheduledScanDates.set(currentDate.join('-'), currentDate) + } + const scanDates = [...scheduledScanDates.values()].sort(compareBackfillDates) + scheduledScanDates.clear() + const fullScanRequired = scheduledFullScan + scheduledFullScan = false + // Why: a launch can invalidate the marker while a long index-heal pass is active. + requestRun(true, generation, scanDates, fullScanRequired) + }, args.initialDelayMs ?? 15_000) + } + + const scheduleRun = (fullScanRequired = false): void => { + if (scheduledTimer) { + clearTimeout(scheduledTimer) + } + scheduledRunGeneration += 1 + scheduledFullScan ||= fullScanRequired + const launchDate = getCodexSessionBackfillDate() + scheduledScanDates.set(launchDate.join('-'), launchDate) + armScheduledRun(scheduledRunGeneration) + } + return { - scheduleInitialRun(): void { - if (initialTimer) { + beginLaunch(leaseId, fullScanRequired = false, startedAt = new Date(), startedSequence): void { + if (args.isQuitting() || activeLaunches.has(leaseId)) { return } - initialTimer = setTimeout(() => { - initialTimer = null - requestRun() - }, args.initialDelayMs ?? 15_000) + if (consumeEarlyPtyExit(earlyPtyExits, leaseId, startedSequence)) { + scheduleRun(true) + return + } + activeLaunches.set(leaseId, startedAt) + scheduleRun(fullScanRequired) }, - requestRun + finishLaunch(leaseId, exitSequence): void { + const startedAt = activeLaunches.get(leaseId) + if (!startedAt) { + if (exitSequence !== undefined) { + recordEarlyPtyExit(earlyPtyExits, leaseId, exitSequence) + } + return + } + activeLaunches.delete(leaseId) + if (args.isQuitting()) { + return + } + for (const scanDate of getCodexSessionBackfillDatesBetween(startedAt, new Date())) { + scheduledScanDates.set(scanDate.join('-'), scanDate) + } + scheduleRun() + }, + scheduleInitialRun(): void { + if (!scheduledTimer) { + armScheduledRun() + } + }, + scheduleRun, + requestRun: () => requestRun() } } +function getCodexSessionBackfillDatesBetween( + startedAt: Date, + finishedAt: Date +): CodexSessionBackfillDate[] { + const dates: CodexSessionBackfillDate[] = [] + const cursor = new Date( + Date.UTC(startedAt.getUTCFullYear(), startedAt.getUTCMonth(), startedAt.getUTCDate()) + ) + const last = new Date( + Date.UTC(finishedAt.getUTCFullYear(), finishedAt.getUTCMonth(), finishedAt.getUTCDate()) + ) + while (cursor <= last) { + dates.push(getCodexSessionBackfillDate(cursor)) + cursor.setUTCDate(cursor.getUTCDate() + 1) + } + return dates +} + +function compareBackfillDates( + left: CodexSessionBackfillDate, + right: CodexSessionBackfillDate +): number { + return left.join('-').localeCompare(right.join('-')) +} + +function recordEarlyPtyExit( + exits: Map, + leaseId: string, + sequence: number +): void { + const now = Date.now() + for (const [id, exit] of exits) { + if (now - exit.recordedAt > EARLY_PTY_EXIT_RETENTION_MS) { + exits.delete(id) + } + } + exits.set(leaseId, { sequence, recordedAt: now }) + while (exits.size > MAX_EARLY_PTY_EXITS) { + const oldestLeaseId = exits.keys().next().value + if (oldestLeaseId === undefined) { + break + } + exits.delete(oldestLeaseId) + } +} + +function consumeEarlyPtyExit( + exits: Map, + leaseId: string, + startedSequence: number | undefined +): boolean { + const exit = exits.get(leaseId) + exits.delete(leaseId) + return ( + exit !== undefined && + startedSequence !== undefined && + exit.sequence > startedSequence && + Date.now() - exit.recordedAt <= EARLY_PTY_EXIT_RETENTION_MS + ) +} + function isStoppedMigrationResult(result: unknown): boolean { return Boolean(result && typeof result === 'object' && 'stopped' in result && result.stopped) } + +function isIncompleteBackfillResult(result: unknown): boolean { + if (!result || typeof result !== 'object') { + return true + } + return ( + isStoppedMigrationResult(result) || + readPositiveResultCount(result, 'failedFiles') || + readPositiveResultCount(result, 'failedDirectories') || + readPositiveResultCount(result, 'failedHealAuditRecords') + ) +} + +function readPositiveResultCount(result: object, key: string): boolean { + const value = key in result ? (result as Record)[key] : undefined + return typeof value === 'number' && value > 0 +} diff --git a/src/main/index.ts b/src/main/index.ts index e5a0050a781..b85a315593b 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -336,6 +336,7 @@ let codexUsage: CodexUsageStore | null = null let openCodeUsage: OpenCodeUsageStore | null = null let codexAccounts: CodexAccountService | null = null let codexRuntimeHome: CodexRuntimeHomeService | null = null +let codexSessionMigration: ReturnType | null = null let claudeAccounts: ClaudeAccountService | null = null let claudeRuntimeAuth: ClaudeRuntimeAuthService | null = null let runtime: OrcaRuntimeService | null = null @@ -389,6 +390,33 @@ let gpuFeatureStatus: Electron.GPUFeatureStatus | null = null let localPtyStartupReady: Promise = Promise.resolve() let localPtyProviderStartupReady: Promise = Promise.resolve() const AGENT_STATE_CRASH_BREADCRUMB_MIN_INTERVAL_MS = 30_000 + +function handleCodexHomePtySpawned(args: { + id: string + codexHomePath: string | null + reattached?: boolean + launchEnv?: NodeJS.ProcessEnv + startedAt?: Date + startedSequence?: number +}): void { + const fullScanRequired = + codexRuntimeHome?.beginHostSystemDefaultSessionMigrationLaunch(args.codexHomePath, { + reattached: args.reattached, + launchEnv: args.launchEnv + }) ?? null + if (fullScanRequired !== null) { + codexSessionMigration?.beginLaunch( + args.id, + args.reattached === true || fullScanRequired, + args.startedAt, + args.startedSequence + ) + } +} + +function handlePtyExit(id: string, exitSequence: number): void { + codexSessionMigration?.finishLaunch(id, exitSequence) +} // Why: on Windows a CLI launch that lost ELECTRON_RUN_AS_NODE would boot the GUI and exit silently; redirect to node mode before the lock gate below. // Both redirects run before the serve-argv rewrite so they still match on the launch argv verbatim. // It is load-bearing for the AppImage one: rewriting first replaces the `serve` positional, so its @@ -1400,6 +1428,8 @@ function openMainWindow(): BrowserWindow { }, // Why: let the PTY layer skip its orphan sweep on the recovery reload that re-fires did-finish-load, so live local sessions survive (#5787). isRecoveryReloadInFlight, + onCodexHomePtySpawned: handleCodexHomePtySpawned, + onPtyExit: handlePtyExit, onBeforeUpdateQuit: () => preserveAgentAuthBeforeRestart({ codexRuntimeHome, claudeRuntimeAuth, store }), updateInstallMode: resolveUpdateInstallMode(isServeMode), @@ -2254,21 +2284,21 @@ void app.whenReady().then(async () => { codexRuntimeHome.isHostSystemDefaultRealHome() && isAgentStatusHooksEnabled(store?.getSettings()) ) - const codexSessionMigration = createCodexSessionMigrationScheduler({ - isEligible: () => codexRuntimeHome?.isHostSystemDefaultRealHome() === true, + codexSessionMigration = createCodexSessionMigrationScheduler({ + isEligible: () => codexRuntimeHome?.isHostSystemDefaultSessionMigrationEligible() === true, isQuitting: () => isQuitting, resolveSystemCodexHomePathOverride: () => resolveHostCodexSessionSourceHome(store!.getSettings()), + prepareScheduledRun: () => codexRuntimeHome?.prepareHostSystemDefaultSessionMigrationPass(), + finishScheduledRun: () => codexRuntimeHome?.finishHostSystemDefaultSessionMigrationPass(), startBackfill: startCodexSessionBackfillInBackground, startIndexHeal: startCodexSessionIndexHealInBackground }) codexAccounts = new CodexAccountService(store, rateLimits, codexRuntimeHome, { onHostSystemDefaultSelected: codexSessionMigration.requestRun }) - // Why: one-time per-host backfill makes historical Orca-managed Codex - // sessions visible to the user's own resume picker and app history (#4444, - // #8612). Deferred so startup and first PTY spawns never compete with the - // sessions tree walk. + // Why: migrate historical shared-home sessions after startup; compatibility + // launches re-arm the non-destructive pass for new rollouts (#4444, #8612, #12480). codexSessionMigration.scheduleInitialRun() claudeRuntimeAuth = new ClaudeRuntimeAuthService(store) claudeAccounts = new ClaudeAccountService(store, rateLimits, claudeRuntimeAuth) @@ -2883,7 +2913,11 @@ void app.whenReady().then(async () => { () => store!.getSettings(), (target) => claudeRuntimeAuth!.prepareForClaudeLaunch(target), store, - prepareCodexSessionResumeForLaunch + prepareCodexSessionResumeForLaunch, + { + onCodexHomePtySpawned: handleCodexHomePtySpawned, + onPtyExit: handlePtyExit + } ) await runtime.refreshRestoredOrchestrationAuthority() await runtime.reconcileLegacyWorkerTerminals() diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index c3d3a7795dc..e3e70598541 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -3074,16 +3074,25 @@ describe('registerPtyHandlers', () => { new Promise((resolve) => (releaseRecovery = resolve)) ) readFileSyncMock.mockReturnValue(TEST_CODEX_AUTH_JSON) + const onCodexHomePtySpawned = vi.fn() handlers.clear() - registerPtyHandlers(mainWindow as never, undefined, () => TEST_CODEX_HOME, (() => ({ - codexManagedAccounts: [ - { - id: 'account-1', - managedHomePath: TEST_CODEX_HOME, - managedHomeRuntime: 'host' - } - ] - })) as never) + registerPtyHandlers( + mainWindow as never, + undefined, + () => TEST_CODEX_HOME, + (() => ({ + codexManagedAccounts: [ + { + id: 'account-1', + managedHomePath: TEST_CODEX_HOME, + managedHomeRuntime: 'host' + } + ] + })) as never, + undefined, + undefined, + { onCodexHomePtySpawned } + ) const spawnPromise = handlers.get('pty:spawn')!(null, { cols: 80, @@ -3094,10 +3103,17 @@ describe('registerPtyHandlers', () => { expect(ensureCodexBackfillRecoveryMock).toHaveBeenCalledWith(TEST_CODEX_HOME) ) expect(spawnMock).not.toHaveBeenCalled() + expect(onCodexHomePtySpawned).not.toHaveBeenCalled() releaseRecovery() - await spawnPromise + const result = (await spawnPromise) as { id: string } expect(spawnMock).toHaveBeenCalledTimes(1) + expect(onCodexHomePtySpawned).toHaveBeenCalledWith({ + id: result.id, + codexHomePath: TEST_CODEX_HOME, + startedAt: expect.any(Date), + startedSequence: expect.any(Number) + }) }) it('does not gate a bare local shell on managed Codex auth', async () => { @@ -3108,19 +3124,37 @@ describe('registerPtyHandlers', () => { return '' }) handlers.clear() - registerPtyHandlers(mainWindow as never, undefined, () => TEST_CODEX_HOME, (() => ({ - codexManagedAccounts: [ - { - id: 'account-1', - managedHomePath: TEST_CODEX_HOME, - managedHomeRuntime: 'host' - } - ] - })) as never) + const onCodexHomePtySpawned = vi.fn() + registerPtyHandlers( + mainWindow as never, + undefined, + () => TEST_CODEX_HOME, + (() => ({ + codexManagedAccounts: [ + { + id: 'account-1', + managedHomePath: TEST_CODEX_HOME, + managedHomeRuntime: 'host' + } + ] + })) as never, + undefined, + undefined, + { onCodexHomePtySpawned } + ) - await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 }) + const result = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24 + })) as { id: string } expect(spawnMock).toHaveBeenCalledOnce() + expect(onCodexHomePtySpawned).toHaveBeenCalledWith({ + id: result.id, + codexHomePath: TEST_CODEX_HOME, + startedAt: expect.any(Date), + startedSequence: expect.any(Number) + }) }) it('leaves an inherited CODEX_HOME untouched for system default when the flag is OFF', async () => { @@ -8883,6 +8917,7 @@ describe('registerPtyHandlers', () => { const prepareClaudeAuth = vi.fn(() => { throw new Error('replacement auth preflight must not run') }) + const onCodexHomePtySpawned = vi.fn() let controller: RuntimeSpawnController | null = null const runtime = { setPtyController: vi.fn((value) => { @@ -8920,7 +8955,8 @@ describe('registerPtyHandlers', () => { undefined, undefined, prepareClaudeAuth, - store as never + store as never, + { onCodexHomePtySpawned } ) const spawnController = controller as unknown as RuntimeSpawnController await spawnController.spawn({ @@ -9014,6 +9050,7 @@ describe('registerPtyHandlers', () => { rows: 40, cwd, command: 'codex resume should-not-run', + launchAgent: 'codex', worktreeId, preAllocatedHandle: 'term-live-owner', tabId, @@ -9039,6 +9076,13 @@ describe('registerPtyHandlers', () => { incarnationId: 'inc-live-owner', isReattach: true }) + expect(onCodexHomePtySpawned).toHaveBeenCalledWith({ + id: 'pty-live-owner', + codexHomePath: null, + reattached: true, + startedAt: expect.any(Date), + startedSequence: expect.any(Number) + }) expect(claimedResult).toMatchObject({ id: 'pty-live-owner', stablePaneOwner: { handle: 'term-live-owner', tabId, leafId } diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 54ce28b6825..7b56d142d3c 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -1202,6 +1202,18 @@ export type PrepareCodexSessionResume = (args: { launchEnv?: NodeJS.ProcessEnv workspacePath?: string }) => Promise + +export type CodexHomePtySpawnedLifecycleArgs = { + id: string + codexHomePath: string | null + reattached?: boolean + launchEnv?: NodeJS.ProcessEnv + startedAt?: Date + startedSequence?: number +} + +let ptyLifecycleSequence = 0 + type PrepareClaudeAuth = ( target?: ClaudeAccountSelectionTarget ) => Promise @@ -2243,6 +2255,8 @@ export function registerPtyHandlers( awaitLocalPtyProviderStartup?: () => Promise // Why: returns true once for the crash-recovery reload so its did-finish-load skips the orphan sweep and keeps live PTYs (#5787). isRecoveryReloadInFlight?: (webContentsId: number) => boolean + onCodexHomePtySpawned?: (args: CodexHomePtySpawnedLifecycleArgs) => void + onPtyExit?: (id: string, exitSequence: number) => void } ): void { // Why: a re-registration means a new window owns delivery — cancel the prior closure's watchdog and neutralize its bridged reset so mark-hidden below can't arm a timer against the dead closure. @@ -3563,6 +3577,7 @@ export function registerPtyHandlers( } function sendPtyExitToRenderer(payload: { id: string; code: number }): void { + options?.onPtyExit?.(payload.id, ++ptyLifecycleSequence) const release = preparePtyExitForRenderer(payload) if (!release) { return @@ -4290,6 +4305,8 @@ export function registerPtyHandlers( }, adoptStablePane, spawn: async (args) => { + const codexHomeLaunchStartedAt = !args.connectionId ? new Date() : undefined + const codexHomeLaunchStartedSequence = !args.connectionId ? ++ptyLifecycleSequence : undefined const preAdoptedStablePane = args.adoptedStablePane ?? null const startupPromise = getLocalPtyStartupPromise(args.connectionId) if (startupPromise) { @@ -4300,7 +4317,7 @@ export function registerPtyHandlers( if (!handle) { throw new Error('terminal_pane_owner_unknown') } - return { + const result = { id: preAdoptedStablePane.result.id, ...(preAdoptedStablePane.result.incarnationId ? { incarnationId: preAdoptedStablePane.result.incarnationId } @@ -4314,6 +4331,16 @@ export function registerPtyHandlers( leafId: preAdoptedStablePane.owner.leafId } } + if (!args.connectionId) { + options?.onCodexHomePtySpawned?.({ + id: result.id, + codexHomePath: null, + reattached: true, + startedAt: codexHomeLaunchStartedAt, + startedSequence: codexHomeLaunchStartedSequence + }) + } + return result } if (!preAdoptedStablePane) { await assertFolderWorkspacePtyPathUsable(args.worktreeId) @@ -4976,6 +5003,16 @@ export function registerPtyHandlers( leafId: owner.surface.leafId, ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}) }) + if (!args.connectionId) { + options?.onCodexHomePtySpawned?.({ + id: result.id, + codexHomePath: selectedCodexHomePath, + reattached: true, + startedAt: codexHomeLaunchStartedAt, + startedSequence: codexHomeLaunchStartedSequence, + ...(env ? { launchEnv: env } : {}) + }) + } return { id: result.id, ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}), @@ -5140,6 +5177,15 @@ export function registerPtyHandlers( } // Why: runtime-owned/background spawns bypass mounted-pane state, so inventory consumers need an explicit signal. sendPtySpawnedToRenderer(result.id) + if (!args.connectionId) { + options?.onCodexHomePtySpawned?.({ + id: result.id, + codexHomePath: selectedCodexHomePath, + startedAt: codexHomeLaunchStartedAt, + startedSequence: codexHomeLaunchStartedSequence, + ...(result.isReattach === true ? { reattached: true } : env ? { launchEnv: env } : {}) + }) + } const response = { id: result.id, ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}), @@ -5623,6 +5669,8 @@ export function registerPtyHandlers( } } ) => { + const codexHomeLaunchStartedAt = !args.connectionId ? new Date() : undefined + const codexHomeLaunchStartedSequence = !args.connectionId ? ++ptyLifecycleSequence : undefined const spawnTiming = createPtySpawnTiming() const startupPromise = getLocalPtyStartupPromise(args.connectionId) if (startupPromise) { @@ -6601,6 +6649,19 @@ export function registerPtyHandlers( } // Why: renderer tab state cannot reliably infer background and reattached PTYs in the daemon inventory. sendPtySpawnedToRenderer(result.id) + if (!args.connectionId) { + options?.onCodexHomePtySpawned?.({ + id: result.id, + codexHomePath: selectedCodexHomePath, + startedAt: codexHomeLaunchStartedAt, + startedSequence: codexHomeLaunchStartedSequence, + ...(result.isReattach === true + ? { reattached: true } + : baseEnv + ? { launchEnv: baseEnv } + : {}) + }) + } return resolvePaneSpawnReservation(paneSpawnReservationKey, paneSpawnReservation, response) } catch (err) { if (pendingRegistrationPtyId) { @@ -7387,7 +7448,11 @@ export function registerHeadlessPtyRuntime( getSettings?: () => GlobalSettings, prepareClaudeAuth?: PrepareClaudeAuth, store?: Store, - prepareCodexSessionResume?: PrepareCodexSessionResume + prepareCodexSessionResume?: PrepareCodexSessionResume, + lifecycle?: { + onCodexHomePtySpawned?: (args: CodexHomePtySpawnedLifecycleArgs) => void + onPtyExit?: (id: string, exitSequence: number) => void + } ): void { // Why: headless `orca serve` has no renderer window but still needs the same PTY handlers so remote clients can drive terminals. const headlessWindow = { @@ -7405,7 +7470,7 @@ export function registerHeadlessPtyRuntime( getSettings, prepareClaudeAuth, store, - { prepareCodexSessionResume } + { prepareCodexSessionResume, ...lifecycle } ) } diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index aeaca19da4a..ad8551acb6d 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -23,6 +23,7 @@ import { registerWorkspaceCleanupHandlers } from '../ipc/workspace-cleanup' import { getLocalPtyProvider, registerPtyHandlers, + type CodexHomePtySpawnedLifecycleArgs, type GetSelectedCodexHomePath, type PrepareCodexSessionResume } from '../ipc/pty' @@ -98,6 +99,8 @@ export function attachMainWindowServices( onBeforeRendererReload?: (args: { webContentsId: number; ignoreCache: boolean }) => void // Why: lets the PTY orphan sweep skip the one crash-recovery reload (#5787). isRecoveryReloadInFlight?: (webContentsId: number) => boolean + onCodexHomePtySpawned?: (args: CodexHomePtySpawnedLifecycleArgs) => void + onPtyExit?: (id: string, exitSequence: number) => void onBeforeUpdateQuit?: () => void | Promise updateInstallMode?: UpdateInstallMode onWorktreeLifecycle?: (event: RuntimeWorktreeLifecycleEvent) => void @@ -125,7 +128,9 @@ export function attachMainWindowServices( prepareCodexSessionResume: options?.prepareCodexSessionResume, awaitLocalPtyStartup: options?.awaitLocalPtyStartup, awaitLocalPtyProviderStartup: options?.awaitLocalPtyProviderStartup, - isRecoveryReloadInFlight: options?.isRecoveryReloadInFlight + isRecoveryReloadInFlight: options?.isRecoveryReloadInFlight, + onCodexHomePtySpawned: options?.onCodexHomePtySpawned, + onPtyExit: options?.onPtyExit } ) // Why: register after registerPtyHandlers so pty:management:* IPC re-installs on macOS re-activation (docs/daemon-staleness-ux.md §Phase 1). diff --git a/src/types/psl.ts b/src/types/psl.ts new file mode 100644 index 00000000000..b34a5329583 --- /dev/null +++ b/src/types/psl.ts @@ -0,0 +1,17 @@ +declare module 'psl' { + export type ParsedDomain = { + input: string + tld: string | null + sld: string | null + domain: string | null + subdomain: string | null + listed: boolean + } + + export type ParseError = { + input: string + error: { code: string; message: string } + } + + export function parse(input: string): ParsedDomain | ParseError +} From e6e197feeddd1adda066c5cd22f7ee056a12c8c1 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:21:47 -0700 Subject: [PATCH 032/289] Add Recent Chats & Terminals to Cmd+J palette with digit shortcuts (#13076) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add Recent Chats & Terminals to Cmd+J palette On empty query, the palette leads with recently accessed chats and terminals, addressable via ⌘1–⌘9 shortcuts. Digit chords are intercepted while the palette is open, preventing workspace switches behind the overlay. Status dots reflect agent blocking and activity. * Fix Cmd+J recent order capture and backfill behavior - Capture unfiltered recent tab order when palette opens, avoiding frozen filtered subsets on search reopen - Backfill recent section when rows drop out (mid-open narrowing) instead of rendering empty - Move create-workspace action to the end as a fallback, not competing with real matches - Add test coverage for order freezing, backfill, and create-action ranking * Fix Cmd+J selection backfill when recent tabs hydrate late Track auto-selected item to distinguish user-moved selections from auto-picks. When recent tabs arrive after the palette opens, reset selection to the top item unless the user explicitly moved it. Use useLayoutEffect to capture the pre-hydration worktree order before tabs render, preventing flashing. Fix worktree budget cap for sessions with no open tabs. Add test coverage for late hydration and user-moved selection preservation. * Rank Cmd+J results by match relevance, lead with stronger section Adds match-relevance scoring to rank search results by which field matched and match position (prefix > word-start > mid-word). When a typed query has results in both worktrees and open tabs, whichever section holds the stronger hit now leads the list, matching user intent — a prefix hit in a tab beats a mid-name hit in a worktree. Decouples live status dots from the palette body subscriptions to prevent the whole list from re-rendering on every agent transition or pane-title change. Dots now own their subscriptions via PaletteLiveStatusProvider, while the body reads status maps as a snapshot, refreshed only when the palette opens or tabs change. Freezes the snapshot identity during animation to keep the selection stable even while closing. * Handle decomposed accents in Cmd+J match relevance scoring Include Unicode combining marks (\p{M}) in word-boundary detection so decomposed characters like café are treated as word boundaries instead of mid-word matches. --- src/main/window/createMainWindow.test.ts | 50 ++ src/main/window/createMainWindow.ts | 18 +- .../components/WorktreeJumpPalette.test.tsx | 556 ++++++++++++++++- .../src/components/WorktreeJumpPalette.tsx | 560 ++++++++++++++---- .../cmd-j/palette-live-status.test.tsx | 189 ++++++ .../components/cmd-j/palette-live-status.tsx | 173 ++++++ .../src/components/sidebar/smart-attention.ts | 128 ++-- ...orktree-jump-palette-status-inputs.test.ts | 76 ++- .../worktree-jump-palette-status-inputs.ts | 61 +- .../src/hooks/ipc-events-test-harness.ts | 45 +- src/renderer/src/hooks/useIpcEvents.test.ts | 73 +++ src/renderer/src/hooks/useIpcEvents.ts | 11 + src/renderer/src/i18n/locales/en.json | 7 +- src/renderer/src/i18n/locales/es.json | 7 +- src/renderer/src/i18n/locales/ja.json | 7 +- src/renderer/src/i18n/locales/ko.json | 7 +- src/renderer/src/i18n/locales/zh.json | 7 +- .../src/lib/cmd-j-match-relevance.test.ts | 222 +++++++ src/renderer/src/lib/cmd-j-match-relevance.ts | 98 +++ src/renderer/src/lib/cmd-j-row-index-jump.ts | 22 + .../src/lib/recent-workspace-tab-rows.test.ts | 263 ++++++++ .../src/lib/recent-workspace-tab-rows.ts | 156 +++++ .../src/lib/worktree-palette-create-action.ts | 31 +- 23 files changed, 2519 insertions(+), 248 deletions(-) create mode 100644 src/renderer/src/components/cmd-j/palette-live-status.test.tsx create mode 100644 src/renderer/src/components/cmd-j/palette-live-status.tsx create mode 100644 src/renderer/src/lib/cmd-j-match-relevance.test.ts create mode 100644 src/renderer/src/lib/cmd-j-match-relevance.ts create mode 100644 src/renderer/src/lib/cmd-j-row-index-jump.ts create mode 100644 src/renderer/src/lib/recent-workspace-tab-rows.test.ts create mode 100644 src/renderer/src/lib/recent-workspace-tab-rows.ts diff --git a/src/main/window/createMainWindow.test.ts b/src/main/window/createMainWindow.test.ts index bbff3b994c3..b6d1ff70035 100644 --- a/src/main/window/createMainWindow.test.ts +++ b/src/main/window/createMainWindow.test.ts @@ -1012,6 +1012,56 @@ describe('createMainWindow', () => { expect(webContents.send).not.toHaveBeenCalledWith('ui:jumpToWorktreeIndex', expect.anything()) }) + // Held-key repeats are contained in main whether or not the floating panel has focus: every + // renderer index path skips e.repeat, so yielding one would leak a raw digit to xterm. + it('contains indexed-switch repeats without dispatching them', () => { + const windowHandlers: Record void> = {} + const webContents = { + on: vi.fn((event, handler) => { + windowHandlers[event] = handler + }), + setZoomLevel: vi.fn(), + setBackgroundThrottling: vi.fn(), + invalidate: vi.fn(), + setWindowOpenHandler: vi.fn(), + send: vi.fn(), + isDevToolsOpened: vi.fn(), + openDevTools: vi.fn(), + closeDevTools: vi.fn() + } + const browserWindowInstance = { + webContents, + on: vi.fn(), + isDestroyed: vi.fn(() => false), + isMaximized: vi.fn(() => true), + isFullScreen: vi.fn(() => false), + getSize: vi.fn(() => [1200, 800]), + setSize: vi.fn(), + maximize: vi.fn(), + show: vi.fn(), + loadFile: vi.fn(), + loadURL: vi.fn() + } + browserWindowMock.mockImplementation(function () { + return browserWindowInstance + }) + + createMainWindow(null) + + const isDarwin = process.platform === 'darwin' + const input = isDarwin + ? { type: 'keyDown', code: 'Digit3', key: '3', meta: true, control: false, alt: false } + : { type: 'keyDown', code: 'Digit3', key: '3', meta: false, control: true, alt: false } + const preventDefault = vi.fn() + windowHandlers['before-input-event']( + { preventDefault } as never, + { ...input, isAutoRepeat: true } as never + ) + + expect(preventDefault).toHaveBeenCalledTimes(1) + expect(webContents.send).not.toHaveBeenCalledWith('ui:jumpToWorktreeIndex', expect.anything()) + }) + it('lets main-window Ctrl+Tab flow to the renderer held switcher', () => { const windowHandlers: Record void> = {} const webContents = { diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index f04cfb72430..17bcd061e81 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -746,17 +746,17 @@ export function createMainWindow( return false } + const isIndexJump = action.type === 'jumpToWorktreeIndex' || action.type === 'jumpToTabIndex' + if (isIndexJump && isAutoRepeat) { + // Contain held-key repeats in main — every renderer index path skips e.repeat, so yielding a + // repeat would leak a raw key to xterm/DOM, and re-firing the jump is never what a hold means. + event.preventDefault() + return true + } + // While the floating panel owns the keyboard, yield indexed switch chords to the renderer // so L2 selects a floating tab instead of switching the main workspace behind the panel. - if ( - floatingPanelFocused && - (action.type === 'jumpToWorktreeIndex' || action.type === 'jumpToTabIndex') - ) { - if (isAutoRepeat) { - // Contain held-key repeats in main — both renderer index paths skip e.repeat, so yielding a repeat would leak a raw key to xterm/DOM. - event.preventDefault() - return true - } + if (floatingPanelFocused && isIndexJump) { return false } diff --git a/src/renderer/src/components/WorktreeJumpPalette.test.tsx b/src/renderer/src/components/WorktreeJumpPalette.test.tsx index 7d84be81be1..81b315d57c7 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.test.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.test.tsx @@ -4,16 +4,20 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as ReactI18Next from 'react-i18next' -import type { Repo, Worktree } from '../../../shared/types' +import type { Repo, Tab, TabGroup, TerminalTab, Worktree } from '../../../shared/types' +import type { AgentStatusEntry, AgentStatusState } from '../../../shared/agent-status-types' import { useAppStore } from '@/store' import type { AppState } from '@/store/types' +import { emitCmdJRowIndexJump } from '@/lib/cmd-j-row-index-jump' import WorktreeJumpPalette from './WorktreeJumpPalette' vi.mock('react-i18next', async (importOriginal) => { const actual = await importOriginal() return { ...actual, - useTranslation: () => ({ t: (_key: string, fallback?: string) => fallback ?? _key }) + useTranslation: () => ({ + t: (_key: string, fallback?: string) => fallback ?? _key + }) } }) @@ -43,11 +47,36 @@ vi.mock('@/components/cmd-j/palette-host-badge', () => ({ getPaletteHostBadge: () => null })) +// Why: activation reaches into window.api and the whole worktree-reveal path; the palette's own +// contract is which result it hands over, so stub the boundary and assert on that. +const { activateWorkspaceTabPaletteResult } = vi.hoisted(() => ({ + activateWorkspaceTabPaletteResult: vi.fn((_result: unknown) => ({ status: 'activated' }) as const) +})) +vi.mock('@/lib/workspace-tab-palette-activation', () => ({ + activateWorkspaceTabPaletteResult: (result: unknown) => activateWorkspaceTabPaletteResult(result) +})) + vi.mock('@/components/ui/command', async () => { const React = await import('react') return { - CommandDialog: ({ children, open }: { children: React.ReactNode; open?: boolean }) => - open ?
{children}
: null, + // Why the commandProps passthrough: cmdk resolves Enter against its `value`, so the controlled + // value is the only honest stand-in for "what would Enter activate" without mounting real cmdk. + CommandDialog: ({ + children, + open, + commandProps + }: { + children: React.ReactNode + open?: boolean + commandProps?: { value?: string; onValueChange?: (next: string) => void } + }) => { + setCommandSelection = commandProps?.onValueChange ?? null + return open ? ( +
+ {children} +
+ ) : null + }, CommandInput: ({ value, onValueChange, @@ -100,6 +129,7 @@ const initialAppState = useAppStore.getInitialState() let testRoot: Root let testContainer: HTMLDivElement let setCommandQuery: ((next: string) => void) | null = null +let setCommandSelection: ((next: string) => void) | null = null function makeRepo(): Repo { return { @@ -179,6 +209,7 @@ describe('WorktreeJumpPalette', () => { beforeEach(() => { globalThis.IS_REACT_ACT_ENVIRONMENT = true setCommandQuery = null + setCommandSelection = null useAppStore.setState(initialAppState, true) testContainer = document.createElement('div') document.body.appendChild(testContainer) @@ -349,3 +380,520 @@ describe('WorktreeJumpPalette', () => { expect(testContainer.textContent).toContain('Feature workspace') }) }) + +function makeTerminalTab(id: string, worktreeId: string, title: string): TerminalTab { + return { + id, + ptyId: `pty-${id}`, + worktreeId, + title, + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 0 + } +} + +function makeUnifiedTab(id: string, worktreeId: string, entityId: string, label: string): Tab { + return { + id, + entityId, + groupId: `group-${worktreeId}`, + worktreeId, + contentType: 'terminal', + label, + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0 + } +} + +function makeGroup(worktreeId: string, tabIds: string[]): TabGroup { + return { + id: `group-${worktreeId}`, + worktreeId, + activeTabId: tabIds[0] ?? null, + tabOrder: tabIds, + recentTabIds: tabIds + } +} + +const LEAF_ID = '11111111-2222-4333-8444-555555555555' + +function makeAgentEntry( + tabId: string, + state: AgentStatusState, + stateStartedAt: number +): AgentStatusEntry { + return { + state, + prompt: '', + updatedAt: stateStartedAt, + stateStartedAt, + paneKey: `${tabId}:${LEAF_ID}`, + stateHistory: [] + } +} + +/** Two worktrees, one terminal tab each, none of them current. */ +function makeRecentTabState(overrides: Partial = {}): Partial { + const alpha = makeWorktree('wt-alpha', 'Alpha workspace') + const beta = makeWorktree('wt-beta', 'Beta workspace') + return { + worktreesByRepo: { 'repo-1': [alpha, beta] }, + showSleepingWorkspaces: true, + ptyIdsByTabId: { + 'term-alpha': ['pty-term-alpha'], + 'term-beta': ['pty-term-beta'] + }, + tabsByWorktree: { + 'wt-alpha': [makeTerminalTab('term-alpha', 'wt-alpha', 'Alpha chat')], + 'wt-beta': [makeTerminalTab('term-beta', 'wt-beta', 'Beta chat')] + }, + unifiedTabsByWorktree: { + 'wt-alpha': [makeUnifiedTab('tab-alpha', 'wt-alpha', 'term-alpha', 'Alpha chat')], + 'wt-beta': [makeUnifiedTab('tab-beta', 'wt-beta', 'term-beta', 'Beta chat')] + }, + groupsByWorktree: { + 'wt-alpha': [makeGroup('wt-alpha', ['tab-alpha'])], + 'wt-beta': [makeGroup('wt-beta', ['tab-beta'])] + }, + activeGroupIdByWorktree: { + 'wt-alpha': 'group-wt-alpha', + 'wt-beta': 'group-wt-beta' + }, + ...overrides + } +} + +/** One tab-heavy worktree plus `count` bare ones, so both sections overflow their caps. */ +function makeManyTabState(count: number): Partial { + const ids = Array.from({ length: count }, (_, index) => `${index}`) + return { + worktreesByRepo: { + 'repo-1': [ + makeWorktree('wt-many', 'Many workspace'), + ...ids.map((id) => makeWorktree(`wt-${id}`, `Spare workspace ${id}`)) + ] + }, + showSleepingWorkspaces: true, + ptyIdsByTabId: Object.fromEntries(ids.map((id) => [`term-${id}`, [`pty-${id}`]])), + tabsByWorktree: { + 'wt-many': ids.map((id) => makeTerminalTab(`term-${id}`, 'wt-many', `Chat ${id}`)) + }, + unifiedTabsByWorktree: { + 'wt-many': ids.map((id) => makeUnifiedTab(`tab-${id}`, 'wt-many', `term-${id}`, `Chat ${id}`)) + }, + groupsByWorktree: { + 'wt-many': [ + makeGroup( + 'wt-many', + ids.map((id) => `tab-${id}`) + ) + ] + }, + activeGroupIdByWorktree: { 'wt-many': 'group-wt-many' } + } +} + +function getRenderedRowIds(): string[] { + return [...testContainer.querySelectorAll('[data-command-item]')].map( + (node) => node.dataset.commandItem ?? '' + ) +} + +/** The id cmdk would activate on Enter. */ +function getCommandValue(): string { + return ( + testContainer.querySelector('[data-command-dialog]')?.dataset.commandValue ?? '' + ) +} + +function getTabRowIds(): string[] { + return [...testContainer.querySelectorAll('[data-command-item^="workspace-tab:"]')] + .map((node) => node.dataset.commandItem ?? '') + .map((id) => id.replace('workspace-tab:', '')) +} + +describe('WorktreeJumpPalette recent chats & terminals', () => { + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + setCommandQuery = null + setCommandSelection = null + activateWorkspaceTabPaletteResult.mockClear() + useAppStore.setState(initialAppState, true) + testContainer = document.createElement('div') + document.body.appendChild(testContainer) + testRoot = createRoot(testContainer) + }) + + afterEach(async () => { + await act(async () => { + testRoot.unmount() + }) + document.body.replaceChildren() + useAppStore.setState(initialAppState, true) + }) + + it('leads the empty-query list with the recent section', async () => { + await renderPalette(makeRecentTabState()) + + const rows = getRenderedRowIds().filter((id) => id.length > 0) + expect(rows[0]).toMatch(/^workspace-tab:/) + expect(rows.some((id) => id.startsWith('worktree:'))).toBe(true) + expect(testContainer.textContent).toContain('Recent Chats & Terminals') + expect(testContainer.textContent).toContain('Recent Worktrees') + }) + + it('caps the recent section so the worktree header stays above the fold', async () => { + await renderPalette(makeManyTabState(12)) + + expect(getTabRowIds()).toHaveLength(6) + expect(testContainer.textContent).toContain('Recent Worktrees') + // Why: the worktree section shrinks against the recent rows so the list holds at 10 total — + // it must never uncap, not even for the frame before the order snapshot lands. + expect(getWorktreeRows().length).toBeLessThanOrEqual(4) + }) + + it('backfills past the cap when rows drop out of the frozen order', async () => { + await renderPalette(makeManyTabState(12)) + const before = getTabRowIds() + + // Why: closing the whole first page stands in for any mid-open narrowing (a filter chip does the + // same thing) — the section must fall through to the next ranked rows, not render empty. + await act(async () => { + useAppStore.setState({ + unifiedTabsByWorktree: { + 'wt-many': (useAppStore.getState().unifiedTabsByWorktree['wt-many'] ?? []).filter( + (tab) => !before.includes(tab.id) + ) + } + } as Partial) + }) + await flushEffects() + + const after = getTabRowIds() + expect(after).toHaveLength(6) + expect(after.some((id) => before.includes(id))).toBe(false) + }) + + /** A tab whose title starts with the query, against worktrees that only match mid-name. */ + function makeTypedRelevanceState(): Partial { + const weak = makeWorktree('wt-weak', 'improve-agent-dashboard-performance') + const host = makeWorktree('wt-host', 'docs-update') + return { + worktreesByRepo: { 'repo-1': [weak, host] }, + showSleepingWorkspaces: true, + ptyIdsByTabId: { 'term-host': ['pty-term-host'] }, + tabsByWorktree: { + 'wt-host': [makeTerminalTab('term-host', 'wt-host', 'Performance Review Main Daemon')] + }, + unifiedTabsByWorktree: { + 'wt-host': [ + makeUnifiedTab('tab-host', 'wt-host', 'term-host', 'Performance Review Main Daemon') + ] + }, + groupsByWorktree: { 'wt-host': [makeGroup('wt-host', ['tab-host'])] }, + activeGroupIdByWorktree: { 'wt-host': 'group-wt-host' } + } + } + + it('leads a typed query with the tab section when it holds the stronger match', async () => { + await renderPalette(makeTypedRelevanceState()) + + await act(async () => { + setCommandQuery?.('perf') + }) + await flushEffects() + + const rows = getRenderedRowIds().filter((id) => id.length > 0) + expect(rows[0]).toBe('workspace-tab:tab-host') + expect(rows).toContain('worktree:wt-weak') + expect(getCommandValue()).toBe('workspace-tab:tab-host') + }) + + it('keeps worktrees ahead of tabs when a worktree holds the stronger match', async () => { + await renderPalette({ + ...makeTypedRelevanceState(), + worktreesByRepo: { + 'repo-1': [ + makeWorktree('wt-strong', 'perf-diff-tighten'), + makeWorktree('wt-host', 'docs-update') + ] + } + }) + + await act(async () => { + setCommandQuery?.('perf-d') + }) + await flushEffects() + + const firstRow = getRenderedRowIds().find((id) => id.length > 0) + expect(firstRow).toBe('worktree:wt-strong') + }) + + it('ranks a typed query by match position inside the worktree section', async () => { + await renderPalette({ + worktreesByRepo: { + 'repo-1': [ + // Why this order: smart sort keeps the input order here, so a promoted prefix hit can only + // come from relevance re-ranking. + makeWorktree('wt-word-a', 'improve-agent-dashboard-performance'), + makeWorktree('wt-word-b', 'rc-perf-update-channels'), + makeWorktree('wt-prefix', 'perf-diff-tighten') + ] + }, + showSleepingWorkspaces: true + }) + + await act(async () => { + setCommandQuery?.('perf') + }) + await flushEffects() + + // Why the two word-start rows keep their input order: relevance ranks by where the match sits + // relative to a word boundary, not by raw offset — equal hits still defer to smart sort. + expect(getRenderedRowIds().filter((id) => id.startsWith('worktree:'))).toEqual([ + 'worktree:wt-prefix', + 'worktree:wt-word-a', + 'worktree:wt-word-b' + ]) + }) + + it('budget-caps the worktree section when nothing fills the recent one', async () => { + await renderPalette({ + worktreesByRepo: { + 'repo-1': Array.from({ length: 14 }, (_, index) => + makeWorktree(`wt-${index}`, `Spare workspace ${index}`) + ) + }, + showSleepingWorkspaces: true + }) + + // Why this shape: a filter chip that drops every open tab lands here too, and uncapping used to + // mount one row per workspace. + expect(getTabRowIds()).toEqual([]) + expect(getWorktreeRows()).toHaveLength(10) + expect(testContainer.textContent).toContain('Type to see all 14 worktrees') + }) + + it('captures the order when tabs hydrate after the palette is already open', async () => { + const hydrated = makeRecentTabState() + await renderPalette({ + ...hydrated, + tabsByWorktree: {}, + unifiedTabsByWorktree: {} + }) + + expect(getTabRowIds()).toEqual([]) + // Why: cmdk claims the first row it sees, which before hydration is a worktree. + const firstWorktreeId = getRenderedRowIds().find((id) => id.startsWith('worktree:')) + expect(firstWorktreeId).toBeDefined() + await act(async () => { + setCommandSelection?.(firstWorktreeId ?? '') + }) + await flushEffects() + + await act(async () => { + useAppStore.setState({ + tabsByWorktree: hydrated.tabsByWorktree, + unifiedTabsByWorktree: hydrated.unifiedTabsByWorktree + } as Partial) + }) + await flushEffects() + + const [topRowId] = getTabRowIds() + expect(getTabRowIds()).toHaveLength(2) + // Enter has to follow the rows up: ⌘1 already points at the first recent chat. + expect(getCommandValue()).toBe(`workspace-tab:${topRowId}`) + + // Why here: an empty snapshot also left the digit chords addressing nothing until reopen. + await act(async () => { + emitCmdJRowIndexJump(0) + }) + await flushEffects() + + expect(activateWorkspaceTabPaletteResult).toHaveBeenCalledWith( + expect.objectContaining({ tabId: topRowId }) + ) + }) + + it('leaves a deliberately moved selection alone when recents land late', async () => { + const hydrated = makeRecentTabState() + await renderPalette({ + ...hydrated, + tabsByWorktree: {}, + unifiedTabsByWorktree: {} + }) + + const worktreeIds = getRenderedRowIds().filter((id) => id.startsWith('worktree:')) + expect(worktreeIds.length).toBeGreaterThan(1) + // Why the second row: only a selection that differs from the auto-picked head proves the user moved it. + const movedTo = worktreeIds[1] + await act(async () => { + setCommandSelection?.(movedTo) + }) + await flushEffects() + + await act(async () => { + useAppStore.setState({ + tabsByWorktree: hydrated.tabsByWorktree, + unifiedTabsByWorktree: hydrated.unifiedTabsByWorktree + } as Partial) + }) + await flushEffects() + + expect(getTabRowIds()).toHaveLength(2) + expect(getCommandValue()).toBe(movedTo) + }) + + it('ranks a blocked agent above a more recently visited idle tab', async () => { + await renderPalette( + makeRecentTabState({ + agentStatusByPaneKey: { + [`term-alpha:${LEAF_ID}`]: makeAgentEntry('term-alpha', 'blocked', Date.now()) + }, + lastVisitedAtByWorktreeId: { 'wt-beta': Date.now() } + }) + ) + + expect(getTabRowIds()).toEqual(['tab-alpha', 'tab-beta']) + }) + + it('freezes the order captured on open while statuses keep changing', async () => { + await renderPalette( + makeRecentTabState({ + lastVisitedAtByWorktreeId: { 'wt-beta': Date.now() } + }) + ) + + expect(getTabRowIds()).toEqual(['tab-beta', 'tab-alpha']) + + await act(async () => { + useAppStore.setState({ + agentStatusByPaneKey: { + [`term-alpha:${LEAF_ID}`]: makeAgentEntry('term-alpha', 'blocked', Date.now()) + } + } as Partial) + }) + await flushEffects() + + expect(getTabRowIds()).toEqual(['tab-beta', 'tab-alpha']) + }) + + it('captures the unfiltered order when reopened after a search', async () => { + await renderPalette(makeRecentTabState()) + + await act(async () => { + setCommandQuery?.('Alpha') + }) + await flushEffects() + + // Why closed-then-reopened: the palette stays mounted, and the open effect clears the query one + // commit after the snapshot effect — so a naive capture would freeze the Alpha-only subset. + await act(async () => { + useAppStore.setState({ activeModal: undefined } as Partial) + }) + await flushEffects() + await act(async () => { + useAppStore.setState({ + activeModal: 'worktree-palette' + } as Partial) + }) + await flushEffects() + + expect(getTabRowIds()).toHaveLength(2) + }) + + it('excludes the current tab from the recent section', async () => { + await renderPalette( + makeRecentTabState({ + activeWorktreeId: 'wt-alpha', + activeTabType: 'terminal', + activeTabId: 'term-alpha', + activeTabIdByWorktree: { 'wt-alpha': 'term-alpha' }, + activeTabTypeByWorktree: { 'wt-alpha': 'terminal' } + }) + ) + + expect(getTabRowIds()).toEqual(['tab-beta']) + }) + + it('activates the row a digit chord addresses while open', async () => { + await renderPalette( + makeRecentTabState({ + lastVisitedAtByWorktreeId: { 'wt-beta': Date.now() } + }) + ) + + expect(getTabRowIds()).toEqual(['tab-beta', 'tab-alpha']) + + await act(async () => { + emitCmdJRowIndexJump(1) + }) + await flushEffects() + + expect(activateWorkspaceTabPaletteResult).toHaveBeenCalledWith( + expect.objectContaining({ tabId: 'tab-alpha' }) + ) + }) + + it('ignores a digit chord beyond the rendered recent rows', async () => { + await renderPalette(makeRecentTabState()) + + await act(async () => { + emitCmdJRowIndexJump(8) + }) + await flushEffects() + + expect(activateWorkspaceTabPaletteResult).not.toHaveBeenCalled() + }) + + it('stops routing digit chords once a query is typed', async () => { + await renderPalette(makeRecentTabState()) + + await act(async () => { + setCommandQuery?.('Alpha') + }) + await flushEffects() + + await act(async () => { + emitCmdJRowIndexJump(0) + }) + await flushEffects() + + expect(activateWorkspaceTabPaletteResult).not.toHaveBeenCalled() + }) + + it('keeps create-worktree below the matches it would otherwise outrank', async () => { + await renderPalette(makeRecentTabState()) + + await act(async () => { + setCommandQuery?.('Alpha') + }) + await flushEffects() + + const rows = getRenderedRowIds().filter((id) => id.length > 0) + expect(rows.at(-1)).toBe('__create_worktree__') + expect(rows.length).toBeGreaterThan(1) + }) + + it('labels a folder workspace row with its display name, not a branch', async () => { + await renderPalette( + makeRecentTabState({ + worktreesByRepo: { + 'repo-1': [ + makeWorktree('wt-alpha', 'Alpha workspace', { + isMainWorktree: true, + branch: '' + }), + makeWorktree('wt-beta', 'Beta workspace') + ] + } + }) + ) + + expect(testContainer.textContent).toContain('Alpha workspace') + }) +}) diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index c4df84abad3..a7abbd36071 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -1,5 +1,13 @@ /* oxlint-disable max-lines */ -import React, { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react' +import React, { + useCallback, + useDeferredValue, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState +} from 'react' import { useShallow } from 'zustand/react/shallow' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' @@ -15,7 +23,15 @@ import { } from 'lucide-react' import { useAppStore } from '@/store' import { getRepoMapFromState, useAllWorktrees } from '@/store/selectors' -import { selectPaletteStatusInputs } from './worktree-jump-palette-status-inputs' +import { + selectPaletteIndexStatusSnapshot, + selectPaletteStatusInputs +} from './worktree-jump-palette-status-inputs' +import { + PaletteLiveStatusProvider, + PaletteRecentTabStatusDot, + PaletteWorktreeStatusDot +} from './cmd-j/palette-live-status' import { CommandDialog, CommandInput, @@ -36,9 +52,12 @@ import { } from '@/components/sidebar/visible-worktrees' import { getLiveAgentStatusByWorktreeId, isInactiveWorkspace } from '@/lib/worktree-activity-state' import { orderEmptyQueryWorktrees } from '@/lib/order-empty-query-worktrees' -import StatusIndicator from '@/components/sidebar/StatusIndicator' +import { + getOpenTabMatchRelevance, + getWorktreeMatchRelevance, + NO_MATCH_RELEVANCE +} from '@/lib/cmd-j-match-relevance' import { cn } from '@/lib/utils' -import { getWorktreeStatus, getWorktreeStatusLabel } from '@/lib/worktree-status' import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { queueWorkspaceActivationTerminalFocus } from '@/lib/workspace-activation-terminal-focus' import { findWorktreeById } from '@/store/slices/worktree-helpers' @@ -79,6 +98,18 @@ import { type WorkspaceTabPaletteSearchResult } from '@/lib/workspace-tab-palette-search' import { activateWorkspaceTabPaletteResult } from '@/lib/workspace-tab-palette-activation' +import { + buildExplicitEntriesByTabId, + type TabPaneInputSources +} from '@/components/sidebar/smart-attention' +import { + buildFocusedGroupTabRecency, + orderRecentWorkspaceTabs, + type RecentWorkspaceTabRow +} from '@/lib/recent-workspace-tab-rows' +import { subscribeCmdJRowIndexJump } from '@/lib/cmd-j-row-index-jump' +import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo' +import { useShortcutKeyComboDetails } from '@/hooks/useShortcutLabel' import { ORCA_BROWSER_FOCUS_REQUEST_EVENT, queueBrowserFocusRequest @@ -141,7 +172,7 @@ import { getSettingsFocusedExecutionHostId, isRuntimeOwnedSshTargetId } from '../../../shared/execution-host' -import type { BrowserPage, BrowserWorkspace, Worktree } from '../../../shared/types' +import type { BrowserPage, BrowserWorkspace, TerminalTab, Worktree } from '../../../shared/types' import { isGitRepoKind } from '../../../shared/repo-kind' import { buildTaskSourceContextFromRepo } from '../../../shared/task-source-context' import { translate } from '@/i18n/i18n' @@ -223,6 +254,41 @@ const CREATE_WORKSPACE_QUICK_ACTION_ITEM_ID = `quick-action:${CREATE_WORKSPACE_Q // Why: outlast the CommandDialog close animation (~150–200ms) so gated status maps stay live until fading rows are gone. const PALETTE_STATUS_INPUTS_LINGER_MS = 300 +type OpenTabPaletteItem = BrowserPaletteItem | SimulatorPaletteItem | WorkspaceTabPaletteItem + +// Why: while the palette is open the workspace digit chord addresses recent rows, so it labels them. +const DIGIT_INDEX_ACTION_ID = 'workspace.selectByIndex' as const +// Why: this is also the ⌘N ceiling — any deeper and RECENT WORKTREES falls below the first screenful. +const EMPTY_QUERY_RECENT_TAB_CAP = 6 +// Why: hold total empty-query rows at the pre-existing 10 so the worktree header stays above the fold. +const EMPTY_QUERY_ROW_BUDGET = 10 +const EMPTY_QUERY_WORKTREE_CAP = 5 +const EMPTY_RECENT_TAB_ORDER: readonly string[] = [] + +function isCurrentOpenTabItem(item: OpenTabPaletteItem): boolean { + return item.type === 'browser-page' ? item.result.isCurrentPage : item.result.isCurrentTab +} + +function PaletteRowShortcutBadge({ + index, + modifierKeys +}: { + index: number | undefined + modifierKeys: readonly string[] +}): React.JSX.Element | null { + if (index === undefined || modifierKeys.length === 0) { + return null + } + return ( + + ) +} + function getComposerPrefetchRepoId( state: ReturnType, initialRepoId?: string @@ -379,17 +445,11 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { ) return () => window.clearTimeout(timer) }, [visible]) + const paletteStatusInputsActive = visible || statusInputsLingering // Why: these hot status maps get a new identity on every app-wide write, so gate the subscription on active-or-closing to stop the always-mounted palette re-rendering on unrelated terminals. // Why: ptyIdsByTabId must be included — slept tabs keep a wake-hint sessionId in tab.ptyId, so without it the palette dot would lie green. - const { - agentStatusByPaneKey, - runtimePaneTitlesByTabId, - ptyIdsByTabId, - terminalLayoutsByTabId, - tabsByWorktree - } = useAppStore(useShallow((s) => selectPaletteStatusInputs(s, visible || statusInputsLingering))) - const agentStatusEpoch = useAppStore((s) => - visible || statusInputsLingering ? s.agentStatusEpoch : 0 + const { ptyIdsByTabId, terminalLayoutsByTabId, tabsByWorktree } = useAppStore( + useShallow((s) => selectPaletteStatusInputs(s, paletteStatusInputsActive)) ) const { prCache, issueCache, hostedReviewCache } = useAppStore( useShallow((s) => selectWorktreePaletteCacheInputs(s, visible || statusInputsLingering)) @@ -406,6 +466,17 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { const browserTabsByWorktree = useAppStore((s) => s.browserTabsByWorktree) const browserPagesByWorkspace = useAppStore((s) => s.browserPagesByWorkspace) const unifiedTabsByWorktree = useAppStore((s) => s.unifiedTabsByWorktree) + // Why non-reactive: agentStatusByPaneKey and runtimePaneTitlesByTabId are the two hottest maps in + // the app, and subscribing re-rendered the whole palette on every agent transition to change + // nothing but the status dots — which now hold their own subscription (PaletteLiveStatusProvider). + // Reading them here snapshots them for indexing, ordering and filtering instead, refreshed when + // the palette opens or the tab set changes: the same freeze-on-open the row order already gets. + const paletteIndexStatus = useMemo( + () => selectPaletteIndexStatusSnapshot(useAppStore.getState(), paletteStatusInputsActive), + // oxlint-disable-next-line react-hooks/exhaustive-deps -- these deps ARE the refresh policy, not reads: re-snapshot when the palette opens or the tab set moves under it, never on the agent churn the snapshot exists to ignore. + [paletteStatusInputsActive, tabsByWorktree, unifiedTabsByWorktree] + ) + const { agentStatusByPaneKey, runtimePaneTitlesByTabId } = paletteIndexStatus const openFiles = useAppStore((s) => s.openFiles) const activeGroupIdByWorktree = useAppStore((s) => s.activeGroupIdByWorktree) const groupsByWorktree = useAppStore((s) => s.groupsByWorktree) @@ -436,6 +507,10 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { const [query, setQuery] = useState('') const deferredQuery = useDeferredValue(query) const [selectedItemId, setSelectedItemId] = useState('') + // Why: the id cmdk auto-selected for the last committed list, so a late recent-order snapshot can + // tell "nobody has moved the highlight yet" from "the user arrowed somewhere deliberately". + const autoSelectedItemIdRef = useRef(null) + const digitShortcutItemsRef = useRef([]) // Why: filters reset on close — a filter that survives reopen silently hides // results in a surface people open reflexively. const [rawFilter, setRawFilter] = useState(EMPTY_PALETTE_FILTER) @@ -540,16 +615,15 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { const isLoading = repos.length > 0 && Object.keys(worktreesByRepo).length === 0 // Why: keep running-agent workspaces visible under "Hide sleeping" even when the live PTY is momentarily absent, matching sidebar. #7197 - const liveAgentActivity = useMemo(() => { - void agentStatusEpoch - const statusByWorktreeId = getLiveAgentStatusByWorktreeId( - agentStatusByPaneKey, - tabsByWorktree, - Date.now() - ) - return { statusByWorktreeId, worktreeIds: new Set(statusByWorktreeId.keys()) } - }, [agentStatusByPaneKey, agentStatusEpoch, tabsByWorktree]) - const worktreeIdsWithLiveAgent = liveAgentActivity.worktreeIds + // Why snapshot-scoped: this decides which rows exist, and rows appearing or vanishing mid-open + // would shift the list under the cursor — the same reason the recent order freezes on open. + const worktreeIdsWithLiveAgent = useMemo( + () => + new Set( + getLiveAgentStatusByWorktreeId(agentStatusByPaneKey, tabsByWorktree, Date.now()).keys() + ), + [agentStatusByPaneKey, tabsByWorktree] + ) // Why: empty-query mirrors sidebar filters so Search opens on the same quiet list; typed search widens to global non-archived scope. const emptyQueryVisibleWorktrees = useMemo( @@ -851,24 +925,53 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { [workspaceTabEntries, deferredQuery] ) - const worktreeItems = useMemo( - () => - worktreeMatches - .map((match) => { - const worktree = worktreeMap.get(match.worktreeId) - if (!worktree) { - return null - } - return { - id: `worktree:${worktree.id}`, - type: 'worktree' as const, + const worktreeRelevanceById = useMemo(() => { + const relevanceById = new Map() + if (!hasQuery) { + return relevanceById + } + for (const match of worktreeMatches) { + const worktree = worktreeMap.get(match.worktreeId) + if (worktree) { + relevanceById.set( + match.worktreeId, + getWorktreeMatchRelevance( match, - worktree - } - }) - .filter((item): item is WorktreePaletteItem => item !== null), - [worktreeMap, worktreeMatches] - ) + worktree, + repoMap.get(worktree.repoId)?.displayName ?? '' + ) + ) + } + } + return relevanceById + }, [hasQuery, repoMap, worktreeMap, worktreeMatches]) + + const worktreeItems = useMemo(() => { + const items = worktreeMatches + .map((match) => { + const worktree = worktreeMap.get(match.worktreeId) + if (!worktree) { + return null + } + return { + id: `worktree:${worktree.id}`, + type: 'worktree' as const, + match, + worktree + } + }) + .filter((item): item is WorktreePaletteItem => item !== null) + if (!hasQuery) { + return items + } + // Why: searchWorktrees preserves smart-sort order, so a mid-string hit used to outrank a prefix + // hit. Sort is stable, so equal relevance still falls back to smart order. + return items.sort( + (a, b) => + (worktreeRelevanceById.get(a.worktree.id) ?? NO_MATCH_RELEVANCE) - + (worktreeRelevanceById.get(b.worktree.id) ?? NO_MATCH_RELEVANCE) + ) + }, [hasQuery, worktreeMap, worktreeMatches, worktreeRelevanceById]) const browserItems = useMemo( () => @@ -900,20 +1003,148 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { [workspaceTabMatches] ) - const openTabItems = useMemo< - (BrowserPaletteItem | SimulatorPaletteItem | WorkspaceTabPaletteItem)[] - >( - () => + const openTabItems = useMemo(() => { + const items = [...browserItems, ...simulatorItems, ...workspaceTabItems] + // Why relevance first: each source's score folds in worktree/tab position, so a prefix hit in a + // far worktree used to sink below a mid-title hit in the current one. Empty query leaves every + // relevance unmatched, so ordering falls straight through to the existing scores. + const relevanceById = new Map( + items.map((item) => [item.id, getOpenTabMatchRelevance(item.result)]) + ) + return items.sort((a, b) => { + const relevance = + (relevanceById.get(a.id) ?? NO_MATCH_RELEVANCE) - + (relevanceById.get(b.id) ?? NO_MATCH_RELEVANCE) + if (relevance !== 0) { + return relevance + } // Why: result builders emit comparable ascending scores, so one sort keeps cross-source ranking consistent. - [...browserItems, ...simulatorItems, ...workspaceTabItems].sort((a, b) => { - if (a.result.score !== b.result.score) { - return a.result.score - b.result.score - } - return a.id.localeCompare(b.id) - }), - [browserItems, simulatorItems, workspaceTabItems] + if (a.result.score !== b.result.score) { + return a.result.score - b.result.score + } + return a.id.localeCompare(b.id) + }) + }, [browserItems, simulatorItems, workspaceTabItems]) + + const terminalTabsById = useMemo(() => { + const byId = new Map() + for (const tabs of Object.values(tabsByWorktree)) { + for (const tab of tabs ?? []) { + byId.set(tab.id, tab) + } + } + return byId + }, [tabsByWorktree]) + + const recentTabPaneSources = useMemo( + () => ({ + entriesByTabId: buildExplicitEntriesByTabId( + agentStatusByPaneKey, + migrationUnsupportedByPtyId + ), + ptyIdsByTabId, + runtimePaneTitlesByTabId, + terminalLayoutsByTabId + }), + [ + agentStatusByPaneKey, + migrationUnsupportedByPtyId, + ptyIdsByTabId, + runtimePaneTitlesByTabId, + terminalLayoutsByTabId + ] ) + // Why: the recent section excludes the current tab (the top slot is never "where you are") and + // archived worktrees, both of which the typed-query index still surfaces. + const recentTabRows = useMemo(() => { + const rows: RecentWorkspaceTabRow[] = [] + for (const item of openTabItems) { + const worktree = worktreeMap.get(item.result.worktreeId) + if (!worktree || worktree.isArchived || isCurrentOpenTabItem(item)) { + continue + } + rows.push({ + id: item.id, + worktreeId: worktree.id, + unifiedTabId: item.type === 'browser-page' ? null : item.result.tabId, + terminalTab: + item.type === 'workspace-tab' && item.result.contentType === 'terminal' + ? (terminalTabsById.get(item.result.entityId) ?? null) + : null, + worktreeLastActivityAt: worktree.lastActivityAt + }) + } + return rows + }, [openTabItems, terminalTabsById, worktreeMap]) + + const recentTabRowById = useMemo( + () => new Map(recentTabRows.map((row) => [row.id, row])), + [recentTabRows] + ) + + // Why: ordering is captured once on open. Live re-ranking would move rows under the cursor and + // send ⌘3 to the wrong row; dots keep updating, positions don't. + const [recentTabOrder, setRecentTabOrder] = useState(EMPTY_RECENT_TAB_ORDER) + const recentTabOrderCapturedRef = useRef(false) + // Why layout, not passive: a post-paint capture shows one frame of worktrees-only, which flashes + // the list, renumbers ⌘1–6 under the user, and lets cmdk latch a worktree as the Enter target. + useLayoutEffect(() => { + if (!visible) { + recentTabOrderCapturedRef.current = false + autoSelectedItemIdRef.current = null + setRecentTabOrder(EMPTY_RECENT_TAB_ORDER) + return + } + // Why: the query and filter are cleared by the open effect below, which runs after this one — + // capturing before that lands would freeze the *previous* session's filtered subset for good. + if (recentTabOrderCapturedRef.current || hasQuery || query.length > 0 || filterActive) { + return + } + const order = orderRecentWorkspaceTabs({ + rows: recentTabRows, + paneSources: recentTabPaneSources, + now: Date.now(), + lastVisitedAtByWorktreeId, + focusedGroupTabRecency: buildFocusedGroupTabRecency(activeGroupIdByWorktree, groupsByWorktree) + }) + if (order.length === 0) { + // Why: tabs can arrive after the palette opens (cold start, session restore, a late tab + // mirror). Latching an empty snapshot would leave Recent dead — and digits inert — until + // close+reopen, so stay unlatched; the stable empty ref keeps this a no-op re-render. + setRecentTabOrder(EMPTY_RECENT_TAB_ORDER) + return + } + recentTabOrderCapturedRef.current = true + setRecentTabOrder(order) + // Why: recents render above the worktrees, so a row auto-selected before they arrived is no + // longer the list head — hand Enter back to the top, matching ⌘1. Untouched selections only: + // a highlight the user moved themselves stays put. + setSelectedItemId((current) => + current === '' || current === autoSelectedItemIdRef.current ? '' : current + ) + }, [ + activeGroupIdByWorktree, + filterActive, + groupsByWorktree, + hasQuery, + lastVisitedAtByWorktreeId, + query.length, + recentTabPaneSources, + recentTabRows, + visible + ]) + + // Why: walk the frozen order rather than re-sorting the tab list — the frozen ids are the ranking, + // so agent churn never reshuffles rows under the cursor. Cap after resolving, not before: a chip + // applied mid-open narrows `openTabItems`, and capping first would leave the section empty. + const recentTabItems = useMemo(() => { + const itemById = new Map(openTabItems.map((item) => [item.id, item])) + return recentTabOrder + .flatMap((id) => itemById.get(id) ?? []) + .slice(0, EMPTY_QUERY_RECENT_TAB_CAP) + }, [openTabItems, recentTabOrder]) + const settingsResults = useMemo( () => buildCmdJSettingsResults(settingsSections), [settingsSections] @@ -1073,13 +1304,20 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { [actionResults, deferredQuery, quickActionContext, settingsResults] ) - // Why: on empty query, cap the worktree section so the OPEN TABS header + ≥1 row stays above the fold; typing lifts the cap. - const EMPTY_QUERY_WORKTREE_CAP = 5 - const EMPTY_QUERY_OPEN_TAB_CAP = 5 - const paletteSections = useMemo(() => { - // Why: the worktree cap only matters when open tabs need above-the-fold protection; uncap with zero open tabs. - const worktreeCap = !hasQuery && openTabItems.length > 0 ? EMPTY_QUERY_WORKTREE_CAP : Infinity + const openTabs = hasQuery + ? capPaletteSection(openTabItems) + : { visible: recentTabItems, overflowCount: 0 } + // Why: the worktree section shrinks against the recent rows to hold the empty-query list at its + // pre-existing 10, so RECENT WORKTREES stays above the fold. An empty recent section hands the + // whole budget to worktrees but never uncaps — a filter chip or a tab-less session used to drop + // every open tab and mount one row per workspace. + const worktreeCap = hasQuery + ? Infinity + : Math.min( + openTabs.visible.length === 0 ? EMPTY_QUERY_ROW_BUDGET : EMPTY_QUERY_WORKTREE_CAP, + Math.max(1, EMPTY_QUERY_ROW_BUDGET - openTabs.visible.length) + ) // Why: a typed query can match hundreds of rows; capping the rendered slice // is what keeps a one-character search from building an unbounded DOM. const worktrees = hasQuery @@ -1087,9 +1325,6 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { : { visible: worktreeItems.slice(0, worktreeCap), overflowCount: 0 } const projectTargets = capPaletteSection(hasQuery ? projectTargetItems : []) const middle = capPaletteSection(hasQuery ? middleItems : []) - const openTabs = hasQuery - ? capPaletteSection(openTabItems) - : { visible: openTabItems.slice(0, EMPTY_QUERY_OPEN_TAB_CAP), overflowCount: 0 } const showWorktreeHint = !hasQuery && worktreeItems.length > worktreeCap return { @@ -1103,18 +1338,58 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { openTabOverflowCount: openTabs.overflowCount, showWorktreeHint } - }, [worktreeItems, projectTargetItems, middleItems, openTabItems, hasQuery]) + }, [worktreeItems, projectTargetItems, middleItems, openTabItems, recentTabItems, hasQuery]) + + // Why: both lists are relevance-sorted, so their heads carry each section's best hit. The stronger + // one leads; ties go to open tabs, matching the empty-query view and favouring a tab already open + // over a workspace the user would have to switch to. + const openTabsLeadSections = useMemo(() => { + if (!hasQuery) { + return true + } + const bestWorktree = worktreeItems[0] + const bestWorktreeRelevance = bestWorktree + ? (worktreeRelevanceById.get(bestWorktree.worktree.id) ?? NO_MATCH_RELEVANCE) + : NO_MATCH_RELEVANCE + const bestOpenTab = openTabItems[0] + const bestOpenTabRelevance = bestOpenTab + ? getOpenTabMatchRelevance(bestOpenTab.result) + : NO_MATCH_RELEVANCE + return bestOpenTabRelevance <= bestWorktreeRelevance + }, [hasQuery, openTabItems, worktreeItems, worktreeRelevanceById]) const selectableItems = useMemo( - () => [ - ...paletteSections.visibleWorktreeItems, - ...paletteSections.visibleProjectTargetItems, - ...paletteSections.visibleMiddleItems, - ...paletteSections.visibleOpenTabItems - ], - [paletteSections] + () => + // Why: mirrors render order, which leads with whichever section holds the strongest match. + openTabsLeadSections + ? [ + ...paletteSections.visibleOpenTabItems, + ...paletteSections.visibleWorktreeItems, + ...paletteSections.visibleProjectTargetItems, + ...paletteSections.visibleMiddleItems + ] + : [ + ...paletteSections.visibleWorktreeItems, + ...paletteSections.visibleProjectTargetItems, + ...paletteSections.visibleMiddleItems, + ...paletteSections.visibleOpenTabItems + ], + [openTabsLeadSections, paletteSections] ) + // Why: badges number the snapshotted recent rows only — ⌘N is meaningless on a typed query. + const recentTabShortcutIndexById = useMemo( + () => + new Map( + hasQuery ? [] : paletteSections.visibleOpenTabItems.map((item, index) => [item.id, index]) + ), + [hasQuery, paletteSections] + ) + + // Why: the binding's own modifiers, minus its digit, so a remap renders honestly on every platform. + const digitShortcutModifiers = + useShortcutKeyComboDetails(DIGIT_INDEX_ACTION_ID)[0]?.keys.slice(0, -1) ?? [] + const { createWorktreeName, showCreateAction } = useMemo( () => getWorktreePaletteCreateActionState({ @@ -1150,9 +1425,10 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { }) } } - const visibleWorkspaceItemCount = visibleWorktreeItems.length + (showCreateAction ? 1 : 0) + // Why the create row is excluded: it's present for every non-empty query, so counting it would + // make "suppress lone headers" always false and reinstate the noise the rule exists to kill. const populatedSectionCount = [ - visibleWorkspaceItemCount, + visibleWorktreeItems.length, visibleProjectTargetItems.length, visibleMiddleItems.length, visibleOpenTabItems.length @@ -1160,7 +1436,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { // Header rule: empty query shows lone headers as signposts; on query, suppress unless both sections are populated (else noise). const showWorktreeHeader = hasQuery - ? visibleWorkspaceItemCount > 0 && populatedSectionCount > 1 + ? visibleWorktreeItems.length > 0 && populatedSectionCount > 1 : visibleWorktreeItems.length > 0 const showOpenTabsHeader = hasQuery ? visibleOpenTabItems.length > 0 && populatedSectionCount > 1 @@ -1169,7 +1445,10 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { hasQuery && visibleProjectTargetItems.length > 0 && populatedSectionCount > 1 const showMiddleHeader = hasQuery && visibleMiddleItems.length > 0 && populatedSectionCount > 1 - if (visibleWorkspaceItemCount > 0) { + const pushWorktreeSection = (): void => { + if (visibleWorktreeItems.length === 0) { + return + } if (showWorktreeHeader) { entries.push({ id: '__header_worktrees__', @@ -1196,6 +1475,38 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { } pushOverflowHint('__hint_worktree_overflow__', worktreeOverflowCount) } + + const pushOpenTabSection = (): void => { + if (visibleOpenTabItems.length === 0) { + return + } + if (showOpenTabsHeader) { + entries.push({ + id: '__header_open_tabs__', + type: 'section-header', + label: hasQuery + ? translate('auto.components.WorktreeJumpPalette.50a1d11d5b', 'Open Tabs') + : translate( + 'auto.components.WorktreeJumpPalette.recentChatsTerminalsHeader', + 'Recent Chats & Terminals' + ) + }) + } + appendPaletteListEntries(entries, visibleOpenTabItems) + pushOverflowHint('__hint_open_tab_overflow__', openTabOverflowCount) + } + + if (!hasQuery) { + // Why: the recent section leads the empty-query view; nothing else in this branch is populated. + pushOpenTabSection() + pushWorktreeSection() + return entries + } + + if (openTabsLeadSections) { + pushOpenTabSection() + } + pushWorktreeSection() if (visibleProjectTargetItems.length > 0) { if (showProjectTargetHeader) { entries.push({ @@ -1210,10 +1521,6 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { appendPaletteListEntries(entries, visibleProjectTargetItems) pushOverflowHint('__hint_project_overflow__', projectTargetOverflowCount) } - if (showCreateAction) { - // Why: project/group jump targets are navigation results — keep them after worktree matches, before the creation fallback. - entries.push({ id: CREATE_WORKTREE_ITEM_ID, type: 'create-worktree' }) - } if (visibleMiddleItems.length > 0) { if (showMiddleHeader) { entries.push({ @@ -1225,25 +1532,28 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { appendPaletteListEntries(entries, visibleMiddleItems) pushOverflowHint('__hint_middle_overflow__', middleOverflowCount) } - if (visibleOpenTabItems.length > 0) { - if (showOpenTabsHeader) { - entries.push({ - id: '__header_open_tabs__', - type: 'section-header', - label: translate('auto.components.WorktreeJumpPalette.50a1d11d5b', 'Open Tabs') - }) - } - appendPaletteListEntries(entries, visibleOpenTabItems) - pushOverflowHint('__hint_open_tab_overflow__', openTabOverflowCount) + if (!openTabsLeadSections) { + pushOpenTabSection() + } + if (showCreateAction) { + // Why: creating a workspace is the fallback for "nothing here matches", so it sits below every + // real result — never above them, where it would steal the default selection from a match. + entries.push({ id: CREATE_WORKTREE_ITEM_ID, type: 'create-worktree' }) } return entries - }, [hasQuery, paletteSections, showCreateAction, worktreeItems.length]) + }, [hasQuery, openTabsLeadSections, paletteSections, showCreateAction, worktreeItems.length]) const selectionItemIds = useMemo( () => getWorktreePaletteSelectionItemIds(listEntries), [listEntries] ) + // Why passive, and why after the snapshot effect: it must record the head cmdk *had* while the + // last frame was on screen, so the snapshot compares against that, not against its own result. + useEffect(() => { + autoSelectedItemIdRef.current = selectionItemIds[0] ?? null + }, [selectionItemIds]) + // Why: "has any worktrees?" counts the full visible list (incl. current) so the palette never falsely claims empty. See docs/cmd-j-empty-query-ordering.md. const hasAnyWorktrees = visibleWorktreesForState.length > 0 const hasAnySearchableWorktrees = hasQuery ? searchScopeWorktrees.length > 0 : hasAnyWorktrees @@ -1636,6 +1946,29 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { ] ) + // Why a ref: the rows change on every agent tick, and re-subscribing on each one would tear down + // the listener mid-chord; the handler only ever needs whatever is on screen right now. + useLayoutEffect(() => { + digitShortcutItemsRef.current = paletteSections.visibleOpenTabItems + }, [paletteSections]) + + // Why: main resolves the digit chord to a workspace jump; while the palette owns the keyboard it + // means "activate recent row N" instead, addressing the snapshotted order the badges show. + // Digits past the last badge fall through to nothing rather than switching behind the overlay. + useEffect(() => { + // Why the live query too: `hasQuery` is deferred, so between the keystroke and the deferred + // commit a digit would still activate a recent row the user has already typed past. + if (!visible || hasQuery || query.length > 0) { + return + } + return subscribeCmdJRowIndexJump((index) => { + const item = digitShortcutItemsRef.current[index] + if (item) { + handleSelectItem(item) + } + }) + }, [handleSelectItem, hasQuery, query.length, visible]) + const handleCreateWorktree = useCallback(() => { skipRestoreFocusRef.current = true const trimmed = createWorktreeName.trim() @@ -1865,7 +2198,11 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { } })() - return ( + // Why the dialog is held in a variable: the provider below owns the only subscription to the hot + // agent-status and pane-title maps, so an agent transition re-renders it and the dots inside it — + // never this body. That only holds while these children keep their element identity across the + // churn, which passing them straight through as `children` is what guarantees. + const paletteDialog = (
-
@@ -2226,6 +2554,9 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { ) const WorkspaceTabIcon = result.contentType === 'terminal' ? SquareTerminal : FileText + // Why null on a typed query: the dot belongs to the frozen recent section — the + // Open Tabs results a search returns show their content icon instead. + const recentRow = hasQuery ? null : (recentTabRowById.get(entry.id) ?? null) return (
-
@@ -2292,6 +2626,10 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { )} +
@@ -2377,6 +2715,10 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { )} +
@@ -2459,6 +2801,10 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { )} +
@@ -2510,6 +2856,12 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { ) + + return ( + + {paletteDialog} + + ) } function getPaletteSupportingTextLabel( diff --git a/src/renderer/src/components/cmd-j/palette-live-status.test.tsx b/src/renderer/src/components/cmd-j/palette-live-status.test.tsx new file mode 100644 index 00000000000..1a809c02501 --- /dev/null +++ b/src/renderer/src/components/cmd-j/palette-live-status.test.tsx @@ -0,0 +1,189 @@ +// @vitest-environment happy-dom + +import React, { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import type { AppState } from '@/store/types' +import type { AgentStatusEntry, AgentStatusState } from '../../../../shared/agent-status-types' +import type { TerminalTab } from '../../../../shared/types' +import { + PaletteLiveStatusProvider, + PaletteRecentTabStatusDot, + PaletteWorktreeStatusDot +} from './palette-live-status' + +vi.mock('@/components/AgentWorkingSpinner', () => ({ + AgentWorkingSpinner: () => +})) + +const initialAppState = useAppStore.getInitialState() +const LEAF = '11111111-2222-4333-8444-555555555555' + +let testRoot: Root +let testContainer: HTMLDivElement + +function makeTerminalTab(id: string, worktreeId: string): TerminalTab { + return { + id, + ptyId: `pty-${id}`, + worktreeId, + title: 'Chat', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 0 + } +} + +function makeAgentEntry(tabId: string, state: AgentStatusState): AgentStatusEntry { + return { + state, + prompt: '', + updatedAt: Date.now(), + stateStartedAt: Date.now(), + paneKey: `${tabId}:${LEAF}`, + stateHistory: [] + } +} + +function setAgentState(state: AgentStatusState): void { + useAppStore.setState((s) => ({ + agentStatusByPaneKey: { [`term-a:${LEAF}`]: makeAgentEntry('term-a', state) }, + agentStatusEpoch: s.agentStatusEpoch + 1 + })) +} + +/** Stands in for the palette body: counts how often the frozen subtree is re-rendered. */ +let bodyRenderCount = 0 +const FrozenBody = React.memo(function FrozenBody({ + children +}: { + children: React.ReactNode +}): React.JSX.Element { + bodyRenderCount += 1 + return <>{children} +}) + +function dotLabels(): string[] { + return [...testContainer.querySelectorAll('.sr-only')].map( + (node) => node.textContent ?? '' + ) +} + +describe('palette live status', () => { + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + bodyRenderCount = 0 + useAppStore.setState(initialAppState, true) + useAppStore.setState({ + tabsByWorktree: { 'wt-a': [makeTerminalTab('term-a', 'wt-a')] }, + ptyIdsByTabId: { 'term-a': ['pty-term-a'] }, + browserTabsByWorktree: {} + } as Partial) + testContainer = document.createElement('div') + document.body.appendChild(testContainer) + testRoot = createRoot(testContainer) + }) + + afterEach(async () => { + await act(async () => { + testRoot.unmount() + }) + document.body.replaceChildren() + useAppStore.setState(initialAppState, true) + }) + + async function render(active = true): Promise { + await act(async () => { + testRoot.render( + + + + + + ) + }) + } + + it('updates a worktree dot when the agent transitions', async () => { + setAgentState('working') + await render() + expect(dotLabels()).toEqual(['Working']) + + await act(async () => { + setAgentState('blocked') + }) + expect(dotLabels()).toEqual(['Needs permission']) + }) + + // Why this is the whole point: the palette body no longer subscribes to agent status, so if the + // dot didn't hold its own subscription it would freeze at whatever it showed when you opened. + it('re-renders the dot without re-rendering the frozen body around it', async () => { + setAgentState('working') + await render() + const rendersAfterMount = bodyRenderCount + + await act(async () => { + setAgentState('blocked') + }) + + expect(dotLabels()).toEqual(['Needs permission']) + expect(bodyRenderCount).toBe(rendersAfterMount) + }) + + it('goes inert while the palette is closed', async () => { + setAgentState('working') + await render(false) + // No live maps flow through, so the dot falls back to the no-tabs state rather than + // reporting a worktree it isn't watching. + expect(dotLabels()).toEqual(['Inactive']) + }) + + it('falls back to the row icon when a recent row has no terminal behind it', async () => { + await act(async () => { + testRoot.render( + + } + /> + + ) + }) + expect(testContainer.querySelector('[data-fallback]')).not.toBeNull() + expect(dotLabels()).toEqual([]) + }) + + it('resolves a live dot for a recent row backed by a terminal', async () => { + setAgentState('working') + await act(async () => { + testRoot.render( + + } + /> + + ) + }) + expect(dotLabels()).toEqual(['Working']) + + await act(async () => { + setAgentState('blocked') + }) + expect(dotLabels()).toEqual(['Needs permission']) + }) +}) diff --git a/src/renderer/src/components/cmd-j/palette-live-status.tsx b/src/renderer/src/components/cmd-j/palette-live-status.tsx new file mode 100644 index 00000000000..5e75a82bfea --- /dev/null +++ b/src/renderer/src/components/cmd-j/palette-live-status.tsx @@ -0,0 +1,173 @@ +import React, { createContext, useContext, useMemo } from 'react' +import { useShallow } from 'zustand/react/shallow' +import { useAppStore } from '@/store' +import StatusIndicator from '@/components/sidebar/StatusIndicator' +import { + buildExplicitEntriesByTabId, + type TabPaneInputSources +} from '@/components/sidebar/smart-attention' +import { getLiveAgentStatusByWorktreeId } from '@/lib/worktree-activity-state' +import { + getWorktreeStatus, + getWorktreeStatusLabel, + type WorktreeStatus +} from '@/lib/worktree-status' +import { + resolveRecentWorkspaceTabStatus, + type RecentWorkspaceTabRow +} from '@/lib/recent-workspace-tab-rows' +import type { LiveAgentWorktreeStatus } from '@/lib/worktree-activity-state' +import type { BrowserWorkspace, TerminalTab, Worktree } from '../../../../shared/types' + +/** Confines the app's hottest status subscriptions here so only the dots re-render on their churn. */ +type PaletteLiveStatus = { + liveAgentStatusByWorktreeId: ReadonlyMap + paneSources: TabPaneInputSources + tabsByWorktree: Record + browserTabsByWorktree: Record + /** Bumped with the maps so consumers re-resolve `now`-sensitive freshness on the same tick. */ + statusEpoch: number +} + +const PaletteLiveStatusContext = createContext(null) + +export function PaletteLiveStatusProvider({ + active, + children +}: { + /** Open, or still animating closed — matches the palette's own status-input gate. */ + active: boolean + children: React.ReactNode +}): React.JSX.Element { + const { + agentStatusByPaneKey, + runtimePaneTitlesByTabId, + ptyIdsByTabId, + terminalLayoutsByTabId, + tabsByWorktree, + browserTabsByWorktree, + migrationUnsupportedByPtyId + } = useAppStore( + useShallow((s) => + active + ? { + agentStatusByPaneKey: s.agentStatusByPaneKey, + runtimePaneTitlesByTabId: s.runtimePaneTitlesByTabId, + ptyIdsByTabId: s.ptyIdsByTabId, + terminalLayoutsByTabId: s.terminalLayoutsByTabId, + tabsByWorktree: s.tabsByWorktree, + browserTabsByWorktree: s.browserTabsByWorktree, + migrationUnsupportedByPtyId: s.migrationUnsupportedByPtyId + } + : EMPTY_LIVE_INPUTS + ) + ) + const statusEpoch = useAppStore((s) => (active ? s.agentStatusEpoch : 0)) + + const value = useMemo(() => { + // Why: `now` decides freshness, so both derivations must read it on the same tick — otherwise a + // "done" dot can outlive its window while the worktree row beside it has already decayed. + const now = Date.now() + return { + liveAgentStatusByWorktreeId: getLiveAgentStatusByWorktreeId( + agentStatusByPaneKey, + tabsByWorktree, + now + ), + paneSources: { + entriesByTabId: buildExplicitEntriesByTabId( + agentStatusByPaneKey, + migrationUnsupportedByPtyId + ), + ptyIdsByTabId, + runtimePaneTitlesByTabId, + terminalLayoutsByTabId + }, + tabsByWorktree, + browserTabsByWorktree, + statusEpoch + } + }, [ + agentStatusByPaneKey, + browserTabsByWorktree, + migrationUnsupportedByPtyId, + ptyIdsByTabId, + runtimePaneTitlesByTabId, + statusEpoch, + tabsByWorktree, + terminalLayoutsByTabId + ]) + + return ( + {children} + ) +} + +const EMPTY_LIVE_INPUTS = Object.freeze({ + agentStatusByPaneKey: {}, + runtimePaneTitlesByTabId: {}, + ptyIdsByTabId: {}, + terminalLayoutsByTabId: {}, + tabsByWorktree: {}, + browserTabsByWorktree: {}, + migrationUnsupportedByPtyId: {} +}) + +function useLiveStatus(): PaletteLiveStatus | null { + return useContext(PaletteLiveStatusContext) +} + +/** + * Live dot for a worktree row. Rendering it instead of resolving the status in the palette body is + * what keeps an agent transition from re-rendering every row in the list. + */ +export function PaletteWorktreeStatusDot({ + worktree +}: { + worktree: Pick +}): React.JSX.Element | null { + const live = useLiveStatus() + if (!live) { + return null + } + const status = getWorktreeStatus( + live.tabsByWorktree[worktree.id] ?? [], + live.browserTabsByWorktree[worktree.id] ?? [], + live.paneSources.ptyIdsByTabId, + live.paneSources.runtimePaneTitlesByTabId, + { liveAgentStatus: live.liveAgentStatusByWorktreeId.get(worktree.id) } + ) + return ( + <> +