From 08f446190abadafa9ae9fc224ded171640822858 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 19 Jul 2026 15:59:36 -0700 Subject: [PATCH 01/54] Standardize per-repo Source Control AI save UX (#9427) * Standardize per-repo Source Control AI save UX with global recipe patter Extract draft/save logic into `useRepositorySourceControlAiGlobalUx` hook and serial persist queue, matching how global action recipes save: selects persist immediately, CLI args and command template draft until per-action Save. * Support draft/commit for per-repo custom-command input - Drafts custom commands locally on keystroke without backend writes - Commits to persistence only on blur or mode selection change - Adds forceRepoMode local state to fix mode-select snapping with empty fields - Mirrors existing pattern used for action recipe text editing - Improves persist queue with repo-switch safety (returns boolean, pins repoId at schedule time) - Adds comprehensive test coverage for settings hook and persist queue * Fix forceRepoMode logic to preserve REPO intent mid-edit Always set forceRepoMode based on field content instead of only when a repo command is present. This preserves REPO intent when the field is empty during editing, only exiting when a value is explicitly entered. Add test for draft discard and revert behavior. --- .../RepositorySourceControlAiActionRows.tsx | 24 +- ...RepositorySourceControlAiCustomCommand.tsx | 56 ++- .../RepositorySourceControlAiSection.test.ts | 80 +--- .../RepositorySourceControlAiSection.tsx | 399 +++--------------- ...repository-source-control-ai-draft.test.ts | 56 --- .../repository-source-control-ai-draft.ts | 308 ++++++++++---- ...sitory-source-control-ai-global-ux.test.ts | 236 +++++++++++ .../repository-source-control-ai-global-ux.ts | 316 ++++++++++++++ ...ry-source-control-ai-persist-queue.test.ts | 149 +++++++ ...ository-source-control-ai-persist-queue.ts | 73 ++++ 10 files changed, 1116 insertions(+), 581 deletions(-) delete mode 100644 src/renderer/src/components/settings/repository-source-control-ai-draft.test.ts create mode 100644 src/renderer/src/components/settings/repository-source-control-ai-global-ux.test.ts create mode 100644 src/renderer/src/components/settings/repository-source-control-ai-global-ux.ts create mode 100644 src/renderer/src/components/settings/repository-source-control-ai-persist-queue.test.ts create mode 100644 src/renderer/src/components/settings/repository-source-control-ai-persist-queue.ts diff --git a/src/renderer/src/components/settings/RepositorySourceControlAiActionRows.tsx b/src/renderer/src/components/settings/RepositorySourceControlAiActionRows.tsx index 58a735e04dc..9fcfe68c077 100644 --- a/src/renderer/src/components/settings/RepositorySourceControlAiActionRows.tsx +++ b/src/renderer/src/components/settings/RepositorySourceControlAiActionRows.tsx @@ -51,7 +51,8 @@ type RepositorySourceControlAiActionRowsProps = { onActionTemplateChange: (actionId: SourceControlActionId, value: string) => void onActionAgentArgsChange: (actionId: SourceControlActionId, value: string) => void onAppendVariable: (actionId: SourceControlActionId, variable: string) => void - isSaving: boolean + /** Per-action saving state for CLI args + command template (matches global recipes). */ + savingActionIds: Partial> actionDirtyById: Record onActionDiscard: (actionId: SourceControlActionId) => void onActionSave: (actionId: SourceControlActionId) => void @@ -67,7 +68,7 @@ export function RepositorySourceControlAiActionRows({ onActionTemplateChange, onActionAgentArgsChange, onAppendVariable, - isSaving, + savingActionIds, actionDirtyById, onActionDiscard, onActionSave @@ -103,6 +104,7 @@ export function RepositorySourceControlAiActionRows({ const agentWarningText = getSourceControlActionAgentWarningText(actionId, effectiveAgent) const agentSupportText = getSourceControlActionAgentSupportText(actionId) const actionDirty = actionDirtyById[actionId] + const isSavingAction = savingActionIds[actionId] === true return (
{hasOverride ? ( -
+

{actionDirty ? translate( - 'auto.components.settings.RepositorySourceControlAiSection.e57dde9d93', + 'auto.components.settings.SourceControlAiActionRecipeDefaults.817128d94e', 'Unsaved changes' ) : translate( - 'auto.components.settings.RepositorySourceControlAiSection.ccb07dd027', + 'auto.components.settings.SourceControlAiActionRecipeDefaults.9d3cc627f8', 'Saved' )}

@@ -268,10 +270,10 @@ export function RepositorySourceControlAiActionRows({ variant="ghost" size="xs" onClick={() => onActionDiscard(actionId)} - disabled={isSaving} + disabled={isSavingAction} > {translate( - 'auto.components.settings.RepositorySourceControlAiSection.67b3ff5467', + 'auto.components.settings.SourceControlAiActionRecipeDefaults.b3914ecbbc', 'Discard' )} @@ -281,15 +283,15 @@ export function RepositorySourceControlAiActionRows({ variant="secondary" size="xs" onClick={() => onActionSave(actionId)} - disabled={!actionDirty || isSaving} + disabled={!actionDirty || isSavingAction} > - {isSaving + {isSavingAction ? translate( - 'auto.components.settings.RepositorySourceControlAiSection.57e6e9d4b1', + 'auto.components.settings.SourceControlAiActionRecipeDefaults.4f549a5fa8', 'Saving...' ) : translate( - 'auto.components.settings.RepositorySourceControlAiSection.152268c295', + 'auto.components.settings.SourceControlAiActionRecipeDefaults.d18d665e12', 'Save' )} diff --git a/src/renderer/src/components/settings/RepositorySourceControlAiCustomCommand.tsx b/src/renderer/src/components/settings/RepositorySourceControlAiCustomCommand.tsx index 6a22472b2ee..e3e6e32cf6b 100644 --- a/src/renderer/src/components/settings/RepositorySourceControlAiCustomCommand.tsx +++ b/src/renderer/src/components/settings/RepositorySourceControlAiCustomCommand.tsx @@ -1,4 +1,5 @@ import type React from 'react' +import { useState } from 'react' import type { SourceControlAiSettings } from '../../../../shared/source-control-ai-types' import { Input } from '../ui/input' import { Label } from '../ui/label' @@ -12,19 +13,27 @@ import { translate } from '@/i18n/i18n' type RepositorySourceControlAiCustomCommandProps = { value: string | undefined source: SourceControlAiSettings + // onChange drafts the value locally (per keystroke); onCommit persists it (on blur / mode change). onChange: (value: string | undefined) => void + onCommit: (value: string | undefined) => void +} + +function isRepoCommand(value: string | undefined): boolean { + return typeof value === 'string' && value.trim().length > 0 } export function RepositorySourceControlAiCustomCommand({ value, source, - onChange + onChange, + onCommit }: RepositorySourceControlAiCustomCommandProps): React.JSX.Element { - // Why: value only counts as a repo command when it is a non-empty trimmed string; - // empty/nullish values make hasRepoCommand select CUSTOM_COMMAND_MODE_INHERIT - // instead of CUSTOM_COMMAND_MODE_REPO, so clearing the input switches mode. - const hasRepoCommand = typeof value === 'string' && value.trim().length > 0 - const mode = hasRepoCommand ? CUSTOM_COMMAND_MODE_REPO : CUSTOM_COMMAND_MODE_INHERIT + // Why: selecting "Repository command" with an empty global/repo value would otherwise snap the + // Select back to inherit (empty is not a repo command). Keep a local intent so the user can type. + const [forceRepoMode, setForceRepoMode] = useState(false) + const hasRepoCommand = isRepoCommand(value) + const mode = + hasRepoCommand || forceRepoMode ? CUSTOM_COMMAND_MODE_REPO : CUSTOM_COMMAND_MODE_INHERIT return (
@@ -45,13 +54,23 @@ export function RepositorySourceControlAiCustomCommand({ ) - // by falling back to click-based selection on child options. + // Why: match on label (textContent) and value so opaque option IDs still select; also handles combobox via click. await refSender('Runtime.callFunctionOn', { objectId: object.objectId, functionDeclaration: `function(val) { @@ -433,9 +415,7 @@ export class CdpBridge { const sender = this.makeCdpSender(guest) await this.ensureDebuggerAttached(guest) - // Why: JS scrollBy is deterministic and doesn't require page focus, - // unlike Input.dispatchMouseEvent mouseWheel which is unreliable in - // Electron webviews and can timeout. + // Why: JS scrollBy needs no focus and is deterministic, unlike mouseWheel which is unreliable in Electron webviews. const expr = amount ? `window.scrollBy(0, ${direction === 'down' ? amount : -amount})` : `window.scrollBy(0, ${direction === 'down' ? 'window.innerHeight' : '-window.innerHeight'})` @@ -501,10 +481,7 @@ export class CdpBridge { clickCount: 1 }) - // Why: custom checkbox patterns (label wrapping hidden input, overlays) - // may not toggle from coordinate-based clicking. Verify state changed - // and fall back to programmatic .click() if needed. Wrapped in try/catch - // because the click may have triggered a re-render that invalidated objectId. + // Why: custom checkboxes may not toggle from a coordinate click; verify state and fall back to programmatic .click(). try { const { result: afterState } = (await refSender('Runtime.callFunctionOn', { objectId: object.objectId, @@ -639,10 +616,7 @@ export class CdpBridge { cssContentSize?: { width: number; height: number } contentSize?: { width: number; height: number } } - // Why: Page.captureScreenshot clip coordinates are CSS pixels. On HiDPI - // displays, Electron can report device-pixel `contentSize`, which causes - // Chromium to tile the page into duplicated quadrants. Prefer - // `cssContentSize` so the clip matches the page's CSS layout size. + // Why: screenshot clip uses CSS pixels; on HiDPI, device-pixel contentSize tiles duplicates, so prefer cssContentSize. const contentSize = metrics.cssContentSize ?? metrics.contentSize if (!contentSize) { throw new BrowserError('browser_error', 'Unable to determine full-page screenshot bounds') @@ -699,10 +673,7 @@ export class CdpBridge { const sender = this.makeCdpSender(guest) await this.ensureDebuggerAttached(guest) - // Why: Network.setCookie requires either domain or url to scope the cookie. - // If the caller doesn't provide a domain, infer it from the current page URL - // so the cookie is usable on the active page without forcing callers to - // know the domain upfront. + // Why: Network.setCookie needs a domain or url to scope the cookie; infer the domain from the current page when omitted. let domain = cookie.domain if (!domain) { const { result: urlResult } = (await sender('Runtime.evaluate', { @@ -747,8 +718,7 @@ export class CdpBridge { if (url) { params.url = url } - // Why: Network.deleteCookies requires at least one of domain or url. - // Infer from current page if neither was provided. + // Why: Network.deleteCookies needs a domain or url; infer from the current page if neither was given. if (!domain && !url) { const { result: urlResult } = (await sender('Runtime.evaluate', { expression: 'location.href', @@ -781,8 +751,7 @@ export class CdpBridge { deviceScaleFactor, mobile }) - // Why: BrowserView's compositor surface can keep the previous host size - // after metrics-only resize, which crops remote screencast clients. + // Why: metrics-only resize can leave the compositor surface at the old size, cropping remote screencast clients. await Promise.resolve(sender('Emulation.setVisibleSize', { width, height })).catch(() => {}) return { width, height, deviceScaleFactor, mobile } @@ -852,9 +821,7 @@ export class CdpBridge { return { requests: [...state.pausedRequests.values()] } } - // TODO: Add interceptContinue/interceptBlock once agent-browser supports per-request - // interception decisions. The CDP Fetch domain supports it, but the agent-browser CLI - // only operates on URL pattern-level routing, creating a design mismatch. + // TODO: Add interceptContinue/interceptBlock once agent-browser supports per-request decisions (CLI is URL-pattern-only). // ── Console/network capture ── @@ -975,8 +942,7 @@ export class CdpBridge { const valueStr = result.value !== undefined ? String(result.value) : (result.description ?? '') - // Why: agent-browser returns { result, origin } — match that shape so both - // bridges satisfy the same BrowserEvalResult contract. + // Why: include origin to match agent-browser's BrowserEvalResult shape across both bridges. const { result: urlResult } = (await sender('Runtime.evaluate', { expression: 'location.origin', returnByValue: true @@ -1011,8 +977,7 @@ export class CdpBridge { } async tabSwitch(index: number): Promise { - // Why: filter to live tabs so indices match what tabList() presents. - // Destroyed-but-not-yet-cleaned entries must be skipped. + // Why: filter to live tabs so indices match tabList(), skipping destroyed-but-uncleaned entries. const liveEntries = [...this.getRegisteredTabs()].filter(([_, wcId]) => { const guest = webContents.fromId(wcId) return guest && !guest.isDestroyed() @@ -1061,10 +1026,7 @@ export class CdpBridge { if (guest && !guest.isDestroyed()) { return guest } - // Why: the stored webContentsId may be stale after a Chromium process swap - // (navigation to a different-origin page, crash recovery). Fall through to - // the auto-select logic rather than immediately failing, since the tab may - // still be alive under a new webContentsId. + // Why: webContentsId goes stale after a process swap; fall through to auto-select since the tab may have a new id. this.activeWebContentsId = null } @@ -1096,11 +1058,7 @@ export class CdpBridge { } private getRegisteredTabs(): Map { - // Why: BrowserManager's tab maps are private. We access the singleton's - // state via the public getGuestWebContentsId method by iterating known tabs. - // This method provides the tab enumeration the CDP bridge needs without - // modifying BrowserManager's encapsulation. In the future a public - // listTabs() method on BrowserManager would be cleaner. + // Why: reach into BrowserManager's private tab map since it exposes no public listTabs(). return (this.browserManager as unknown as { webContentsIdByTabId: Map }) .webContentsIdByTabId } @@ -1176,9 +1134,7 @@ export class CdpBridge { } try { - // Why: browser tabs are already debugger-attached by BrowserManager's - // anti-detection injection. Reusing that attachment lets CDP commands - // run instead of failing with "another debugger is already attached." + // Why: BrowserManager already attached the debugger; reuse it to avoid "another debugger is already attached." if (!guest.debugger.isAttached()) { guest.debugger.attach('1.3') } @@ -1194,24 +1150,19 @@ export class CdpBridge { await sender('DOM.enable') await sender('Network.enable') - // Why: cross-origin iframes run in separate renderer processes (OOPIFs) and - // are invisible to the parent's CDP session. setAutoAttach with flatten:true - // gives each OOPIF its own sessionId that we can target with sendCommand. + // Why: OOPIF iframes are invisible to the parent CDP session; flatten:true gives each a targetable sessionId. await sender('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: false, flatten: true }) - // Why: attaching the CDP debugger sets navigator.webdriver = true and - // exposes other automation signals that Cloudflare Turnstile checks. - // Override them on every new document load so challenges succeed. + // Why: CDP attach exposes automation signals (navigator.webdriver) that Cloudflare checks; override per new document. await sender('Page.addScriptToEvaluateOnNewDocument', { source: ANTI_DETECTION_SCRIPT }) - // Why: only remove CDP bridge listeners from the previous attach cycle. - // Screencast/proxy sessions share this debugger and own their teardown hooks. + // Why: only remove this bridge's listeners; screencast/proxy sessions share the debugger and own their teardown. this.removeDebuggerListeners(guest, state) const detachListener = (): void => { @@ -1226,8 +1177,7 @@ export class CdpBridge { state.snapshotResult = null state.navigationId = null } - // Why: an unhandled alert/confirm/prompt blocks ALL subsequent CDP commands. - // Auto-dismissing prevents the session from hanging indefinitely. + // Why: an unhandled JS dialog blocks all subsequent CDP commands; auto-dismiss to avoid hanging. if (method === 'Page.javascriptDialogOpening') { const dialog = params as { type: string; message: string } | undefined guest.debugger @@ -1236,8 +1186,7 @@ export class CdpBridge { }) .catch(() => {}) } - // Why: track cross-origin iframe sessions so we can route CDP commands - // and accessibility tree queries to the correct session. + // Why: track iframe sessions so CDP commands and AX queries route to the correct session. if (method === 'Target.attachedToTarget') { const p = params as | { @@ -1247,7 +1196,6 @@ export class CdpBridge { | undefined if (p?.sessionId && p.targetInfo?.type === 'iframe' && p.targetInfo.targetId) { state.iframeSessions.set(p.targetInfo.targetId, p.sessionId) - // Enable domains on iframe session guest.debugger.sendCommand('DOM.enable', {}, p.sessionId).catch(() => {}) guest.debugger.sendCommand('Accessibility.enable', {}, p.sessionId).catch(() => {}) guest.debugger.sendCommand('Runtime.enable', {}, p.sessionId).catch(() => {}) @@ -1264,8 +1212,7 @@ export class CdpBridge { } } } - // Why: console and network events are buffered per-tab so the agent can - // retrieve them on demand via the capture commands. + // Why: buffer console/network events per-tab so the agent can retrieve them on demand. if (state.capturing) { if (method === 'Runtime.consoleAPICalled') { const p = params as @@ -1314,8 +1261,7 @@ export class CdpBridge { timestamp: p.timestamp ?? Date.now() } state.networkLog.push(entry) - // Why: track requestId→entry so loadingFinished can attribute - // size to the correct response, not just the most recent one. + // Why: map requestId→entry so loadingFinished attributes size to the right response, not the latest one. if (p.requestId) { state.networkRequestMap.set(p.requestId, entry) } @@ -1343,8 +1289,7 @@ export class CdpBridge { } } } - // Why: request interception buffers paused requests so the agent can - // inspect and decide to continue or block them. + // Why: buffer paused requests so the agent can later inspect and continue or block them. if (state.intercepting && method === 'Fetch.requestPaused') { const p = params as | { @@ -1376,10 +1321,7 @@ export class CdpBridge { private makeCdpSender(guest: Electron.WebContents, sessionId?: string): CdpCommandSender { return (method: string, params?: Record) => { const command = guest.debugger.sendCommand(method, params, sessionId) as Promise - // Why: Electron's CDP sendCommand can hang indefinitely if the debugger - // session is stale (e.g. after a renderer process swap that wasn't detected). - // A 10s timeout prevents the RPC from blocking until the CLI's socket timeout. - // The timer is cancelled on success/failure to avoid dangling timer accumulation. + // Why: Electron's CDP sendCommand can hang on a stale debugger session, so a 10s timeout bounds the RPC. let timer: ReturnType return Promise.race([ command.finally(() => clearTimeout(timer)), @@ -1421,9 +1363,7 @@ export class CdpBridge { ) } - // Why: iframe refs use a child session whose navigation history is independent - // of the parent page. Checking the parent's navId against an iframe session - // would always mismatch and falsely reject valid refs. + // Why: iframe refs use a child session with independent nav history, so a parent-navId check would falsely reject them. if (!entry.sessionId) { const currentNavId = await this.getNavigationId(sender) if (state.navigationId && currentNavId !== state.navigationId) { @@ -1441,10 +1381,7 @@ export class CdpBridge { await refSender('DOM.describeNode', { backendNodeId: entry.backendDOMNodeId }) return entry } catch { - // Why: dynamic pages (React, Vue) re-render DOM nodes frequently. A ref - // from a snapshot taken seconds ago may point to a detached node even though - // an identical element exists. Re-query the AX tree to find the fresh node - // by matching role + name, avoiding a wasted retry for the agent. + // Why: dynamic pages re-render nodes, detaching snapshot refs; re-query the AX tree by role+name for the fresh node. const recovered = await this.tryRecoverRef(refSender, entry) if (recovered) { entry.backendDOMNodeId = recovered @@ -1480,10 +1417,7 @@ export class CdpBridge { return { cx: (x1 + x3) / 2, cy: (y1 + y3) / 2 } } - // Why: elements inside cross-origin iframes report coordinates relative to - // the iframe viewport, but Input.dispatchMouseEvent operates on the parent - // page's coordinate space. We find the iframe element on the parent page and - // add its top-left offset to translate iframe-local coords to page coords. + // Why: cross-origin iframes report iframe-local coords, but Input events use parent-page space; add the iframe offset. private async getIframeOffset( guest: Electron.WebContents, sessionId: string @@ -1495,9 +1429,7 @@ export class CdpBridge { for (const [targetId, sid] of state.iframeSessions) { if (sid === sessionId) { try { - // Why: use Target.getTargetInfo to get the iframe's URL, then match it - // against DOM iframe src attributes to find the correct element's position. - // This correctly handles pages with multiple iframes. + // Why: match the iframe's target URL against DOM iframe src to pick the right element on multi-iframe pages. const { targetInfo } = (await parentSender('Target.getTargetInfo', { targetId })) as { targetInfo: { url?: string } } @@ -1526,8 +1458,7 @@ export class CdpBridge { return { offsetX: rect.x, offsetY: rect.y } } } - // Why: iframe may have redirected after load, making src differ - // from the actual target URL. Match by origin as a fallback. + // Why: iframe may redirect after load so src differs from target URL; match by origin as a fallback. try { const targetOrigin = new URL(targetUrl).origin for (const rect of rects) { @@ -1554,8 +1485,7 @@ export class CdpBridge { return { offsetX: 0, offsetY: 0 } } - // Why: for elements inside iframes, translates iframe-local coordinates to - // page-level coordinates that Input.dispatchMouseEvent expects. + // Why: Input.dispatchMouseEvent uses parent-page coords, so translate iframe-local coords for iframe elements. private async getPageCoordinates( guest: Electron.WebContents, refEntry: RefEntry, @@ -1569,9 +1499,7 @@ export class CdpBridge { return { cx: localCx + offsetX, cy: localCy + offsetY } } - // Why: uses nth-index to pick the correct duplicate when multiple elements - // share the same role+name. Without this, recovery always returns the first - // match which may not be the element the ref originally pointed to. + // Why: nth-index disambiguates duplicate role+name matches so recovery hits the original element, not the first match. private async tryRecoverRef(sender: CdpCommandSender, entry: RefEntry): Promise { try { const { nodes } = (await sender('Accessibility.getFullAXTree')) as { @@ -1626,9 +1554,7 @@ export class CdpBridge { } private async waitForLoad(sender: CdpCommandSender, guest: Electron.WebContents): Promise { - // Why: wait for document.readyState=complete first, then wait for network - // idle (no pending requests for 500ms). This handles SPAs that fire 'load' - // before async content is rendered. + // Why: SPAs fire 'load' before async content renders, so also wait for 500ms of network idle. const TIMEOUT_MS = 25_000 const IDLE_MS = 500 const startedAt = Date.now() @@ -1792,9 +1718,7 @@ export class CdpBridge { } } -// Why: CDP's Input.dispatchKeyEvent requires `windowsVirtualKeyCode` (not `keyCode`) -// and `text` for keys that produce default browser actions (Enter submits forms, -// Tab moves focus). Without `text`, Chrome fires the event but doesn't perform the action. +// Why: Input.dispatchKeyEvent needs `text` for keys with default actions (Enter/Tab), or Chrome skips the action. type KeyDefinition = { key: string code: string @@ -1823,10 +1747,7 @@ function resolveKeyDefinition(key: string): KeyDefinition { if (KEY_DEFINITIONS[key]) { return KEY_DEFINITIONS[key] } - // Why: single characters need proper code values — digits use "DigitN", - // letters use "KeyX", and other characters use their char code for the - // windowsVirtualKeyCode. Invalid codes cause events to be dropped by sites - // that check event.code. + // Why: sites that check event.code drop events with invalid code values. if (key.length === 1) { const charCode = key.charCodeAt(0) if (charCode >= 48 && charCode <= 57) { diff --git a/src/main/browser/grab-guest-script.ts b/src/main/browser/grab-guest-script.ts index 0613d7319eb..8841fb6cf97 100644 --- a/src/main/browser/grab-guest-script.ts +++ b/src/main/browser/grab-guest-script.ts @@ -1,20 +1,6 @@ -/* eslint-disable max-lines -- Why: the guest overlay runtime is a single -self-contained JS string template that must be injected atomically into the -guest page. Splitting it across modules would require a string concatenation -build step that adds complexity without improving auditability. */ -// --------------------------------------------------------------------------- -// Browser Context Grab — guest overlay runtime builder -// -// This module produces self-contained JavaScript strings that main injects into -// browser guests via executeJavaScript(). The guest runtime is intentionally -// ephemeral: it installs on arm, resolves once on finalize, and fully removes -// itself on teardown. -// -// Why a string builder rather than a bundled file: Orca's browser guests have -// no preload and no Node access. The injected code must be a plain JS string -// that runs in the page's own world. Keeping it as a template here lets main -// version it alongside the rest of the grab lifecycle. -// --------------------------------------------------------------------------- +/* eslint-disable max-lines -- the guest overlay runtime is one self-contained JS string injected atomically; splitting it adds a concat build step for no auditability gain. */ +// Browser Context Grab — builds self-contained JS strings injected into guests via executeJavaScript(). +// Why a string builder not a bundle: guests have no preload/Node; injected code must be plain JS in the page's own world. type GuestScriptAction = 'arm' | 'awaitClick' | 'finalize' | 'extractHover' | 'teardown' @@ -42,10 +28,7 @@ export function buildGuestOverlayScript(action: GuestScriptAction): string { } } -// --------------------------------------------------------------------------- -// The arm script installs the overlay container and hover tracking. -// It stores state on window.__orcaGrab so finalize/teardown can access it. -// --------------------------------------------------------------------------- +// arm: install the overlay + hover tracking; state lives on window.__orcaGrab so finalize/teardown can reach it. const ARM_SCRIPT = `(function() { 'use strict'; @@ -839,11 +822,7 @@ const ARM_SCRIPT = `(function() { return true; })()` -// --------------------------------------------------------------------------- -// The awaitClick script returns a Promise that resolves when the user clicks -// on the full-viewport overlay. The click never reaches the page because the -// overlay host has pointer-events:all and the handler calls stopPropagation. -// --------------------------------------------------------------------------- +// awaitClick: resolve when the user clicks the overlay; stopPropagation + pointer-events:all keep the click off the page. const AWAIT_CLICK_SCRIPT = `new Promise(function(resolve, reject) { 'use strict'; var grab = window.__orcaGrab; @@ -919,9 +898,6 @@ const AWAIT_CLICK_SCRIPT = `new Promise(function(resolve, reject) { }; })` -// --------------------------------------------------------------------------- -// The finalize script extracts the payload for the currently hovered element. -// --------------------------------------------------------------------------- const FINALIZE_SCRIPT = `(function() { 'use strict'; var grab = window.__orcaGrab; @@ -939,12 +915,7 @@ const FINALIZE_SCRIPT = `(function() { return payload; })()` -// --------------------------------------------------------------------------- -// The extractHover script reads the payload for the currently hovered element -// WITHOUT cleaning up. The overlay and awaitClick listener stay active so the -// user can continue picking elements. Used by keyboard shortcuts (C/S) that -// copy the hovered element without requiring a click first. -// --------------------------------------------------------------------------- +// extractHover: read payload but keep overlay/listeners active so the user can keep picking (C/S shortcut copy, no click). const EXTRACT_HOVER_SCRIPT = `(function() { 'use strict'; var grab = window.__orcaGrab; @@ -958,9 +929,6 @@ const EXTRACT_HOVER_SCRIPT = `(function() { } })()` -// --------------------------------------------------------------------------- -// The teardown script removes the overlay and cleans up all state. -// --------------------------------------------------------------------------- const TEARDOWN_SCRIPT = `(function() { 'use strict'; var grab = window.__orcaGrab; diff --git a/src/main/claude-accounts/runtime-auth-service.ts b/src/main/claude-accounts/runtime-auth-service.ts index 72a29cfa6a9..3d901f872c0 100644 --- a/src/main/claude-accounts/runtime-auth-service.ts +++ b/src/main/claude-accounts/runtime-auth-service.ts @@ -1,6 +1,4 @@ -/* eslint-disable max-lines -- Why: Claude account switching has one safety -boundary: runtime auth materialization. Keeping file, Keychain, snapshot, and -env-patch semantics together prevents PTY launch and quota fetch paths drifting. */ +/* eslint-disable max-lines -- Why: keeps file/Keychain/snapshot/env-patch auth semantics together so PTY launch and quota-fetch paths can't drift. */ import { execFileSync } from 'node:child_process' import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs' import { dirname, join } from 'node:path' @@ -98,10 +96,7 @@ export class ClaudeRuntimeAuthService { private readonly pathResolver = new ClaudeRuntimePathResolver() private mutationQueue: Promise = Promise.resolve() private lastSyncedAccountId: string | null = null - // Why: tracks the credentials Orca last wrote to the shared credentials file. - // On managed→system-default transition, if the file differs from this value, - // an external login (e.g. `claude auth login`) overwrote it — so Orca adopts - // the file as the new system default instead of restoring a stale snapshot. + // Why: creds Orca last wrote to the shared file; a mismatch on managed→default transition means an external login overwrote it, so adopt it as the new default. private lastWrittenCredentialsJson: string | null = null private hasMaterializedRuntimeAuth = false private hasLastWrittenOauthAccount = false @@ -217,17 +212,13 @@ export class ClaudeRuntimeAuthService { previousManagedOauthAccount ) ) { - // Why: switching away while Claude is live must preserve verified - // token refreshes before replacing the shared runtime credentials. + // Why: switching away while Claude is live must preserve verified token refreshes before replacing shared runtime credentials. await this.writeManagedCredentials( previousAccount, outgoingReadBackResult.runtimeCredentialsJson ) } else { - // Why: Claude's runtime credential blob can lack enough identity - // proof to attribute a live-session refresh. Do not persist that - // unverified blob, but also do not block the user from moving new - // terminals to the selected managed account. + // Why: the runtime blob may lack identity proof for a live-session refresh; skip persisting it, but still let new terminals move to the account. console.warn( '[claude-runtime-auth] Skipping unverified live Claude auth read-back while switching accounts' ) @@ -297,9 +288,7 @@ export class ClaudeRuntimeAuthService { }) return } - // Why: WSL managed Claude accounts are already isolated by their Linux - // CLAUDE_CONFIG_DIR. Materializing them into Windows ~/.claude would mix - // two runtime auth stores and break the Terminal-default runtime contract. + // Why: WSL managed accounts are isolated by their Linux CLAUDE_CONFIG_DIR; materializing into Windows ~/.claude would mix two auth stores. this.clearLastWrittenRuntimeState() return } @@ -364,10 +353,7 @@ export class ClaudeRuntimeAuthService { ) } - // Why: Claude CLI refreshes expired OAuth tokens and writes them back to - // .credentials.json. If we detect the runtime file differs from what Orca - // last wrote, the CLI must have refreshed — so we preserve those tokens - // back to managed storage before overwriting runtime with managed state. + // Why: the CLI writes refreshed tokens to .credentials.json; if runtime differs from our last write, preserve them to managed storage before overwriting. if (this.lastSyncedAccountId === activeAccount.id) { if (this.skipNextReadBackForAccountId === activeAccount.id) { this.skipNextReadBackForAccountId = null @@ -383,9 +369,7 @@ export class ClaudeRuntimeAuthService { } else if ( readBackResult.status === 'rejected' && readBackResult.runtimeCredentialsChanged && - // Why: a live Claude that lost a refresh-token race wipes its runtime - // blob (empty tokens). Preserving that wreckage would leave every new - // session logged out — rematerialize managed credentials instead. + // Why: a live Claude that lost a refresh race can wipe its runtime blob (empty tokens); preserving that would log out every new session. readBackResult.hasValidChangedRuntimeCredentials && hasLiveClaudePtys() ) { @@ -398,13 +382,11 @@ export class ClaudeRuntimeAuthService { this.readManagedOauthAccount(activeAccount) ) ) { - // Why: this Claude process was launched under the active managed - // account, but persistence still needs positive account proof. + // Why: this Claude launched under the active managed account, but persistence still needs positive account proof. await this.writeManagedCredentials(activeAccount, readBackResult.runtimeCredentialsJson) credentialsJson = readBackResult.runtimeCredentialsJson } else { - // Why: while Claude is running, an unknown refresh can still belong - // to a live session. Rewriting stale managed auth logs that session out. + // Why: while Claude runs, an unknown refresh may belong to a live session; rewriting stale managed auth logs it out. console.warn( '[claude-runtime-auth] Preserving changed Claude runtime credentials while live Claude terminals are running' ) @@ -420,14 +402,7 @@ export class ClaudeRuntimeAuthService { this.skipNextReadBackForAccountId = null } - // Why: own the OAuth refresh whenever no live `claude` owns these - // credentials — both switching into an account and re-syncing the active - // account with an expired token. A single-use refresh token is rotated and - // persisted to managed storage atomically before we materialize it, so the - // runtime never gets a stale token that fails with invalid_grant. Skipped - // entirely while a Claude PTY is live: that process owns the credentials - // and refreshing here would race its own rotation (double-rotation - // invalidates one copy) — the read-back above preserves its refresh instead. + // Why: rotate+persist the single-use token to managed storage before materializing (else runtime gets a stale token that fails invalid_grant); skip while a live PTY owns the creds since refreshing would double-rotate it (invalidating one copy) — read-back preserves its refresh instead. const liveClaudePtys = hasLiveClaudePtys() if (liveClaudePtys && isOauthTokenExpiring(credentialsJson)) { this.managedRefreshDeferredByLivePtyAccountId = activeAccount.id @@ -445,8 +420,7 @@ export class ClaudeRuntimeAuthService { const paths = this.pathResolver.getRuntimePaths() this.writeRuntimeCredentials(credentialsJson) if (process.platform === 'darwin') { - // Why: Claude Code 2.1+ reads the scoped service, while older builds read - // the legacy unsuffixed service. Runtime switching must satisfy both. + // Why: Claude Code 2.1+ reads the scoped service, older builds the legacy unsuffixed one; runtime switching must satisfy both. try { await writeActiveClaudeKeychainCredentialsForRuntime(credentialsJson, paths.configDir) } catch (error) { @@ -469,10 +443,7 @@ export class ClaudeRuntimeAuthService { this.hasMaterializedRuntimeAuth = true } - // Why: called by ClaudeAccountService before syncForCurrentSelection() after - // re-auth or add-account. Those flows write fresh tokens to managed storage, - // so the read-back must be skipped to avoid overwriting them with stale - // runtime tokens. + // Why: re-auth/add-account write fresh managed tokens; skip the next read-back so stale runtime tokens can't overwrite them. clearLastWrittenCredentialsJson( accountId = this.store.getSettings().activeClaudeManagedAccountId ): void { @@ -526,14 +497,7 @@ export class ClaudeRuntimeAuthService { if (match.kind !== 'matched') { continue } - // Why: on cold app start we cannot tell whether matching runtime - // credentials are a fresh CLI refresh or stale state. Adopt when the - // token expiry proves runtime is newer, OR the refresh token rotated - // and runtime is not provably older. A rotated refresh token with - // equal/missing expiry is a genuine CLI refresh we'd otherwise drop - // (stranding a stale managed token); but if expiry proves runtime is - // older, managed already holds the newer token (e.g. a prior read-back - // or proactive refresh), so reject it. + // Why: on cold start we can't tell a fresh CLI refresh from stale runtime creds; adopt only when expiry or a rotated refresh token proves runtime is newer than managed. if (this.lastWrittenCredentialsJson === null) { const fresher = this.runtimeCredentialsAreFresher( runtimeContents.credentialsJson, @@ -587,16 +551,13 @@ export class ClaudeRuntimeAuthService { } return { status: 'persisted' } } catch (error) { - // Why: read-back is best-effort. A transient fs error must not block the - // forward sync path — the worst case is one more stale-token cycle, which - // is strictly better than failing the entire sync. + // Why: read-back is best-effort; a transient fs error must not block forward sync (worst case: one more stale-token cycle). console.warn('[claude-runtime-auth] Failed to read back refreshed tokens:', error) return { status: 'rejected', runtimeCredentialsChanged: this.runtimeCredentialsChangedSinceLastWrite(baselineCredentialsJson), - // Why: an fs error hides whether a live session's refresh is present, - // so err toward preserving runtime state like before. + // Why: an fs error hides whether a live session's refresh is present, so err toward preserving runtime state. hasValidChangedRuntimeCredentials: true } } @@ -723,8 +684,7 @@ export class ClaudeRuntimeAuthService { settings = this.store.getSettings() ): ClaudeAccountSelectionTarget { if (process.platform === 'win32' && settings.localAccountRuntime === 'wsl') { - // Why: account auth defaults follow account runtime settings, not hidden - // legacy terminal WSL settings that can outlive the Terminal UI control. + // Why: auth defaults follow account runtime settings, not legacy terminal WSL settings that can outlive the Terminal UI. return { runtime: 'wsl', wslDistro: settings.localAccountWslDistro ?? null } } return { runtime: 'host' } @@ -799,9 +759,7 @@ export class ClaudeRuntimeAuthService { return 'mismatch' } - // Why: this mirrors the Codex runtime-home guard. If another Claude login - // or missed live process rewrites shared runtime credentials, do not - // persist those credentials into the selected managed account. + // Why: mirrors the Codex runtime-home guard; don't persist shared runtime creds into the managed account if another login rewrote them. const selectedOrganizationUuid = this.normalizeField( account.organizationUuid ?? managedIdentity?.organizationUuid ?? @@ -1075,14 +1033,11 @@ export class ClaudeRuntimeAuthService { /** * Proactively refresh an account's OAuth token and persist the rotation to - * managed storage. Returns the refreshed credentials JSON when a rotation was - * stored, or null when no refresh happened (token still valid, no refresh - * token, or the network call failed — in which case the caller keeps the - * existing credentials, never worse than before). + * managed storage. Returns the refreshed credentials JSON, or null when no + * refresh happened (token valid, no refresh token, or network failure). * - * Caller guarantees this account is not the live/active one and runs inside - * the serialized mutation queue, so a single-use refresh token can't be - * rotated concurrently. + * Caller guarantees this account isn't the live/active one and runs inside the + * serialized mutation queue, so the single-use refresh token can't rotate concurrently. */ private async refreshManagedAccountTokenIfNeeded( account: ClaudeManagedAccount, @@ -1271,9 +1226,7 @@ export class ClaudeRuntimeAuthService { previouslyWrittenCredentialsJson ) let hasCredentialSurfaceOwnership = fileCredentialsOwned - // Why: runtime auth restore is two-phase: prove ownership before mutating - // any surface, then restore OAuth first. If OAuth fails, the credential - // proof remains intact for retry. + // Why: prove ownership before mutating anything, and restore OAuth first so a failure leaves the credential proof intact for retry. this.lastWrittenCredentialsJson = previouslyWrittenCredentialsJson let scopedSnapshot: ClaudeKeychainSnapshotValue | null = null let legacySnapshot: ClaudeKeychainSnapshotValue | null = null @@ -1326,9 +1279,7 @@ export class ClaudeRuntimeAuthService { if (this.hasLastWrittenOauthAccount) { return this.lastWrittenOauthAccount } - // Why: persisted managed metadata is an account identity hint, not proof - // that Orca wrote .claude.json. Use it only after another surface proves - // the current runtime auth still belongs to the managed account. + // Why: managed metadata hints identity but isn't proof Orca wrote .claude.json; use only after a credential surface proves ownership. if (hasCredentialSurfaceOwnership && ownedOauthAccount !== undefined) { return ownedOauthAccount } @@ -1779,10 +1730,7 @@ export class ClaudeRuntimeAuthService { private writeRuntimeCredentials(contents: string): void { const credentialsPath = this.pathResolver.getRuntimePaths().credentialsPath mkdirSync(dirname(credentialsPath), { recursive: true }) - // Why: repeated Claude spawns sync auth, but credentials rarely change. - // Skipping unchanged rewrites avoids Windows EPERM contention in #1507. - // Still verify the file because another Claude process may have rewritten - // runtime credentials since Orca last materialized them. + // Why: skip unchanged rewrites to dodge Windows EPERM contention (#1507); re-verify the file since another Claude may have rewritten it. if ( this.lastWrittenCredentialsJson === contents && this.fileContentsEqual(credentialsPath, contents) @@ -1838,8 +1786,7 @@ export class ClaudeRuntimeAuthService { return parsed as Record } } catch { - // Why: invalid config is an unknown external state. Replacing it with a - // fresh object could silently erase user or Claude-owned settings. + // Why: invalid config is unknown external state; return null so we don't erase user or Claude-owned settings. return null } return null diff --git a/src/main/claude/hook-service.ts b/src/main/claude/hook-service.ts index 980c9604bec..95304f19fc6 100644 --- a/src/main/claude/hook-service.ts +++ b/src/main/claude/hook-service.ts @@ -54,24 +54,14 @@ function getManagedScript( 'setlocal', ...(options.skipWhenDevinImportsClaude ? [ - // Why: Devin imports .claude hooks by default. Skip Orca's managed - // Claude hook there so status posts stay attributed to Devin. + // Why: Devin imports .claude hooks by default; skip Orca's managed hook there so status posts stay attributed to Devin. `if not "%DEVIN_PROJECT_DIR%"=="" goto :${WINDOWS_HOOK_STDIN_DRAIN_LABEL}` ] : []), - // Why: the endpoint file holds the *live* port/token for this Orca - // install. A PTY that survived an Orca restart has stale PORT/TOKEN - // baked into its env from the old instance — loading `endpoint.cmd` - // (`set KEY=VALUE` lines) via `call` refreshes them so the hook - // reaches the current server. Falls through to PTY env if the file - // is missing (first run / pre-endpoint-file / running outside Orca). + // Why: call the endpoint file to refresh port/token — a PTY that survived an Orca restart carries stale env; falls through to PTY env if missing. 'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul', ...buildWindowsHookEnvironmentGuardLines(), - // Why: post via curl.exe, not a second PowerShell. Claude's launcher is - // already an encoded PowerShell command (Git Bash needs it to survive - // spaces); a PowerShell post on top of that meant two interpreter - // startups per hook. The post runs inside the .cmd (cmd.exe context), so - // curl works the same here as for the POSIX/Codex hooks. + // Why: post via curl.exe, not PowerShell — Claude's launcher is already encoded PowerShell, so a PS post would double interpreter startups per hook. buildWindowsAgentHookCurlPostCommand('claude'), 'exit /b 0', ...buildWindowsHookStdinDrainEpilogue(), @@ -84,40 +74,22 @@ function getManagedScript( ...buildPosixHookPayloadCapture(), ...(options.skipWhenDevinImportsClaude ? [ - // Why: Devin imports .claude hooks by default. Skip Orca's managed - // Claude hook there so status posts stay attributed to Devin. + // Why: Devin imports .claude hooks by default; skip Orca's managed hook there so status posts stay attributed to Devin. 'if [ -n "$DEVIN_PROJECT_DIR" ]; then', ' exit 0', 'fi' ] : []), - // Why: the endpoint file holds the *live* port/token for this Orca - // install. PTYs that survive an Orca restart have stale PORT/TOKEN - // baked into their env from the old instance — sourcing the file here - // lets us reach the new server. Falls back to PTY env if the file is - // missing (first-run / pre-endpoint-file scripts / running outside Orca). - // Why: suppress stderr on the `.` builtin. A TOCTOU race (endpoint unlinked - // between the `[ -r ]` test and the source) or a malformed line (e.g. CRLF - // bled in from a cross-platform userData copy) would otherwise print a - // parse error that agent transcripts could surface. Stale coords → dead - // port → silent-fail is the documented fail-open path anyway — the env-var - // guards below handle the empty PORT/TOKEN case — so swallowing the noise - // here is strictly better than leaking shell errors into the hook output. - // `|| :` defends against an eventual `set -e` in an outer script context - // (not present today) aborting the hook on a parse error. + // Why: source the endpoint file to refresh port/token — a PTY that survived an Orca restart carries stale env; falls back to PTY env if missing. + // Why: suppress stderr / || : so a stray parse error (TOCTOU or CRLF) can't leak into hook output or trip an outer set -e. 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', 'fi', 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', ' exit 0', 'fi', - // Why: worktreeId embeds a filesystem path, so hand-building JSON in POSIX - // shell is not safe once a path contains quotes or newlines. Post the raw - // hook payload plus metadata as form fields and let the receiver parse it. - // Timeout caps best-effort hook posts if the local listener stalls. - // Why: pipe payload to curl's stdin (`payload@-`) instead of an inline - // `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl - // command line (EDR command-line false positives). Wire body is identical. + // Why: paths can hold quotes/newlines, so hand-building JSON in shell is unsafe; post the raw payload + metadata as form fields for the receiver to parse. + // Why: pipe payload to curl stdin (`payload@-`), not an inline arg, so large tool output stays off the command line (EDR false positives). 'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/claude" \\', ' --connect-timeout 0.5 --max-time 1.5 \\', ' -H "Content-Type: application/x-www-form-urlencoded" \\', @@ -155,10 +127,7 @@ export class ClaudeHookService { } } - // Why: Report `partial` when only some managed events are registered so the - // sidebar surfaces a degraded install rather than a false-positive - // `installed`. Each CLAUDE_EVENTS entry must contain the managed command for - // the integration to function end-to-end. + // Why: report `partial` when only some events are registered so the sidebar shows a degraded install, not a false-positive `installed`. const command = getManagedCommand(scriptPath) const missing: string[] = [] let presentCount = 0 @@ -219,24 +188,13 @@ export class ClaudeHookService { return this.getStatus() } - // Why: install Orca's Claude hook settings on the remote box rather than the - // local machine. Caller passes the user's SFTP handle plus the resolved - // remote `$HOME`; POSIX-only by design (Windows-remote deferred). + // Why: install the Claude hook on the remote box (via SFTP); POSIX-only by design (Windows-remote deferred). async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise { - // Why: remote-Windows is out of scope for v1 — we ship POSIX-shaped paths - // and a `.sh` managed script body. The remote platform is gated by the - // relay's capability RPC at a higher layer; we cannot detect it from - // `process.platform` here (that's the local box). + // Why: remote-Windows is out of scope; ship POSIX paths. process.platform here is the local box, not the remote, so it can't gate this. const remoteConfigPath = getRemoteConfigPath(remoteHome, this.options.settings) const remoteScriptFileName = getPosixManagedScriptFileName(this.options.settings) const remoteScriptPath = `${remoteHome.replace(/\/$/, '')}/.orca/agent-hooks/${remoteScriptFileName}` - // Why: SFTP reads/writes fail far more often than local fs (network drops, - // EACCES on remote dirs, disk full, channel closed). Wrap the entire - // install flow in try/catch so a transient I/O failure surfaces as a - // structured `state: 'error'` result for the UI, not an unstructured - // rejection the caller has to remember to handle. A `null` config - // specifically means "file present but unparseable" — keep that branch - // distinct so the user sees an actionable message. + // Why: SFTP I/O fails often (network/EACCES/disk); wrap install so transient failures surface as structured state:'error' rather than an unhandled rejection. try { const config = await readHooksJsonRemote(sftp, remoteConfigPath) if (!config) { @@ -249,19 +207,12 @@ export class ClaudeHookService { } } - // Why: the POSIX wrapper is identical regardless of where the script - // lands; only the path differs. Reuse the same wrapper helper. + // Why: the POSIX wrapper is identical regardless of where the script lands; only the path differs. const command = getRemoteManagedCommand(remoteScriptPath) const nextConfig = applyManagedHooks(config, command, remoteScriptFileName) - // Why: write the script first, then the settings — settings.json - // referencing a missing script body would fire `command not found` on - // every tool call until the user re-runs install. Doing it in this - // order means a partial-failure mid-install at worst leaves the user - // with a working script no settings.json points at (a no-op), instead - // of broken settings.json. - // Why: SSH remotes use POSIX `.sh` hook paths even when Orca itself is - // running on Windows; never derive remote script syntax from local OS. + // Why: write script before settings — a mid-install failure then leaves a harmless orphan script, not settings.json pointing at a missing one. + // Why: SSH remotes use POSIX `.sh` paths even when Orca runs on Windows; never derive remote script syntax from the local OS. await writeManagedScriptRemote( sftp, remoteScriptPath, diff --git a/src/main/cli/cli-installer.ts b/src/main/cli/cli-installer.ts index 431c04e917b..18d2fcc8f18 100644 --- a/src/main/cli/cli-installer.ts +++ b/src/main/cli/cli-installer.ts @@ -105,13 +105,7 @@ export class CliInstaller { this.processPathEnv = options.processPathEnv ?? process.env.PATH ?? process.env.Path ?? null this.commandPathOverride = options.commandPathOverride ?? process.env.ORCA_CLI_INSTALL_PATH ?? null - // Why: resolved once at construction — existsSync must not run on every - // getStatus() call (hot path). /usr/local/bin is absent by default on Apple - // Silicon Macs (Homebrew moved to /opt/homebrew); fall back to ~/.local/bin - // which is user-writable, requires no elevated permissions, and is the - // XDG-standard user bin dir already on PATH via shell init on arm64. - // defaultMacCommandPath is a test seam: it feeds into the existence check - // so tests can simulate arm64 without relying on the real /usr/local/bin. + // Why: resolved once here (getStatus is hot); /usr/local/bin is absent on Apple Silicon, so fall back to user-writable ~/.local/bin. const candidateMacPath = options.defaultMacCommandPath ?? DEFAULT_MAC_COMMAND_PATH this.macCommandPath = existsSync(dirname(candidateMacPath)) ? candidateMacPath @@ -202,21 +196,15 @@ export class CliInstaller { await this.installAppImageWrapper(status.commandPath, status.launcherPath) await this.removeLegacyLinuxCommandIfManaged(status.launcherPath) } else if (this.isWindowsPackagedBundledCommand(status.commandPath, status.launcherPath)) { - // Why: packaged Windows already ships resources/bin/orca.exe. Registration - // only owns the user PATH entry; rewriting the asset makes it recurse. + // Why: packaged Windows already ships resources/bin/orca.exe; registration only owns the PATH entry. } else { - // Why: mkdir stays here for the Windows wrapper path — the target dir is - // user-writable (%LOCALAPPDATA%) so EACCES cannot occur. The symlink path - // handles its own mkdir inside installSymlink so EACCES triggers the - // privileged-runner instead of propagating as an unhandled rejection. + // Why: the Windows wrapper dir is user-writable (%LOCALAPPDATA%), so mkdir here can't hit EACCES. await mkdir(dirname(status.commandPath), { recursive: true }) await this.installWindowsWrapper(status.commandPath, status.launcherPath) } if (this.platform === 'win32') { - // Why: Windows shells discover commands via the user PATH, not by walking - // arbitrary app install directories. The CLI installer therefore owns the - // user-scoped PATH entry instead of assuming the desktop installer did it. + // Why: Windows shells find commands via user PATH, so the installer owns that entry, not the desktop installer. await this.ensureWindowsPathEntry(dirname(status.commandPath)) } @@ -319,12 +307,10 @@ export class CliInstaller { const status = await this.inspectSymlink(commandPath, launcherPath) if (status.state !== 'not_installed') { if (reachedDefaultCommandPath && !isDefaultCommandPath && status.state === 'conflict') { - // Why: a non-Orca command after an empty/default install slot can be - // shadowed safely by installing there; no user file needs replacing. + // Why: a non-Orca command after an empty default slot can be shadowed by installing there; no user file replaced. continue } - // Why: PATH lookup is first-match-wins; use the executable command the - // shell will actually run, while preserving conflicts that shadow Orca. + // Why: PATH lookup is first-match-wins; return the command the shell will actually run, preserving shadowing conflicts. return commandPath } } @@ -345,8 +331,7 @@ export class CliInstaller { } if (!this.isPackaged) { - // Why: default dev registration is a separate command, while tests and - // diagnostics can still exercise production paths via commandPathOverride. + // Why: dev uses a separate command; tests/diagnostics still reach production paths via commandPathOverride. if (this.platform === 'darwin') { return `/usr/local/bin/${DEV_COMMAND_NAME}` } @@ -363,18 +348,13 @@ export class CliInstaller { } if (this.platform === 'linux') { - // Why: Linux does not have a single privileged global shell-command flow - // equivalent to macOS's /usr/local/bin integration. ~/.local/bin is the - // least surprising user-scoped location that many distros already expose. - // Why `orca-ide`: GNOME Orca (the screen reader) ships /usr/bin/orca on - // most Linux distros. Using `orca-ide` avoids shadowing that system - // command, matching the executableName already used for the Electron binary. + // Why: Linux lacks a privileged global command flow; ~/.local/bin is the least-surprising user-scoped dir. + // Why `orca-ide`: GNOME Orca ships /usr/bin/orca, so avoid shadowing that screen reader. return join(this.homePath, '.local', 'bin', LINUX_COMMAND_NAME) } if (this.platform === 'win32') { - // Why: NSIS /D installs can live outside LOCALAPPDATA. The packaged - // resources directory is the authoritative native launcher location. + // Why: NSIS /D installs can live outside LOCALAPPDATA, so use the packaged resources dir as authoritative. return getBundledLauncherPath(this.platform, this.resourcesPath) } @@ -412,10 +392,7 @@ export class CliInstaller { if (status.state === 'stale') { await unlink(status.commandPath as string) } - // Why: mkdir is placed here (not in install()) so that an EACCES/EPERM - // failure — e.g. /usr/local/bin absent on Intel Mac — falls into the - // privileged-runner catch below instead of surfacing as an unhandled - // rejection that leaves Settings silently showing "not installed". + // Why: mkdir stays here (not install()) so an EACCES falls into the privileged-runner catch below. await mkdir(dirname(status.commandPath as string), { recursive: true }) await symlink(status.launcherPath as string, status.commandPath as string) } catch (error) { @@ -423,10 +400,7 @@ export class CliInstaller { throw error } - // Why: macOS shell-command registration should behave like VS Code and - // place a stable symlink in /usr/local/bin instead of rewriting shell rc - // files. Fallback to an elevated shell command keeps the public command - // stable even when the app lacks direct write access to that directory. + // Why: fall back to an elevated shell to place the /usr/local/bin symlink (VS Code-style) when direct write is denied. await this.privilegedRunner( `mkdir -p ${quoteShell(dirname(status.commandPath as string))} && ` + `ln -sfn ${quoteShell(status.launcherPath as string)} ${quoteShell(status.commandPath as string)}` @@ -465,8 +439,7 @@ export class CliInstaller { return } - // Why: after the Linux command rename, the old Orca-owned `orca` symlink - // would keep shadowing GNOME Orca even though the new command is installed. + // Why: after the Linux command rename, the old `orca` symlink would keep shadowing GNOME Orca. await unlink(legacyCommandPath) } catch (error) { if (isMissingError(error)) { @@ -492,8 +465,7 @@ export class CliInstaller { return true } - // Why: AppImage upgrades can leave a legacy symlink into a now-gone FUSE - // mount; the stable AppImage path is not a sibling of that old target. + // Why: AppImage upgrades can strand a legacy symlink into a now-gone FUSE mount that isn't a sibling of the stable path. return /(?:^|[/\\])resources[/\\]bin[/\\]orca$/.test(resolvedTarget) } @@ -502,8 +474,7 @@ export class CliInstaller { } private async installAppImageWrapper(commandPath: string, appImagePath: string): Promise { - // Why: unlike macOS symlink install, AppImage uses the user-writable Linux - // command dir and must create it before writing the wrapper file. + // Why: the AppImage command dir is user-writable, so create it before writing the wrapper. await mkdir(dirname(commandPath), { recursive: true }) await writeFile(commandPath, buildAppImageCliWrapper(appImagePath), { encoding: 'utf8', @@ -644,8 +615,7 @@ export class CliInstaller { } if (this.platform === 'darwin') { - // Why: prior packaged installs can leave a symlink to an older Orca.app - // resources launcher, but arbitrary user-owned symlinks must not be replaced. + // Why: reclaim symlinks to an older Orca.app launcher, but never replace arbitrary user-owned symlinks. return /(?:^|[/\\])[^/\\]+\.app[/\\]Contents[/\\]Resources[/\\]bin[/\\][^/\\]+$/.test( resolvedTarget ) @@ -670,8 +640,7 @@ export class CliInstaller { const siblingDevUserDataPath = `${packagedUserDataPath}-dev` const siblingDevLauncherDir = resolve(siblingDevUserDataPath, ...DEV_LAUNCHER_DIR) - // Why: development builds generate launchers under the sibling `*-dev` - // profile; packaged Orca must be able to reclaim that public command. + // Why: dev builds generate launchers under the sibling `*-dev` profile; packaged Orca must reclaim that command. return ( basename(siblingDevUserDataPath) === `${basename(packagedUserDataPath)}-dev` && isPathInsideOrEqual(siblingDevLauncherDir, resolvedTarget) @@ -901,13 +870,11 @@ export class CliInstaller { if (result.state === 'success') { return { value: result.value, expandable: result.expandable } } - // Why: PATH updates are read-modify-write; continuing after an unknown read - // could replace the user's existing PATH with an incomplete value. + // Why: PATH is read-modify-write; continuing after a failed read could clobber the user's PATH with a partial value. throw new Error(`${result.detail} No PATH changes were made.`) } - // Why: raw PowerShell errors reach the UI, so translate denied PATH writes - // while preserving the original diagnostic as the error cause. + // Why: raw PowerShell errors reach the UI, so translate denied PATH writes (keeping the original as cause). private async writeWindowsUserPathEntry( value: string, pathDirectory: string, @@ -954,10 +921,7 @@ async function ensureDevLauncher(args: { ) await mkdir(dirname(launcherPath), { recursive: true }) - // Why: packaged Orca ships real platform launchers under resources/bin, but - // development builds do not have that stable asset layout. Generating a - // launcher in userData lets us validate the shell-command flow without - // changing the packaged registration contract. + // Why: dev builds lack the packaged resources/bin launcher, so generate one in userData to validate the flow. const content = args.platform === 'win32' ? buildWindowsDevLauncher(args.execPath, args.cliEntryPath, args.userDataPath) @@ -967,9 +931,7 @@ async function ensureDevLauncher(args: { mode: args.platform === 'win32' ? undefined : 0o755 }) if (args.commandName === DEV_COMMAND_NAME && args.platform !== 'win32') { - // Why: dev PTYs prepend userData/cli/bin to PATH, and product-owned - // commands are documented as `orca ...`. Keep that local alias fresh - // without claiming the global production command. + // Why: dev PTYs prepend this dir to PATH, so keep a local `orca` alias without claiming the global command. await writeFile(join(dirname(launcherPath), 'orca'), content, { encoding: 'utf8', mode: 0o755 @@ -1045,9 +1007,7 @@ function extractManagedUnixLauncherTarget(content: string): string | null { return null } - // Why: older dev installs wrote a generated shell launcher directly to - // /usr/local/bin/orca. Treat only Orca's compiled CLI entrypoints as managed; - // arbitrary user scripts that happen to launch Electron must stay conflicts. + // Why: only Orca's compiled CLI entrypoints count as managed; arbitrary Electron-launching scripts stay conflicts. return /(?:^|[/\\])(?:out|app\.asar\.unpacked[/\\]out)[/\\]cli[/\\]index\.js$/.test(cliPath) ? cliPath : null @@ -1153,8 +1113,7 @@ function isMissingError(error: unknown): boolean { ) } -// Why: localized permission errors retain these .NET/ACL markers even when -// their human-readable PowerShell text is mojibake. +// Why: localized permission errors keep these .NET/ACL markers even when the PowerShell text is mojibake. function isWindowsUserPathPermissionError(error: unknown): boolean { if (!(error instanceof Error)) { return false @@ -1199,9 +1158,7 @@ async function writeWindowsUserPath(value: string): Promise { await runWindowsPathCommand([ '-NoProfile', '-Command', - // Why: PATH registration must stay user-scoped on Windows so the Orca - // desktop app can manage the public shell command without requiring - // elevation or mutating machine-wide environment state. + // Why: user-scoped PATH avoids requiring elevation or mutating machine-wide state. `[Environment]::SetEnvironmentVariable('Path', ${quotePowerShell(value)}, 'User')` ]) } @@ -1224,8 +1181,7 @@ function runWindowsPathCommand(args: string[]): Promise { resolve(stdout) } - // Why: Windows PATH reads/writes back CLI Settings; wedged PowerShell must - // not keep command registration status or install/remove pending forever. + // Why: bound wedged PowerShell so PATH reads/writes can't leave CLI registration pending forever. const timeout = setTimeout(() => { child?.kill() finish(new Error(`Windows PATH command timed out after ${WINDOWS_PATH_WRITE_TIMEOUT_MS}ms.`)) diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index 29e181bd775..5c6e42f63d4 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -1,7 +1,4 @@ -/* eslint-disable max-lines -- Why: this service owns the single runtime-home -contract for Codex inside Orca. Keeping path resolution, system-default -snapshots, auth materialization, and recovery together prevents account-switch -semantics from drifting across PTY launch, login, and quota fetch paths. */ +/* eslint-disable max-lines -- Why: keeps Codex's whole runtime-home contract in one place so account-switch semantics don't drift across launch/login/quota paths. */ import { appendFileSync, copyFileSync, @@ -92,20 +89,11 @@ type CodexReadBackMatch = | { kind: 'none' | 'ambiguous' } export class CodexRuntimeHomeService { - // Why: tracks whether the runtime auth.json currently mirrors a managed - // account. When null, runtime auth follows the user's system-default - // ~/.codex/auth.json instead of being written back to a managed account. + // Which managed account runtime auth.json mirrors; null means it follows system-default ~/.codex instead of a managed account. private lastSyncedAccountId: string | null = null - // Why: tracks the auth.json content Orca last wrote to the runtime CODEX_HOME. - // Between syncs, if the file differs, Codex CLI refreshed the token — so - // Orca writes back the refreshed token to managed storage before overwriting. - // On managed→system-default transition, if the file differs, an external - // login (e.g. `codex auth login`) overwrote it — so Orca adopts the file as - // the new system default instead of restoring a stale snapshot. + // Last auth.json Orca wrote to the runtime home; a later diff signals an out-of-band change (Codex token refresh, or external login to adopt). private lastWrittenAuthJson: string | null = null - // Why: WSL terminals have their own stable runtime homes per distro. They - // cannot share the host baseline or host sync can make stale WSL auth look - // newer than managed storage. + // Why: WSL terminals have per-distro runtime homes; sharing the host baseline can make stale WSL auth look newer than managed storage. private readonly lastWrittenWslAuthJsonByDistro = new Map() private readonly lastSyncedWslAccountIdByDistro = new Map() private readonly wslRuntimeHomePathByDistro = new Map() @@ -124,20 +112,13 @@ export class CodexRuntimeHomeService { settings.codexManagedAccounts, normalizeCodexRuntimeSelection(settings).host ) - // Why: WSL-managed homes are never materialized into host ~/.codex. - // Treating one as "last synced" makes cold start look like a host-account - // transition and can restore/delete host auth that Orca never touched. + // Why: WSL-managed homes never touch host ~/.codex; treating one as "last synced" makes cold start mangle host auth Orca never touched. this.lastSyncedAccountId = this.getWslManagedHomePath(activeAccount) ? null : normalizeCodexRuntimeSelection(settings).host } - /** - * Materializes the runtime home needed before launching the CLI. - * - * Historical session bridging is requested in the background so launch setup - * returns as soon as the active runtime home is ready. - */ + /** Materializes the runtime home needed before launching the CLI (session bridging runs in the background so setup returns once it's ready). */ prepareForCodexLaunch(target?: CodexAccountSelectionTarget): string | null { if (target?.runtime === 'wsl') { const wslTarget = this.resolveWslDefaultTarget(target) @@ -150,8 +131,7 @@ export class CodexRuntimeHomeService { this.syncForCurrentSelection() syncSystemCodexResourcesIntoManagedHome() syncSystemConfigIntoManagedCodexHome() - // Why: historical Codex sessions can be large; bridge them after launch - // setup so starting a fresh Codex TUI never waits on a full tree walk. + // Why: sessions can be large; bridge them after launch so starting a fresh TUI never waits on a full tree walk. void startSystemCodexSessionBridgeInBackground( {}, resolveHostCodexSessionSourceHome(this.store.getSettings()) @@ -171,16 +151,14 @@ export class CodexRuntimeHomeService { if (!distro) { return } - // Why: history-only override lets custom-CODEX_HOME users bridge from their - // real home; falls back to /.codex, which auth/config still use. + // Why: history-only override lets custom-CODEX_HOME users bridge from their real home; falls back to /.codex. const systemCodexHomePath = resolveWslCodexSessionSourceHome(this.store.getSettings(), distro) ?? this.getWslSystemCodexHomePath({ runtime: 'wsl', wslDistro: distro }) if (!systemCodexHomePath || systemCodexHomePath === runtimeHomePath) { return } - // Why: WSL history must be hardlinked inside the distro; host-side links - // cannot bridge Windows and WSL filesystems in a resume-visible way. + // Why: WSL history must be hardlinked inside the distro; host-side links can't bridge Windows and WSL filesystems in a resume-visible way. void startWslCodexSessionBridgeInBackground({ distro, systemCodexHomePath, @@ -240,8 +218,7 @@ export class CodexRuntimeHomeService { if (!systemHomePath || systemHomePath === runtimeHomePath) { return } - // Why: WSL uses a distro-local CODEX_HOME, so host resource mirroring - // cannot provide the distro user's global instructions. + // Why: WSL uses a distro-local CODEX_HOME, so host resource mirroring can't provide the distro user's global instructions. syncCodexGlobalInstructionsIntoManagedHome({ systemHomePath, managedHomePath: runtimeHomePath @@ -313,10 +290,7 @@ export class CodexRuntimeHomeService { } }) } - // Why: only restore the system-default mirror when transitioning FROM a - // managed account. When no managed account was ever active, later syncs - // should mirror the user's current ~/.codex/auth.json instead of - // replaying an old snapshot on every PTY launch / rate-limit fetch. + // Why: only restore the system-default mirror when leaving a managed account; otherwise later syncs mirror current ~/.codex instead of replaying an old snapshot. if (this.lastSyncedAccountId !== null) { this.restoreSystemDefaultSnapshot({ detectExternalLogin: outgoingReadBackResult !== 'rejected' @@ -332,16 +306,12 @@ export class CodexRuntimeHomeService { ) { this.restoreSystemDefaultSnapshot({ detectExternalLogin: false }) } else if (logoutMarkerStatus.kind === 'system-default-changed') { - // Why: a real ~/.codex logout after a local runtime logout should - // keep runtime auth absent instead of restoring the stale snapshot. + // Why: a real ~/.codex logout after a local runtime logout should keep runtime auth absent, not restore the stale snapshot. this.captureSystemDefaultSnapshot({ force: true }) this.persistRuntimeLogoutMarker(null) this.lastWrittenAuthJson = null } else if (this.lastWrittenAuthJson === null) { - // Why: Orca-launched Codex sessions now use an Orca-owned CODEX_HOME - // even when no managed account is selected. Seed that runtime home - // from the user's current system-default auth once so dev/prod Orca - // terminals stay logged in without mutating ~/.codex on startup. + // Why: unmanaged sessions use an Orca-owned CODEX_HOME; seed it once from system-default auth so terminals stay logged in without mutating ~/.codex. this.restoreSystemDefaultSnapshot({ detectExternalLogin: false }) } else { this.persistRuntimeLogoutMarker() @@ -376,10 +346,7 @@ export class CodexRuntimeHomeService { this.captureSystemDefaultSnapshot({ force: true }) } - // Why: Codex CLI refreshes expired OAuth tokens in CODEX_HOME/auth.json. - // If we detect the runtime file differs from what Orca last wrote, the CLI - // must have refreshed — so we preserve those tokens back to managed - // storage before overwriting runtime with managed state. + // Why: Codex refreshes OAuth tokens in the runtime auth.json; if it differs from Orca's last write, read those back to managed storage before overwriting. if (this.lastSyncedAccountId === activeAccount.id) { if (this.skipNextReadBackForAccountId === activeAccount.id) { this.skipNextReadBackForAccountId = null @@ -397,10 +364,7 @@ export class CodexRuntimeHomeService { this.writeRuntimeAuth(readFileSync(activeAuthPath, 'utf-8')) } - // Why: called by CodexAccountService before syncForCurrentSelection() after - // re-auth or add-account. Those flows write fresh tokens to managed storage, - // so the read-back must be skipped to avoid overwriting them with stale - // runtime tokens. + // Why: re-auth/add-account write fresh managed tokens, so skip the next read-back to avoid clobbering them with stale runtime tokens. clearLastWrittenAuthJson( accountId = normalizeCodexRuntimeSelection(this.store.getSettings()).host ): void { @@ -463,8 +427,7 @@ export class CodexRuntimeHomeService { } return 'rejected' } - // Why: after app restart, Orca has no last-written baseline. Identity - // alone cannot prove runtime auth is newer than managed storage. + // Why: after restart there's no last-written baseline, so identity alone can't prove runtime auth is newer than managed storage. if ( lastWrittenAuthJson === null && !this.runtimeAuthIsFresher(runtimeContents, match.managedAuthContents) @@ -482,9 +445,7 @@ export class CodexRuntimeHomeService { } return 'persisted' } catch (error) { - // Why: read-back is best-effort. A transient fs error must not block the - // forward sync path — the worst case is one more stale-token cycle, which - // is strictly better than failing the entire sync. + // Why: read-back is best-effort; a transient fs error must not block the forward sync — worst case is one more stale-token cycle. console.warn('[codex-runtime-home] Failed to read back refreshed tokens:', error) return 'rejected' } @@ -534,9 +495,7 @@ export class CodexRuntimeHomeService { const settings = this.store.getSettings() const selectedAccountId = getSelectedCodexAccountIdForTarget(settings, target) if (selectedAccountId === null) { - // Why: the system-default account changes outside Orca (login, logout, - // token refresh). Read its real home directly so a cached runtime copy - // cannot stay stale; filesystem probing in the fetcher is asynchronous. + // Why: the system-default account changes outside Orca, so read its real home directly to avoid a stale cached runtime copy. return this.getWslSystemCodexHomePath(target) } const cachedRuntimeHomePath = this.wslRuntimeHomePathByDistro.get(distro) @@ -545,9 +504,7 @@ export class CodexRuntimeHomeService { this.lastSyncedWslAccountIdByDistro.has(distro) && this.lastSyncedWslAccountIdByDistro.get(distro) === selectedAccountId ) { - // Why: RateLimitService resolves provenance twice per poll. Account - // changes sync explicitly, so repeated resolution must stay path-only - // instead of blocking main on UNC reads and a wsl.exe migration probe. + // Why: RateLimitService resolves provenance twice per poll; stay path-only so it doesn't block main on UNC reads and a wsl.exe probe. return cachedRuntimeHomePath } } @@ -640,9 +597,7 @@ export class CodexRuntimeHomeService { (mirroredSystemDefaultAuth === null && this.runtimeAuthIsFresher(runtimeAuth, systemAuth))) ) { - // Why: WSL runtime homes are per-distro and their in-memory baseline is - // lost on app restart. A same-identity fresher runtime auth is a Codex - // token refresh and should be copied back before we mirror ~/.codex. + // Why: WSL baselines are lost on restart, so a same-identity fresher runtime auth is a token refresh; copy it back before mirroring ~/.codex. this.writeRuntimeAuthAtPath(systemAuthPath, runtimeAuth) this.lastWrittenWslAuthJsonByDistro.set(distro, runtimeAuth) this.lastSyncedWslAccountIdByDistro.set(distro, null) @@ -722,8 +677,7 @@ export class CodexRuntimeHomeService { ) const nextLinuxPath = `${activeLinuxPath}.next-${process.pid}-${Date.now()}` const activeLinuxParentPath = this.dirnameLinuxPath(activeLinuxPath) - // Why: WSL drops bash argv here and login-shell cleanup can turn explicit - // `exit 0` into status 1, so keep this script literal and fall-through. + // Why: WSL drops bash argv, so keep the script literal; login-shell cleanup turns `exit 0` into status 1, so fall through. execFileSync( 'wsl.exe', [ @@ -844,10 +798,7 @@ export class CodexRuntimeHomeService { } const managedIdentity = this.readIdentityFromAuthContents(managedAuthContents) - // Why: old live Codex PTYs can still write refreshed tokens into the - // shared runtime home after the user switches accounts. Never persist - // that write into the newly active managed account unless the auth claims - // still match the account Orca believes is selected. + // Why: old PTYs may write refreshed tokens into the shared runtime after an account switch; only persist when the auth still matches the selected account. const selectedEmail = this.firstNonNull( this.normalizeField(activeAccount.email), managedIdentity?.email @@ -893,9 +844,7 @@ export class CodexRuntimeHomeService { return false } - // Why: stale managed Codex PTYs share the same runtime home. Only read a - // runtime refresh back into ~/.codex when the auth still claims the same - // system-default identity Orca mirrored earlier. + // Why: stale PTYs share the runtime home; only read a refresh back to ~/.codex when the auth still claims the mirrored system-default identity. if ( systemDefaultIdentity.email && runtimeIdentity.email && @@ -1238,9 +1187,7 @@ export class CodexRuntimeHomeService { this.migrateLegacySessions(managedHomePath, accountId) } - // Why: migration is intentionally one-shot. Re-importing every startup - // would keep replaying stale managed-home state back into the shared - // runtime and make it feel nondeterministic. + // Why: migration is one-shot; re-importing every startup would replay stale managed-home state into the shared runtime. writeFileAtomically( this.getMigrationMarkerPath(), `${JSON.stringify({ completedAt: Date.now(), migratedHomeCount: managedHomes.length })}\n` @@ -1348,8 +1295,7 @@ export class CodexRuntimeHomeService { } private appendListedFiles(target: string[], source: readonly string[]): void { - // Why: migrating legacy session trees must tolerate directories larger than - // V8's argument limit for spread calls. + // Why: tolerate directories larger than V8's argument limit for spread calls. for (const filePath of source) { target.push(filePath) } @@ -1366,8 +1312,7 @@ export class CodexRuntimeHomeService { try { appendFileSync(diagnosticsPath, `${JSON.stringify(record)}\n`, { encoding: 'utf-8' }) } catch (error) { - // Why: conflict diagnostics are useful, but must not make the one-shot - // migration fail after the session file has already been preserved. + // Why: diagnostics must not fail the one-shot migration after the session file is already preserved. console.warn('[codex-runtime-home] Failed to append migration diagnostic:', error) } } @@ -1418,17 +1363,13 @@ export class CodexRuntimeHomeService { systemDefaultAuth === mirroredSystemDefaultAuth && this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuth, mirroredSystemDefaultAuth) ) { - // Why: system-default Codex now refreshes tokens inside Orca's - // runtime CODEX_HOME. Read that refresh back to ~/.codex so the next - // sync does not overwrite fresh runtime credentials with stale ones. + // Why: Codex refreshes tokens in the runtime CODEX_HOME; read that back to ~/.codex so the next sync won't clobber fresh creds with stale ones. this.writeSystemDefaultAuth(runtimeAuth) this.captureSystemDefaultSnapshot({ force: true }) this.lastWrittenAuthJson = runtimeAuth return } - // Why: the unmanaged path used to read ~/.codex directly. Mirror later - // external logins/logouts into Orca's runtime home so ordinary Orca - // Codex sessions keep matching the user's current system-default state. + // Why: mirror external logins/logouts into Orca's runtime home so unmanaged Codex sessions keep matching the current system-default state. this.captureSystemDefaultSnapshot({ force: true }) this.writeRuntimeAuth(systemDefaultAuth) } @@ -1449,18 +1390,14 @@ export class CodexRuntimeHomeService { } if (options.detectExternalLogin && !existsSync(runtimeAuthPath)) { - // Why: once Orca owns the runtime CODEX_HOME, deleting auth.json there is - // a local logout signal for Orca-launched Codex sessions, not a reason to - // rewrite the user's real ~/.codex snapshot back into place. + // Why: with Orca owning CODEX_HOME, a deleted runtime auth.json is a local logout, not a cue to restore the user's real ~/.codex snapshot. this.persistRuntimeLogoutMarker() this.lastWrittenAuthJson = null return } if (options.detectExternalLogin) { - // Why: while a managed account is selected, the runtime auth file exists - // with managed credentials. If ~/.codex/auth.json vanished meanwhile, - // switching back must preserve that external system-default logout. + // Why: if ~/.codex/auth.json vanished while a managed account was selected, switching back must preserve that external system-default logout. rmSync(runtimeAuthPath, { force: true }) this.captureSystemDefaultSnapshot({ force: true }) this.persistRuntimeLogoutMarker() @@ -1507,9 +1444,7 @@ export class CodexRuntimeHomeService { } private clearRuntimeAuthAfterSystemDefaultLogout(runtimeAuthPath: string): void { - // Why: when the real ~/.codex auth disappears, Orca should treat that as an - // external logout for unmanaged sessions, even if runtime auth had already - // refreshed inside Orca's CODEX_HOME. + // Why: a vanished ~/.codex auth means external logout for unmanaged sessions, even if runtime auth already refreshed in Orca's CODEX_HOME. rmSync(runtimeAuthPath, { force: true }) this.captureSystemDefaultSnapshot({ force: true }) this.persistRuntimeLogoutMarker() @@ -1522,8 +1457,7 @@ export class CodexRuntimeHomeService { } private writeRuntimeAuth(contents: string): void { - // Why: auth.json contains sensitive credentials. Restrict to owner-only - // so other users on a shared Linux/macOS machine cannot read it. + // Why: auth.json holds credentials; restrict to owner-only so other users on a shared machine cannot read it. this.clearRuntimeLogoutMarker() if (this.fileContentsEqual(this.getRuntimeAuthPath(), contents)) { this.ensureOwnerOnlyMode(this.getRuntimeAuthPath()) @@ -1634,9 +1568,7 @@ export class CodexRuntimeHomeService { ) { return parsed as CodexSystemDefaultSnapshot } - // Why: pre-PR snapshots wrote raw auth.json contents verbatim. Treat any - // valid JSON object without an authJson wrapper as the legacy format so - // upgraders do not lose their system-default auth on first deselect. + // Why: pre-PR snapshots stored raw auth.json; treat objects lacking an authJson wrapper as legacy so upgraders don't lose their auth. if ( parsed && typeof parsed === 'object' && @@ -1656,9 +1588,7 @@ export class CodexRuntimeHomeService { } } -// Why: the seed config is read over UNC but consumed by Codex inside WSL, so -// relative path-valued settings must anchor to the Linux-side source home; a -// verbatim copy breaks Codex config load (os error 2). +// Why: Codex reads this config inside WSL, so relative path settings must anchor to the Linux-side home (verbatim copy breaks load, os error 2). export function prepareWslRuntimeSeedConfig( configContents: string, sourceHomePath: string diff --git a/src/main/codex-accounts/service.ts b/src/main/codex-accounts/service.ts index 2a0fb54bdc4..1eb3dedf561 100644 --- a/src/main/codex-accounts/service.ts +++ b/src/main/codex-accounts/service.ts @@ -1,6 +1,4 @@ -/* eslint-disable max-lines -- Why: this service intentionally keeps Codex -account lifecycle, path safety, login, and identity parsing in one audited -main-process module so the managed-account boundary stays explicit. */ +/* eslint-disable max-lines -- Why: keeps Codex account lifecycle, path safety, login, and identity parsing in one audited main-process module. */ import { randomUUID } from 'node:crypto' import { execFileSync, spawn } from 'node:child_process' import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs' @@ -55,8 +53,7 @@ type ResolvedCodexIdentity = { type CanonicalCodexConfig = { contents: string - /** Home the config was read from, in the path style Codex sees at runtime - * (Linux-side for WSL); relative path-valued settings resolve against it. */ + /** Home the config was read from, in the path style Codex sees (Linux-side for WSL); relative settings resolve against it. */ sourceHomePath: string } @@ -77,9 +74,7 @@ function shellQuote(value: string): string { } export class CodexAccountService { - // Why: account mutations read settings, do async work (login, rate-limit - // refresh), then write settings. Without serialization, overlapping calls - // (e.g. double-click "Add Account") can cause lost updates. + // Why: serialize the read-modify-write of settings; overlapping calls (e.g. double-click Add) would lose updates. private mutationQueue: Promise = Promise.resolve() constructor( @@ -170,8 +165,7 @@ export class CodexAccountService { this.runtimeHome.clearLastWrittenAuthJson(account.id) this.runtimeHome.syncForCurrentSelection() - // Why: the new account becomes active, so the previous active account is - // now inactive and its last-known usage should be cached for the switcher. + // Why: switching activates the new account, so cache the outgoing account's usage for the switcher. const outgoingAccountId = getSelectedCodexAccountIdForTarget(settings, targetSelection) await this.rateLimits.refreshForCodexAccountChange(outgoingAccountId, targetSelection) return this.getSnapshot() @@ -215,9 +209,7 @@ export class CodexAccountService { this.runtimeHome.clearLastWrittenAuthJson(accountId) this.runtimeHome.syncForCurrentSelection(getCodexSelectionTargetForAccount(account)) - // Why: re-auth can change which actual Codex identity the managed home - // points at. Force a fresh read immediately so the status bar cannot keep - // showing the previous account's quota under the updated label. + // Why: re-auth can change the underlying Codex identity, so force a fresh read to avoid showing stale quota. await this.rateLimits.refreshForCodexAccountChange( undefined, getCodexSelectionTargetForAccount(account) @@ -244,8 +236,7 @@ export class CodexAccountService { this.runtimeHome.syncForCurrentSelection() this.safeRemoveManagedHome(account.managedHomePath) - // Why: a removed account can no longer appear in the switcher dropdown, - // so purge its cached usage to avoid stale entries. + // Why: removed accounts can't appear in the switcher, so purge cached usage to avoid stale entries. this.rateLimits.evictInactiveCodexCache(accountId) await this.rateLimits.refreshForCodexAccountChange( getSelectedCodexAccountIdForTarget(settings, getCodexSelectionTargetForAccount(account)) === @@ -358,9 +349,7 @@ export class CodexAccountService { const managedHomePath = join(this.getManagedAccountsRoot(), accountId, 'home') mkdirSync(managedHomePath, { recursive: true }) - // Why: Codex expects CODEX_HOME to be a concrete directory it can own. We - // pre-create the directory and leave a marker so future cleanup code can - // prove the path belongs to Orca before deleting anything. + // Why: marker lets future cleanup prove the path belongs to Orca before deleting anything. writeFileSync(join(managedHomePath, '.orca-managed-home'), `${accountId}\n`, 'utf-8') return { managedHomePath: this.assertManagedHomePath(managedHomePath), @@ -462,12 +451,7 @@ export class CodexAccountService { } const trustedManagedHomePath = this.assertManagedHomePath(managedHomePath) - // Why: Orca account switching is meant to swap Codex credentials and quota - // identity, not silently fork the user's sandbox/config defaults. Syncing - // one canonical config into every managed home keeps auth isolated per - // account while preserving consistent Codex behavior. Managed homes are - // real CODEX_HOMEs for `codex login`, so relative path-valued settings - // must keep resolving against the home the config was read from. + // Why: sync one canonical config per home so switching swaps only auth/quota; rewrite relative paths to resolve against the source home. this.writeManagedConfig( trustedManagedHomePath, rewriteRelativePathConfigValues(canonicalConfig.contents, canonicalConfig.sourceHomePath) @@ -507,8 +491,7 @@ export class CodexAccountService { } try { - // Why: the config is read over UNC but consumed by Codex inside WSL, so - // path rewrites must anchor to the Linux-side ~/.codex, not the UNC path. + // Why: read over UNC but consumed inside WSL, so path rewrites must anchor to the Linux-side ~/.codex, not the UNC path. return { contents: readFileSync(configPath, 'utf-8'), sourceHomePath: `${wslHome}/.codex` } } catch (error) { console.warn('[codex-accounts] Failed to read WSL canonical config:', error) @@ -523,8 +506,7 @@ export class CodexAccountService { return } } catch { - // Why: read errors should not make a stale config look current; the - // atomic write path owns Windows ACL repair and persistent error surfacing. + // Why: a read error must not make a stale config look current; atomic write owns ACL repair and error surfacing. } writeFileAtomically(configPath, contents) } @@ -561,8 +543,7 @@ export class CodexAccountService { throw originalError } - // Why: explicit re-auth is allowed to recover from a lost empty container, - // but only at the exact Orca-owned account path persisted for this account. + // Why: re-auth may recreate a lost empty home, but only at the exact Orca-owned path persisted for this account. mkdirSync(expectedManagedHomePath, { recursive: true }) writeFileSync(join(expectedManagedHomePath, '.orca-managed-home'), `${account.id}\n`, 'utf-8') return this.assertManagedHomePath(expectedManagedHomePath) @@ -687,18 +668,11 @@ export class CodexAccountService { throw new Error('Managed Codex home directory does not exist on disk.') } - // realpath() requires the leaf to exist. For pre-login add flow we create - // the home directory first so the containment check still verifies the - // canonical on-disk target rather than trusting persisted text blindly. + // realpath() requires the leaf to exist; verify the canonical on-disk target rather than trusting persisted text. const canonicalCandidate = realpathSync(resolvedCandidate) const canonicalRoot = realpathSync(resolvedRoot) - // Why: the prefix check must compare canonical paths on both sides. On - // macOS, userData sits under /var/folders/... which realpath resolves to - // /private/var/folders/...; comparing a canonical candidate against a - // non-canonical root would spuriously reject every managed home. In dev - // mode (orca-dev/ vs orca/) this check also filters out production-rooted - // paths before downstream sync runs. + // Why: both sides must be canonical — macOS /var/folders realpaths to /private/var, else every managed home is rejected. if ( canonicalCandidate !== canonicalRoot && !canonicalCandidate.startsWith(canonicalRoot + sep) @@ -727,8 +701,7 @@ export class CodexAccountService { linuxHomePath: string, expectedAccountId: string ): void { - // Why: WSL home creation can fail after mkdir/marker write but before the - // path is trusted. Cleanup must prove the marker/account ID inside WSL. + // Why: creation can fail after mkdir/marker but before trust, so cleanup must verify the marker/account ID inside WSL. try { execFileSync( 'wsl.exe', @@ -784,13 +757,10 @@ export class CodexAccountService { return } - // Why: managed homes live at //home. Removing - // just the home/ leaf leaves an empty / directory behind. + // Why: homes live at //home; removing the home/ leaf leaves an empty / behind. try { const parentDir = resolve(managedHomePath, '..') - // Why: managedHomePath is already canonicalized by assertManagedHomePath, - // so the root must be canonicalized too for the prefix check to work on - // macOS where userData resolves through /private/var. + // Why: canonicalize the root too so the prefix check works on macOS where userData resolves through /private/var. const root = realpathSync(this.getManagedAccountsRoot()) if (parentDir.startsWith(root + sep) && parentDir !== root) { rmSync(parentDir, { recursive: true, force: true }) @@ -816,11 +786,7 @@ export class CodexAccountService { } : (() => { const codexCommand = resolveCodexCommand() - // Why: on Windows, resolveCodexCommand() may return a .cmd/.bat file - // (e.g. codex.cmd from npm). Node's child_process.spawn cannot execute - // batch scripts directly without shell:true, but shell:true with an args - // array causes DEP0190 because args are concatenated, not escaped. - // Fix: detect batch scripts and invoke cmd.exe /c explicitly. + // Why: Windows codex may be a .cmd/.bat; spawn+shell:true would trigger DEP0190, so invoke cmd.exe /c explicitly. const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(codexCommand, ['login']) return { command: spawnCmd, @@ -834,8 +800,7 @@ export class CodexAccountService { })() const child = spawn(spawnConfig.command, spawnConfig.args, { stdio: ['ignore', 'pipe', 'pipe'], - // Why: route through cmd.exe for .cmd/.bat entrypoints would otherwise - // flash a console window in the packaged GUI app on Windows. + // Why: prevents a console window flash for .cmd/.bat entrypoints routed through cmd.exe on Windows. windowsHide: true, env: spawnConfig.env }) @@ -881,9 +846,7 @@ export class CodexAccountService { const onError = (error: Error): void => { settle(() => { const isEnoent = (error as NodeJS.ErrnoException).code === 'ENOENT' - // Why: ENOENT can mean either the codex binary doesn't exist OR the - // script's shebang interpreter (node) isn't in PATH. When we resolved - // codex to a full path, ENOENT almost certainly means node is missing. + // Why: ENOENT is ambiguous — missing codex binary or missing node in PATH; a resolved full path implies node is missing. const isBareCommand = spawnConfig.codexCommand === 'codex' const message = isEnoent ? isBareCommand @@ -963,10 +926,7 @@ export class CodexAccountService { const authFilePath = join(this.assertManagedHomePath(managedHomePath), 'auth.json') const raw = JSON.parse(readFileSync(authFilePath, 'utf-8')) as Record - // Why: API-key-based auth files have no OAuth tokens or JWT identity - // claims. Returning nulls causes the caller to fail with a clear - // "could not resolve the account email" error rather than crashing - // on missing nested token fields. + // Why: API-key auth files have no OAuth/JWT claims; return nulls so the caller fails cleanly instead of crashing. if (typeof raw.OPENAI_API_KEY === 'string' && raw.OPENAI_API_KEY.trim() !== '') { return { idToken: null, diff --git a/src/main/codex/config-settings-promotion.ts b/src/main/codex/config-settings-promotion.ts index 7e8552bb9b6..a7a1add6857 100644 --- a/src/main/codex/config-settings-promotion.ts +++ b/src/main/codex/config-settings-promotion.ts @@ -19,16 +19,9 @@ import { updateTomlLineScanState } from './config-toml-line-scan' -// Why: the config mirror rewrites the runtime config.toml from ~/.codex on -// every launch (and on background rate-limit fetches), so settings the user -// changes inside Orca-launched Codex silently revert. Promotion diffs the -// runtime file against a baseline of what Orca last wrote — anything that -// differs is a change Codex persisted for the user and belongs in ~/.codex. +// Why: the mirror reverts in-Codex config changes each launch; promotion salvages them by diffing the last baseline. -// Why: only the user-preference scalars the Codex TUI itself persists -// (/model writes model + model_reasoning_effort, /approvals writes -// approval_policy + sandbox_mode). Every key added here gets written into the -// user's real ~/.codex/config.toml, so grow this list deliberately. +// Why: only scalars the Codex TUI persists; each key here is written to the user's real ~/.codex, so grow deliberately. export const PROMOTED_CODEX_SETTING_KEYS = [ 'model', 'model_reasoning_effort', @@ -38,8 +31,7 @@ export const PROMOTED_CODEX_SETTING_KEYS = [ type TopLevelSettingValue = { raw: string - // Why: a value that opens a multiline string/array cannot be replaced or - // copied line-by-line safely, so it is excluded from promotion entirely. + // Why: a multiline string/array value can't be replaced line-by-line, so it's excluded from promotion. multiline: boolean } @@ -78,9 +70,7 @@ function readSettingsBaseline(runtimeHomePath: string): Map | nu } } -// Why: only keys in the top-level preamble are scanned — Codex writes profile -// overrides into [profiles.*] tables, and rewriting nested tables surgically -// is not worth the risk for stage-1 promotion. +// Why: only top-level preamble keys are scanned; rewriting nested [profiles.*] tables isn't worth the risk here. function readTopLevelSettingValues(configPath: string): Map { const result = new Map() if (!existsSync(configPath)) { @@ -108,19 +98,16 @@ function readTopLevelSettingValues(configPath: string): Map = {} for (const [key, value] of readTopLevelSettingValues(runtimeTomlPath)) { if (!value.multiline) { @@ -130,8 +117,7 @@ export function snapshotCodexRuntimeSettingsBaseline( const file: SettingsBaselineFile = { version: 1, settings } const baselinePath = getSettingsBaselinePath(runtimeHomePath) const serialized = `${JSON.stringify(file, null, 2)}\n` - // Why: launch preparation can run repeatedly; skip byte-identical rewrites - // so an unchanged pass does no disk writes. + // Why: launch prep runs repeatedly; skip byte-identical rewrites to avoid needless disk writes. if (existsSync(baselinePath) && readFileSync(baselinePath, 'utf-8') === serialized) { return } @@ -157,19 +143,16 @@ function getHostPromotionHomes(): CodexSettingsPromotionHomes { } /** - * Promotes setting changes the user made inside Orca-launched Codex (written - * by Codex into the runtime config.toml) into ~/.codex/config.toml. Runs - * before the config mirror so the promoted values survive the same mirror - * pass instead of reverting. WSL callers pass explicit per-distro homes; the - * default is the host runtime home and host ~/.codex. + * Promotes in-Codex setting changes from the runtime config.toml into ~/.codex/config.toml. + * Runs before the config mirror so promoted values survive it instead of reverting. + * WSL callers pass explicit per-distro homes; default is the host runtime home and ~/.codex. */ export function promoteCodexRuntimeSettingsToSystem(homes?: CodexSettingsPromotionHomes): boolean { try { promoteCodexRuntimeSettingsToSystemUnsafe(homes ?? getHostPromotionHomes()) return true } catch (error) { - // Why: promotion is best-effort launch prep; callers preserve the runtime - // for retry, while a malformed file must not block Codex launch itself. + // Why: promotion is best-effort launch prep; a malformed file must not block Codex launch. console.warn('[codex-settings-promotion] failed to promote runtime settings', error) return false } @@ -185,11 +168,7 @@ function promoteCodexRuntimeSettingsToSystemUnsafe(homes: CodexSettingsPromotion if (!existsSync(runtimeTomlPath)) { return } - // Why: without a baseline of what Orca last mirrored (first launch after - // upgrading to a build with promotion, or a corrupted snapshot), a stale - // runtime value is indistinguishable from a fresh in-Codex change. Skip - // this pass — the mirror writes the first baseline and promotion starts on - // the next one. + // Why: without a baseline, a stale runtime value looks like a fresh in-Codex change; skip until the mirror writes one. const baseline = readSettingsBaseline(runtimeHomePath) if (!baseline) { return @@ -210,8 +189,7 @@ function promoteCodexRuntimeSettingsToSystemUnsafe(homes: CodexSettingsPromotion if (system?.multiline) { continue } - // Why: ~/.codex stays source of truth — if the user also edited it there - // since the baseline, the outside edit wins over the in-Codex change. + // Why: ~/.codex is source of truth — an outside edit since the baseline wins over the in-Codex change. if (system?.raw !== baseline.get(key)) { continue } @@ -220,23 +198,16 @@ function promoteCodexRuntimeSettingsToSystemUnsafe(homes: CodexSettingsPromotion if (updates.size === 0) { return } - // Why: a genuinely fresh host has no ~/.codex yet; without the directory - // the atomic write ENOENTs and the following mirror wipes the setting. - // Owner-only: the directory holds auth.json and the full user config. + // Why: a fresh host has no ~/.codex; create it owner-only (holds auth.json) or the atomic write ENOENTs and the mirror wipes it. mkdirSync(systemHomePath, { recursive: true, mode: 0o700 }) const writeTarget = resolvePromotionWriteTarget(systemTomlPath) - // Why: a dangling dotfile-manager symlink can point into a directory tree - // that has not been materialized yet; preserve the link and create its real - // parent so the atomic temp file can be written beside the target. + // Why: a dangling symlink may target an unmade dir tree; create its real parent so the atomic temp write has a home. mkdirSync(dirname(writeTarget.path), { recursive: true, mode: 0o700 }) const targetExists = existsSync(writeTarget.path) const systemContent = targetExists ? readFileSync(writeTarget.path, 'utf-8') : '' const nextContent = upsertTopLevelSettingsInContent(systemContent, updates) if (targetExists && parseWslUncPath(writeTarget.path)) { - // Why: symlink metadata through the \\wsl$ 9P provider is not reliable - // enough for realpath/lstat detection, and an atomic rename would replace - // a WSL-side dotfile symlink with a plain file. Writing through the - // existing file preserves the Linux-side inode (and its mode). + // Why: \\wsl$ 9P symlink metadata is unreliable; write through the existing file to preserve the WSL-side inode. writeFileSync(writeTarget.path, nextContent, 'utf-8') return } @@ -245,11 +216,7 @@ function promoteCodexRuntimeSettingsToSystemUnsafe(homes: CodexSettingsPromotion }) } -// Why: promotion rewrites the user's real config.toml. Follow an existing -// symlink (dotfile managers) instead of replacing the link with a plain file, -// and carry the real file's mode forward — an atomic write without a mode -// would widen a user-restricted 0600 config to the process umask default. -// A new or unreadable target is created owner-only. +// Why: follow an existing dotfile-manager symlink and carry its mode forward so an atomic write can't widen a 0600 config. function resolvePromotionWriteTarget(systemTomlPath: string): { path: string; mode: number } { try { const realPath = realpathSync(systemTomlPath) @@ -282,8 +249,7 @@ function resolveDanglingSymlinkTarget(linkPath: string): string { return currentPath } } - // Why: replacing any link in a cycle would destroy dotfile-manager state; - // abort promotion and leave the runtime/baseline intact for manual repair. + // Why: replacing any link in a cycle would destroy dotfile-manager state; abort instead. throw new Error(`Codex config symlink cycle at ${linkPath}`) } @@ -310,8 +276,7 @@ export function upsertTopLevelSettingsInContent( state = updateTomlLineScanState(state, line) } - // Why: CRLF configs keep a trailing \r after the split; new lines must use - // the file's existing endings or a Windows-owned config becomes mixed-EOL. + // Why: match the file's existing EOL (CRLF split leaves a trailing \r) so a Windows config doesn't go mixed-EOL. const usesCrlf = content.includes('\r\n') const insertions: string[] = [] for (const [key, raw] of updates) { diff --git a/src/main/codex/config-toml-trust.test.ts b/src/main/codex/config-toml-trust.test.ts index 9f8efbea403..0b661a3d590 100644 --- a/src/main/codex/config-toml-trust.test.ts +++ b/src/main/codex/config-toml-trust.test.ts @@ -28,10 +28,7 @@ import { type CodexTrustEntry } from './config-toml-trust' -// Why: this hash was captured from a real Codex 0.129 `/hooks` approval. If -// Codex changes its serialization or normalization rules, this test fails -// loudly instead of silently shipping bad trust entries that put hooks back -// into the review pile. +// Why: captured from a real Codex 0.129 `/hooks` approval; fails loudly if Codex's serialization drifts. const REAL_APPROVED_COMMAND = '/bin/sh "/tmp/orca-case-b-mCmCe6/agent-hooks/codex-hook.sh"' const REAL_APPROVED_HASH = 'sha256:bc013489dba495431d3790fda62ee5a7d907a7c491e29ad26238c3a5d6d2b163' @@ -153,12 +150,7 @@ describe('computeTrustedHash', () => { }) it('drops the matcher on user_prompt_submit/stop like matcher_pattern_for_event', () => { - // Why: Codex ignores matchers on these two events and hashes the - // identity WITHOUT the matcher field. A definition carrying - // `"matcher": ""` (common in third-party installers) must therefore - // hash identically to one without a matcher, or the system-trust - // mirror misses and the stale-entry sweep deletes the trust Codex - // wrote — re-prompting the user on every launch. + // Why: Codex hashes these two events WITHOUT the matcher, so `"matcher": ""` must hash like no matcher. for (const eventLabel of ['user_prompt_submit', 'stop'] as const) { const base: CodexTrustEntry = { sourcePath: '/x/hooks.json', @@ -174,12 +166,7 @@ describe('computeTrustedHash', () => { }) it('pins the matcher-omitted hash for a Stop entry that carries an empty matcher', () => { - // Why: regression pin for the shape observed in the wild (a real - // Codex 0.140 config.toml, path anonymized): Codex stores the - // matcher-omitted hash for Stop even when hooks.json carries - // `"matcher": ""`, so we must never fold the matcher into this - // identity. If serialization or normalization drifts, this constant - // fails loudly. + // Why: regression pin (real Codex 0.140 config) — Stop uses the matcher-omitted hash even with `"matcher": ""`. expect( computeTrustedHash({ sourcePath: '/home/user/.codex/hooks.json', @@ -291,8 +278,7 @@ describe('computeTrustKey', () => { }) it('uses native Windows backslashes in the trust key Codex looks up', () => { - // Why: Codex 0.140 writes approved Windows hook trust keys as raw native - // paths under [hooks.state]. + // Why: Codex 0.140 writes approved Windows hook trust keys as raw native paths under [hooks.state]. const winPath = 'C:\\Users\\Rod\\AppData\\Roaming\\orca\\hooks.json' const key = computeTrustKey({ sourcePath: winPath, @@ -306,8 +292,7 @@ describe('computeTrustKey', () => { }) it('preserves literal backslashes in non-Windows-style fallback paths', () => { - // Why: SSH/POSIX paths can legally contain `\` as a filename character; - // only Windows-style separators should be normalized. + // Why: POSIX/SSH paths can contain `\` as a filename char; only Windows separators get normalized. expect(getCodexCanonicalTrustPath('/tmp/with\\literal/hooks.json')).toBe( '/tmp/with\\literal/hooks.json' ) @@ -387,8 +372,7 @@ describe('upsertHookTrustEntries', () => { expect(written).not.toContain('STALE') expect(written).toContain('[unrelated]') expect(written).toContain('value = 42') - // Why: we only own the [hooks.state.""] block — the [features] - // block must be unchanged. + // Why: we only own the [hooks.state.""] block — [features] must be untouched. expect(written).toContain('[features]\nhooks = true') }) @@ -507,8 +491,7 @@ describe('upsertHookTrustEntries', () => { } upsertHookTrustEntries(configPath, [entry]) const firstWrite = readFileSync(configPath, 'utf-8') - // Why: a no-op upsert must not roll the .bak forward — repeated calls - // (e.g. from app start) would otherwise destroy the last recoverable copy. + // Why: a no-op upsert must not roll .bak forward, or repeated calls destroy the last recoverable copy. rmSync(`${configPath}.bak`, { force: true }) upsertHookTrustEntries(configPath, [entry]) expect(existsSync(`${configPath}.bak`)).toBe(false) @@ -516,8 +499,7 @@ describe('upsertHookTrustEntries', () => { }) it('replaces a stale block written with CRLF line endings without duplicating', () => { - // Why: regression — Windows-style \r\n in the existing config previously - // caused the header pattern to miss and append a duplicate block. + // Why: regression — \r\n in the existing config made the header pattern miss and append a duplicate. const key = '/x/hooks.json:pre_tool_use:0:0' const original = [ '[features]', @@ -598,8 +580,7 @@ describe('upsertHookTrustEntries', () => { expect(written).toContain('foo = 1') }) - // Why: TOML supports both basic-string and literal-string quoted keys; - // header detection must respect `]` inside `'...'` too. + // Why: TOML allows literal-string quoted keys, so header detection must respect `]` inside `'...'`. it('preserves an unrelated table whose literal-string key contains a `]`', () => { const key = '/x/hooks.json:pre_tool_use:0:0' const original = [ @@ -749,8 +730,7 @@ describe('upsertHookTrustEntries', () => { }) it('treats `\\"""` inside a multi-line basic string as an escaped quote, not a close', () => { - // Why: a basic multi-line string with `\"` escapes must not be misread as - // closing early — content and any following real header must survive intact. + // Why: `\"` escapes in a multi-line basic string must not be misread as closing early. const original = [ 'prompt = """', 'use \\"\\"\\" carefully', @@ -779,8 +759,7 @@ describe('upsertHookTrustEntries', () => { }) it('escapes literal `"` and `\\` in non-Windows source paths inside the trust block header', () => { - // Why: a backslash in a POSIX path can be a literal filename character, so - // it must still be escaped instead of normalized away. + // Why: a backslash in a POSIX path is a literal filename char, so escape it instead of normalizing. const entry: CodexTrustEntry = { sourcePath: '/x/with"quote\\and\\back/hooks.json', eventLabel: 'pre_tool_use', @@ -797,9 +776,7 @@ describe('upsertHookTrustEntries', () => { }) it('overwrites an existing block whose header has leading whitespace (TOML allows indent)', () => { - // Why: regression — buildHeaderPattern used to require column-0 headers, - // but the reader accepts indented ones. That mismatch caused upsert to - // append a duplicate `[hooks.state.""]` block, producing invalid TOML. + // Why: regression — buildHeaderPattern required column-0 headers but the reader accepts indented ones. const key = '/x/hooks.json:pre_tool_use:0:0' const original = ` [hooks.state."${key}"]\nenabled = true\ntrusted_hash = "sha256:OLD"\n` writeFileSync(configPath, original, 'utf-8') @@ -821,9 +798,7 @@ describe('upsertHookTrustEntries', () => { }) it('preserves `enabled = false` when the user hand-edited it before reinstall', () => { - // Why: regression — auto-install on app start used to clobber a - // hand-disabled hook back to enabled = true, removing the only way to - // mute Orca's hook short of full uninstall. + // Why: regression — auto-install used to clobber a hand-disabled hook back to enabled = true. const key = '/x/hooks.json:pre_tool_use:0:0' const original = `[hooks.state."${key}"]\nenabled = false\ntrusted_hash = "sha256:OLD"\n` writeFileSync(configPath, original, 'utf-8') @@ -844,9 +819,7 @@ describe('upsertHookTrustEntries', () => { }) it('overwrites an existing block when the file ends without a trailing newline', () => { - // Why: regression — buildHeaderPattern used to require a trailing - // `\r?\n`, missing it caused the upsert path to take the no-match branch - // and append a duplicate block at EOF. + // Why: regression — buildHeaderPattern required a trailing newline, appending a duplicate at EOF. const key = '/x/hooks.json:pre_tool_use:0:0' const original = `[hooks.state."${key}"]\nenabled = true\ntrusted_hash = "sha256:OLD"` writeFileSync(configPath, original, 'utf-8') @@ -868,10 +841,7 @@ describe('upsertHookTrustEntries', () => { }) it('overwrites an existing block whose header has an inline comment', () => { - // Why: regression — buildHeaderPattern used to require the header line - // to end at \r?\n or EOF, missing TOML-valid trailing comments. The - // upsert path then took the no-match branch and appended a duplicate - // `[hooks.state.""]` block, producing invalid TOML. + // Why: regression — buildHeaderPattern missed TOML-valid trailing comments and appended a duplicate block. const key = '/x/hooks.json:pre_tool_use:0:0' const original = `[hooks.state."${key}"] # user note\nenabled = true\ntrusted_hash = "sha256:OLD"\n` writeFileSync(configPath, original, 'utf-8') @@ -893,8 +863,7 @@ describe('upsertHookTrustEntries', () => { }) it('finds and replaces a legacy forward-slash block when Orca upserts with native backslash key', () => { - // Why: Codex 0.140 can expose Windows trust keys with either separator - // shape depending on startup cwd, so Orca replaces stale blocks with both. + // Why: Codex 0.140 exposes Windows keys with either separator depending on cwd, so replace both. const backslashPath = 'C:\\Users\\Rod\\AppData\\Roaming\\orca\\hooks.json' const legacyKey = `${backslashPath.replace(/\\/g, '/')}:session_start:0:0` const original = [ @@ -923,8 +892,7 @@ describe('upsertHookTrustEntries', () => { }) it('produces exactly one Windows separator pair after two consecutive upserts', () => { - // Why: idempotency guard — repeated auto-install on app start must not - // accumulate duplicate trust blocks and produce invalid TOML. + // Why: idempotency guard — repeated auto-install must not accumulate duplicate blocks. const entry: CodexTrustEntry = { sourcePath: 'C:\\Users\\Rod\\AppData\\Roaming\\orca\\hooks.json', eventLabel: 'session_start', @@ -941,8 +909,7 @@ describe('upsertHookTrustEntries', () => { }) it('falls back to TOML basic-string headers when a Windows path contains an apostrophe', () => { - // Why: TOML literal-string table keys cannot contain apostrophes, but - // Windows user/profile paths can. + // Why: TOML literal-string keys can't hold apostrophes, but Windows profile paths can. const entry: CodexTrustEntry = { sourcePath: "C:\\Users\\O'Connor\\AppData\\Roaming\\orca\\hooks.json", eventLabel: 'session_start', @@ -964,10 +931,7 @@ describe('upsertHookTrustEntries', () => { }) it('finds a Codex-written block with lowercased username when Orca key has mixed-case username', () => { - // Why: realpathSync.native casing can differ between what Codex wrote - // (C:\Users\rod\...) and what Orca resolves (C:\Users\Rod\...). - // normalizeHookTrustKeyForLookup case-folds Windows-shaped paths so the - // existing block is replaced rather than a duplicate appended. + // Why: realpathSync.native casing can differ from what Codex wrote, so case-fold to replace not duplicate. const lowercasePath = 'C:\\Users\\rod\\AppData\\Roaming\\orca\\hooks.json' const mixedCasePath = 'C:\\Users\\Rod\\AppData\\Roaming\\orca\\hooks.json' const literalKey = `${lowercasePath}:session_start:0:0` @@ -1057,8 +1021,7 @@ describe('upsertProjectTrustLevel', () => { }) it('preserves CRLF endings and writes native Windows path separators in the header', () => { - // Why: local project trust still follows Codex's realpath display, while - // remote project trust preserves the SSH provider's canonical path string. + // Why: local trust follows Codex's realpath; remote trust preserves the SSH provider's canonical path. const original = ['[profiles.default]', 'model = "gpt-5"', ''].join('\r\n') const updated = upsertProjectTrustLevelInContent(original, 'C:\\Users\\nw\\repo', 'trusted') @@ -1070,8 +1033,7 @@ describe('upsertProjectTrustLevel', () => { }) it('updates an existing Windows backslash project block after separator normalization', () => { - // Why: hook trust now writes paired Windows variants, but project trust - // must still repair an existing single project table in place. + // Why: hook trust writes paired Windows variants, but project trust still repairs a single table in place. const original = [ '[projects."C:\\\\Users\\\\nw\\\\repo"]', 'notes = "keep"', @@ -1089,8 +1051,7 @@ describe('upsertProjectTrustLevel', () => { }) it('updates an existing legacy Windows forward-slash project block', () => { - // Why: older Orca builds normalized Windows project paths to forward - // slashes; native-backslash hook fixes must not duplicate those blocks. + // Why: older Orca builds normalized to forward slashes; backslash fixes must not duplicate them. const original = [ '[projects."C:/Users/nw/repo"]', 'notes = "keep"', @@ -1149,8 +1110,7 @@ describe('upsertProjectTrustLevel', () => { }) it('keeps case-distinct WSL Linux project paths as separate trust blocks', () => { - // Why: the \\wsl$\ share is case-insensitive on Windows, but the - // Linux path underneath is not — .../Repo and .../repo are two projects. + // Why: \\wsl$\ is case-insensitive but the Linux path under it is not — two distinct projects. const existingPath = '\\\\wsl$\\Ubuntu\\home\\u\\Repo' const incomingPath = '\\\\wsl$\\Ubuntu\\home\\u\\repo' const original = [`[projects.'${existingPath}']`, 'trust_level = "untrusted"', ''].join('\n') @@ -1161,16 +1121,14 @@ describe('upsertProjectTrustLevel', () => { expect(updated.match(/\[projects\./g)).toHaveLength(2) expect(updated).toContain(`[projects.'${existingPath}']`) - // Why: serializer writes basic-string headers via escapeTomlString; assert - // that form so the fixture can't drift from real header matching. + // Why: serializer writes basic-string headers via escapeTomlString; assert that exact form. expect(updated).toContain(`[projects."${escapeTomlString(incomingPath)}"]`) expect(updated).toContain('trust_level = "untrusted"') expect(updated).toContain('trust_level = "trusted"') }) it('updates the same WSL project block across wsl$ and wsl.localhost spellings', () => { - // Why: the two share spellings alias the same distro filesystem, so a - // revoked project must not be re-trusted under the other spelling. + // Why: the two share spellings alias the same distro, so a revoke must not survive under the other. const original = [ "[projects.'\\\\wsl$\\Ubuntu\\home\\u\\proj']", 'trust_level = "untrusted"', @@ -1204,8 +1162,7 @@ describe('upsertProjectTrustLevel', () => { }) it('preserves an already-canonical remote Windows project path', () => { - // Why: SSH project paths are resolved on the remote; local realpath would - // canonicalize the wrong machine if the same path happens to exist locally. + // Why: SSH paths resolve on the remote; local realpath would canonicalize the wrong machine. const updated = upsertProjectTrustLevelInContent('', 'C:/Users/nw/repo', 'trusted', { alreadyCanonical: true }) @@ -1241,8 +1198,7 @@ describe('normalizeCodexProjectPathForLookup', () => { }) it('merges separator and distro-casing variants of the same WSL path', () => { - // Why: separators and the case-insensitive \\wsl$\ share may drift, - // but the same Linux path must still resolve to one trust key. + // Why: separator and \\wsl$\ casing may drift, but the same Linux path is one trust key. expect(normalizeCodexProjectPathForLookup('\\\\wsl$\\Ubuntu\\home\\u\\proj')).toBe( normalizeCodexProjectPathForLookup('//WSL$/ubuntu/home/u/proj') ) @@ -1451,9 +1407,7 @@ describe('removeHookTrustEntries', () => { }) it('preserves the line separator when no blank line precedes the removed block', () => { - // Why: regression — removeTrustBlock used to cut from match.index (the - // captured leading newline) and fused the previous content into the next - // header, producing invalid TOML like `a = 1[other]`. + // Why: regression — removeTrustBlock cut the leading newline, fusing prior content into the next header. const key = '/x/hooks.json:pre_tool_use:0:0' const original = [ 'a = 1', @@ -1498,8 +1452,7 @@ describe('removeHookTrustEntries', () => { }) it('removes a block whose header has an inline comment', () => { - // Why: paired with the upsert regression; the same pattern mismatch - // would silently leave the dead block in place during uninstall. + // Why: same pattern mismatch as the upsert regression would leave the dead block during uninstall. const key = '/x/hooks.json:pre_tool_use:0:0' const original = `[hooks.state."${key}"] # user note\nenabled = true\ntrusted_hash = "sha256:K"\n` writeFileSync(configPath, original, 'utf-8') @@ -1574,10 +1527,7 @@ describe('readHookTrustEntries', () => { }) it('normalizes backslash block key to forward-slash at ingestion', () => { - // Why: a real Windows path on disk like C:\foo gets written escaped as - // `C:\\foo` inside the TOML key. The Map key is normalized (backslash -> - // forward-slash) so computeTrustKey lookups match regardless of how Codex - // encoded the path separator. + // Why: normalize the Map key (backslash -> forward-slash) so computeTrustKey lookups match either encoding. const original = [ '[hooks.state."C:\\\\foo\\\\hooks.json:pre_tool_use:0:0"]', 'enabled = true', @@ -1608,8 +1558,7 @@ describe('readHookTrustEntries', () => { }) it('supports case-insensitive lookups for Windows hook trust keys read from config', () => { - // Why: Codex and realpathSync.native can disagree on user-path casing; - // status checks still need Map.get(computeTrustKey(...)) to find the row. + // Why: Codex and realpathSync.native can disagree on path casing, but lookups must still match. const rawKey = 'C:\\Users\\rod\\AppData\\Roaming\\orca\\hooks.json:session_start:0:0' const lookupKey = 'C:/Users/Rod/AppData/Roaming/orca/hooks.json:session_start:0:0' const original = [ @@ -1651,8 +1600,7 @@ describe('readHookTrustEntries', () => { }) it('keeps case-distinct WSL UNC hook paths distinct', () => { - // Why: the \\wsl$\ share is case-insensitive, but the Linux tail - // is not — folding the whole path would merge two distinct hook sources. + // Why: \\wsl$\ is case-insensitive but the Linux tail is not — don't fold two distinct sources. const upperKey = '\\\\wsl$\\Ubuntu\\home\\u\\Repo\\hooks.json:session_start:0:0' const lowerKey = '\\\\wsl$\\Ubuntu\\home\\u\\repo\\hooks.json:session_start:0:0' writeFileSync( @@ -1720,8 +1668,7 @@ describe('readHookTrustEntries', () => { }) it('does not extract a fake [hooks.state.""] header from inside a """ block', () => { - // Why: a header-shaped line embedded in a multi-line basic string must not - // be parsed as a real trust entry. + // Why: a header-shaped line inside a multi-line basic string must not parse as a real entry. const original = [ 'description = """', '[hooks.state."fake-key"]', @@ -1753,9 +1700,7 @@ describe('readHookTrustEntries', () => { }) it('reads a block whose header has an inline comment', () => { - // Why: regression — headerLineRegex used to reject TOML-valid trailing - // comments, hiding existing trust entries from getStatus and causing - // it to misreport hooks as untrusted. + // Why: regression — headerLineRegex rejected TOML-valid trailing comments, hiding trust entries. const key = '/x/hooks.json:pre_tool_use:0:0' const original = `[hooks.state."${key}"] # user note\nenabled = true\ntrusted_hash = "sha256:CMT"\n` writeFileSync(configPath, original, 'utf-8') @@ -1777,8 +1722,7 @@ describe('parseTrustKey', () => { }) it('parses a Windows-style sourcePath whose drive letter contains a colon', () => { - // Why: validates the "anchor on the LAST three colons" approach so colons - // inside the sourcePath itself round-trip correctly. + // Why: anchor on the LAST three colons so colons inside sourcePath round-trip. expect(parseTrustKey('C:\\Users\\x\\.codex\\hooks.json:session_start:2:3')).toEqual({ sourcePath: 'C:\\Users\\x\\.codex\\hooks.json', eventLabel: 'session_start', @@ -1825,8 +1769,7 @@ describe('parseTrustKey', () => { }) }) - // Why: Number('') === 0 silently passes Number.isInteger; without strict - // canonical-form validation, malformed keys would coerce into valid ones. + // Why: Number('') === 0 passes Number.isInteger, so empty segments need explicit rejection. it('returns null for empty group/handler segments', () => { expect(parseTrustKey('/x/hooks.json:pre_tool_use::0')).toBeNull() expect(parseTrustKey('/x/hooks.json:pre_tool_use:0:')).toBeNull() @@ -1841,9 +1784,7 @@ describe('parseTrustKey', () => { }) describe('upsertHookTrustEntries with array-of-tables boundaries', () => { - // Why: findNextTableHeader must treat `[[array.of.tables]]` as a block - // boundary; otherwise an upsert/remove can consume past array entries - // into unrelated user content. + // Why: [[array.of.tables]] must count as a block boundary, else upsert/remove eats past array entries. it('stops the replacement at a following [[array.of.tables]] header', () => { const key = '/x/hooks.json:pre_tool_use:0:0' const original = [ diff --git a/src/main/codex/config-toml-trust.ts b/src/main/codex/config-toml-trust.ts index eede0efea32..1313c4d1195 100644 --- a/src/main/codex/config-toml-trust.ts +++ b/src/main/codex/config-toml-trust.ts @@ -17,13 +17,8 @@ import { updateTomlLineScanState } from './config-toml-line-scan' -// Why: Codex 0.129+ gates each hook on a `trusted_hash` entry in -// ~/.codex/config.toml under [hooks.state.""]. Without it the hook is in -// the "review required" pile and never fires, so the agent-status sidebar -// silently goes blank. We reproduce Codex's hash so install() can register -// trust the same way `/hooks` would. Algorithm reverse-engineered from -// codex-rs/hooks/src/engine/discovery.rs (command_hook_hash) + -// codex-rs/config/src/fingerprint.rs (version_for_toml). +// Why: Codex 0.129+ gates each hook on a `trusted_hash` in config.toml under [hooks.state.""]; without it the hook never fires (agent-status goes blank). +// Hash algorithm reverse-engineered from codex-rs/hooks/src/engine/discovery.rs (command_hook_hash) + config/src/fingerprint.rs (version_for_toml). export type CodexEventLabel = | 'pre_tool_use' @@ -46,8 +41,7 @@ export type CodexTrustEntry = { handlerIndex: number /** The exact `command` string written to hooks.json. */ command: string - /** Effective timeout in seconds. When undefined, defaults to 600. - * Explicit values are clamped to a minimum of 1. */ + /** Effective timeout in seconds; defaults to 600 when undefined, explicit values clamped to a minimum of 1. */ timeoutSec?: number /** Whether the handler is async. Defaults to false. */ async?: boolean @@ -55,12 +49,9 @@ export type CodexTrustEntry = { matcher?: string /** Optional statusMessage field. */ statusMessage?: string - /** Verbatim hash to write instead of computing one. Used when carrying a - * Codex-written approval across files, so trust survives even if Codex's - * hash algorithm drifts from computeTrustedHash. Never fed into hashing. */ + /** Verbatim hash to write instead of computing one (survives Codex hash-algorithm drift). Never fed into hashing. */ trustedHash?: string - /** Explicit enabled state to write. When absent, a pre-existing - * `enabled = false` on the target block is preserved. */ + /** Explicit enabled state to write; when absent, a pre-existing `enabled = false` is preserved. */ enabled?: boolean } @@ -71,8 +62,7 @@ export type CodexHookTrustState = { export type CodexProjectTrustLevel = 'trusted' | 'untrusted' -// Why: callers use computeTrustKey() for lookups, while existing TOML can carry -// Codex-written separator/casing variants. Normalize both sides at the Map edge. +// Why: normalize keys at the Map edge so Codex-written separator/casing variants match computeTrustKey() lookups. class HookTrustEntryMap extends Map { override get(key: string): CodexHookTrustState | undefined { return super.get(normalizeHookTrustKeyForLookup(key)) @@ -91,8 +81,7 @@ class HookTrustEntryMap extends Map { } } -// Why: matches Codex's canonical_json. Sorts object keys recursively before -// SHA-256ing; arrays preserve order. +// Why: matches Codex's canonical_json — recursively sorts object keys before hashing; arrays keep order. function canonicalize(value: unknown): unknown { if (Array.isArray(value)) { return value.map(canonicalize) @@ -107,14 +96,7 @@ function canonicalize(value: unknown): unknown { return value } -// Why: reproduces matcher_pattern_for_event (codex-rs -// hooks/src/events/common.rs). Codex ignores matchers on -// UserPromptSubmit/Stop and drops them from the hook identity BEFORE -// hashing, so a hooks.json entry carrying `"matcher": ""` on those -// events must hash the same as one without it. Including it computes a -// hash Codex never writes: system trust then looks stale, and -// removeStaleRuntimeHookTrustEntries deletes the entry Codex wrote on -// every launch — an endless re-trust prompt for those two events. +// Why: mirrors codex-rs matcher_pattern_for_event (hooks/src/events/common.rs) — Codex drops matchers on user_prompt_submit/stop before hashing, so including one yields a hash it never writes → endless re-trust. function matcherPatternForEvent( eventLabel: CodexEventLabel, matcher: string | undefined @@ -133,12 +115,7 @@ function matcherPatternForEvent( } } -// Why: reproduces command_hook_hash. NormalizedHookIdentity has `group: -// MatcherGroup` flattened in, so the wire shape is { event_name, matcher?, -// hooks: [] }. `matcher` is omitted (not null) when -// absent — Rust's Option=None drops through the TOML→JSON path — -// and normalized per event first (see matcherPatternForEvent). -// Handler is normalized to timeout=600 (or explicit, min 1) and async=false. +// Why: reproduces Codex's command_hook_hash; wire shape is { event_name, matcher?, hooks:[handler] } with matcher omitted (not null) when absent. export function computeTrustedHash(entry: CodexTrustEntry): string { const handler: Record = { type: 'command', @@ -167,10 +144,7 @@ export function computeTrustKey(entry: CodexTrustEntry): string { export function getCodexCanonicalTrustPath(sourcePath: string): string { try { - // Why: Codex canonicalizes trust paths before building config keys. On - // macOS, /var is a symlink to /private/var; trusting the raw path still - // leaves the TUI in review/trust prompts. On Windows, Codex 0.140 writes - // hook state keys with native raw backslashes under an explicit parent table. + // Why: Codex canonicalizes trust paths (e.g. macOS /var→/private/var symlink); a raw path leaves the TUI stuck in trust prompts. return realpathSync.native(sourcePath) } catch { return normalizeWindowsPathForCodexLookup(sourcePath) @@ -179,8 +153,7 @@ export function getCodexCanonicalTrustPath(sourcePath: string): string { function getCodexCanonicalProjectPath(projectPath: string): string { try { - // Why: local project trust still needs Codex's realpath shape, but remote - // SSH callers pass an already-canonical remote path string. + // Why: local trust needs Codex's realpath shape, but remote SSH callers already pass a canonical path. return realpathSync.native(projectPath) } catch { return projectPath @@ -209,20 +182,17 @@ function usesWindowsPathSeparators(sourcePath: string): boolean { ) } -// Why: Codex and Orca can disagree on quote style, separators, and casing for -// the same Windows project, including when the caller targets a remote host. +// Why: Codex and Orca can disagree on quote style, separators, and casing for the same Windows project. export function normalizeCodexProjectPathForLookup(projectPath: string): string { if (!usesWindowsPathSeparators(projectPath)) { return projectPath } - // Why: the Linux path under a WSL share is case-sensitive, so folding it would - // conflate distinct dirs (e.g. .../Repo vs .../repo) onto one trust key. + // Why: the Linux tail under a WSL share is case-sensitive, so folding it would conflate distinct dirs onto one key. const slashedPath = normalizeWindowsPathSeparators(projectPath) return foldWslUncPathCaseInsensitiveParts(slashedPath) ?? slashedPath.toLowerCase() } -// Why: trust revocations recorded before WSL tails compared case-sensitively -// can carry drifted casing; fold fully so matching errs toward revoked. +// Why: old revocations can carry drifted casing; fold fully so matching errs toward revoked. export function normalizeCodexProjectPathForRevocationLookup(projectPath: string): string { const normalized = normalizeCodexProjectPathForLookup(projectPath) return usesWindowsPathSeparators(projectPath) ? normalized.toLowerCase() : normalized @@ -234,9 +204,7 @@ export function parseTrustKey(key: string): { groupIndex: number handlerIndex: number } | null { - // Why: keys have shape `:::`. - // sourcePath itself may contain `:` (Windows drive letters), so anchor the - // parse at the LAST three colons rather than the first. + // Why: sourcePath may contain `:` (Windows drive letters), so anchor the parse at the last three colons. const lastColon = key.lastIndexOf(':') if (lastColon === -1) { return null @@ -273,8 +241,7 @@ export function parseTrustKey(key: string): { } } -// Why: Number('') === 0 and Number('1e2') === 100 both pass Number.isInteger, -// so reject any non-canonical decimal form before numeric conversion. +// Why: Number('') === 0 and Number('1e2') === 100 both pass Number.isInteger, so reject non-canonical decimal forms first. function isCanonicalNonNegativeInt(value: string): boolean { return /^(0|[1-9]\d*)$/.test(value) } @@ -292,25 +259,14 @@ function isCodexEventLabel(value: string): value is CodexEventLabel { ) } -// Why: TOML 1.0 forbids BOMs but real-world editors (especially on Windows) sometimes -// write them. A leading  would break header regexes anchored at `^[ \t]*\[`, so -// strip it once at the file boundary and let the rest of the parser stay simple. +// Why: strip a leading BOM (some Windows editors write one) so header regexes anchored at `^[ \t]*\[` still match. function readTomlFile(configPath: string): string { const raw = readFileSync(configPath, 'utf-8') return raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw } -// Why: regex-edit ~/.codex/config.toml rather than parse + reserialize. The -// file is hand-edited by users (and other tools) and a round-trip through -// any TOML library would lose comments, key ordering, and inline-table -// style. We only ever (a) replace an existing [hooks.state.""] block -// keyed by *our* known hook keys, or (b) append a new block at EOF. Other -// content is byte-preserved. -// Why: this is a read-modify-write with no inter-process lock. Codex CLI's -// /hooks flow also writes [hooks.state.*] blocks in this file, so two -// concurrent writers can lose each other's edits. writeConfigAtomically -// prevents partial writes but not lost updates. install() is idempotent -// (deterministic hashes), so the next install() repairs drift. +// Why: regex-edit config.toml (not parse+reserialize) to byte-preserve user comments, key ordering, and inline-table style. +// Why: this read-modify-write has no lock and races Codex's /hooks writer, but idempotent install() repairs any lost update. export function upsertHookTrustEntries( configPath: string, entries: readonly CodexTrustEntry[] @@ -402,10 +358,7 @@ export function upsertProjectTrustLevelInContent( return `${existing.slice(0, headerLineEnd)}${eol}${trustLine}${existing.slice(headerLineEnd)}` } -// Why: build the canonical block we own. The two field names mirror what -// Codex itself writes when the user approves via /hooks (HookStateToml -// fields). `enabled` is plumbed through so an existing user-set -// `enabled = false` survives reinstall. +// Why: field names mirror Codex's HookStateToml (/hooks approval); `enabled` is plumbed so a user-set `enabled = false` survives reinstall. function buildTrustBlock(key: string, hash: string, enabled: boolean): string { return [ `[hooks.state.${formatHookStateTableKey(key)}]`, @@ -417,8 +370,7 @@ function buildTrustBlock(key: string, hash: string, enabled: boolean): string { function formatHookStateTableKey(key: string): string { const parsed = parseTrustKey(key) if (parsed && usesWindowsPathSeparators(parsed.sourcePath) && !key.includes("'")) { - // Why: Codex 0.140 trusts Windows hooks only when the state table keys - // match the raw native path shape it writes after /hooks approval. + // Why: Codex 0.140 trusts Windows hooks only when state table keys match the raw native path shape it writes. return `'${key}'` } return `"${escapeTomlString(key)}"` @@ -436,8 +388,7 @@ function getTrustKeyWriteVariants(key: string): string[] { ].filter((variant, index, variants) => variants.indexOf(variant) === index) } -// Why: TOML basic strings forbid raw control chars; escape backslash first so -// later substitutions don't double-escape the inserted backslashes. +// Why: escape backslash first so later substitutions don't double-escape the inserted backslashes. export function escapeTomlString(value: string): string { return value .replaceAll('\\', '\\\\') @@ -469,19 +420,12 @@ function upsertTrustBlocks( if (content.length === 0) { return `${block}\n` } - // Why: leave one blank line before our appended block so the file stays - // readable, but don't compound separators when the file already ends in - // a blank line. + // Why: one blank line before the appended block, without compounding separators when the file already ends blank. const separator = content.endsWith('\n\n') ? '' : content.endsWith('\n') ? '\n' : '\n\n' return `${content}${separator}${block}\n` } - // Why: preserve a user-set `enabled = false` so a hand-disabled hook is not - // silently re-enabled by the next auto-install on app start. - // If duplicate blocks already exist, treat any disabled copy as authoritative - // while collapsing the malformed TOML back to one table. - // An explicit enabled state (write-back promotion of an in-Orca /hooks - // toggle) overrides that preservation: it IS the user's latest decision. + // Why: preserve a user-set `enabled = false` (any disabled duplicate wins) unless an explicit state overrides it. const enabled = explicitEnabled ?? !ranges.some((range) => { @@ -505,8 +449,7 @@ function upsertTrustBlocks( } function buildTrustBlocks(keys: readonly string[], hash: string, enabled: boolean): string { - // Why: Codex 0.140 can expose Windows hook-state keys with either native - // backslashes or forward slashes depending on the cwd string used at startup. + // Why: Codex 0.140 exposes Windows hook-state keys with either backslashes or forward slashes depending on startup cwd. return keys.map((key) => buildTrustBlock(key, hash, enabled)).join('\n\n') } @@ -533,13 +476,10 @@ type TrustBlockRange = { end: number } -// Why: separator and casing drift between Codex-written keys (raw backslash, -// potentially lowercased) and Orca-built keys (forward-slash, realpathSync.native -// casing) must not prevent findTrustBlockRanges from matching an existing block. +// Why: separator/casing drift between Codex-written and Orca-built keys must not stop findTrustBlockRanges from matching. export function normalizeHookTrustKeyForLookup(key: string): string { const parsed = parseTrustKey(key) - // Why: fold by path shape, not host platform — hook sources on WSL and SSH - // Windows remotes need the same folding when Orca runs on macOS or Linux. + // Why: fold by path shape, not host platform — WSL/SSH Windows remotes need folding even when Orca runs on macOS/Linux. const foldedPath = normalizeCodexProjectPathForLookup(parsed ? parsed.sourcePath : key) return parsed ? `${foldedPath}:${parsed.eventLabel}:${parsed.groupIndex}:${parsed.handlerIndex}` @@ -578,8 +518,7 @@ type ParsedTomlString = { endIndex: number } -// Why: Codex can write hook-state dotted keys as TOML basic strings while -// users/Codex TUI may leave equivalent literal-string keys behind. +// Why: hook-state keys appear as both TOML basic strings and equivalent literal-string keys. function parseHookStateHeaderKey(line: string): string | null { const trimmed = line.trimStart() const prefixMatch = /^\[[ \t]*hooks[ \t]*\.[ \t]*state[ \t]*\.[ \t]*/.exec(trimmed) @@ -599,8 +538,7 @@ function parseHookStateHeaderKey(line: string): string | null { } export function parseCodexProjectHeaderPath(line: string): string | null { - // Why: mirror section headers come from split CRLF files and retain the - // terminal carriage return, while direct upserts scan CR-stripped lines. + // Why: mirror section headers retain a terminal CR (split CRLF files) while direct upserts scan CR-stripped lines. const trimmed = line.replace(/\r$/, '').trimStart() const prefixMatch = /^\[[ \t]*projects[ \t]*\.[ \t]*/.exec(trimmed) if (!prefixMatch) { @@ -688,9 +626,7 @@ function skipTomlInlineWhitespace(line: string, startIndex: number): number { } return index } -// Why: quoted keys can contain `]` (e.g. `[hooks.state."a]b"]`) and `[` lines -// inside multi-line strings aren't headers, so we need a stateful scanner — -// a flat regex misclassifies both cases. +// Why: quoted keys can contain `]` and `[` lines inside multi-line strings aren't headers, so a flat regex misclassifies both — need a stateful scan. function findNextTableHeader(text: string): number { let cursor = 0 let scanState = createTomlLineScanState() @@ -701,9 +637,7 @@ function findNextTableHeader(text: string): number { const line = rawLine.replace(/\r$/, '') if (isTomlStructuralLine(scanState)) { const trimmed = line.trimStart() - // Why: stop at both `[table]` and `[[array.of.tables]]` — both end our - // block. Skipping `[[ ]]` here would let our slice consume past array - // entries into unrelated user content. + // Why: stop at both `[table]` and `[[array.of.tables]]`; skipping `[[ ]]` would let the slice consume unrelated content. if (trimmed.startsWith('[') && isCompleteTableHeader(trimmed)) { return cursor } @@ -717,10 +651,7 @@ function findNextTableHeader(text: string): number { return -1 } -// Why: walk the header byte-by-byte so `]` inside a quoted key segment -// doesn't terminate us early. Basic strings honor `\` escapes; literal -// strings (single quotes) don't allow escapes per TOML spec. -// Accepts both `[table]` and `[[array.of.tables]]` since either ends a block. +// Why: walk byte-by-byte so a `]` inside a quoted key segment doesn't terminate the header early. function isCompleteTableHeader(line: string): boolean { if (!line.startsWith('[')) { return false @@ -775,10 +706,7 @@ function isCompleteTableHeader(line: string): boolean { return false } -// Why: same atomic-rename + .bak rotation pattern as writeHooksJson — a -// half-written config.toml can brick a user's Codex install, so write to -// tmp and rename. Random-suffix tmp name avoids cross-process races on -// rapid reinstalls. +// Why: a half-written config.toml can brick Codex, so write to a random-suffix tmp then rename (.bak rotation), avoiding cross-process races. export function writeConfigAtomically(configPath: string, contents: string): void { const dir = dirname(configPath) mkdirSync(dir, { recursive: true }) @@ -838,8 +766,7 @@ export function readHookTrustEntries(configPath: string): Map): void { - // Why: Codex rejects unknown top-level hooks.json fields, so plugin manager - // bookkeeping such as `_managed` must not survive Orca's rewrite. + // Why: Codex rejects unknown top-level hooks.json fields, so plugin bookkeeping like `_managed` must not survive Orca's rewrite. writeHooksJson(configPath, { hooks }) } @@ -95,8 +90,7 @@ function getCodexConfigTomlPath(): string { return join(getOrcaManagedCodexHomePath(), 'config.toml') } -// Why: the managed-event subset of the shared PascalCase→label map; the -// full mapping lives in codex-hook-identity.ts so promotion can't drift. +// Why: managed-event subset of the shared label map; full mapping lives in codex-hook-identity.ts so promotion can't drift. const CODEX_EVENT_LABEL: Record<(typeof CODEX_EVENTS)[number], CodexEventLabel> = { SessionStart: CODEX_HOOK_EVENT_LABEL.SessionStart!, UserPromptSubmit: CODEX_HOOK_EVENT_LABEL.UserPromptSubmit!, @@ -141,8 +135,7 @@ export type { CodexWslRuntimeHookInstallPlan } function wrapReadablePosixHookCommand(scriptPath: string): string { const quoted = `'${scriptPath.replaceAll("'", "'\\''")}'` - // Why: WSL runtime hooks are written from Windows through UNC, where the - // executable bit is not reliable; a missing script must still own stdin. + // Why: WSL hooks are written from Windows over UNC where the exec bit is unreliable; a missing script must still own stdin. return `if [ -f ${quoted} ] && [ -r ${quoted} ]; then /bin/sh ${quoted}; else ${POSIX_HOOK_STDIN_DRAIN_COMMAND}; fi` } @@ -240,9 +233,7 @@ function commandUsesCodexPluginOnlyPlaceholder(command: string | undefined): boo } function removeCodexPluginEnvironmentCommands(definitions: HookDefinition[]): HookDefinition[] { - // Why: Orca mirrors system hooks into a plain runtime hooks.json. Plugin - // placeholders only work for Codex plugin hook sources, so copying those - // commands here strips the environment they require and turns them into 127s. + // Why: plugin placeholders only resolve for Codex plugin hook sources; mirroring them into a plain runtime hooks.json turns them into 127s. return removeManagedCommands(definitions, commandUsesCodexPluginOnlyPlaceholder) } @@ -282,9 +273,7 @@ function getRuntimeHooksWithSystemUserHooks( continue } - // Why: runtime hooks are derived from the user's system hooks plus Orca's - // managed hooks. Reusing old runtime user-hook copies would keep deleted or - // edited ~/.codex/hooks.json entries alive for new Orca-launched sessions. + // Why: rebuild from system hooks; reusing old runtime copies would keep deleted/edited ~/.codex/hooks.json entries alive for new sessions. nextHooks[eventName] = dedupeHookDefinitions(systemUserDefinitions) } @@ -314,8 +303,7 @@ function getTrustedSystemUserHookSignatures( try { trustEntries = readHookTrustEntries(getSystemCodexConfigTomlPath()) } catch (error) { - // Why: a hand-broken system config.toml should only disable user-hook - // trust mirroring; Orca's managed runtime hooks can still be installed. + // Why: a hand-broken system config.toml should only disable user-hook trust mirroring, not block Orca's managed runtime hooks. console.warn('[codex-hook-service] failed to read system hook trust entries', error) return signatures } @@ -346,8 +334,7 @@ function getTrustedSystemUserHookSignatures( return } const signature = getCodexHookTrustSignature(entry) - // Why: runtime deduping collapses identical system hook definitions; - // if any duplicate remains enabled, keep the mirrored hook enabled. + // Why: runtime deduping collapses identical definitions; if any duplicate stays enabled, keep the mirrored hook enabled. if (state.enabled || !signatures.has(signature)) { signatures.set(signature, state) } @@ -372,11 +359,7 @@ function resolveTrustedSystemHookState( return { enabled: reorderedEnabled, trustedHash: expectedHash } } if (state?.trustedHash) { - // Why: carry a key-matched system hash verbatim instead of dropping it as - // stale. Codex is the authority on its own hash algorithm; recomputing - // here is what turned #7110-style hash drift into an endless re-approval - // loop. If the hash is genuinely stale (edited hook), Codex prompts — - // exactly what a plain ~/.codex session would do. + // Why: carry a key-matched system hash verbatim — recomputing caused #7110 re-approval loops since Codex owns its hash algorithm. return { enabled: state.enabled !== false, trustedHash: state.trustedHash } } return null @@ -402,8 +385,7 @@ function getTrustedSystemHookHashesByEvent( trustedHashesByEvent.set(parsed.eventLabel, hashes) } const enabled = state.enabled !== false - // Why: Codex trust keys include hook indices. If a user reorders hooks, - // the hash still proves the same event+command identity was approved. + // Why: Codex trust keys include hook indices, but the hash still proves the same event+command identity was approved after a reorder. if (enabled || !hashes.has(state.trustedHash)) { hashes.set(state.trustedHash, enabled) } @@ -488,8 +470,7 @@ function buildHookTrustHeaderKeyPattern(key: string): string { const alternatives = [...new Set(keyVariants)].flatMap((variant) => { const quoted = [`"${escapeRegex(escapeTomlString(variant))}"`] if (!variant.includes("'")) { - // Why: tolerate raw-backslash literal keys left by Codex/manual approval - // while Orca repairs mirrored runtime trust across both Windows variants. + // Why: tolerate raw-backslash literal keys from Codex/manual approval while repairing mirrored runtime trust across both Windows variants. quoted.push(`'${escapeRegex(variant)}'`) } return quoted @@ -573,11 +554,9 @@ function cleanupLegacySystemManagedHooks(): void { } } - // Why: Codex hooks moved to Orca's managed CODEX_HOME; old entries in - // ~/.codex would keep external Codex sessions reporting into Orca. + // Why: Codex hooks moved to Orca's managed CODEX_HOME; stale ~/.codex entries would keep external Codex sessions reporting into Orca. if (removedManagedHook) { - // Why: this is the user's system hooks file, not Orca's runtime copy. - // Remove only stale Orca hook entries and preserve other managers' metadata. + // Why: this is the user's system hooks file, not Orca's runtime copy; remove only stale Orca entries and preserve other managers' metadata. writeHooksJson(legacyConfigPath, { ...config, hooks: nextHooks }) } removeMatchingTrustEntries(getSystemCodexConfigTomlPath(), trustEntries) @@ -612,8 +591,7 @@ function cleanupLegacyCodexProfileHooks(): void { if (next === existing) { return } - // Why: #2778 wrote Orca hooks into a Codex profile file. Runtime CODEX_HOME - // supersedes that representation, so remove only Orca's marked block. + // Why: #2778 wrote Orca hooks into a Codex profile file; runtime CODEX_HOME supersedes it, so remove only Orca's marked block. if (next.trim().length === 0) { unlinkSync(profilePath) } else { @@ -639,10 +617,7 @@ function removeRuntimeManagedHookTrustEntries(configPath: string): void { const managedEventLabels = new Set( CODEX_EVENTS.map((event) => CODEX_EVENT_LABEL[event]) ) - // Why: only drop entries WE wrote. The same config.toml can contain - // user-approved trust entries for non-Orca commands, so match by hash - // equivalence to our managed command — a sourcePath-only filter would - // wipe the user's manually-approved entries. + // Why: drop only entries we wrote — match by hash to our managed command, since a sourcePath-only filter would wipe user-approved non-Orca entries. const ourKeys: string[] = [] const canonicalConfigPath = getCodexCanonicalTrustPath(configPath) for (const [key, state] of existingEntries) { @@ -662,8 +637,7 @@ function removeRuntimeManagedHookTrustEntries(configPath: string): void { groupIndex: parts.groupIndex, handlerIndex: parts.handlerIndex, command, - // Why: match the timeout install() wrote, or remove() would fail to - // recognize (and clean up) its own managed trust entries. + // Why: match the timeout install() wrote, or remove() can't recognize and clean up its own managed trust entries. timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS } const recognizedHashes = new Set([ @@ -679,8 +653,7 @@ function removeRuntimeManagedHookTrustEntries(configPath: string): void { removeHookTrustEntries(tomlPath, ourKeys) } } catch (error) { - // Best effort — stale trust entries are harmless once hooks.json no - // longer references the hook. Log so a programmer error doesn't disappear silently. + // Best effort — stale trust is harmless once hooks.json no longer references the hook; log so a programmer error isn't silent. console.warn('[codex-hook-service] failed to clean trust entries', error) } } @@ -725,8 +698,7 @@ function removeWslRuntimeManagedHookTrustEntries(plan: CodexWslRuntimeHookInstal removeHookTrustEntries(plan.tomlPath, ourKeys) } } catch (error) { - // Why: removing disabled WSL status hooks should be best-effort like the - // host cleanup path; stale trust is inert once hooks.json no longer points at us. + // Why: best-effort like host cleanup; stale trust is inert once hooks.json no longer points at us. console.warn('[codex-hook-service] failed to clean WSL trust entries', error) } } @@ -749,8 +721,7 @@ function removeStaleWslRuntimeManagedHookTrustEntries( continue } const sourcePath = parts.sourcePath - // Why: this cleanup owns only guest-side WSL trust. A runtime config can - // still contain user Windows/remote hooks, which must remain untouched. + // Why: this cleanup owns only guest-side WSL trust; leave any user Windows/remote hooks in the runtime config untouched. if (!sourcePath.startsWith('/') || !sourcePath.endsWith('/hooks.json')) { continue } @@ -782,10 +753,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { return [ '@echo off', 'setlocal', - // Why: see claude/hook-service.ts for rationale. The endpoint file holds - // the live port/token for this Orca install; sourcing it here lets a - // surviving PTY reach the current server even though its env points at - // the prior Orca's coordinates. + // Why: the endpoint file holds this install's live port/token; sourcing it lets a surviving PTY reach the current server (see claude/hook-service.ts). 'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul', ...buildWindowsHookEnvironmentGuardLines(), buildWindowsAgentHookCurlPostCommand('codex'), @@ -798,15 +766,12 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { return [ '#!/bin/sh', ...buildPosixHookPayloadCapture(), - // Why: see claude/hook-service.ts for rationale. Sourcing refreshes - // PORT/TOKEN/ENV/VERSION from the current Orca so a surviving PTY keeps - // reporting after a restart. + // Why: sourcing refreshes PORT/TOKEN/ENV/VERSION from the current Orca so a surviving PTY keeps reporting after a restart (see claude/hook-service.ts). 'load_hook_endpoint() {', ' endpoint_path="$1"', ' case "$endpoint_path" in', ' *.cmd)', - // Why: Windows passes endpoint.cmd into WSL through WSLENV path translation. - // Parse only Orca's known assignments; cmd.exe `set` lines are not shell syntax. + // Why: Windows passes endpoint.cmd into WSL via WSLENV; parse only Orca's known assignments since cmd.exe `set` lines aren't shell syntax. ' endpoint_cr=$(printf "\\r")', ' while IFS= read -r endpoint_line || [ -n "$endpoint_line" ]; do', ' endpoint_line=${endpoint_line%"$endpoint_cr"}', @@ -833,13 +798,8 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { ' curl_bin="$1"', ' connect_timeout="${2:-0.5}"', ' max_time="${3:-1.5}"', - // Why: worktreeId embeds a filesystem path, so hand-building JSON in POSIX - // shell is not safe once a path contains quotes or newlines. Post the raw - // hook payload plus metadata as form fields and let the receiver parse it. - // Timeout caps best-effort hook posts if the local listener stalls. - // Why: pipe payload to curl's stdin (`payload@-`) instead of an inline - // `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl - // command line (EDR command-line false positives). Wire body is identical. + // Why: worktreeId embeds a path, so hand-building JSON in shell is unsafe with quotes/newlines; post raw payload plus metadata as form fields instead. + // Why: pipe payload to curl's stdin (`payload@-`) not an inline arg, so tens-of-KB tool output stays off the command line (EDR false positives). ' printf \'%s\' "$payload" | "$curl_bin" -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/codex" \\', ' --connect-timeout "$connect_timeout" --max-time "$max_time" \\', ' --noproxy "127.0.0.1" \\', @@ -923,8 +883,7 @@ function installManagedHooksIntoWslRuntime( writeManagedScript(plan.scriptPath, getManagedScript('posix')) writeCodexHooksJson(plan.configPath, nextHooks) try { - // Why: WSL runtime homes may carry user hook approvals we did not rebuild - // here; only upsert Orca's entries instead of sweeping the whole source. + // Why: WSL runtime homes may carry user hook approvals we didn't rebuild; upsert only Orca's entries instead of sweeping the whole source. upsertHookTrustEntries(plan.tomlPath, trustEntries) removeStaleWslRuntimeManagedHookTrustEntries(plan.tomlPath, trustEntries) } catch (error) { @@ -974,8 +933,7 @@ function refreshWslRuntimeUserHooks(plan: CodexWslRuntimeHookInstallPlan): Agent writeCodexHooksJson(plan.configPath, nextHooks) removeWslRuntimeManagedHookTrustEntries(plan) try { - // Why: the disabled path may be reached after the WSL mount root changed, - // so cleanup cannot be scoped only to the plan's current source path. + // Why: the disabled path may run after the WSL mount root changed, so cleanup can't be scoped to the plan's current source path. removeStaleWslRuntimeManagedHookTrustEntries(plan.tomlPath, []) } catch (error) { console.warn('[codex-hook-service] failed to clean stale WSL trust entries', error) @@ -989,8 +947,7 @@ function refreshWslRuntimeUserHooks(plan: CodexWslRuntimeHookInstallPlan): Agent } } -// Why: transport failures preserve the last known-good identity, while a -// successful absence probe is strong enough to revoke trust immediately. +// Why: transport failures preserve last known-good identity; a successful absence probe is strong enough to revoke trust immediately. function getWslHookReconciliationAction(args: { settlement: WslCanonicalPathSettlement isCurrentGeneration: boolean @@ -1013,8 +970,7 @@ function getWslHookReconciliationAction(args: { return 'reinstall' } -// Why: fold only the Windows-case-insensitive portion — a full lowercase would -// let case-distinct WSL runtime homes share one reconciliation generation slot. +// Why: fold only the Windows-case-insensitive portion; a full lowercase would let case-distinct WSL homes share one reconciliation slot. function getWslReconciliationKey(runtimeHomePath: string): string { return normalizeCodexProjectPathForLookup(runtimeHomePath) } @@ -1114,15 +1070,10 @@ export class CodexHookService { } } - // Why: Report `partial` when managed events are missing OR when their - // trust entries are missing/stale. Codex 0.129+ silently drops untrusted - // hooks, so a green status without trust verification is misleading. + // Why: Codex 0.129+ silently drops untrusted hooks, so report `partial` when managed events OR their trust entries are missing/stale. const command = getManagedCommand(scriptPath) const tomlPath = getCodexConfigTomlPath() - // Why: an unreadable config.toml (EACCES/EIO) is distinct from "file - // absent" (which returns an empty Map without throwing). Hooks.json may - // still be fine, so report partial with a specific reason rather than - // collapsing to a generic error or masking it as universally-stale trust. + // Why: an unreadable config.toml (EACCES/EIO) differs from "absent" (empty Map); hooks.json may be fine, so report partial with a specific reason. let trustEntries: Map let trustReadError: string | null = null try { @@ -1138,16 +1089,12 @@ export class CodexHookService { let presentCount = 0 for (const eventName of CODEX_EVENTS) { const definitions = Array.isArray(config.hooks?.[eventName]) ? config.hooks![eventName]! : [] - // Why: older installs appended this command, while current installs - // prepend it. Picking the last match keeps status repair conservative - // if duplicate managed definitions survive from a stale hooks.json. + // Why: older installs appended, current ones prepend; last-match keeps status repair conservative when stale duplicate definitions survive. let foundGroupIndex = -1 let foundHandlerIndex = -1 definitions.forEach((definition, idx) => { const hooks = definition.hooks ?? [] - // Why: mirror the LAST-match-wins rule at the group level — if a user - // merged hook arrays and ended up with our command at multiple indices - // in one group, the surviving runtime entry is the last one. + // Why: last-match-wins at the group level — if merged hook arrays repeat our command, the surviving runtime entry is the last one. const handlerIdx = hooks.findLastIndex((hook) => hook.command === command) if (handlerIdx !== -1) { foundGroupIndex = idx @@ -1159,14 +1106,9 @@ export class CodexHookService { continue } presentCount += 1 - // Why: a stale hash blocks firing the same as a missing entry, so - // compare against the canonical hash we would write. - // Why: capture the actual handler index — Codex's hook_key uses the - // positional handlerIndex, and a user-merged hook array can put our - // command at a non-zero slot, so hardcoding 0 would misreport trust. - // Why: the managed hook is written with `timeout` (see install()), and - // Codex folds the handler timeout into its trust hash. Hash the same - // timeout here or status would report every managed hook as stale-trust. + // Why: a stale hash blocks firing like a missing entry, so compare against the canonical hash we would write. + // Why: Codex's hook_key is positional, so hardcoding handlerIndex 0 misreports trust for user-merged hook arrays. + // Why: hash the same `timeout` install() writes, since Codex folds it into the trust hash or every managed hook reports stale-trust. const trustInput: CodexTrustEntry = { sourcePath: configPath, eventLabel: CODEX_EVENT_LABEL[eventName], @@ -1188,8 +1130,7 @@ export class CodexHookService { let detail: string | null if (presentCount === 0) { state = 'not_installed' - // Why: surface the trust read error even when not_installed so the user - // has actionable info if config.toml is broken. + // Why: surface the trust read error even when not_installed, so a broken config.toml gives actionable info. detail = trustReadError !== null ? `Trust entries unverifiable: ${trustReadError}` : null } else if ( missing.length === 0 && @@ -1221,10 +1162,7 @@ export class CodexHookService { install(): AgentHookInstallStatus { const configPath = getConfigPath() const scriptPath = getManagedScriptPath() - // Why: must run before this install rewrites hooks.json/config.toml — - // approvals the user made inside Orca-launched Codex are keyed to the - // previous launch's runtime layout, and stale-trust cleanup below would - // delete them once the system config stops backing them. + // Why: run before install rewrites hooks.json/config.toml, since stale-trust cleanup would delete in-Orca approvals keyed to the prior layout. promoteCodexRuntimeHookApprovalsToSystem() const config = readHooksJson(configPath) if (!config) { @@ -1237,29 +1175,20 @@ export class CodexHookService { } } - // Why: match by script filename (not exact command string) so a fresh - // install sweeps stale entries left by older builds or a different - // Electron userData path (dev vs. prod). Without this, repeated installs - // accumulate duplicate hook entries pointing at defunct scripts. + // Why: match by script filename (not exact command) so a fresh install sweeps stale entries from older builds or a different userData path. const isManagedCommand = createManagedCommandMatcher(getCodexManagedScriptFileName()) const command = getManagedCommand(scriptPath) const hookPlan = getRuntimeHooksWithSystemUserHooks(config.hooks, isManagedCommand) const nextHooks = hookPlan.hooks const managedEvents = new Set(CODEX_EVENTS) - // Why: sweep managed entries out of events we no longer subscribe to - // (e.g., PreToolUse from a prior install). Without this, users who - // already had PreToolUse registered would keep firing stale hooks on - // every auto-approved tool call after the app upgrade. + // Why: sweep managed entries from events we no longer subscribe to (e.g. a prior install's PreToolUse), else they keep firing stale hooks after upgrade. for (const [eventName, definitions] of Object.entries(nextHooks)) { if (managedEvents.has(eventName)) { continue } if (!Array.isArray(definitions)) { - // Why: a malformed hooks.json entry (non-array value for an event name) - // would make removeManagedCommands throw. Skip instead — we aren't - // going to sweep something we can't parse, and the install() for - // managed events below still runs. + // Why: a non-array event value would make removeManagedCommands throw; skip the unparsable entry, managed events below still install. continue } const cleaned = removeManagedCommands(definitions, isManagedCommand) @@ -1270,10 +1199,7 @@ export class CodexHookService { } } - // Why: Codex 0.129+ requires a per-hook trust entry in config.toml or the - // hook sits in the "review required" pile. We compute the trust hash for - // each managed entry as we install it and persist it alongside hooks.json - // so the user does not have to /hooks-approve after every install. + // Why: Codex 0.129+ requires a per-hook config.toml trust entry or the hook needs manual /hooks-approve; precompute the hash to avoid that. const mirroredUserTrustEntries = moveMirroredRuntimeUserTrustAfterManagedStatusHook( hookPlan.trustEntries ) @@ -1285,11 +1211,8 @@ export class CodexHookService { hooks: [buildManagedCommandHook(command)] } nextHooks[eventName] = [definition, ...cleaned] - // Why: the status hook must run before user hooks so a slow - // PostToolUse/Stop hook cannot leave the sidebar stuck on the previous - // state while Codex visibly reports that hooks are still running. - // timeoutSec mirrors the hook's `timeout` so the trust hash matches the - // entry actually written to hooks.json. + // Why: status hook runs before user hooks so a slow PostToolUse/Stop hook can't leave the sidebar stuck on the previous state. + // Why: timeoutSec mirrors the hook's `timeout` so the trust hash matches the entry written to hooks.json. trustEntries.push({ sourcePath: configPath, eventLabel: CODEX_EVENT_LABEL[eventName], @@ -1303,17 +1226,12 @@ export class CodexHookService { config.hooks = nextHooks writeManagedScript(scriptPath, getManagedScript()) writeCodexHooksJson(configPath, nextHooks) - // Why: trust entries write last so a half-write can't leave a hash - // pointing at a hook that doesn't exist. Surface failures — without this, - // getStatus would report green for a hook Codex won't actually fire. + // Why: trust entries write last so a half-write can't leave a hash pointing at a nonexistent hook. + // Why: surface trust-write failures — otherwise getStatus reports green for a hook Codex won't fire. try { const tomlPath = getCodexConfigTomlPath() syncSystemConfigIntoManagedCodexHome() - // Why: system user hook approvals are mirrored into runtime CODEX_HOME. - // If the user later revokes approval in ~/.codex/config.toml, preserving - // all old runtime [hooks.state.*] blocks would keep Orca Codex trusted. - // Upsert first so duplicate repair can preserve a disabled managed copy - // before stale cleanup removes old managed hook keys. + // Why: don't let revoked ~/.codex approvals survive as stale runtime trust; upsert before cleanup to preserve a disabled managed copy. upsertHookTrustEntries(tomlPath, trustEntries) removeStaleRuntimeHookTrustEntries(tomlPath, configPath, trustEntries) applyMirroredRuntimeUserHookTrustStates(tomlPath, mirroredUserTrustEntries) @@ -1340,14 +1258,9 @@ export class CodexHookService { sftp: SFTPWrapper, remoteHome: string, options?: { - /** Explicit CODEX_HOME dir (flat layout: hooks.json/config.toml at its - * root). WSL sessions read Orca's managed runtime home, not ~/.codex — - * installing to the default location leaves those sessions hookless. */ + /** Explicit CODEX_HOME dir (flat layout). WSL sessions read Orca's managed runtime home, not ~/.codex, so the default location leaves them hookless. */ codexHomeDir?: string - /** Skip the trust write when config.toml doesn't exist yet. The WSL - * runtime home's config.toml is seeded only-if-absent by the launch - * path; creating it here first would silently cancel that seed. A - * later (idempotent) reinstall upserts trust once the seed lands. */ + /** Skip the trust write when config.toml is absent — the WSL launch path seeds it only-if-absent, so creating it here would cancel that seed. */ deferTrustUntilConfigToml?: boolean } ): Promise { @@ -1404,13 +1317,10 @@ export class CodexHookService { } config.hooks = nextHooks - // Why: script/settings first, trust TOML last. A partial trust write - // leaves Codex asking for approval rather than executing a missing script. - // Why: SSH remotes use POSIX `.sh` hook paths even when Orca itself is - // running on Windows; never derive remote script syntax from local OS. + // Why: write script/settings before trust TOML; a partial trust write leaves Codex asking approval instead of running a missing script. + // Why: SSH remotes use POSIX `.sh` paths even when Orca runs on Windows; never derive remote script syntax from local OS. await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript('posix')) - // Why: SSH installs edit the user's remote ~/.codex/hooks.json directly. - // Preserve non-Orca top-level metadata while replacing the hooks tree. + // Why: SSH edits the user's remote ~/.codex/hooks.json directly, so preserve non-Orca top-level metadata while replacing the hooks tree. await writeHooksJsonRemote(sftp, remoteConfigPath, { ...config, hooks: nextHooks }) try { const existingTomlRaw = await readTextFileRemote(sftp, remoteTomlPath) @@ -1460,13 +1370,11 @@ export class CodexHookService { refreshRuntimeUserHooks(): AgentHookInstallStatus { const configPath = getConfigPath() - // Why: same as install() — capture in-Orca approvals before this refresh - // rewrites the runtime files they are keyed against. + // Why: capture in-Orca approvals before this refresh rewrites the runtime files they are keyed against. promoteCodexRuntimeHookApprovalsToSystem() const config = readHooksJson(configPath) if (!config) { - // Why: disabled launch prep used to call remove(); preserve its legacy - // cleanup behavior even when runtime hooks.json is malformed. + // Why: disabled launch prep once called remove(); preserve that legacy cleanup even when runtime hooks.json is malformed. cleanupLegacyManagedHookRepresentations() return { agent: 'codex', @@ -1486,10 +1394,7 @@ export class CodexHookService { const tomlPath = getCodexConfigTomlPath() const trustEntries = hookPlan.trustEntries.map(({ entry }) => entry) syncSystemConfigIntoManagedCodexHome() - // Why: this path is used when Orca status hooks are disabled. The - // runtime CODEX_HOME should keep user hooks, but not Orca-managed trust. - // Write current mirrored user trust first so stale cleanup compares - // against current hashes while deleting old managed hook keys. + // Why: disabled path keeps user hooks but not managed trust; write mirrored trust first so stale cleanup compares against current hashes. upsertHookTrustEntries(tomlPath, trustEntries) removeStaleRuntimeHookTrustEntries(tomlPath, configPath, trustEntries) applyMirroredRuntimeUserHookTrustStates(tomlPath, hookPlan.trustEntries) @@ -1513,8 +1418,7 @@ export class CodexHookService { const configExists = existsSync(configPath) const config = readHooksJson(configPath) if (!config) { - // Why: a malformed runtime hooks.json should not strand old hooks in - // ~/.codex or the legacy profile after the user disables Codex hooks. + // Why: a malformed hooks.json shouldn't strand old hooks in ~/.codex or the legacy profile after disabling. cleanupLegacyManagedHookRepresentations() return { agent: 'codex', @@ -1526,14 +1430,11 @@ export class CodexHookService { } const nextHooks = { ...config.hooks } - // Why: same broad matcher as install(), so remove() also cleans up stale - // entries from older builds even if the current scriptPath has moved. + // Why: same broad matcher as install() so stale entries from older builds get cleaned even if scriptPath moved. const isManagedCommand = createManagedCommandMatcher(getCodexManagedScriptFileName()) for (const [eventName, definitions] of Object.entries(nextHooks)) { if (!Array.isArray(definitions)) { - // Why: a malformed hooks.json entry (non-array value for an event name) - // would make removeManagedCommands throw. Skip instead — we have no - // managed commands to remove from something we can't parse. + // Why: a non-array event value would make removeManagedCommands throw; skip it. continue } const cleaned = removeManagedCommands(definitions, isManagedCommand) @@ -1544,14 +1445,11 @@ export class CodexHookService { } } if (configExists) { - // Why: remove() can be the only repair path for a parseable runtime file - // whose top-level plugin metadata makes Codex reject hooks.json. + // Why: remove() may be the only repair path for a file whose top-level plugin metadata makes Codex reject hooks.json. writeCodexHooksJson(configPath, nextHooks) } - // Why: also drop our trust entries so config.toml doesn't accumulate dead - // [hooks.state."..."] blocks across install/remove cycles. Best-effort — - // a stale entry is harmless once hooks.json no longer references it. + // Why: drop trust entries so config.toml doesn't accumulate dead [hooks.state] blocks across install/remove cycles. removeRuntimeManagedHookTrustEntries(configPath) cleanupLegacyManagedHookRepresentations() diff --git a/src/main/cursor/hook-service.ts b/src/main/cursor/hook-service.ts index b1ccdbf126d..9ce9172f8fb 100644 --- a/src/main/cursor/hook-service.ts +++ b/src/main/cursor/hook-service.ts @@ -26,22 +26,8 @@ import { buildWindowsHookStdinDrainEpilogue } from '../agent-hooks/hook-stdin-contract' -// Why: cursor-agent exposes a declarative hooks.json surface at -// ~/.cursor/hooks.json (https://cursor.com/docs/hooks) with camelCase event -// names. We subscribe to the minimum set that drives the sidebar spinner and -// turn-boundary detection: -// - beforeSubmitPrompt: new turn starts (carries the user's prompt) -// - stop: turn ends (→ done) -// - preToolUse/postToolUse/postToolUseFailure: in-flight tool preview -// between submit and stop — without these the pane appears idle for the -// entire duration of a long tool-heavy turn -// - beforeShellExecution / beforeMCPExecution: shell/MCP tool preview (→ working) -// - afterAgentResponse: carries the final composed reply text so the -// dashboard can surface it on done -// sessionStart / sessionEnd are intentionally NOT subscribed — cursor-agent -// fires them at process lifetime boundaries rather than at turn boundaries, -// and sessionStart's fire-time can race the first beforeSubmitPrompt in a -// way that would reset the prompt cache for the just-submitted turn. +// cursor-agent's declarative hooks surface (https://cursor.com/docs/hooks); subscribe to the minimum set for spinner + turn detection. +// sessionStart/sessionEnd are NOT subscribed: they fire at process (not turn) boundaries and can race/reset the just-submitted turn's prompt cache. const CURSOR_EVENTS = [ 'beforeSubmitPrompt', 'stop', @@ -76,10 +62,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { return [ '@echo off', 'setlocal', - // Why: see claude/hook-service.ts for rationale. The endpoint file holds - // the live port/token for this Orca install; sourcing it here lets a - // surviving PTY reach the current server even though its env points at - // the prior Orca's coordinates. + // Why: source the endpoint file so a surviving PTY reaches the current server, not the prior Orca's coordinates (see claude/hook-service.ts). 'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul', ...buildWindowsHookEnvironmentGuardLines(), buildWindowsAgentHookPostCommand('cursor'), @@ -92,22 +75,15 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { return [ '#!/bin/sh', ...buildPosixHookPayloadCapture(), - // Why: see claude/hook-service.ts for rationale. Sourcing refreshes - // PORT/TOKEN/ENV/VERSION from the current Orca so a surviving PTY keeps - // reporting after a restart. + // Why: sourcing refreshes PORT/TOKEN/ENV from the current Orca so a surviving PTY keeps reporting after a restart (see claude/hook-service.ts). 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', 'fi', 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', ' exit 0', 'fi', - // Why: worktreeId embeds a filesystem path, so hand-building JSON in POSIX - // shell is not safe once a path contains quotes or newlines. Post the raw - // hook payload plus metadata as form fields and let the receiver parse it. - // Timeout caps best-effort hook posts if the local listener stalls. - // Why: pipe payload to curl's stdin (`payload@-`) instead of an inline - // `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl - // command line (EDR command-line false positives). Wire body is identical. + // Why: worktreeId embeds a path, so hand-building JSON in shell is unsafe (quotes/newlines); post raw payload as form fields instead. + // Why: pipe payload via curl stdin (`payload@-`), not an inline arg, so large tool output stays off the command line (EDR false positives). 'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/cursor" \\', ' --connect-timeout 0.5 --max-time 1.5 \\', ' -H "Content-Type: application/x-www-form-urlencoded" \\', @@ -144,9 +120,7 @@ export class CursorHookService { let presentCount = 0 for (const eventName of CURSOR_EVENTS) { const definitions = Array.isArray(config.hooks?.[eventName]) ? config.hooks![eventName]! : [] - // Why: Cursor's schema places the command directly on the definition - // (not nested under a `hooks` array as Claude does), so match both - // shapes to stay robust against future schema changes. + // Why: Cursor puts command directly on the definition (Claude nests under `hooks`); match both shapes. const hasCommand = definitions.some( (definition) => definition.command === command || @@ -189,22 +163,14 @@ export class CursorHookService { } const command = getManagedCommand(scriptPath) - // Why: Cursor's hooks.json wraps the map under a top-level "hooks" key - // (same as Claude/Codex). A fresh file with no prior hook install will - // have config.hooks === undefined. + // Why: config.hooks is undefined on a fresh file with no prior hook install. const nextHooks = { ...config.hooks } const managedEvents = new Set(CURSOR_EVENTS) - // Why: match by script filename (not exact command string) so a fresh - // install sweeps stale entries left by older builds or a different - // Electron userData path (dev vs. prod). Without this, repeated installs - // accumulate duplicate hook entries pointing at defunct scripts. + // Why: match by script filename (not exact command) so installs sweep stale entries from older builds or a different userData path. const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName()) - // Why: sweep managed entries out of events we no longer subscribe to - // (e.g. a future rename where we drop an event name). Without this, - // users who already had that event registered would keep firing stale - // hooks after the app upgrade. + // Why: sweep managed entries from events we no longer subscribe to, else upgraded users keep firing stale hooks. for (const [eventName, definitions] of Object.entries(nextHooks)) { if (managedEvents.has(eventName)) { continue @@ -226,24 +192,16 @@ export class CursorHookService { for (const eventName of CURSOR_EVENTS) { const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : [] - // Sweep both the Claude-shaped (hooks[].command) and Cursor-shaped - // (definition.command) variants so repeated installs converge on a - // single managed entry. + // Sweep both Claude-shaped (hooks[].command) and Cursor-shaped (definition.command) variants so installs converge on one entry. const cleaned = removeManagedCommands(current, isManagedCommand).filter( (definition) => !isManagedCommand(definition.command as string | undefined) ) - // Why: Cursor's documented schema puts `command` directly on the - // definition (not under `hooks`). Emit that shape so cursor-agent - // actually invokes the script. + // Why: Cursor's schema puts `command` directly on the definition (not under `hooks`); emit that shape. const definition: HookDefinition = buildManagedCommandDefinition(command) nextHooks[eventName] = [...cleaned, definition] } - // Why: cursor-agent's config schema requires a top-level `version: 1` - // (see https://cursor.com/docs/hooks). Preserve an existing value if the - // user has already pinned one, otherwise stamp the default so a fresh - // install produces a valid file. HooksConfig has an index signature on - // extra keys, so assign via a Record-typed object to satisfy the type. + // Why: cursor-agent's schema requires top-level `version: 1` (https://cursor.com/docs/hooks); keep any user-pinned value. const nextConfig: Record = { ...config, hooks: nextHooks } if (nextConfig.version === undefined) { nextConfig.version = 1 @@ -253,12 +211,7 @@ export class CursorHookService { return this.getStatus() } - // Why: install Orca's managed Cursor hooks on the remote box. Mirrors - // ClaudeHookService.installRemote — POSIX-only, uses the same SFTP-backed - // primitives, and emits Cursor's documented schema (top-level `command` - // on each definition + top-level `version: 1`) so cursor-agent on the - // remote actually invokes the script. See docs/design/agent-status-over-ssh.md - // §8. + // Installs managed Cursor hooks on an SSH remote (POSIX-only). See docs/design/agent-status-over-ssh.md §8. async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise { const remoteConfigPath = `${remoteHome.replace(/\/$/, '')}/.cursor/hooks.json` const remoteScriptPath = `${remoteHome.replace(/\/$/, '')}/.orca/agent-hooks/cursor-hook.sh` @@ -280,8 +233,7 @@ export class CursorHookService { for (const eventName of CURSOR_EVENTS) { const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : [] - // Why: same dual-shape sweep as the local install — repeated - // installs converge on a single managed entry. + // Why: dual-shape sweep so repeated installs converge on a single managed entry. const cleaned = removeManagedCommands(current, isManagedCommand).filter( (definition) => !isManagedCommand(definition.command as string | undefined) ) @@ -294,11 +246,8 @@ export class CursorHookService { nextConfig.version = 1 } - // Why: script-then-config order so a partial-failure mid-install at - // worst leaves a working script no settings.json points at — see - // ClaudeHookService.installRemote. - // Why: SSH remotes use POSIX `.sh` hook paths even when Orca itself is - // running on Windows; never derive remote script syntax from local OS. + // Why: script-then-config order so a partial mid-install leaves a working script nothing points at. + // Why: SSH remotes always use POSIX `.sh` hook paths even when Orca runs on Windows; never derive from local OS. await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript('posix')) await writeHooksJsonRemote(sftp, remoteConfigPath, nextConfig) diff --git a/src/main/devin/hook-service.ts b/src/main/devin/hook-service.ts index 8b4cc439058..82b00a71c3c 100644 --- a/src/main/devin/hook-service.ts +++ b/src/main/devin/hook-service.ts @@ -39,12 +39,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { return [ '@echo off', 'setlocal', - // Why: the endpoint file holds the *live* port/token for this Orca - // install. A PTY that survived an Orca restart has stale PORT/TOKEN - // baked into its env from the old instance — loading `endpoint.cmd` - // (`set KEY=VALUE` lines) via `call` refreshes them so the hook - // reaches the current server. Falls through to PTY env if the file - // is missing (first run / pre-endpoint-file / running outside Orca). + // Why: endpoint file holds the live port/token; a PTY that outlives an Orca restart carries stale env, so `call` it to refresh (else PTY env). 'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul', ...buildWindowsHookEnvironmentGuardLines(), buildWindowsAgentHookPostCommand('devin'), @@ -57,33 +52,16 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { return [ '#!/bin/sh', ...buildPosixHookPayloadCapture(), - // Why: the endpoint file holds the *live* port/token for this Orca - // install. PTYs that survive an Orca restart have stale PORT/TOKEN - // baked into their env from the old instance — sourcing the file here - // lets us reach the new server. Falls back to PTY env if the file is - // missing (first-run / pre-endpoint-file scripts / running outside Orca). - // Why: suppress stderr on the `.` builtin. A TOCTOU race (endpoint unlinked - // between the `[ -r ]` test and the source) or a malformed line (e.g. CRLF - // bled in from a cross-platform userData copy) would otherwise print a - // parse error that agent transcripts could surface. Stale coords → dead - // port → silent-fail is the documented fail-open path anyway — the env-var - // guards below handle the empty PORT/TOKEN case — so swallowing the noise - // here is strictly better than leaking shell errors into the hook output. - // `|| :` defends against an eventual `set -e` in an outer script context - // (not present today) aborting the hook on a parse error. + // Why: endpoint file holds the live port/token; PTYs that outlive an Orca restart carry stale env, so source it to reach the new server (else PTY env). + // Why: silence the `.` builtin (2>/dev/null + `|| :`) so a TOCTOU race or CRLF-mangled line can't leak shell parse errors into agent transcripts (fail-open). 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', 'fi', 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', ' exit 0', 'fi', - // Why: worktreeId embeds a filesystem path, so hand-building JSON in POSIX - // shell is not safe once a path contains quotes or newlines. Post the raw - // hook payload plus metadata as form fields and let the receiver parse it. - // Timeout caps best-effort hook posts if the local listener stalls. - // Why: pipe payload to curl's stdin (`payload@-`) instead of an inline - // `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl - // command line (EDR command-line false positives). Wire body is identical. + // Why: worktreeId embeds a filesystem path, so hand-building JSON in shell is unsafe (quotes/newlines); post as form fields instead. + // Why: pipe payload to curl's stdin (payload@-) not an inline arg, so large tool output stays off the command line (EDR false positives). 'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/devin" \\', ' --connect-timeout 0.5 --max-time 1.5 \\', ' -H "Content-Type: application/x-www-form-urlencoded" \\', @@ -115,10 +93,7 @@ export class DevinHookService { } } - // Why: Report `partial` when only some managed events are registered so the - // sidebar surfaces a degraded install rather than a false-positive - // `installed`. Each DEVIN_EVENTS entry must contain the managed command for - // the integration to function end-to-end. + // Why: report `partial` when only some managed events are registered, so the sidebar shows a degraded install instead of a false `installed`. const command = getDevinManagedCommand(scriptPath) const missing: string[] = [] let presentCount = 0 @@ -178,28 +153,15 @@ export class DevinHookService { return this.getStatus() } - // Why: install Orca's Devin hook settings on the remote box rather than the - // local machine. Caller passes the user's SFTP handle plus the resolved - // remote `$HOME`; POSIX-only by design (Windows-remote deferred). + // Why: install the Devin hook on the remote box (SFTP handle + resolved remote $HOME); POSIX-only by design. async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise { - // Why: remote-Windows is out of scope for v1 — we ship POSIX-shaped paths - // and a `.sh` managed script body. The remote platform is gated by the - // relay's capability RPC at a higher layer; we cannot detect it from - // `process.platform` here (that's the local box). + // Why: remote-Windows is out of scope for v1; process.platform here is the local box, not the remote, so assume POSIX. const remoteConfigPath = getDevinRemoteConfigPath(remoteHome) const remoteScriptFileName = getDevinPosixManagedScriptFileName() const remoteScriptPath = `${remoteHome.replace(/\/$/, '')}/.orca/agent-hooks/${remoteScriptFileName}` - // Why: SFTP reads/writes fail far more often than local fs (network drops, - // EACCES on remote dirs, disk full, channel closed). Wrap the entire - // install flow in try/catch so a transient I/O failure surfaces as a - // structured `state: 'error'` result for the UI, not an unstructured - // rejection the caller has to remember to handle. A `null` config - // specifically means "file present but unparseable" — keep that branch - // distinct so the user sees an actionable message. + // Why: SFTP I/O fails far more often than local fs; wrap the flow so failures surface as a structured error, not an unhandled rejection. try { - // Why: Devin config.json is JSONC (comments); stock - // JSON.parse rejects them. Read the raw text via SFTP and parse with - // jsonc-parser, mirroring the local readDevinHooksConfig path. + // Why: Devin config.json is JSONC (comments), so JSON.parse rejects it; parse via jsonc-parser. const body = await readTextFileRemote(sftp, remoteConfigPath) const config = body === null ? {} : parseDevinHooksConfigText(body, 'remote Devin config.json') @@ -213,19 +175,12 @@ export class DevinHookService { } } - // Why: the POSIX wrapper is identical regardless of where the script - // lands; only the path differs. Reuse the same wrapper helper. + // Why: the POSIX wrapper body is path-independent, so reuse the same helper. const command = getDevinRemoteManagedCommand(remoteScriptPath) const nextConfig = applyDevinManagedHooks(config, command, remoteScriptFileName) - // Why: write the script first, then the settings — settings.json - // referencing a missing script body would fire `command not found` on - // every tool call until the user re-runs install. Doing it in this - // order means a partial-failure mid-install at worst leaves the user - // with a working script no settings.json points at (a no-op), instead - // of broken settings.json. - // Why: SSH remotes use POSIX `.sh` hook paths even when Orca itself is - // running on Windows; never derive remote script syntax from local OS. + // Why: write script before settings so a mid-install failure never leaves settings.json referencing a missing script. + // Why: SSH remotes use POSIX `.sh` hooks even when Orca runs on Windows; never derive remote script syntax from local OS. await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript('posix')) await writeHooksJsonRemote(sftp, remoteConfigPath, nextConfig) diff --git a/src/main/gemini/hook-service.ts b/src/main/gemini/hook-service.ts index e078476edce..1615ea336f9 100644 --- a/src/main/gemini/hook-service.ts +++ b/src/main/gemini/hook-service.ts @@ -27,15 +27,8 @@ import { buildWindowsHookStdinDrainEpilogue } from '../agent-hooks/hook-stdin-contract' -// Why: Gemini CLI fires `BeforeAgent` when a turn starts and `AfterAgent` when -// it completes. `AfterTool` marks the resumption of model work after a tool -// call, which maps back to `working`. Gemini has no permission-prompt hook -// (approvals flow through inline UI), so Orca cannot surface a waiting state -// for Gemini — that is an upstream limitation, not an Orca bug. -// -// Gemini's native pre-tool event is BeforeTool, not Claude/Codex's PreToolUse. -// Keep installing the pre-tool status hook, but sweep stale PreToolUse entries -// below so current Gemini CLI no longer warns about an invalid event bucket. +// Why: Gemini has no permission-prompt hook (approvals are inline UI), so Orca can't show a waiting state — upstream limitation. +// Why: Gemini's pre-tool event is BeforeTool, not Claude/Codex's PreToolUse; sweep stale PreToolUse entries below. const GEMINI_EVENTS = ['BeforeAgent', 'AfterAgent', 'AfterTool', 'BeforeTool'] as const function getConfigPath(): string { @@ -61,14 +54,9 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { return [ '@echo off', 'setlocal', - // Why: Gemini expects valid JSON on stdout even when the hook has nothing - // to return. Emit `{}` first so the agent never stalls parsing our - // output, even if the env-var guards below cause an early exit. + // Why: emit `{}` first so Gemini never stalls parsing stdout, even if the guards below exit early. 'echo {}', - // Why: see claude/hook-service.ts for rationale. The endpoint file holds - // the live port/token for this Orca install; sourcing it here lets a - // surviving PTY reach the current server even though its env points at - // the prior Orca's coordinates. + // Why: source the endpoint file so a surviving PTY reaches the current server. See claude/hook-service.ts. 'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul', ...buildWindowsHookEnvironmentGuardLines(), buildWindowsAgentHookPostCommand('gemini'), @@ -80,27 +68,18 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { return [ '#!/bin/sh', - // Why: Gemini expects valid JSON on stdout even when the hook has nothing - // to return. Emit `{}` first so the agent never stalls parsing our output, - // even if the env-var guards below cause an early exit. + // Why: emit `{}` first so Gemini never stalls parsing stdout, even if the guards below exit early. 'printf "{}\\n"', ...buildPosixHookPayloadCapture(), - // Why: see claude/hook-service.ts for rationale. Sourcing refreshes - // PORT/TOKEN/ENV/VERSION from the current Orca so a surviving PTY keeps - // reporting after a restart. + // Why: source refreshes endpoint coords so a PTY surviving an Orca restart keeps reporting. See claude/hook-service.ts. 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', 'fi', 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', ' exit 0', 'fi', - // Why: worktreeId embeds a filesystem path, so hand-building JSON in POSIX - // shell is not safe once a path contains quotes or newlines. Post the raw - // hook payload plus metadata as form fields and let the receiver parse it. - // Timeout caps best-effort hook posts if the local listener stalls. - // Why: pipe payload to curl's stdin (`payload@-`) instead of an inline - // `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl - // command line (EDR command-line false positives). Wire body is identical. + // Why: worktreeId embeds a path, so post form fields, not hand-built JSON that breaks on quotes/newlines. + // Why: pipe payload via curl stdin (`payload@-`) so large tool output stays off the command line (EDR false positives). 'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/gemini" \\', ' --connect-timeout 0.5 --max-time 1.5 \\', ' -H "Content-Type: application/x-www-form-urlencoded" \\', @@ -179,18 +158,12 @@ export class GeminiHookService { const command = getManagedCommand(scriptPath) const nextHooks = { ...config.hooks } - // Why: match by script filename (not exact command string) so a fresh - // install sweeps stale entries left by older builds or a different - // Electron userData path (dev vs. prod). Without this, repeated installs - // accumulate duplicate hook entries pointing at defunct scripts. + // Why: match by filename not exact command, so installs sweep stale entries instead of duplicating them. const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName()) const managedEvents = new Set(GEMINI_EVENTS) - // Why: when Orca stops subscribing to an event, install() must sweep the - // old managed entry out of any leftover event bucket. Otherwise a stale - // hook such as PreToolUse survives forever in ~/.gemini/settings.json and - // continues firing even though the current build no longer wants it. + // Why: sweep managed entries from dropped event buckets so stale hooks (e.g. PreToolUse) don't keep firing. for (const [eventName, definitions] of Object.entries(nextHooks)) { if (managedEvents.has(eventName)) { continue @@ -222,11 +195,7 @@ export class GeminiHookService { return this.getStatus() } - // Why: install Orca's managed Gemini hooks on the remote box. Mirrors - // ClaudeHookService.installRemote — POSIX-only, uses the same SFTP-backed - // primitives, and lays down the same script body the local install - // generates so a remote-side Gemini CLI behaves identically. See - // docs/design/agent-status-over-ssh.md §8. + // POSIX-only remote install mirroring ClaudeHookService.installRemote. See docs/design/agent-status-over-ssh.md §8. async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise { const remoteConfigPath = `${remoteHome.replace(/\/$/, '')}/.gemini/settings.json` const remoteScriptPath = `${remoteHome.replace(/\/$/, '')}/.orca/agent-hooks/gemini-hook.sh` @@ -247,8 +216,7 @@ export class GeminiHookService { const isManagedCommand = createManagedCommandMatcher('gemini-hook.sh') const managedEvents = new Set(GEMINI_EVENTS) - // Why: remote installs must sweep legacy managed event buckets too. - // Otherwise stale PreToolUse entries keep warning in SSH Gemini sessions. + // Why: sweep legacy managed event buckets so stale PreToolUse stops warning in SSH Gemini sessions. for (const [eventName, definitions] of Object.entries(nextHooks)) { if (managedEvents.has(eventName)) { continue @@ -275,10 +243,8 @@ export class GeminiHookService { } config.hooks = nextHooks - // Why: write the script first so an interrupted install never leaves - // settings.json pointing at a missing script. See ClaudeHookService. - // Why: SSH remotes use POSIX `.sh` hook paths even when Orca itself is - // running on Windows; never derive remote script syntax from local OS. + // Why: write the script before settings.json so an interrupted install never points at a missing script. + // Why: SSH remotes always use POSIX `.sh` paths even when Orca runs on Windows. await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript('posix')) await writeHooksJsonRemote(sftp, remoteConfigPath, config) @@ -314,14 +280,10 @@ export class GeminiHookService { } const nextHooks = { ...config.hooks } - // Why: same broad matcher as install(), so remove() also cleans up stale - // entries from older builds even if the current scriptPath has moved. + // Why: match by filename so remove() sweeps stale entries even after the script path moved. const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName()) for (const [eventName, definitions] of Object.entries(nextHooks)) { - // Why: a malformed settings.json entry (non-array value for an event - // name) would make removeManagedCommands throw via definitions.flatMap. - // Skip — remove() must fail open so a broken user config never blocks - // uninstall. + // Why: fail open on malformed (non-array) entries so a broken user config never blocks uninstall. if (!Array.isArray(definitions)) { continue } diff --git a/src/main/git/repo.test.ts b/src/main/git/repo.test.ts index d4abbd2501e..90199d1c69b 100644 --- a/src/main/git/repo.test.ts +++ b/src/main/git/repo.test.ts @@ -15,9 +15,7 @@ import { searchBaseRefs } from './repo' -// Why: these tests exercise real git state (not mocked gitExecFileAsync) -// because the change under test is in the `for-each-ref` glob argument -// shape — the exact kind of bug a mock-based test would miss. +// Why: use real git state (not mocked) because the bug is in the for-each-ref glob shape a mock would miss. function git(cwd: string, args: string[]): string { return execFileSync('git', args, { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }) @@ -25,21 +23,14 @@ function git(cwd: string, args: string[]): string { function initRepo(dir: string): void { git(dir, ['init', '--quiet']) - // Why: explicit symbolic-ref rather than `git init --initial-branch=main` - // (which requires git >= 2.28). Setting HEAD before the first commit - // makes the initial branch `main` regardless of host git version or - // `init.defaultBranch` config. + // Why: `--initial-branch=main` needs git >= 2.28; symbolic-ref before the first commit forces `main` on any git version. git(dir, ['symbolic-ref', 'HEAD', 'refs/heads/main']) git(dir, ['config', 'user.email', 'test@test.com']) git(dir, ['config', 'user.name', 'Test']) git(dir, ['commit', '--allow-empty', '-m', 'initial', '--quiet']) } -/** - * Create a remote-tracking ref in `mainDir` at the given short-name - * (e.g. `origin/main`) by creating a packed ref under refs/remotes. - * Uses `update-ref` so we don't need to actually configure a live remote. - */ +/** Create a remote-tracking ref via `update-ref`, avoiding a live remote. */ function createRemoteRef(mainDir: string, shortName: string, sha: string): void { git(mainDir, ['update-ref', `refs/remotes/${shortName}`, sha]) } @@ -137,7 +128,6 @@ describe('searchBaseRefs (widened glob)', () => { const sha = getHeadSha(tmpDir) createRemoteRef(tmpDir, 'origin/main', sha) createRemoteRef(tmpDir, 'upstream/main', sha) - // symbolic-ref creates the HEAD pseudo-ref git(tmpDir, ['symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main']) git(tmpDir, ['symbolic-ref', 'refs/remotes/upstream/HEAD', 'refs/remotes/upstream/main']) @@ -164,9 +154,7 @@ describe('searchBaseRefs (widened glob)', () => { expect(results).toContain('local-only') }) - // Why: a single-word query must match a term in ANY segment of a slashed - // branch name (`user/feature`), not just the leaf. fnmatch `*` does not - // cross `/`, so the glob must use `**` to span ref segments. + // Why: fnmatch `*` doesn't cross `/`, so a single-word query needs `**` to match any segment of a slashed name. it('finds a local slashed branch when the query lands in a deep segment', async () => { git(tmpDir, ['branch', 'feature/login']) @@ -311,10 +299,7 @@ describe('searchBaseRefs (widened glob)', () => { await expect(searchBaseRefs(tmpDir, '', Number.NaN)).resolves.toEqual([]) }) - // Why: the picker displays results as `/` and labels - // `origin/main` as "Current", so users naturally retype the displayed - // format. Before segment-wise globbing this returned [] because the - // slash in the query made every fnmatch pattern unable to match. + // Why: users retype the displayed `/` format, so a slashed query must still match. it('finds the ref when the query is in display format `/`', async () => { const sha = getHeadSha(tmpDir) createRemoteRef(tmpDir, 'upstream/main', sha) @@ -334,8 +319,7 @@ describe('searchBaseRefs (widened glob)', () => { expect(results).toContain('upstream/feature-x') expect(results).toContain('upstream/feature-y') - // Why: `upstream/feat` pins the remote segment to *upstream*, so - // origin/feature-x must not leak in. + // Why: `upstream/feat` pins the remote segment to *upstream*, so origin/feature-x must not leak in. expect(results).not.toContain('origin/feature-x') }) @@ -343,10 +327,7 @@ describe('searchBaseRefs (widened glob)', () => { const sha = getHeadSha(tmpDir) createRemoteRef(tmpDir, 'upstream/main', sha) - // Why: `main/upstream` means "remote matching *main*, branch matching - // *upstream*". upstream/main has remote=upstream (no `main`) and - // branch=main (no `upstream`), so it must NOT match — confirms each - // token is pinned to its own segment, not treated as a free substring. + // Why: each token is pinned to its own segment (remote vs branch), so `main/upstream` must not match `upstream/main`. const results = await searchBaseRefs(tmpDir, 'main/upstream') expect(results).not.toContain('upstream/main') @@ -357,10 +338,7 @@ describe('searchBaseRefs (widened glob)', () => { createRemoteRef(tmpDir, 'upstream/main', sha) git(tmpDir, ['symbolic-ref', 'refs/remotes/upstream/HEAD', 'refs/remotes/upstream/main']) - // Why: typing `upstream/HEAD` must not surface the pseudo-ref even - // though it's a valid display-format query — the HEAD filter runs - // after the glob match, so coverage of the filter must survive - // changes to the glob shape. + // Why: the HEAD filter runs after the glob match, so display-format queries must still drop the pseudo-ref. const results = await searchBaseRefs(tmpDir, 'upstream/HEAD') expect(results).not.toContain('upstream/HEAD') @@ -370,10 +348,7 @@ describe('searchBaseRefs (widened glob)', () => { const sha = getHeadSha(tmpDir) createRemoteRef(tmpDir, 'upstream/main', sha) - // Why: empty tokens from trailing/leading/doubled slashes would - // degrade to `**` segments with fnmatch, matching nothing useful - // and silently breaking the feature. Filtering empty tokens keeps - // the query behavior identical to the intended non-empty tokens. + // Why: empty tokens from stray slashes would degrade to `**` and match nothing, so they're filtered out. expect(await searchBaseRefs(tmpDir, 'upstream/')).toContain('upstream/main') expect(await searchBaseRefs(tmpDir, '/upstream')).toContain('upstream/main') expect(await searchBaseRefs(tmpDir, 'upstream//main')).toContain('upstream/main') @@ -515,15 +490,13 @@ describe('getDefaultBaseRef (regression — unchanged behavior)', () => { }) it('does NOT fall through to upstream/main when origin/* is absent', () => { - // Why: this is the explicit design decision — the default probe order - // is origin-only. upstream-aware defaulting is deferred work. + // Why: default probe order is origin-only by design; upstream-aware defaulting is deferred. const sha = getHeadSha(tmpDir) createRemoteRef(tmpDir, 'upstream/main', sha) const result = getDefaultBaseRef(tmpDir) - // With no origin/* but a local main branch (initRepo creates one), - // we expect the local `main` — NOT `upstream/main`. + // initRepo creates a local `main`, so with no origin/* we expect it — not `upstream/main`. expect(result).toBe('main') expect(result).not.toBe('upstream/main') }) diff --git a/src/main/git/repo.ts b/src/main/git/repo.ts index fa1c23ebf0d..df5b2865132 100644 --- a/src/main/git/repo.ts +++ b/src/main/git/repo.ts @@ -33,14 +33,9 @@ function gitExecOptions( } /** - * Ordered probe list used to resolve a repo's default base ref when no - * explicit origin/HEAD symbolic-ref is set. `returnAs` is the short-name - * format the UI expects (matches how `git for-each-ref --format=%(refname:short)` - * would render the ref). - * - * Why: shared between the local path (getDefaultBaseRefAsync) and the SSH - * relay path in src/main/ipc/repos.ts so both resolve identical defaults - * for equivalent repo states. + * Ordered probe list for a repo's default base ref when no origin/HEAD symbolic-ref is set. + * `returnAs` is the short-name format the UI expects (as `for-each-ref --format=%(refname:short)` renders it). + * Shared local/SSH so both resolve identical defaults. */ export const DEFAULT_BASE_REF_PROBES: readonly { ref: string; returnAs: string }[] = [ { ref: 'refs/remotes/origin/main', returnAs: 'origin/main' }, @@ -50,12 +45,8 @@ export const DEFAULT_BASE_REF_PROBES: readonly { ref: string; returnAs: string } ] /** - * Walk DEFAULT_BASE_REF_PROBES in order, returning the first ref whose - * existence is confirmed by `hasRef`. Returns null if none exist. - * - * Why: abstracts the "how do we test a ref exists" detail so the local - * path (hasGitRefAsync) and the SSH path (provider.exec rev-parse) can - * share a single authoritative probe ordering. + * Walk DEFAULT_BASE_REF_PROBES in order, returning the first ref `hasRef` confirms, or null. + * Abstracts the existence test so local and SSH paths share one authoritative probe ordering. */ async function resolveDefaultBaseRefFromProbes( hasRef: (ref: string) => Promise @@ -68,9 +59,7 @@ async function resolveDefaultBaseRefFromProbes( return null } -/** - * Check if a path is a valid git repository (regular or bare). - */ +/** Check if a path is a valid git repository (regular or bare). */ export function isGitRepo(path: string): boolean { try { if (!existsSync(path) || !statSync(path).isDirectory()) { @@ -80,8 +69,7 @@ export function isGitRepo(path: string): boolean { return false } - // Authoritative positive signal: ask git directly. Covers regular work - // trees, linked worktrees (gitfile), submodules, and bare repos. + // Ask git directly first — authoritative for work trees, linked worktrees, submodules, and bare repos. const gitProbeResult = probeGitRepo(path) if (gitProbeResult === 'repo') { return true @@ -90,20 +78,11 @@ export function isGitRepo(path: string): boolean { return false } - // Why: `git rev-parse` can fail to produce a clean answer for reasons - // unrelated to repo-ness — a transient spawn failure or git-shim hiccup in - // the packaged app, resource pressure in the Electron main process, or a - // repo whose config errors out. Treating every such failure as "not a repo" - // silently downgrades a real repository to a plain folder (worktrees, SCM, - // PRs all disappear) and is the regression behind the spurious "Open as - // Folder" prompt. Fall back to a validated `.git` marker so a directory that - // genuinely carries Git metadata is still recognized; a directory with only - // a garbage `.git` file has no valid marker and is correctly rejected. + // Why: rev-parse can fail for reasons unrelated to repo-ness (spawn hiccup, config error); fall back to a + // validated `.git` marker instead of downgrading a real repo to a plain folder (the spurious "Open as Folder" bug). const markerScan = scanGitMarkerSync(path) if (markerScan.status === 'valid' && !warnedMarkerFallbackThisSession) { - // Why: warn only once per session. The folder scanner calls isGitRepo for - // many paths; if git is genuinely unavailable, warning per path would flood - // the main-process logs without adding signal beyond the first occurrence. + // Why: warn once per session; the folder scanner calls isGitRepo for many paths and would otherwise flood logs. warnedMarkerFallbackThisSession = true console.warn('[isGitRepo] git rev-parse could not confirm repo; accepted via .git marker', { path @@ -179,23 +158,16 @@ export function getGitRepoRoot(path: string): string { export function normalizeGitRepoRootForInputPath(inputPath: string, rootPath: string): string { const inputWsl = parseWslUncPath(inputPath) if (inputWsl && rootPath.startsWith('/')) { - // Why: WSL git reports Linux-native roots; Orca must persist the UNC path so - // later local git calls keep routing through the WSL-aware runner. + // Why: WSL git reports Linux-native roots; persist the UNC path so later git calls keep routing through the WSL runner. return toWindowsWslPath(rootPath, inputWsl.distro) } return normalizeRuntimePathSeparators(rootPath) } /** - * Filesystem-only check for genuine Git metadata, used as a fallback when git - * cannot give a clean answer. Strict enough to reject a directory whose `.git` - * is a garbage file (preserving the validation added in 18ed7b27d): - * - `.git` directory: accepted only if it has real common or linked-worktree - * gitdir shape, so empty/incomplete `.git/` folders are rejected. - * - `.git` file: accepted only if its `gitdir:` target resolves to valid Git - * metadata, covering linked worktrees and submodules. - * - bare repo root: accepted when HEAD + objects/ + refs/ are present and the - * config does not mark it as a regular worktree admin dir. + * Filesystem-only fallback check for genuine Git metadata when git can't answer cleanly. Strict enough to + * reject a garbage `.git` file (validation from 18ed7b27d): accepts a `.git` dir/file with real gitdir shape + * or a bare-repo root (HEAD + objects/ + refs/, not a worktree admin dir). */ function scanGitMarkerSync(path: string): GitMarkerScanResult { const realPath = resolveRealPathSync(path) @@ -207,8 +179,7 @@ function scanGitMarkerSync(path: string): GitMarkerScanResult { realPathScan.status === 'valid' && pathsReferToSameEntry(lexicalScan.rootPath, realPathScan.rootPath) ) { - // Why: preserve lexical spellings such as /var vs /private/var, but let a - // symlink from one repo into another bind to the real target repo like git. + // Why: preserve lexical spellings (/var vs /private/var), but let a cross-repo symlink bind to the real target like git. return lexicalScan } return realPathScan @@ -439,18 +410,14 @@ function isGitBooleanFalse(value: string): boolean { return ['', 'false', 'no', 'off', '0'].includes(value.toLowerCase()) } -/** - * Get a human-readable name for the repo from its path. - */ +/** Get a human-readable name for the repo from its path. */ export function getRepoName(path: string): string { const name = basename(path) // Strip .git suffix from bare repos return name.endsWith('.git') ? name.slice(0, -4) : name } -/** - * Get the remote origin URL, or null if not set. - */ +/** Get the remote origin URL, or null if not set. */ export function getRemoteUrl(path: string): string | null { try { return getRemoteUrlByName(path, 'origin') @@ -486,8 +453,7 @@ function getVerifiedOriginHeadBaseRef(path: string): string | null { cwd: path }).trim() - // Why: origin/HEAD may survive a default-branch rename while pointing at a - // deleted ref; verify before trusting it over the probe list. + // Why: origin/HEAD may survive a default-branch rename pointing at a deleted ref; verify before trusting it. return ref && hasGitRef(path, ref) ? gitRefToDefaultBaseRef(ref) : null } catch { return null @@ -495,14 +461,8 @@ function getVerifiedOriginHeadBaseRef(path: string): string | null { } /** - * Resolve the default base ref for new worktrees. - * Prefer the remote primary branch over a potentially stale local branch. - * - * Why: returns `null` when no candidate ref is resolvable. Previously this - * fell through to a hardcoded `'origin/main'` even when that ref did not - * exist, which silently handed `git worktree add` a bad ref and produced - * an opaque git error. Callers now fail loudly with a useful message, or - * degrade gracefully for non-creation uses (e.g. hosted URL building). + * Resolve the default base ref for new worktrees, preferring the remote primary over a stale local branch. + * Returns null when nothing resolves (rather than a hardcoded `origin/main`) so callers fail loudly or degrade. */ export function getDefaultBaseRef(path: string): string | null { const originHeadBaseRef = getVerifiedOriginHeadBaseRef(path) @@ -510,8 +470,7 @@ export function getDefaultBaseRef(path: string): string | null { return originHeadBaseRef } - // Why: walk the shared DEFAULT_BASE_REF_PROBES list so the sync path and the - // async/SSH paths cannot drift on which refs are tried or in what order. + // Why: walk the shared DEFAULT_BASE_REF_PROBES so sync and async/SSH paths can't drift on ref order. for (const { ref, returnAs } of DEFAULT_BASE_REF_PROBES) { if (hasGitRef(path, ref)) { return returnAs @@ -528,14 +487,8 @@ export async function getBaseRefDefault( } /** - * Return { ahead, behind } for localRef vs remoteRef, or null on git failure. - * - * Why: `rev-list --left-right --count A...B` emits `\t` — - * ahead = commits on A not reachable from B; behind = commits on B not - * reachable from A. This is the merge-base-symmetric delta used by the - * stale-base dispatch guard (§3.1). Returning null on any failure (bad - * ref, corrupt repo, non-numeric output) lets callers degrade gracefully - * instead of failing dispatch on a probe error. + * Return { ahead, behind } (merge-base-symmetric delta) for localRef vs remoteRef, or null on failure. + * ahead = commits on localRef not in remoteRef; behind = the reverse. Used by the stale-base dispatch guard (§3.1). */ export function getRemoteDrift( repoPath: string, @@ -559,12 +512,8 @@ export function getRemoteDrift( } /** - * Up to `limit` commit subjects present on remoteRef but not localRef, in - * recency order. Returns [] on git failure. - * - * Why: powers the preamble drift section (§3.2) so a worker dispatched - * against an acknowledged-stale base can see at a glance whether the - * drift touches their task area. + * Up to `limit` commit subjects on remoteRef but not localRef, recency order; [] on git failure. + * Powers the preamble drift section (§3.2) so a worker sees whether stale-base drift touches its area. */ export function getRecentDriftSubjects( repoPath: string, @@ -584,29 +533,18 @@ export function getRecentDriftSubjects( } } -/** - * Parse `git remote` stdout into a count of configured remotes. - * - * Why: shared between the local path and the SSH relay path so the - * count semantics cannot drift. - */ +/** Parse `git remote` stdout into a remote count. Shared local/SSH so count semantics can't drift. */ export function parseRemoteCount(stdout: string): number { return stdout.split('\n').filter((line) => line.trim().length > 0).length } -/** - * Count the repo's configured remotes by shelling out `git remote`. - * Returns 0 on error — callers use 0 as "unknown / do not render the - * multi-remote hint", preserving today's no-hint behavior on failure. - */ +/** Count configured remotes via `git remote`; returns 0 on error (callers read 0 as "unknown / no hint"). */ export async function getRemoteCount(path: string): Promise { try { const { stdout } = await gitExecFileAsync(['remote'], { cwd: path }) return parseRemoteCount(stdout) } catch (err) { - // Why: surface the failure for diagnostics; callers treat 0 as "unknown / - // do not render the multi-remote hint", but silently swallowing the error - // makes a missing hint impossible to debug. + // Why: log so a missing multi-remote hint is debuggable; callers still treat 0 as "unknown". console.warn('[getRemoteCount] git remote failed', { path, err }) return 0 } @@ -638,17 +576,10 @@ async function resolveVerifiedOriginHeadBaseRefViaExec(exec: GitExec): Promise { const originHeadBaseRef = await resolveVerifiedOriginHeadBaseRefViaExec(exec) @@ -678,30 +609,10 @@ async function getDefaultBaseRefAsync( } /** - * Build the argv for `git for-each-ref` used by ref search, given an - * already-normalized query string. + * Build the argv for `git for-each-ref` used by ref search, given an already-normalized query. * - * Why: glob `refs/remotes/*\/*` (not `refs/remotes/origin/*`) so fork - * workflows can discover branches from any configured remote (e.g. - * `upstream/main`). The picker would otherwise structurally deny the - * correct answer for fork contributors — see docs/upstream-base-ref-design.md. - * - * Why paired leaf/ancestor globs for a single-segment query: `git for-each-ref` - * uses fnmatch-style globs where `*` does NOT cross `/`. Slash-named branch - * refs need an ancestor-segment glob for `user` in `user/feature`, a leaf glob - * for `feature`, and the same remote-side shape so typing a remote name like - * `upstream` keeps working. - * - * Why the multi-segment branch: the picker displays results as - * `upstream/main`, so users naturally retype that format. With a single - * glob, `upstream/main` becomes `refs/remotes/*upstream/main*\/*` — five - * path segments, zero matches. Splitting on `/` and emitting one - * `**` per ref segment maps directly to git's ref structure - * (`refs/remotes//`, `refs/heads/`) and makes - * display-format queries actually find the ref on screen. - * - * Why shared: the local path and the SSH relay path must send the exact - * same argv so results cannot diverge between transports. + * Why: glob every remote (`refs/remotes/*\/*`), not just origin, so fork workflows can find branches like + * `upstream/main` — see docs/upstream-base-ref-design.md. Shared with the SSH relay path so argv can't diverge. */ const REF_SEARCH_CANDIDATE_MULTIPLIER = 4 const REF_SEARCH_LEGACY_HEADROOM = 100 @@ -737,30 +648,18 @@ export function buildSearchBaseRefsArgv( '--sort=-committerdate', ...(excludeRemoteHead ? [ - // Why: exclude remote HEAD pseudo-refs before --count so the bounded - // candidate window is spent on refs the picker can actually display. + // Why: exclude remote HEAD pseudo-refs before --count so the candidate window holds displayable refs. '--exclude=refs/remotes/**/HEAD' ] : []), - // Why: empty Branch-tab searches use broad globs; cap git output before - // execFile/SSH buffers capture every ref in very large repositories. + // Why: cap git output so broad globs don't overflow execFile/SSH buffers in very large repos. `--count=${candidateCount}` ] - // Why: split on `/` so display-format queries (`upstream/main`) route - // each token to one git ref segment. Filter empty tokens so trailing - // (`upstream/`), leading (`/main`), or doubled (`upstream//main`) - // slashes don't produce empty `**` segments that degrade to useless - // patterns. A single remaining token means the user hasn't committed - // to a remote-plus-branch query yet — route through the widened - // single-segment globs below instead of pinning to one segment. + // Why: split on `/` so display-format queries route each token to one ref segment; filter empties from stray slashes. const tokens = getRefSearchTokens(normalizedQuery) if (tokens.length <= 1) { const q = tokens[0] ?? '' - // Why `**`, not `*`: git for-each-ref globs are fnmatch-style where a - // single `*` does NOT cross `/`. Slash-named branches (`user/feature`) - // are the norm, so match both leaf and ancestor branch-name segments. - // The remote ancestor glob also preserves remote-name queries like - // `upstream` while `**/` keeps flat names like `main` working. + // Why `**` not `*`: fnmatch `*` can't cross `/`, so match slash-named branches at both leaf and ancestor segments. return [ ...base, `refs/heads/**/*${q}*`, @@ -769,11 +668,7 @@ export function buildSearchBaseRefsArgv( `refs/remotes/**/*${q}*/**` ] } - // Why: multi-token queries like `upstream/main` map one `*token*` per - // ref segment, so each token is matched within a single git ref - // segment (fnmatch `*` cannot cross `/`). The picker displays results - // as `/`, so users naturally retype that format; this - // branch is what makes re-typing a visible result actually find it. + // Why: one `*token*` per ref segment because fnmatch `*` can't cross `/`; lets a retyped `/` result match. const segmented = tokens.map((token) => `*${token}*`).join('/') const substringQuery = tokens.join('/') const remoteBranchRootPatterns = @@ -785,9 +680,7 @@ export function buildSearchBaseRefsArgv( : [`refs/remotes/*/${substringQuery}*`, `refs/remotes/*/${substringQuery}*/**`] const segmentedPatterns = [`refs/remotes/${segmented}`, `refs/heads/${segmented}`] const branchRootPatterns = [ - // Why: branch names often contain slashes (`plan/docs`). Segment-wise - // display-format globs only align with `/`; these root - // patterns also match the local branch name beneath any configured remote. + // Why: branch names often contain slashes (plan/docs); these root patterns also match a local branch beneath any remote. `refs/heads/${substringQuery}*`, `refs/heads/${substringQuery}*/**`, ...remoteBranchRootPatterns @@ -865,9 +758,7 @@ export async function getDefaultRemote( options: LocalGitExecOptions = {} ): Promise { const defaultRef = await getDefaultBaseRefAsync(path, options) - // Why: getDefaultBaseRefAsync returns null when no default branch can be - // detected (e.g. a brand-new repo with no commits on origin). Guard so we - // don't crash on .includes(); fall through to the remote-list heuristics. + // Why: getDefaultBaseRefAsync returns null when no default branch exists; guard so .includes() can't crash. const defaultBranch = defaultRef ? defaultRef.includes('/') ? defaultRef.split('/').slice(1).join('/') @@ -930,14 +821,11 @@ export async function searchBaseRefDetails( const normalizedQuery = normalizeRefSearchQuery(query) try { - // Why: argv (including the two-remote-glob rationale) lives in - // buildSearchBaseRefsArgv so the SSH sibling cannot drift. + // Why: argv lives in buildSearchBaseRefsArgv so the SSH sibling cannot drift. const remotes = await listRemoteNames(path) const tokens = getRefSearchTokens(normalizedQuery) if (tokens.length > 1) { - // Why: ambiguous slash queries need both display-format matches - // (`upstream/feat`) and local branch-name matches (`plan/docs`). - // Parse and merge buckets before the final limit so neither side starves. + // Why: slash queries need both display-format and local-branch matches; merge before the limit so neither starves. const results = await Promise.all([ runSearchBaseRefsGit(path, normalizedQuery, limit, { remoteNames: remotes, @@ -959,10 +847,7 @@ export async function searchBaseRefDetails( }) return parseAndFilterSearchRefDetails(result.stdout, limit, remotes) } catch (err) { - // Why: surface the failure for diagnostics; callers treat `[]` as "no - // matches", but silently swallowing the error makes a missing result - // set impossible to debug. Mirrors the SSH sibling in - // src/main/ipc/repos.ts. + // Why: log so a missing result set is debuggable; callers still treat [] as "no matches". console.warn('[searchBaseRefs] for-each-ref failed', { path, err }) return [] } @@ -981,15 +866,8 @@ async function listRemoteNames(path: string, options: LocalGitExecOptions = {}): } /** - * Parse `git for-each-ref --format=%(refname)%00%(refname:short)` stdout - * into a deduped list of short refs, filtering out `/HEAD` - * pseudo-refs, honoring a limit. - * - * Why: shared between the local `searchBaseRefs` and the SSH branch in - * `src/main/ipc/repos.ts` so both return identical, correctly-filtered - * results. The same bug class (wrong filter ordering, HEAD leaking into - * results, duplicate short refs) that motivated this helper originally - * lived in a single location; two copies double the regression surface. + * Parse `git for-each-ref --format=%(refname)%00%(refname:short)` stdout into a deduped list of short + * refs, dropping `/HEAD` pseudo-refs. Shared with the SSH branch in ipc/repos.ts so filtering can't drift. */ export function parseAndFilterSearchRefs(stdout: string, limit: number): string[] { return parseAndFilterSearchRefDetails(stdout, limit).map((entry) => entry.refName) @@ -1010,22 +888,13 @@ export function parseAndFilterSearchRefDetails( .map((line) => { const nul = line.indexOf('\0') if (nul < 0) { - // Why: defensive fallback for an unlikely %(refname) format change. - // Drop the entry — emitting a full refname as a "short" ref would - // hand callers a ref they can't use (and would bypass the HEAD - // filter below, since we could no longer tell a `/HEAD` - // pseudo-ref from a local branch named `foo/HEAD`). + // Why: no NUL means an unexpected %(refname) format; drop it rather than hand callers an unusable "short" ref. return null } return { full: line.slice(0, nul), short: line.slice(nul + 1) } }) .filter((entry): entry is { full: string; short: string } => entry !== null) - // Why: drop `refs/remotes//HEAD` pseudo-refs. Uses `.+` (not - // `[^/]+`) because git allows slashes in remote names, so nested - // remotes like `refs/remotes/foo/bar/HEAD` also match. A local branch - // named `foo/HEAD` (rare but valid per git check-ref-format) is - // preserved because its `full` is `refs/heads/foo/HEAD`, which does - // not match this pattern. + // Why: drop `/HEAD` pseudo-refs; `.+` (not `[^/]+`) since git allows slashes in remote names. .filter(({ full }) => !/^refs\/remotes\/.+\/HEAD$/.test(full)) .filter(({ short }) => { if (seen.has(short)) { @@ -1038,9 +907,7 @@ export function parseAndFilterSearchRefDetails( refName: short, localBranchName: resolveLocalBranchName(full, short, sortedRemotes) })) - // Why: `Math.max(0, limit)` — treat pathological `limit <= 0` as - // "zero results" rather than "at least 1". More honest than silently - // returning a single ref when the caller explicitly asked for none. + // Why: Math.max(0, limit) so pathological limit <= 0 yields zero results, not one. .slice(0, Math.max(0, limit)) ) } @@ -1099,9 +966,7 @@ export async function getBranchConflictKind( return false } const shortRef = trimmed.replace(/^refs\/remotes\//, '') - // Why: git allows slashes in remote names. Use the configured remote - // list so foo/bar/feature resolves as branch "feature" for remote - // "foo/bar", matching searchBaseRefDetails. + // Why: git allows slashes in remote names; use the configured list so foo/bar/feature resolves to branch "feature". return resolveLocalBranchName(trimmed, shortRef, remoteNames) === branchName }) @@ -1121,10 +986,7 @@ function isAllowedRemoteBaseRef(refName: string, allowedBaseRef: string | undefi return refName === normalizedAllowedRef } -/** - * Build a hosted URL (e.g. GitHub, GitLab, Bitbucket) for a specific file - * and line in the repo. Returns null when the remote isn't a recognized host. - */ +/** Build a hosted URL (GitHub/GitLab/Bitbucket) for a file+line; null when the remote isn't a recognized host. */ export function getRemoteFileUrl( repoPath: string, relativePath: string, @@ -1144,10 +1006,7 @@ export function getRemoteFileUrl( return buildHostedRemoteFileUrl(remoteUrl, relativePath, defaultBranch, line) } -/** - * Build a hosted URL (e.g. GitHub, GitLab, Bitbucket) for a commit. Returns - * null when the origin remote isn't a recognized host. - */ +/** Build a hosted URL (GitHub/GitLab/Bitbucket) for a commit; null when origin isn't a recognized host. */ export function getRemoteCommitUrl(repoPath: string, sha: string): string | null { const remoteUrl = getRemoteUrl(repoPath) if (!remoteUrl) { diff --git a/src/main/git/runner.ts b/src/main/git/runner.ts index 598ed2a9781..fac9f57ecac 100644 --- a/src/main/git/runner.ts +++ b/src/main/git/runner.ts @@ -1,13 +1,10 @@ -/* eslint-disable max-lines -- Why: command routing, WSL translation, and -git/gh/glab wrappers must stay co-located so platform behavior remains -consistent across every repo-scoped subprocess call. */ +/* eslint-disable max-lines -- command routing, WSL translation, and git/gh/glab wrappers stay co-located for consistent platform behavior. */ /** * Centralized git/gh/command runner with transparent WSL support. * - * Why: When a repo lives on a WSL filesystem (UNC path like \\wsl.localhost\Ubuntu\...), - * native Windows binaries (git.exe, gh.exe, rg.exe) are either absent or extremely slow. - * This module detects WSL paths and routes command execution through `wsl.exe -d ` - * with translated Linux paths, so every call site gets WSL support for free. + * Why: when a repo lives on a WSL filesystem, native Windows binaries (git.exe, + * gh.exe, rg.exe) are absent or slow, so this routes execution through + * `wsl.exe -d ` with translated Linux paths. */ import { execFile, @@ -42,16 +39,13 @@ import { } from '../../shared/wsl-login-shell-command' import { UNTRANSLATED_GIT_OUTPUT_ENV } from '../../shared/git-output-locale' import { endSubprocessStdin } from '../../shared/subprocess-stdin-write' -// Re-exported for existing importers; lightweight consumers should import these -// from './exec-error' directly so they do not depend on this heavy module. +// Re-exported for existing importers; lightweight consumers should import from './exec-error' to avoid this heavy module. import { extractExecError, parseRetryAfterMs } from './exec-error' export { extractExecError, parseRetryAfterMs } // ─── Core resolution ──────────────────────────────────────────────── -// `LANGUAGE=en LC_ALL=en_US.UTF-8 LANG=en_US.UTF-8` — env-assignment prefix for -// WSL-routed git, where spawn env cannot cross the wsl.exe boundary. Values are -// shell-safe unquoted (alnum, dot, dash, underscore only). +// Env-assignment prefix for WSL-routed git, where spawn env can't cross the wsl.exe boundary; values are shell-safe unquoted. const GIT_OUTPUT_LOCALE_SHELL_PREFIX = Object.entries(UNTRANSLATED_GIT_OUTPUT_ENV) .map(([key, value]) => `${key}=${value}`) .join(' ') @@ -65,14 +59,10 @@ type ResolvedCommand = { } /** - * Translate any Windows-style paths in command arguments to Linux paths - * when the command will execute inside WSL. + * Translate Windows-style path arguments to Linux paths for commands run in WSL. * - * Why: callers like worktree-create pass Windows paths (e.g. the workspace - * directory) as git arguments. WSL git doesn't understand Windows paths, - * so we must translate them. WSL UNC paths (\\wsl.localhost\...) are - * converted to their native Linux form; regular Windows drive paths - * (C:\Users\...) are converted to /mnt/c/Users/... + * Why: callers pass Windows paths as git arguments, which WSL git can't read. + * UNC paths (\\wsl.localhost\…) become native Linux; drive paths (C:\…) → /mnt/c/… */ function translateArgsForWsl(args: string[]): string[] { return args.map(translateArgForWsl) @@ -155,8 +145,7 @@ function resolveHostGitHubCli(command: 'gh', args: string[]): ResolvedCommand { return { binary: command, args, - // Why: host gh cannot use a WSL UNC cwd reliably. We only fall back - // for commands with explicit repo/API context, so no repo cwd is required. + // Why: host gh can't use a WSL UNC cwd; we only fall back for commands with explicit repo/API context, so none is needed. cwd: undefined, wsl: null } @@ -183,13 +172,10 @@ function isHostCommandMissing(err: unknown, command: 'gh' | 'glab'): boolean { } /** - * Given a command, its arguments, and a working directory, resolve whether - * the invocation should be routed through wsl.exe. + * Resolve whether a command invocation should be routed through wsl.exe. * - * Why `bash -c "cd ... && ..."` instead of `--cd`: wsl.exe's --cd flag - * does not work reliably when invoked via Node's execFile/spawn (it fails - * with ERROR_PATH_NOT_FOUND in some configurations). Using bash -c with - * an explicit cd is universally supported. + * Why `bash -c "cd … && …"` instead of `--cd`: wsl.exe's --cd fails with + * ERROR_PATH_NOT_FOUND under Node's execFile/spawn in some configs. */ function resolveCommand( command: string, @@ -202,16 +188,8 @@ function resolveCommand( return { binary: command, args, cwd, wsl: null } } - // Why: global gh callers (rate_limit, listAccessibleProjects) have no - // meaningful cwd to derive a WSL distro from. On WSL-only Windows setups, - // gh.exe isn't on the host PATH and the spawn fails with ENOENT. Allow - // callers to pass a distro hint so we can route through wsl.exe regardless. - // TODO(wsl-default-distro): the codebase currently has no persistent - // "default WSL distro" setting — distros are derived from individual repo - // paths. Until such a setting exists, global gh callers without an explicit - // override silently fall back to host gh.exe, which on WSL-only Windows - // installs will ENOENT. The wslDistroOverride parameter is the hook for - // wiring a future setting in without re-plumbing the runner. + // Why: global gh callers (rate_limit, listAccessibleProjects) have no cwd to derive a distro from; a distro hint still routes through wsl.exe. + // TODO(wsl-default-distro): no default-distro setting yet, so override-less global gh callers fall back to host gh.exe (ENOENT on WSL-only installs). const cwdWsl = cwd ? parseWslPath(cwd) : null const wsl: WslPathInfo | null = cwdWsl ?? (wslDistroOverride ? { distro: wslDistroOverride, linuxPath: '' } : null) @@ -220,20 +198,12 @@ function resolveCommand( } const translatedArgs = translateArgsForWsl(args) - // Why: env set on wsl.exe stays on the Windows side (WSLENV forwards only - // named vars), so the untranslated-output locale must ride the command - // string for git stderr parsers to work inside the distro (issue #7808). + // Why: env on wsl.exe stays Windows-side (WSLENV forwards only named vars), so the locale must ride the command string (issue #7808). const localePrefix = command === 'git' ? `${GIT_OUTPUT_LOCALE_SHELL_PREFIX} ` : '' const escapedCommand = quotePosixShell(command) - // Why: shell-escape each argument to prevent word splitting / glob expansion - // inside the bash -c string. Single quotes are safe for all chars except - // single quotes themselves, which we escape as '\'' (end quote, escaped - // literal, reopen quote). + // Why: shell-escape each arg to prevent word splitting / glob expansion inside the bash -c string. const escapedArgs = translatedArgs.map(quotePosixShell) - // Why: when cwd is supplied as a WSL UNC path, prepend `cd &&` - // so the command runs in the expected directory. When the caller only - // supplied a distro override (no cwd), skip the cd entirely — the gh CLI - // doesn't need a particular cwd for global calls like `api rate_limit`. + // Why: prepend `cd &&` for a UNC cwd; skip it when only a distro override was given (global gh needs no cwd). const linuxCwd = cwdWsl?.linuxPath ?? (cwd && wslDistroOverride ? translateArgForWsl(cwd) : null) const shellCmd = linuxCwd ? `cd ${quotePosixShell(linuxCwd)} && ${localePrefix}${escapedCommand} ${escapedArgs.join(' ')}` @@ -258,9 +228,7 @@ function resolveCommand( return { binary: 'wsl.exe', args: ['-d', wsl.distro, '--', 'bash', '-c', shellCmd], - // Why: cwd is set to undefined because wsl.exe handles directory switching - // via the cd inside bash -c. Setting a UNC cwd on the Node process would - // be redundant and can cause issues with some Node internals. + // Why: the `cd` inside bash -c handles the directory; a UNC cwd on the Node process is redundant and can break Node internals. cwd: undefined, wsl } @@ -268,12 +236,7 @@ function resolveCommand( // ─── Git-specific runners ─────────────────────────────────────────── -// Why: Node's execFile only honors maxBuffer when it is a number — passing -// `undefined` (which happens whenever a caller omits the option) disables the -// cap entirely, so a command that prints more than V8's ~512MB max string -// length crashes the main process uncatchably inside execFile's exit handler -// (Array.join over the buffered chunks). Apply this floor so no git call can -// ever buffer without a bound. Matches the relay's MAX_GIT_BUFFER. +// Why: execFile disables its cap when maxBuffer is undefined; unbounded output over V8's string max crashes main uncatchably — keep in sync with relay MAX_GIT_BUFFER. export const DEFAULT_GIT_MAX_BUFFER = 10 * 1024 * 1024 type GitExecOptions = { @@ -334,8 +297,7 @@ function killSpawnedCommandTree(child: ChildProcess): Promise { return new Promise((resolve) => { let killer: ChildProcess try { - // Why: Windows shims and wsl.exe can own descendants; wait for /t tree - // cleanup before settling so a timed-out command cannot outlive its probe. + // Why: Windows shims/wsl.exe own descendants; wait for /t tree cleanup so a timed-out command can't outlive its probe. killer = spawn('taskkill', ['/pid', String(pid), '/t', '/f'], { stdio: 'ignore', windowsHide: true @@ -457,8 +419,7 @@ function execFileCapture( try { const spawnStartedAt = performance.now() - // Why: the abort listener below owns tree-aware cleanup. Node's - // signal handler could kill wsl.exe before taskkill sees its children. + // Why: our abort listener owns tree cleanup; Node's signal handler could kill wsl.exe before taskkill sees its children. child = execFile( command, args, @@ -495,8 +456,7 @@ function execFileCapture( endSubprocessStdin(child.stdin, options.stdin) } - // Why: Node's native timeout can wait forever for signal-ignoring CLIs; - // enforce our own deadline and settle after bounded process-tree cleanup. + // Why: Node's timeout waits forever on signal-ignoring CLIs; enforce our own deadline with bounded tree cleanup. if (options.timeout && options.timeout > 0) { timer = setTimeout(() => { if (settled || terminating) { @@ -623,19 +583,14 @@ export function gitOptionalLocksDisabledEnv( } /** - * Append git config entries through the GIT_CONFIG_COUNT / GIT_CONFIG_KEY_n / - * GIT_CONFIG_VALUE_n env protocol (git >= 2.31), composing with any count - * already present in `env` so we never clobber config a caller injected the - * same way. + * Append git config via the GIT_CONFIG_COUNT/KEY_n/VALUE_n env protocol (git >= 2.31), + * composing with any count already in `env` so we never clobber a caller's config. */ export { appendGitConfigEnv } /** - * Pin Orca-spawned git to untranslated English output so stderr/progress - * parsers keep working under any user locale (issue #7808; see - * UNTRANSLATED_GIT_OUTPUT_ENV for the full rationale). Terminal git is - * untouched. Injected by every git runner in this module; WSL-routed spawns - * get the same values via GIT_OUTPUT_LOCALE_SHELL_PREFIX instead. + * Pin Orca-spawned git to untranslated English output so stderr/progress parsers + * work under any user locale (issue #7808). Terminal git is untouched. */ export function untranslatedGitOutputEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { return { ...env, ...UNTRANSLATED_GIT_OUTPUT_ENV } @@ -649,11 +604,9 @@ export function promptGuardGitEnv( } /** - * Credential-prompt guard for a general-purpose shell environment (terminal - * PTYs, hook scripts): everything promptGuardGitEnv does EXCEPT the issue-7808 - * locale pins. Those exist so Orca can parse stderr of git it spawns itself; - * forcing LC_ALL/LANG/LANGUAGE onto a user's shell would change the locale of - * every child process, not just git's. + * Credential-prompt guard for a general-purpose shell (PTYs, hook scripts): + * like promptGuardGitEnv but without the issue-7808 locale pins, which would + * change the locale of every child process, not just git's. */ export function promptGuardShellEnv( env: NodeJS.ProcessEnv = process.env, @@ -663,21 +616,15 @@ export function promptGuardShellEnv( } /** - * Force git to be non-interactive so it fails fast instead of blocking forever - * on a prompt. Without this, a git read-path call (status, worktree list, …) - * that hits an auth/credential prompt or an SSH host-key confirmation hangs on - * stdin with no terminal to answer it; on the headless `serve` runtime those - * stuck calls pile up and the runtime stops answering all clients (issue #5308). + * Force git non-interactive so it fails fast instead of hanging on a prompt with + * no terminal to answer it; on headless `serve` those stuck calls wedge every + * client (issue #5308). * - * - GIT_TERMINAL_PROMPT=0: git refuses to prompt for credentials and errors out. - * - GIT_ASKPASS / SSH_ASKPASS: emptied when unset so no GUI/askpass helper can - * pop a prompt and block. A caller-provided askpass is preserved on purpose — - * custom askpass setups commonly *serve* credentials non-interactively, and - * blanking them would break those fetches. - * - GIT_SSH_COMMAND BatchMode=yes: SSH fails instead of waiting on an - * interactive password/host-key prompt. BatchMode does NOT change host trust - * (an unknown host still errors, it just won't hang). Only added when the - * caller hasn't set its own GIT_SSH_COMMAND. + * - GIT_TERMINAL_PROMPT=0: git errors instead of prompting for credentials. + * - GIT_ASKPASS / SSH_ASKPASS: emptied when unset so no GUI helper blocks; a + * caller-provided askpass is preserved (custom setups serve creds non-interactively). + * - GIT_SSH_COMMAND BatchMode=yes: SSH errors instead of prompting (doesn't change + * host trust); only added when the caller hasn't set its own. */ export function nonInteractiveGitEnv( env: NodeJS.ProcessEnv = process.env, @@ -687,9 +634,7 @@ export function nonInteractiveGitEnv( if (!next.GIT_SSH_COMMAND) { next.GIT_SSH_COMMAND = 'ssh -o BatchMode=yes' if (platform === 'win32') { - // Why: forward across the WSL boundary only when we set the value — - // plain `ssh` resolves inside the distro, whereas a caller's - // Windows-specific GIT_SSH_COMMAND must not leak into Linux git. + // Why: forward GIT_SSH_COMMAND to WSL only when we set it — a caller's Windows-specific value must not leak into Linux git. addWslEnvKeys(next, ['GIT_SSH_COMMAND']) } } @@ -852,8 +797,7 @@ async function buildNetworkSshPolicyEnv(options: GitExecOptions): Promise<{ const batchModeCommand = buildOpenSshBatchModeCommand(configuredCommand) if (!batchModeCommand) { - // Why: custom wrappers are executable user policy; rewriting their argv is - // riskier than relying on prompt guards plus the caller's target timeout. + // Why: custom SSH wrappers are user policy; rewriting their argv is riskier than relying on prompt guards + timeout. return { env: promptEnv, mode: 'configured-wrapper-passthrough' } } @@ -872,10 +816,7 @@ export async function gitExecFileAsync( args: string[], options: GitExecOptions ): Promise<{ stdout: string; stderr: string }> { - // Why wrap here: the resolved binary path / WSL detection is internal - // detail; the span attributes track the user-visible `git - // ` form so dashboards group cleanly by intent rather than by - // platform-conditional binary path. + // Why: span the user-visible `git ` form, not the resolved binary, so dashboards group by intent. return withGitSpan( { args, ...(options.cwd !== undefined ? { cwd: options.cwd } : {}) }, async () => { @@ -893,8 +834,7 @@ export async function gitExecFileAsync( maxBuffer: options.maxBuffer, timeout: options.timeout, stdin: options.stdin, - // Why: never let a git read-path call block on an interactive prompt - // (issue #5308) — fail fast instead of hanging the runtime. + // Why: never let a git read-path call block on an interactive prompt (issue #5308) — fail fast. env: policy.env, signal: options.signal }) @@ -973,8 +913,7 @@ export async function gitExecFileAsyncBuffer( return { stdout } } -/** Result of a streamed git command. `stoppedEarly` is true when the caller's - * onStdout hook asked to stop and the child was killed before exiting. */ +/** Result of a streamed git command; `stoppedEarly` is true when onStdout asked to stop before the child exited. */ export type GitStreamResult = { stoppedEarly: boolean } type GitStreamOptions = { @@ -985,10 +924,8 @@ type GitStreamOptions = { /** Byte backstop; defaults to DEFAULT_GIT_MAX_BUFFER. */ maxBuffer?: number /** - * Called for each decoded stdout chunk as it arrives. Return true to stop: - * the child is killed and the promise resolves with stoppedEarly=true. This - * lets a streaming parser bail out (e.g. once an entry limit is reached) - * without ever buffering the full output. + * Called for each decoded stdout chunk. Return true to stop: the child is + * killed and the promise resolves with stoppedEarly=true. */ onStdout: (chunk: string) => boolean | void } @@ -996,11 +933,9 @@ type GitStreamOptions = { /** * Stream a git command's stdout incrementally instead of buffering it whole. * - * Why: status on a repo with an enormous un-ignored folder can emit more output - * than fits in a single string, crashing the process when buffered. Streaming - * lets the parser count entries as they arrive and stop git the moment a limit - * is crossed, so memory stays bounded. Built on gitSpawn so WSL routing is - * preserved. stderr is bounded; a non-zero exit rejects (unless we stopped it). + * Why: output larger than V8's max string (e.g. status on a repo with a huge + * un-ignored folder) crashes the process when buffered; streaming keeps memory + * bounded and lets the parser stop git early. Built on gitSpawn for WSL routing. */ export async function gitStreamStdout( args: string[], @@ -1026,9 +961,7 @@ export async function gitStreamStdout( let stdoutBytes = 0 let stderr = '' let stderrBytes = 0 - // Why: decode statefully so a multibyte UTF-8 character split across two - // chunks (common with non-ASCII filenames) isn't corrupted into - // replacement characters and mis-parsed. + // Why: decode statefully so a multibyte UTF-8 char split across chunks isn't corrupted into replacement chars. const stdoutDecoder = new StringDecoder('utf8') const stderrDecoder = new StringDecoder('utf8') @@ -1066,9 +999,7 @@ export async function gitStreamStdout( if (decoded.length === 0) { return } - // Why: the parser callback is caller-supplied; a throw here would escape - // the stream event handler and crash the main process (the exact failure - // mode this streaming path exists to prevent). Convert it to a rejection. + // Why: a throw from the caller's parser would escape this event handler and crash main; convert to a rejection. let shouldStop: boolean | void try { shouldStop = options.onStdout(decoded) @@ -1078,8 +1009,7 @@ export async function gitStreamStdout( return } if (shouldStop === true) { - // Why: parser hit its limit. Kill git and resolve cleanly — the - // partial output we already parsed is the intended result. + // Parser hit its limit: kill git and resolve cleanly with the partial output. stoppedEarly = true void killSpawnedCommandTree(child) finish(null) @@ -1121,11 +1051,7 @@ export async function gitStreamStdout( }) } -// Why: sync git calls run on the Electron main thread. Local git is normally -// fast, but a repo on a dead network drive / cloud-placeholder path can hang -// git on filesystem timeouts for minutes with no timeout set — the leading -// explanation for issue #7225's 127s "Not Responding" freeze. Callers needing -// longer operations should use the async runners instead. +// Why: sync git blocks the main thread; a dead network drive can hang git for minutes without a timeout (issue #7225's 127s freeze). const GIT_EXEC_SYNC_TIMEOUT_MS = 15_000 /** @@ -1154,8 +1080,7 @@ export function gitExecFileSync( timeout: options.timeout ?? GIT_EXEC_SYNC_TIMEOUT_MS }) as string } finally { - // Sync exec holds the main thread for its whole duration, so the entire - // call is main-thread block time — the cost issue #7576 flags. + // Sync exec blocks the main thread for its whole duration — the cost issue #7576 flags. recordSubprocessSpawn(resolved.binary, resolved.args, performance.now() - spawnStartedAt) } } @@ -1184,23 +1109,9 @@ export function gitSpawn( // ─── gh CLI runners ───────────────────────────────────────────────── -// Why: non-repo-scoped gh calls (listAccessibleProjects, rate_limit, etc.) -// have no meaningful cwd. Allow it to be omitted so the one WSL-aware wrapper -// serves both repo-scoped and global callers and we stop having two spawn -// sites (the other one — a plain execFileAsync in project-view.ts — bypasses -// retry/backoff and any future quota tracker). -// Why: `wslDistro` is an explicit hint for global (cwd-less) gh callers on -// WSL-only Windows installs where gh.exe isn't on the host PATH. When set, -// resolveCommand routes the spawn through `wsl.exe -d -- gh ...` -// even without a UNC cwd to parse a distro from. Repo-scoped callers should -// keep using cwd — the distro derives from the path automatically there. -// Why: `idempotent` gates the transient-error retry. When undefined we -// auto-detect from argv (writes are detected by `-X POST/PATCH/PUT/DELETE` -// or a `query=mutation …` arg); callers can also pass an explicit override. -// A 5xx/socket reset after the request reaches GitHub but before the -// response returns is the canonical case where the server-side write -// succeeded; retrying would create a duplicate comment/issue/label addition. -// See bug-scan finding 1. +// `cwd?` omitted for non-repo-scoped gh calls (rate_limit, listAccessibleProjects) so one WSL-aware wrapper serves both. +// `wslDistro?` routes global cwd-less gh through `wsl.exe -d ` on WSL-only Windows where gh.exe isn't on host PATH. +// `idempotent?` gates transient-error retry (auto-detected from argv); retrying a write that already reached GitHub would duplicate it. type GhExecOptions = Omit & { cwd?: string wslDistro?: string @@ -1208,8 +1119,7 @@ type GhExecOptions = Omit & { } const NON_IDEMPOTENT_METHODS = new Set(['POST', 'PATCH', 'PUT', 'DELETE']) -// `gh ` write subcommands. Reads (view/list/status/checks) -// are absent on purpose so the default of "retry" stays for them. +// `gh ` write subcommands; reads are absent on purpose so they keep retrying. const NON_IDEMPOTENT_GH_VERBS = new Set([ 'create', 'edit', @@ -1251,9 +1161,7 @@ function argsLookIdempotent(args: string[]): boolean { return false } } - // `gh api` auto-switches GET→POST when -f/-F/--field/--raw-field body - // fields are supplied without an explicit -X. Track those to classify - // such calls as non-idempotent. + // `gh api` auto-POSTs when -f/-F/--field body fields are given without -X; track them. if (a === '-f' || a === '-F' || a === '--field' || a === '--raw-field') { hasApiBodyField = true } else if ( @@ -1264,8 +1172,7 @@ function argsLookIdempotent(args: string[]): boolean { ) { hasApiBodyField = true } - // `gh api graphql -f query=mutation(...){ ... }` — detect mutation queries - // so writes via the GraphQL endpoint also fail fast on transient errors. + // Detect GraphQL `query=mutation(…)` so endpoint writes also fail fast on transient errors. if (a.startsWith('query=')) { hasGraphQlQuery = true const trimmed = a.slice('query='.length).trimStart().toLowerCase() @@ -1274,10 +1181,7 @@ function argsLookIdempotent(args: string[]): boolean { } } } - // `gh api ... -f foo=bar` with no explicit method: gh switches to POST. - // Treat as non-idempotent so a transient 5xx after the server applied - // the write doesn't retry and duplicate it. GraphQL reads are the exception: - // gh sends them as POST body fields, but a query operation is idempotent. + // `gh api -f foo=bar` with no -X auto-POSTs → non-idempotent; GraphQL query bodies are the exception (still reads). if ( args[0] === 'api' && hasApiBodyField && @@ -1286,10 +1190,7 @@ function argsLookIdempotent(args: string[]): boolean { ) { return false } - // `gh issue close`, `gh pr edit`, `gh pr merge`, etc. The first arg is the - // noun (issue/pr/repo/label/...) and the second is the verb. Defaulting - // `gh api` calls without an explicit -X to GET-equivalent (idempotent) is - // intentional: callers that POST through `gh api` set `-X POST`. + // `gh ` writes (args[1]); `gh api` without -X defaults to idempotent GET, so it's excluded here. if (args.length >= 2 && args[0] !== 'api') { if (NON_IDEMPOTENT_GH_VERBS.has(args[1])) { return false @@ -1301,12 +1202,9 @@ function argsLookIdempotent(args: string[]): boolean { /** * Classify whether a gh execFile rejection is worth retrying. * - * Why: gh surfaces HTTP status in stderr as "HTTP 504", "HTTP 502", etc. - * Network resets and DNS hiccups also show up as stderr substrings. We retry - * those and 429 (rate-limited) — but only 429s without an explicit - * Retry-After (the caller is better off propagating so the UI can show the - * actual wait time). The primary-rate-limit 403 branch is NOT retried: those - * require the user to back off for minutes, which is not transient. + * Why: gh surfaces HTTP status as stderr substrings ("HTTP 504", econnreset, …). + * Retry 5xx/network resets and 429 only without Retry-After (propagate those so + * the UI can show the wait); primary-rate-limit 403 is never transient. */ export function isTransientGhError(stderr: string): boolean { const s = stderr.toLowerCase() @@ -1328,18 +1226,10 @@ export function isTransientGhError(stderr: string): boolean { return false } -// Why: total of 3 attempts (original + 2 retries) with 250ms → 1s backoff. -// These are standard "transient 5xx" values. Longer waits push past user -// patience for an interactive action; shorter waits would hammer the same -// unhealthy upstream that just failed. The array length defines retry count; -// total attempts = length + 1. +// Why: 3 attempts total (250ms → 1s backoff); array length defines retry count (total attempts = length + 1). const GH_RETRY_DELAYS_MS = [250, 1000] as const -// Why: the upstream Retry-After header is server-suggested but unbounded — -// GitHub has been observed to send tens-of-seconds values on rare incidents, -// and a malicious or misconfigured proxy could send anything. Cap the wait -// at 30s so a single transient gh call can never block the IPC main thread -// for longer than the user's patience budget for an interactive action. +// Why: Retry-After is unbounded and untrusted; cap at 30s so a gh call can't block the IPC thread indefinitely. const GH_RETRY_AFTER_MAX_MS = 30_000 const DEFAULT_GH_EXEC_TIMEOUT_MS = 30_000 @@ -1367,17 +1257,14 @@ function nonInteractiveGhEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.Proce * Async gh CLI execution. Drop-in replacement for * `execFileAsync('gh', args, { cwd, encoding, ... })`. * - * Retries transient 5xx / 429 (without Retry-After) / network-reset failures - * with exponential backoff. Non-transient errors (auth, 404, rate-limit 403, - * validation, 429-with-Retry-After) fail fast on the first attempt. + * Retries transient 5xx / 429-without-Retry-After / network-reset failures with + * exponential backoff; other errors fail fast. */ export async function ghExecFileAsync( args: string[], options: GhExecOptions = {} ): Promise<{ stdout: string; stderr: string }> { - // Why: while a bucket's primary rate limit is exhausted, every spawn would - // return the same 403 — fail fast without paying the subprocess cost. The - // rate_limit probe itself is exempt so the breaker can learn the reset time. + // Why: while a bucket is rate-limited every spawn returns 403 — fail fast; the probe is exempt so the breaker can learn the reset. const rateLimitBucket = classifyGhRateLimitBucket(args) if (!isGhRateLimitProbe(args)) { const blockedUntilMs = getGhRateLimitBlockedUntilMs(rateLimitBucket) @@ -1395,8 +1282,7 @@ export async function ghExecFileAsync( cwd: resolved.cwd, encoding: (options.encoding ?? 'utf-8') as BufferEncoding, maxBuffer: options.maxBuffer, - // Why: GitHub detail IPC powers PR cards, Tasks, and URL worktree - // creation; one stuck gh child must fail visibly, not wedge every lane. + // Why: bound gh so one stuck child fails visibly instead of wedging the IPC lane. timeout: options.timeout ?? defaultGhExecTimeoutMs(options.env), env: nonInteractiveGhEnv(options.env) }) @@ -1417,8 +1303,7 @@ export async function ghExecFileAsync( ) { const wslResolved = resolveDefaultWslCli('gh', args) if (wslResolved) { - // Why: WSL-only Windows installs have no gh.exe on the host PATH, but - // global calls like rate_limit/auth do not carry a repo cwd to route by. + // Why: WSL-only Windows installs have no host gh.exe, and global calls (rate_limit/auth) carry no cwd to route by. resolved = wslResolved attemptedDefaultWslFallback = true attempt = -1 @@ -1432,22 +1317,10 @@ export async function ghExecFileAsync( continue } const isLastAttempt = attempt >= GH_RETRY_DELAYS_MS.length - // Why: only retry idempotent calls. A 5xx/socket reset can arrive - // after the server already applied a POST/PATCH/PUT/DELETE; retrying - // would duplicate the write (e.g. double-post a comment, double-add - // a label). When the caller doesn't say, we auto-detect from argv — - // explicit `-X ` and GraphQL `query=mutation …` are treated - // as non-idempotent. See bug-scan finding 1. + // Why: only retry idempotent calls; a transient error can arrive after a write already applied, so retrying would duplicate it. const idempotent = options.idempotent ?? argsLookIdempotent(args) if (idempotent && !isLastAttempt && isTransientGhError(stderr)) { - // Why: when the upstream surfaced a Retry-After (e.g. on a transient - // 5xx that GitHub explicitly recommends backing off for), honor it - // instead of using our default backoff — sleeping less than the - // server suggests just earns another failure and burns our retry - // budget. Cap at GH_RETRY_AFTER_MAX_MS so a pathologically large - // hint can't block IPC for minutes; if the real wait is longer, the - // attempt will fail again and the error will propagate to the UI - // where the user can see it. + // Why: honor the server's Retry-After over our backoff (a shorter sleep just re-fails); cap so a huge hint can't stall IPC. const retryAfterMs = parseRetryAfterMs(stderr) const delayMs = retryAfterMs !== null @@ -1464,13 +1337,7 @@ export async function ghExecFileAsync( } // ─── glab CLI runner ──────────────────────────────────────────────── -// Why: parallel to gh CLI runner above. GitLab support is added by -// cloning gh's surface rather than abstracting both behind a generic -// runner — keeping them as parallel implementations matches the -// project's clone-and-adapt approach for new providers and avoids -// touching the working gh path. Reuses the shared retry/transient -// helpers since HTTP-status- and TCP-error-based classification is -// provider-agnostic. +// Why: cloned from the gh runner rather than abstracted behind a generic runner, to avoid touching the working gh path. type GlabExecOptions = Omit & { cwd?: string @@ -1479,21 +1346,9 @@ type GlabExecOptions = Omit & { allowDefaultWslFallback?: boolean } +/** Async glab CLI execution; drop-in for execFileAsync('glab', …). Retry policy mirrors ghExecFileAsync. */ /** - * Async glab CLI execution. Drop-in replacement for - * `execFileAsync('glab', args, { cwd, encoding, ... })`. - * - * Retry policy mirrors ghExecFileAsync. - */ -/** - * glab's `--hostname` flag rejects a host that carries a port - * ("error parsing --hostname: invalid hostname"). A self-hosted GitLab on a - * non-default port (e.g. `gitlab.example.com:8443`) must instead be selected - * via the `GITLAB_HOST` env var, which accepts `host:port`. Translate any - * `--hostname host:port` pair into `GITLAB_HOST` so every call site (`api`, - * `auth status`, …) works against ported self-hosted instances. Port-less - * `--hostname` values are left untouched. - * + * glab's `--hostname` rejects host:port, so a ported self-hosted GitLab must use the GITLAB_HOST env var instead — translate it. * @internal exported for tests. */ export function redirectPortedHostnameToEnv( @@ -1555,9 +1410,7 @@ export async function glabExecFileAsync( } } const isLastAttempt = attempt >= GH_RETRY_DELAYS_MS.length - // Why: mirror gh's write-safety gate. A transient error after GitLab - // applies a POST/PATCH/PUT/DELETE must not create duplicate comments, - // issues, or merge actions through an automatic retry. + // Why: mirror gh's write-safety gate — don't auto-retry a non-idempotent write that GitLab may already have applied. const idempotent = options.idempotent ?? argsLookIdempotent(args) if (idempotent && !isLastAttempt && isTransientGhError(stderr)) { const retryAfterMs = parseRetryAfterMs(stderr) @@ -1602,10 +1455,7 @@ export function wslAwareSpawn( /** * Translate absolute Linux paths in git output back to Windows UNC paths. - * - * Why: when git runs inside WSL, paths in output (e.g. `git worktree list`) - * are Linux-native (/home/user/repo). The rest of Orca needs Windows UNC - * paths (\\wsl.localhost\Ubuntu\home\user\repo) to read files via Node fs. + * Why: git-in-WSL emits Linux-native paths, but Orca reads files via Node fs, which needs Windows UNC. */ export function translateWslOutputPaths( output: string, @@ -1618,15 +1468,11 @@ export function translateWslOutputPaths( return output } - // Replace absolute Linux paths that start with / and look like filesystem - // paths in structured git output (e.g. "worktree /home/user/repo/feature") + // Rewrite absolute Linux paths in structured git output (e.g. "worktree /home/user/repo/feature") to Windows UNC. return output.replace(/(?<=worktree )(\/.+)$/gm, (_match, linuxPath: string) => toWindowsWslPath(linuxPath, distro) ) } -/** - * Get the WSL info for a path, if applicable. Convenience re-export so - * consumers don't need to import from wsl.ts directly. - */ +/** Convenience re-export of wsl.ts path helpers so consumers don't import it directly. */ export { parseWslPath, toLinuxPath, toWindowsWslPath, isWslPath } from '../wsl' diff --git a/src/main/git/status.ts b/src/main/git/status.ts index ae818b52567..d325003fd2b 100644 --- a/src/main/git/status.ts +++ b/src/main/git/status.ts @@ -75,12 +75,7 @@ type SubmodulePathsCacheEntry = { paths: string[]; expiresAt: number } const submodulePathsCache = new Map() let submodulePathsCacheGeneration = 0 -// Why: the effective-upstream resolution chain (symbolic-ref + rev-parse ×2-3 -// + config snapshot) costs 4-5 subprocess spawns and only changes when branch -// or git config changes. Ahead/behind is a pure function of the two rev-list -// endpoints, so a recently-resolved name can be revalidated with one rev-list -// spawn per poll tick; a failed rev-list (deleted ref) falls back to a full -// re-resolve. Issue #7576: this path dominated idle main-process spawn churn. +// Why: cache the upstream name to skip its 4-5-spawn resolution chain each poll; revalidate via one rev-list (issue #7576). const RESOLVED_UPSTREAM_NAME_CACHE_TTL_MS = 60_000 type ResolvedUpstreamNameCacheEntry = { @@ -97,9 +92,7 @@ const gitDiffReadDedupe = new InFlightPromiseDedupe() const effectiveUpstreamStatusWriteGeneration = new Map() const statusReadsInFlight = new Map>() -// Why: a mutation invalidates both in-flight diff reads and in-flight status -// coalescing; clearing only the diff dedupe would let a post-mutation -// getStatus() join a pre-mutation read and return stale entries. +// Why: clear both diff and status in-flight caches; clearing only diff would let getStatus() join a pre-mutation read. export function invalidateGitReadCaches(): void { gitDiffReadDedupe.clear() statusReadsInFlight.clear() @@ -113,8 +106,7 @@ export async function runWithGitReadCacheInvalidation(run: () => Promise): try { return await run() } finally { - // Why: a read that started during the mutation can be stale too, so the - // post-mutation boundary retires both pre-existing and overlapping reads. + // Why: a read that started mid-mutation can be stale too, so invalidate again after. invalidateGitReadCaches() } } @@ -125,8 +117,7 @@ export function clearSubmodulePathsCacheForTests(): void { function clearSubmodulePathsCache(): void { submodulePathsCache.clear() - // Why: a pre-mutation .gitmodules read must not repopulate the cache after - // the mutation invalidated it. + // Why: bump the generation so a pre-mutation read can't repopulate the invalidated cache. submodulePathsCacheGeneration += 1 } @@ -139,8 +130,7 @@ function gitRuntimeOptionsKey(options: GitRuntimeOptions): readonly unknown[] { } function getSubmodulePathsCacheKey(worktreePath: string, options: GitRuntimeOptions): string { - // Why: the same path string can refer to different filesystem views across - // WSL distros, so the `.gitmodules` cache must follow runtime routing. + // Why: the same path can map to different WSL-distro filesystems, so key the cache by runtime routing. return [worktreePath, ...gitRuntimeOptionsKey(options)].join('\0') } @@ -182,8 +172,7 @@ function rememberSubmodulePaths(cacheKey: string, paths: string[], now: number): trimSubmodulePathsCache() } -// Why: status tests reuse this reset hook, so every cross-call memoization layer -// must reset together even though the historical name mentions upstream only. +// Why: tests reuse this hook, so every memoization layer resets together despite the upstream-only name. export function clearEffectiveUpstreamStatusCacheForTests(): void { effectiveUpstreamStatusCache.clear() effectiveUpstreamStatusInFlight.clear() @@ -222,8 +211,7 @@ export async function getStatus( if (options.signal) { return runGetStatus(worktreePath, options) } - // Why: dedupe only concurrent identical reads; after settle, callers must - // execute a fresh status read rather than observing a cached result. + // Why: dedupe only concurrent identical reads; after settle, callers must run a fresh read. const cacheKey = getStatusReadKey(worktreePath, options) const inFlightStatus = statusReadsInFlight.get(cacheKey) if (inFlightStatus) { @@ -265,21 +253,15 @@ async function runGetStatus( const lineStatsWriteToken = beginGitStatusLineStatsCacheWrite(lineStatsCacheKey) let effectiveUpstreamStatus: GitUpstreamStatus | undefined let statusSucceeded = false - // Why: a negative/fractional/NaN limit would trigger spurious early-stop or - // inconsistent truncation; fall back to the default unless it's a valid - // non-negative integer (0 explicitly disables the cap). + // Why: a bad limit (negative/fractional/NaN) breaks early-stop; require a valid non-negative int (0 disables the cap). const limit = typeof options.limit === 'number' && Number.isInteger(options.limit) && options.limit >= 0 ? options.limit : DEFAULT_GIT_STATUS_LIMIT - // Why: detectConflictOperation (4 existsSync + readFile) and git status are - // independent. Running them concurrently saves one round-trip of I/O latency. + // Why: detectConflictOperation and git status are independent, so run them concurrently to save I/O latency. const conflictPromise = detectConflictOperation(worktreePath) - // Why: -c core.quotePath=false keeps non-ASCII filenames (Japanese, emoji, - // etc.) as raw UTF-8 instead of git's default C-style octal escapes wrapped - // in double quotes. Without it, the parsed entry.path is unreadable in the - // sidebar and downstream `git show :"docs/\346..."` lookups silently miss. + // Why: core.quotePath=false keeps non-ASCII paths as raw UTF-8, not octal escapes, so entry.path is readable and lookups match. const statusArgs = [ '-c', 'core.quotePath=false', @@ -292,9 +274,7 @@ async function runGetStatus( statusArgs.push('--ignored=matching') } - // Why: stream + parse incrementally and stop git the moment the entry count - // crosses `limit`, so a repo with an enormous un-ignored folder never buffers - // a status listing big enough to crash the process. See StatusPorcelainParser. + // Why: stream + parse and stop at `limit` so a huge un-ignored folder can't buffer enough to crash the process. const parser = new StatusPorcelainParser() let didHitLimit = false const conflictOperation = await conflictPromise @@ -303,8 +283,7 @@ async function runGetStatus( const { stoppedEarly } = await gitStreamStdout(statusArgs, { cwd: worktreePath, wslDistro: options.wslDistro, - // Why: status polling is read-like; avoid refreshing the index and racing - // terminal Git commands on `.git/worktrees/*/index.lock`. + // Why: status polling is read-like; disable optional locks to avoid racing terminal Git on index.lock. env: gitOptionalLocksDisabledEnv(), signal: options.signal, onStdout: (chunk) => parser.update(chunk, limit) @@ -315,22 +294,18 @@ async function runGetStatus( didHitLimit = stoppedEarly statusSucceeded = true } catch (error) { - // Why: an aborted scan must reject, not resolve — swallowing here would let - // a cancelled request be mistaken for a completed (empty) status result. + // Why: an aborted scan must reject, not resolve as an empty result. if (options.signal?.aborted) { throw error } // Not a git repo or git not available } - // Why: the parser stops one entry past the limit (it checks after pushing), so - // trim back to exactly `limit` for a stable "first N shown" contract. + // Why: the parser overshoots by one (checks after pushing), so trim to exactly `limit`. const entries = didHitLimit ? parser.entries.slice(0, limit) : parser.entries const { head, branch, upstreamName, upstreamAheadBehind } = parser.branch - // Why: unmerged (`u`) records need async per-file git lookups, so the parser - // collected their raw lines; resolve them now. Conflicts are rare and never - // the source of huge output, so this stays off the streamed hot path. + // Why: unmerged (`u`) records need async per-file lookups; resolve them off the hot path (conflicts are rare). if (!didHitLimit) { for (const line of parser.unmergedLines) { const unmergedEntry = await parseUnmergedEntry(worktreePath, line) @@ -350,11 +325,7 @@ async function runGetStatus( options ) try { - // Why: the probe promise and its name/negative caches are shared by - // concurrent status reads, so one caller's abort must not reject the - // shared probe or evict warm cache state for the others. The probe is - // small and its cached result stays useful, so run it unbound from - // this request's signal. + // Why: the shared probe/caches serve concurrent reads, so run it unbound from this signal — one abort mustn't reject it for others. const { signal: _requestSignal, ...sharedProbeOptions } = options effectiveUpstreamStatus = await readOrProbeEffectiveUpstreamStatus( cacheKey, @@ -364,18 +335,12 @@ async function runGetStatus( options.bypassEffectiveUpstreamNegativeCache === true ) } catch { - // Why: git status polling should not fail just because the richer - // upstream probe hit a transient ref/read error; the explicit - // upstream-status path will surface those failures when invoked. + // Why: don't fail status polling on a transient upstream-probe error; the explicit upstream path surfaces those. } } } - // Why: attach per-area line counts for the sidebar. Diffs run after status - // (we need the entry list first) and only for areas that have entries, so a - // clean tree costs zero extra git calls. Skipped when the limit was hit — - // running numstat over a huge change set would reintroduce the cost the limit - // exists to avoid, matching how a "huge" repo disables extra git features. + // Why: line counts run only for areas with entries (clean tree = 0 calls); skip past the limit to avoid numstat over a huge set. if (!didHitLimit) { await reuseOrRecomputeGitStatusLineStats({ cacheKey: lineStatsCacheKey, @@ -390,8 +355,7 @@ async function runGetStatus( clearGitStatusLineStatsCacheKey(lineStatsCacheKey, lineStatsWriteToken) } - // Why: abort after the stream (e.g. during unmerged/upstream/line-stats work) - // must still reject — never resolve a cancelled scan as a completed result. + // Why: an abort after the stream (unmerged/upstream/line-stats work) must still reject, not resolve. if (options.signal?.aborted) { const error = new Error('The operation was aborted.') error.name = 'AbortError' @@ -423,15 +387,13 @@ async function runGetStatus( } function getStatusLineStatsCacheKey(worktreePath: string, options: GitRuntimeOptions = {}): string { - // Why: identical path strings can address different Linux filesystems in - // different WSL distros, so derived stats must follow Git's execution host. + // Why: identical paths can map to different WSL-distro filesystems, so key stats by Git's execution host. return `${options.wslDistro ?? 'native'}\0${worktreePath}` } /** * Resolve a submodule's own worktree path from a parent worktree + relative - * submodule path, rejecting anything that escapes the parent. Shared by the - * on-demand submodule status query and the submodule-aware diff router. + * submodule path, rejecting anything that escapes the parent. */ export function resolveSubmoduleWorktreePath(worktreePath: string, submodulePath: string): string { if (!submodulePath || submodulePath.includes('\0') || path.isAbsolute(submodulePath)) { @@ -446,11 +408,8 @@ export function resolveSubmoduleWorktreePath(worktreePath: string, submodulePath } /** - * Run a plain status inside a submodule's own worktree. Used by the lazy - * "expand submodule" flow — the parent status only reports a single gitlink - * row, so the inner per-file changes are fetched on demand here. Entry paths - * are relative to the submodule root; the renderer prefixes them with the - * submodule path. + * Run a plain status inside a submodule's own worktree (lazy "expand submodule" + * flow). Entry paths are relative to the submodule root; the renderer prefixes them. */ export async function getSubmoduleStatus( worktreePath: string, @@ -459,9 +418,7 @@ export async function getSubmoduleStatus( ): Promise { const submoduleWorktreePath = resolveSubmoduleWorktreePath(worktreePath, submodulePath) const workingResult = await getStatus(submoduleWorktreePath, options) - // Why: a moved gitlink (clean worktree) has no uncommitted status rows; its - // real changes live between the parent-recorded commit and the checked-out - // commit. Surface those as inner rows so the expansion isn't empty. + // Why: a moved gitlink (clean worktree) has no status rows; surface the parent-commit→checkout range as inner rows. const fromOid = options.staged ? await readGitlinkOidFromTree(worktreePath, 'HEAD', submodulePath, options) : (await readGitlinkOidFromIndex(worktreePath, submodulePath, options)) || @@ -494,9 +451,8 @@ export async function getSubmoduleStatus( } /** - * List files changed between two submodule commits as status rows. Used when a - * gitlink pointer moved so the expanded submodule shows the committed file - * changes (each row diffs the file across the two commits). + * List files changed between two submodule commits as status rows — used when a + * gitlink pointer moved so the expanded submodule shows committed changes. */ async function computeSubmoduleRangeEntries( submoduleWorktreePath: string, @@ -567,15 +523,11 @@ async function runNumstat( ) return parseNumstat(stdout) } catch (error) { - // Why: an aborted pass must reject so a cancelled scan is never treated as - // a completed one; only a genuine (non-abort) numstat failure degrades to - // uncounted rows below. + // Why: an aborted pass must reject; only a genuine numstat failure degrades to uncounted rows. if (options.signal?.aborted) { throw error } - // Why: a numstat failure (e.g. transient lock) should leave rows without - // counts rather than break the whole status refresh. Null (vs an empty - // map) tells the caller the pass is incomplete and must not be cached. + // Why: a numstat failure leaves rows uncounted; null (not empty map) flags the pass incomplete and uncacheable. return null } } @@ -708,8 +660,7 @@ function rememberEffectiveUpstreamStatus( probedSameNameOriginRef: boolean, writeGeneration: number ): void { - // Why: hasConfiguredPushTarget gates a write action. Re-probe it each poll - // rather than keeping a stale positive target after branch config changes. + // Why: hasConfiguredPushTarget gates a write action; re-probe each poll rather than cache a stale positive. if (status.hasUpstream || status.hasConfiguredPushTarget) { effectiveUpstreamStatusCache.delete(cacheKey) effectiveUpstreamStatusWriteGeneration.set(cacheKey, writeGeneration + 1) @@ -722,8 +673,7 @@ function rememberEffectiveUpstreamStatus( if (!probedSameNameOriginRef) { return } - // Why: a stable no-upstream branch should not spawn failed git probes every - // source-control poll, but remote refs can appear after push/fetch. + // Why: cache the negative so a stable no-upstream branch doesn't re-probe every poll (TTL lets push/fetch refs appear). effectiveUpstreamStatusCache.set(cacheKey, { status, expiresAt: now + EFFECTIVE_UPSTREAM_NEGATIVE_CACHE_TTL_MS @@ -758,8 +708,7 @@ async function readOrProbeEffectiveUpstreamStatus( } } - // Why: source-control mount and root git refresh can overlap during startup. - // Coalesce the richer upstream probe so a stable missing ref fails once. + // Why: overlapping refreshes at startup — coalesce the upstream probe so a stable missing ref fails once. const writeGeneration = effectiveUpstreamStatusWriteGeneration.get(cacheKey) ?? 0 const probe = probeOrRevalidateEffectiveUpstreamStatus( cacheKey, @@ -809,8 +758,7 @@ async function probeOrRevalidateEffectiveUpstreamStatus( ) return { status, probedSameNameOriginRef: false } } catch (error) { - // Why: an aborted probe says nothing about the ref; evicting the warm - // name cache here would force a pointless full re-resolve next scan. + // Why: an aborted probe says nothing about the ref; don't evict the warm name cache. if (options.signal?.aborted) { throw error } @@ -889,12 +837,7 @@ async function parseUnmergedEntry( worktreePath: string, line: string ): Promise { - // Why: porcelain v2 unmerged entries are fully space-separated (like type-1 - // ordinary entries), NOT tab-separated. The format is: - // u

- // The path starts at field index 10 and may contain spaces, so we join the - // remaining fields. The earlier tab-based parsing silently dropped all - // unmerged entries because the tab was never present. + // Why: porcelain v2 `u` records are space-separated (not tab); path is field 10+ and may contain spaces, so join the tail. const parts = line.split(' ') const xy = parts[1] const modeStage1 = parts[3] @@ -905,9 +848,7 @@ async function parseUnmergedEntry( return null } - // Why: submodule conflicts (mode 160000) are out of scope for v1. - // Presenting them with normal file-conflict UX would be misleading because - // submodule resolution requires different Git commands and user mental model. + // Why: submodule conflicts (mode 160000) are out of scope for v1 — they need different resolution UX. if ([modeStage1, modeStage2, modeStage3].some((mode) => mode === '160000')) { return null } @@ -917,9 +858,7 @@ async function parseUnmergedEntry( return null } - // Why: porcelain v2 `u` records do not provide rename-origin metadata (unlike - // `2` records), so oldPath is intentionally omitted. v1 should not promise - // rename ancestry in conflict rows without a separate Git query. + // Why: porcelain v2 `u` records lack rename-origin metadata, so oldPath is intentionally omitted. return { path: filePath, area: 'unstaged', @@ -950,16 +889,8 @@ function parseConflictKind(xy: string): GitConflictKind | null { } } -// Why: the `status` field on conflict entries is a *rendering compatibility* -// choice for existing icon/color plumbing, not a semantic claim about the file. -// The conflict badge and subtype carry the real meaning. We use 'modified' when -// a working-tree file exists and 'deleted' when it does not, so that downstream -// consumers (file explorer decorations, tab badges) get a reasonable fallback -// without needing conflict-aware upgrades in v1. -// -// For `deleted_by_us` / `deleted_by_them` and the `added_by_*` variants, Git's -// behavior depends on the merge strategy, so we check the filesystem rather -// than hardcoding an assumption. +// Why: `status` here is a rendering-compat choice for icon/color plumbing, not semantic; the conflict badge carries the real meaning. +// Why: for deleted_by_*/added_by_* variants Git's result depends on merge strategy, so check the filesystem. async function getConflictCompatibilityStatus( worktreePath: string, filePath: string, @@ -976,25 +907,13 @@ async function getConflictCompatibilityStatus( try { return existsSync(path.join(worktreePath, filePath)) ? 'modified' : 'deleted' } catch { - // Why: if the filesystem check throws (permissions error, unmounted path, - // etc.), 'modified' is the safer fallback. It avoids suppressing the row - // from the sidebar and avoids a misleading 'deleted' when we simply could - // not check. The conflict badge still carries the real semantics. + // Why: on an fs check failure, 'modified' is safer — it keeps the row visible rather than falsely showing 'deleted'. return 'modified' } } -// Why: there is an inherent race between the `git status` call and these -// fs.existsSync checks — the HEAD file may not yet exist or may already be -// cleaned up by the time we check. In that case we fall back to 'unknown' for -// one poll cycle, which is acceptable. The renderer uses this to label the -// merge summary ("Merge conflicts" vs "Rebase conflicts" vs generic "Conflicts"). -// -// Why rebase detection relies on rebase-merge/ or rebase-apply/ directories -// instead of REBASE_HEAD: those directories persist for the entire rebase, so -// they cover both conflicting and non-conflicting steps. REBASE_HEAD, by -// contrast, only exists on some steps and can also be left behind after a -// completed rebase, which would make the UI show a stale "Rebasing" badge. +// Why: the git-status → existsSync race can miss a transient HEAD; fall back to 'unknown' for one poll cycle. +// Why: detect rebase from rebase-merge/ or rebase-apply/ dirs (persist all steps), not REBASE_HEAD (partial, lingers → stale badge). export async function detectConflictOperation(worktreePath: string): Promise { const gitDir = await resolveGitDir(worktreePath) const mergeHead = path.join(gitDir, 'MERGE_HEAD') @@ -1061,9 +980,8 @@ export async function resolveGitDir(worktreePath: string): Promise { } /** - * List configured submodule paths (relative, forward-slash) for a worktree, - * cached briefly. Read from `.gitmodules` so a single diff click doesn't pay - * for an index-wide `ls-files` scan. Used to route gitlink/inner diffs. + * List configured submodule paths (relative, forward-slash) for a worktree, cached + * briefly. Read from `.gitmodules` to avoid an index-wide `ls-files` scan. */ export async function listSubmodulePaths( worktreePath: string, @@ -1075,8 +993,7 @@ export async function listSubmodulePaths( if (cached) { return cached } - // Why: prune on misses so removed worktrees do not accumulate while hot - // cache hits stay O(1). + // Why: prune on misses so removed worktrees don't accumulate; hot hits stay O(1). pruneExpiredSubmodulePathsCache(now) const cacheGeneration = submodulePathsCacheGeneration let paths: string[] = [] @@ -1174,9 +1091,8 @@ async function readWorkingSubmoduleHead( } /** - * Synthesize a gitlink pointer diff. Git represents submodule commit changes as - * a one-line `Subproject commit ` swap, so feeding the old/new oids through - * the normal text differ matches git's own rendering. + * Synthesize a gitlink pointer diff: Git represents submodule commit changes as a + * one-line `Subproject commit ` swap, so the old/new oids feed the text differ. */ async function buildSubmodulePointerDiff( worktreePath: string, @@ -1184,8 +1100,7 @@ async function buildSubmodulePointerDiff( staged: boolean, compareAgainstHead: boolean, options: GitRuntimeOptions, - // Why: default to the validated resolver so every caller (not just loadDiff) - // is protected from a .gitmodules path escaping the parent worktree. + // Why: default to the validated resolver so every caller is guarded against path escape. submoduleWorktreePath = resolveSubmoduleWorktreePath(worktreePath, submodulePath) ): Promise { let leftOid = '' @@ -1212,9 +1127,8 @@ async function buildSubmodulePointerDiff( } /** - * Diff a file inside a submodule across two of its commits. Used when the parent - * gitlink moved but the submodule worktree is clean — the change is committed, - * so compare the recorded commit's blob against the checked-out commit's blob. + * Diff a file inside a submodule across two of its commits — used when the parent + * gitlink moved but the submodule worktree is clean (change is committed). */ async function buildSubmoduleInnerCommitRangeDiff( submoduleWorktreePath: string, @@ -1256,8 +1170,7 @@ export async function getDiff( compareAgainstHead = false, options: GitRuntimeOptions = {} ): Promise { - // Why: register the in-flight dedupe synchronously (before any await) so - // concurrent identical reads coalesce; submodule routing happens inside. + // Why: register the dedupe synchronously (before any await) so concurrent identical reads coalesce. return gitDiffReadDedupe.run( stableInFlightKey([ 'diff', @@ -1278,17 +1191,12 @@ async function loadDiff( compareAgainstHead: boolean, options: GitRuntimeOptions ): Promise { - // Why: gitlink paths can't be read as blobs (`git show HEAD:` is a "bad - // object") and a submodule working dir reads as empty, so route submodule - // diffs explicitly: the gitlink root → pointer diff, inner files → recurse - // into the submodule's own worktree. + // Why: gitlink paths can't be read as blobs, so route submodule diffs explicitly (root → pointer, inner → recurse). const submodulePaths = await listSubmodulePaths(worktreePath, options) if (submodulePaths.length > 0) { const matchedSubmodule = findContainingSubmodule(submodulePaths, filePath) if (matchedSubmodule) { - // Why: matchedSubmodule originates from .gitmodules, so validate it against - // the worktree boundary before any inner read — a crafted submodule path - // must not let the diff escape the selected repo. + // Why: validate the .gitmodules-derived path against the worktree boundary so a crafted one can't escape the repo. const submoduleWorktreePath = resolveSubmoduleWorktreePath(worktreePath, matchedSubmodule) const normalizedFilePath = filePath.replace(/\\/g, '/').replace(/\/+$/, '') if (normalizedFilePath === matchedSubmodule) { @@ -1309,9 +1217,7 @@ async function loadDiff( const toOid = staged ? await readGitlinkOidFromIndex(worktreePath, matchedSubmodule, options) : await readWorkingSubmoduleHead(submoduleWorktreePath, options) - // Why: when the gitlink moved but the submodule worktree is clean, the - // file's change lives in committed history — diff the two commits. Only - // fall back to the working-tree blob read when the commit didn't move. + // Why: a moved gitlink with a clean submodule worktree means the change is committed — diff the two commits. if (fromOid && toOid && fromOid !== toOid) { return buildSubmoduleInnerCommitRangeDiff( submoduleWorktreePath, @@ -1362,8 +1268,7 @@ async function loadDiff( modifiedIsBinary, filePath ) - // Why: mark a proven deletion so previewers can fall back to the original bytes - // without mistaking a read failure's empty modified side for a deletion. + // Why: mark a proven deletion so previewers don't mistake a read failure's empty side for one. if (result.kind === 'binary' && modifiedDeleted) { return { ...result, modifiedDeleted: true } } @@ -1387,8 +1292,7 @@ export async function getBranchCompare( const compareRef = await resolveCompareRef(worktreePath, options) summary.compareRef = compareRef - // Why: short remote display refs like "origin/main" can collide with a local - // branch of the same name. Compare against the proven remote-tracking ref. + // Why: short refs like "origin/main" can collide with a local branch; use the proven remote-tracking ref. const resolvedBaseRef = await resolveWorktreeAddBaseRef(baseRef, (qualifiedRef) => hasWorktreeBaseCommitRef(worktreePath, qualifiedRef, options) ) @@ -1402,16 +1306,13 @@ export async function getBranchCompare( try { baseOid = await resolveRefOid(worktreePath, resolvedBaseRef, options) summary.baseOid = baseOid - // Why: new remote worktrees can be on an unborn branch until the first - // commit. There are no committed branch changes yet; surfacing this as a - // compare error makes the source-control panel look broken. + // Why: an unborn branch (new remote worktree) has no changes yet; a compare error would look broken. summary.changedFiles = 0 summary.commitsAhead = 0 summary.status = 'ready' return { summary, entries: [] } } catch { - // Preserve the existing unborn-head message when even the base is not - // resolvable; callers cannot compare or present a useful empty state. + // Preserve the unborn-head message when even the base is unresolvable. } summary.status = 'unborn-head' summary.errorMessage = @@ -1637,14 +1538,12 @@ async function loadBranchChanges( headOid: string, options: GitRuntimeOptions = {} ): Promise { - // Why: see core.quotePath=false rationale in getStatus — same reason here so - // branch-diff entries render with their real UTF-8 paths. + // Why: core.quotePath=false keeps real UTF-8 paths — see getStatus rationale. const gitOptions = { ...gitOptionsForWorktree(worktreePath, options), maxBuffer: MAX_GIT_SHOW_BYTES } - // Why: both diffs walk the same range and are independent, so start them - // together instead of serializing two potentially large git operations. + // Why: both diffs are independent, so run them concurrently instead of serializing. const [{ stdout }, { stdout: numstat }] = await Promise.all([ gitExecFileAsync( ['-c', 'core.quotePath=false', 'diff', '--name-status', '-M', '-C', mergeBase, headOid], @@ -1658,8 +1557,7 @@ async function loadBranchChanges( const statsByPath = parseNumstat(numstat) const entries: GitBranchChangeEntry[] = [] - // [Fix]: Split by /\r?\n/ instead of '\n' to handle Git CRLF output on Windows, - // preventing trailing \r characters in extracted file paths. + // Why: split on /\r?\n/ so Git's CRLF output on Windows leaves no trailing \r in paths. for (const line of stdout.split(/\r?\n/)) { if (!line) { continue @@ -1678,8 +1576,7 @@ async function loadCommitChanges( commitOid: string, options: GitRuntimeOptions = {} ): Promise { - // Why: root commits have no parent tree; diff-tree --root asks git to - // compare against the repository's empty tree without hardcoding hash format. + // Why: root commits have no parent tree; diff-tree --root uses git's empty tree, avoiding a hardcoded hash-format-specific oid. const args = parentOid ? ['-c', 'core.quotePath=false', 'diff', '--name-status', '-M', '-C', parentOid, commitOid] : [ @@ -1713,8 +1610,7 @@ async function loadCommitChanges( ...gitOptionsForWorktree(worktreePath, options), maxBuffer: MAX_GIT_SHOW_BYTES } - // Why: commit diff rows need metadata and line counts, but those git queries - // do not depend on each other. + // Why: the two git queries are independent, so run them in parallel. const [{ stdout }, { stdout: numstat }] = await Promise.all([ gitExecFileAsync(args, gitOptions), gitExecFileAsync(numstatArgs, gitOptions) @@ -1872,9 +1768,7 @@ async function readWorkingTreeFile(filePath: string): Promise try { fileStat = await stat(filePath) } catch (error) { - // Why: ENOENT means the working-tree file is genuinely gone (a deletion); - // any other stat error is a read failure, which must not be reported as an - // absence since callers fall back to the original bytes only for deletions. + // Why: only ENOENT is a real deletion; other stat errors are read failures, not absence. return { content: '', isBinary: false, @@ -1885,8 +1779,7 @@ async function readWorkingTreeFile(filePath: string): Promise return { content: '', isBinary: false, exists: false } } if (fileStat.size > MAX_GIT_SHOW_BYTES) { - // Why: git blob reads are capped through maxBuffer; mirror that bound for - // unstaged working-tree content before readFile can pull in huge assets. + // Why: mirror git's maxBuffer cap for working-tree reads so readFile can't pull in huge assets. return { content: '', isBinary: true, exists: true } } try { @@ -1932,15 +1825,12 @@ function buildDiffResult( modifiedContent, originalIsBinary, modifiedIsBinary, - // Why: binary diff previews were originally image-only, so the renderer - // still checks `isImage` before showing a preview component. Preserve - // that legacy flag for PDFs until the wider contract is renamed. + // Why: renderer still checks legacy `isImage` before previewing, so set it for PDFs too until the contract is renamed. ...(mimeType ? { isImage: true, mimeType } : {}) } as GitDiffResult } - // Why: if the diff exceeds safe render limits, avoid sending large text - // payloads and return metadata so the renderer can show fallback UI. + // Why: over the render limit, return metadata instead of huge text so the renderer can show fallback UI. const largeDiffRenderLimit = getLargeDiffRenderLimit({ originalContent, modifiedContent }) if (largeDiffRenderLimit.limited) { return { @@ -2049,9 +1939,7 @@ export async function getStagedCommitContext( if (!isMaxBufferOverflowError(error)) { throw error } - // Why: a very large staged diff overflows maxBuffer (ENOBUFS). The patch is - // optional context that gets truncated to STAGED_DIFF_BYTE_BUDGET anyway, so - // degrade to the file-name summary instead of failing commit-message generation. + // Why: staged patch is optional context (truncated later anyway); degrade to file-name summary rather than fail. console.warn( '[git] Staged patch too large to read; using file summary only:', describeMaxBufferOverflowError(error) @@ -2075,9 +1963,7 @@ export async function commitChanges( await gitExecFileAsync(['commit', '-m', message], gitOptionsForWorktree(worktreePath, options)) return { success: true } } catch (error) { - // Why: surface whichever channel carries the useful message. Pre-commit/GPG - // hook failures write to stderr; "nothing to commit, working tree clean" - // writes to stdout. Try stderr first, fall back to stdout, then error.message. + // Why: useful message may be on stderr (hook/GPG failures) or stdout ("nothing to commit"), so try both then message. const readStringField = (field: string): string | null => { if (typeof error === 'object' && error && field in error) { const v = (error as Record)[field] @@ -2149,8 +2035,7 @@ function normalizeGitPathForCompare(filePath: string): string { } function literalPathspec(filePath: string, options: GitRuntimeOptions): string { - // Why: Windows validation produces backslashes, but Git running inside WSL - // needs POSIX paths. Host paths stay untouched so POSIX filenames remain literal. + // Why: Git inside WSL needs POSIX paths, but host paths must stay literal, so convert backslashes only for WSL. const runtimePath = options.wslDistro ? filePath.replace(/\\/g, '/') : filePath return `:(literal)${runtimePath}` } @@ -2177,8 +2062,7 @@ async function listTrackedPathSpecs( ...gitOptionsForWorktree(worktreePath, options) } ) - // Why: a tracked directory can contain enough paths for push(...split) - // to exceed the JavaScript argument limit before discard decisions run. + // Why: a tracked directory can hold enough paths to exceed the JS argument limit. for (const trackedPath of stdout.split('\0')) { if (trackedPath) { trackedPaths.push(trackedPath) diff --git a/src/main/git/worktree.ts b/src/main/git/worktree.ts index 84ce3963192..92fb4437d77 100644 --- a/src/main/git/worktree.ts +++ b/src/main/git/worktree.ts @@ -84,10 +84,7 @@ const SPARSE_CHECKOUT_DETECTION_CONCURRENCY = 8 const PRUNABLE_EXISTENCE_PROBE_CONCURRENCY = 8 -// Why: bound `git worktree add` so a OneDrive cloud-placeholder base path can't -// stall its checkout writes for minutes (STA-1292) — a stuck create then fails -// fast instead of spinning forever. Generous enough not to kill a legit large -// checkout (mirrors the SYNC runner's cloud-placeholder floor, #7225). +// Why: bound `git worktree add` so a OneDrive cloud-placeholder stall fails fast (STA-1292); generous enough for a legit large checkout (#7225). export const WORKTREE_ADD_TIMEOUT_MS = 180_000 export const WORKTREE_REMOVAL_PREFLIGHT_TIMEOUT_MS = 30_000 @@ -155,9 +152,7 @@ function parseRemoteTrackingLocalBaseRef( return undefined } - // Why: Only refs proven to be remote-tracking refs get refresh status. - // Local branches can contain slashes (e.g. release/2026) and must not - // produce a fake "Local 2026 was not refreshed" warning. + // Why: only proven remote-tracking refs get refresh status; slash-containing local branches (release/2026) must not fake a "not refreshed" warning. const shortRemoteRef = remoteTrackingRef.slice(remoteRefPrefix.length) const slashIndex = shortRemoteRef.indexOf('/') if (slashIndex <= 0) { @@ -196,9 +191,7 @@ async function evaluateLocalBaseRefRefreshability( let localOid = '' let remoteOid = '' try { - // Why: advisory and mutating paths must agree on "safe to fast-forward". - // `rev-list A...B` proves the local branch exists, has no local-only - // commits, and tells the toast whether it is actually behind. + // Why: advisory and mutating paths must agree on "safe to fast-forward"; `rev-list A...B` proves no local-only commits and how far behind. const { stdout } = await gitExecFileAsync( ['rev-list', '--left-right', '--count', `${parsed.fullRef}...${remoteTrackingRef}`], gitExecOptions(repoPath, options) @@ -208,8 +201,7 @@ async function evaluateLocalBaseRefRefreshability( return { refreshable: false, result: { ...resultBase, status: 'skipped_not_fast_forward' } } } if (!shouldInspectOwner(parsedDrift.behind)) { - // Why: a current local ref cannot produce an update suggestion, so the - // advisory path need not resolve OIDs or inspect its owner worktree. + // Why: a current local ref yields no update suggestion, so the advisory path skips OID resolution and owner inspection. return undefined } const { stdout: localOidOutput } = await gitExecFileAsync( @@ -238,8 +230,7 @@ async function evaluateLocalBaseRefRefreshability( } try { - // Why: if the local base branch is checked out anywhere, turning on the - // setting would only update it when that owner worktree is clean. + // Why: if the local base branch is checked out, only update it when that owner worktree is clean. const { stdout: worktreeListOutput } = await gitExecFileAsync( ['worktree', 'list', '--porcelain'], gitExecOptions(repoPath, options) @@ -276,9 +267,7 @@ async function evaluateLocalBaseRefRefreshability( } } - // Why: localBranch isn't checked out anywhere, so there is no working tree - // to desync — a bare ref fast-forward (update-ref) is safe. Omitting - // ownerWorktreePath signals the mutating path to take that branch. + // Why: localBranch isn't checked out anywhere, so a bare-ref fast-forward is safe; omitting ownerWorktreePath signals the mutating path to take it. return { refreshable: true, ...resultBase, @@ -332,8 +321,7 @@ async function persistWorktreeCreationBase( } catch (error) { console.warn(`addWorktree: failed to set ${configKey} for ${worktreePath}`, error) try { - // Why: reused branch names may carry stale base metadata; if replacement - // fails, remove the old value so consumers do not trust outdated lineage. + // Why: reused branch names may carry stale base metadata; if replacement fails, unset it so consumers don't trust stale lineage. await gitExecFileAsync(['config', '--local', '--unset-all', configKey], { ...gitExecOptions(worktreePath, options) }) @@ -356,8 +344,7 @@ async function unsetWorktreeCreationBase( ...gitExecOptions(worktreePath, options) }) } catch { - // Best-effort cleanup; missing keys and locked config both leave the - // original sparse setup error as the actionable failure. + // Best-effort cleanup; leave the original sparse-setup error as the actionable failure. } } @@ -383,8 +370,7 @@ function resolveRevParsePath(repoPath: string, value: string): string { if (posix.isAbsolute(value) || win32.isAbsolute(value)) { return value } - // Old git ignores `--path-format=absolute`, so a relative toplevel/git-dir - // must be resolved against the scanned repo path. + // Old git ignores `--path-format=absolute`, so resolve a relative toplevel/git-dir against the scanned repo path. return looksLikeWindowsPath(repoPath) ? win32.resolve(repoPath, value) : posix.resolve(repoPath, value) @@ -393,11 +379,8 @@ function resolveRevParsePath(repoPath: string, value: string): string { type RepoLocation = { topLevel: string; commonDir: string } function parseRepoLocation(repoPath: string, output: string): RepoLocation | undefined { - // Old git (pre `--path-format`) echoes the unrecognized flag to stdout and - // exits 0 rather than erroring, so drop any echoed `-`-prefixed lines and - // read the two trailing path lines (toplevel, then git-common-dir). Strip only - // the trailing CR, not surrounding spaces — git paths may legitimately start - // or end with a space. + // Old git echoes the unrecognized `--path-format` flag and exits 0, so drop `-`-prefixed lines and + // read the last two path lines (toplevel, git-common-dir); strip only trailing CR — paths may have edge spaces. const lines = output .split('\n') .map((line) => (line.endsWith('\r') ? line.slice(0, -1) : line)) @@ -430,8 +413,7 @@ async function readRepoLocation( gitExecOptions(repoPath, options) ) if (hasUnsupportedRevParsePathFormatEcho(stdout)) { - // Why: some old Git versions echo the unknown option and exit zero; - // remember that compatibility signal even though parsing can recover. + // Why: some old Git echoes the unknown option and exits zero; remember that compat signal even though parsing recovers. capabilities.rememberUnsupported('rev-parse-path-format') } return parseRepoLocation(resolveBasePath, stdout) @@ -457,11 +439,8 @@ async function normalizeMainWorktreePath( ): Promise { const mainIndex = worktrees.findIndex((worktree) => worktree.isMainWorktree) const mainWorktree = worktrees[mainIndex] - // Compare in the Git-output space: under WSL the porcelain/rev-parse paths are - // Linux while repoPath is a UNC path, so without translating, a UNC repoPath - // never matches the early-return and fires a needless rev-parse on every poll. - // Use the platform-independent UNC parser so the comparison holds regardless - // of host OS; the runner still receives the original repoPath for WSL routing. + // Why: under WSL, porcelain/rev-parse paths are Linux but repoPath is UNC; compare in Git-output + // space so the early-return matches and we skip a needless rev-parse per poll (runner still gets repoPath). const wslRepo = parseWslUncPath(repoPath) const comparablePath = wslRepo ? wslRepo.linuxPath : repoPath if (!mainWorktree || areWorktreePathsEqual(mainWorktree.path, comparablePath)) { @@ -473,10 +452,8 @@ async function normalizeMainWorktreePath( return worktrees } - // Why: only a separate-git-dir/submodule main worktree reports the Git - // directory as the main entry — i.e. the main entry equals git-common-dir. - // A linked worktree's main entry is a real working root, so gating on this - // equality avoids overwriting it with the linked worktree's own toplevel. + // Why: only a separate-git-dir/submodule main worktree reports git-common-dir as its path; gate on + // that equality so we don't overwrite a linked worktree's real working root with its own toplevel. if (!areWorktreePathsEqual(mainWorktree.path, location.commonDir)) { return worktrees } @@ -527,8 +504,7 @@ export function parseWorktreeList( const rawReason = line.slice('locked'.length).trim() lockReason = options.nulDelimited ? rawReason : decodeGitCQuotedPath(rawReason) } else if (line === 'prunable' || line.startsWith('prunable ')) { - // Why: Git ≥ 2.36 flags registrations whose directory is gone; ignoring - // it surfaces the stale worktree as a live workspace (issue #8389). + // Why: Git ≥2.36 flags registrations whose directory is gone; ignoring it shows the stale worktree as live (#8389). prunable = true const rawReason = line.slice('prunable'.length).trim() prunableReason = options.nulDelimited ? rawReason : decodeGitCQuotedPath(rawReason) @@ -610,8 +586,7 @@ async function readWorktreeList( ) }, async () => { - // Why: `-z` is required to preserve worktree paths containing newlines, - // but Git <2.36 rejects it. Keep the line parser as the fallback. + // Why: `-z` preserves worktree paths with newlines but Git <2.36 rejects it; fall back to the line parser. const { stdout } = await gitExecFileAsync(['worktree', 'list', '--porcelain'], { cwd: repoPath, ...options @@ -621,11 +596,8 @@ async function readWorktreeList( parseWorktreeList(stdout), options ) - // Why: this `-z`-unsupported fallback (Git <2.36) also serves Git <2.31, - // which emits no `prunable` annotation; probe each linked worktree path - // for existence instead of treating stale registrations as live. On Git - // 2.31–2.35 `parseWorktreeList` already set `prunable`, so the probe is a - // harmless backstop that skips those entries (issue #8389). + // Why: Git <2.31 emits no `prunable`, so probe each linked path for existence instead of trusting + // stale registrations; a harmless backstop on 2.31–2.35 where parseWorktreeList already set it (#8389). return annotatePrunableByExistence(normalized, repoPath, options) }, isUnsupportedWorktreeListZError @@ -645,11 +617,8 @@ async function annotatePrunableByExistence( const index = nextIndex nextIndex += 1 const worktree = worktrees[index] - // Git only marks linked worktrees prunable, and never locked ones (a - // lock shields the registration even when the directory is missing). The - // `locked` annotation is only parsed on Git >=2.31, so on older Git a - // locked+missing worktree cannot be shielded here. A missing main - // worktree is handled by the repo-level ENOENT paths. + // Git only prunes linked worktrees, never locked ones (a lock shields a missing dir; `locked` + // parses only on Git >=2.31). A missing main worktree is handled by the repo-level ENOENT paths. if ( !worktree || worktree.isMainWorktree || @@ -709,17 +678,10 @@ export async function listWorktreeGraph( } } -// Why: a cold start lists every repo's worktrees from several independent -// paths (renderer worktrees fetch, lineage resolution, boot pty-registry -// hydration), and each `git worktree list` is a full process spawn — -// expensive on Windows (issue #7225). Sharing the in-flight promise collapses -// concurrent duplicate scans; nothing is served after a scan settles. +// Why: cold start triggers many concurrent `git worktree list` spawns per repo (expensive on Windows, #7225); share the in-flight promise to collapse duplicates. const inFlightWorktreeScans = new Map>() -// Why: a caller listing AFTER a mutation completed must observe it, so it may -// not join a scan that started before the mutation. Bumping the generation on -// mutation completion retires older in-flight scans from sharing (they still -// settle for their original callers). +// Why: a listing after a mutation must not join a scan that predates it; bumping the generation on mutation retires older in-flight scans from sharing. const worktreeScanGenerations = new Map() function hasInFlightWorktreeScanForRepo(repoPath: string): boolean { @@ -733,8 +695,7 @@ function hasInFlightWorktreeScanForRepo(repoPath: string): boolean { } function bumpWorktreeScanGeneration(repoPath: string): void { - // Why: generations only prevent joining a pre-mutation scan. Without an - // active scan, retaining the repo path just leaks completed mutation keys. + // Why: generations only prevent joining a pre-mutation scan; with no active scan, keeping the repo path just leaks completed mutation keys. if (!hasInFlightWorktreeScanForRepo(repoPath)) { return } @@ -742,8 +703,7 @@ function bumpWorktreeScanGeneration(repoPath: string): void { } function pruneWorktreeScanGeneration(repoPath: string): void { - // Why: ordinary scan settlement should stay O(1); only repos invalidated - // during an active scan need the cross-generation in-flight check. + // Why: keep ordinary scan settlement O(1); only repos invalidated during an active scan need the cross-generation check. if (!worktreeScanGenerations.has(repoPath)) { return } @@ -813,9 +773,7 @@ async function listWorktreesUnshared( if (isNotGitRepositoryError(err)) { return [] } - // Why: a silent catch turns git compatibility or repo-state failures into - // opaque downstream errors like "Worktree created but not found in listing". - // Surface the cause so future regressions show up immediately. + // Why: don't swallow git-compat/repo-state failures — else they resurface as opaque "created but not found in listing" errors. console.warn(`[git/worktree] listWorktrees failed for ${repoPath}:`, err) return [] } @@ -853,8 +811,7 @@ async function annotateSparseCheckoutStatus( } } - // Why: worktree refreshes run on git-status polling paths. Many worktrees can - // otherwise fan out `.git`/sparse-checkout filesystem probes all at once. + // Why: cap concurrency so status-poll refreshes don't fan out many sparse-checkout filesystem probes at once. const workerCount = Math.min(SPARSE_CHECKOUT_DETECTION_CONCURRENCY, worktrees.length) await Promise.all(Array.from({ length: workerCount }, () => detectNext())) return annotated @@ -913,17 +870,14 @@ async function refreshLocalBaseRefForWorktreeCreate( return { ...resultBase, status: 'updated', ownerWorktreePath: currentOwner.path } } - // Why: no owner worktree — fast-forward the bare ref. The expected-old-OID - // form makes this a no-op-safe compare-and-swap if the ref moved since the - // evaluation snapshot. + // Why: no owner worktree — fast-forward the bare ref; the expected-old-OID form is a no-op-safe CAS if the ref moved since evaluation. await gitExecFileAsync( ['update-ref', evaluation.fullRef, evaluation.remoteOid, evaluation.localOid], gitExecOptions(repoPath, options) ) return { ...resultBase, status: 'updated' } } catch { - // update-ref/reset can fail on locked refs, filesystem errors, or unusual - // worktree states. Worktree creation should still proceed. + // update-ref/reset can fail on locked refs or odd worktree states; worktree creation should still proceed. return { ...resultBase, status: 'skipped_error' } } } @@ -934,10 +888,9 @@ async function refreshLocalBaseRefForWorktreeCreate( * @param worktreePath - Absolute path where the worktree will be created * @param branch - Branch name for the new worktree * @param baseBranch - Optional base branch to create from (defaults to HEAD) - * @remarks Side effect: passes `--no-track`, writes `branch..base` - * for new-branch worktrees with a base ref, and may write - * `push.autoSetupRemote=true` to the repo's shared config. Config writes are - * best-effort and warn-only. See body comments below for the full rationale. + * @remarks Side effects (best-effort, warn-only): passes `--no-track`, writes + * `branch..base` for new-branch worktrees with a base ref, and may + * write `push.autoSetupRemote=true` to the repo's shared config. */ export async function addWorktree( repoPath: string, @@ -985,20 +938,13 @@ async function performAddWorktree( // Why: -b would create a new branch instead of checking out the selected one. args.push(worktreePath, branch) } else { - // Why: --no-track keeps the new branch from inheriting the base ref's - // upstream, so `git status` doesn't report "behind by N" against the base - // pre-publish and tools/agents don't misread an unpublished branch as - // out-of-sync. First push sets the upstream — see push.autoSetupRemote - // below for the terminal ergonomics. + // Why: --no-track avoids inheriting the base's upstream so `git status` won't misreport "behind by N" pre-publish; first push sets it (see push.autoSetupRemote below). args.push('--no-track', '-b', branch, worktreePath) if (baseBranch) { effectiveBase = await resolveWorktreeAddBaseRef(baseBranch, (qualifiedRef) => hasWorktreeBaseCommitRef(repoPath, qualifiedRef, options) ) - // Why: resolving the creation base first distinguishes real - // remote-tracking refs from slash-containing local branch names. - // The mutation stays behind the explicit setting so the default - // remains conservative. + // Why: resolve the creation base first to distinguish remote-tracking refs from slash-containing local branches (mutation gated behind the explicit setting). if (refreshLocalBaseRef) { localBaseRefRefresh = await refreshLocalBaseRefForWorktreeCreate( repoPath, @@ -1021,8 +967,7 @@ async function performAddWorktree( } await gitExecFileAsync(args, { ...gitExecOptions(repoPath, options), - // Why: bound the checkout so a OneDrive cloud-placeholder stall (STA-1292) - // fails fast rather than hanging worktree creation indefinitely. + // Why: bound the checkout so a OneDrive cloud-placeholder stall (STA-1292) fails fast instead of hanging. timeout: WORKTREE_ADD_TIMEOUT_MS }) @@ -1034,38 +979,13 @@ async function performAddWorktree( await persistWorktreeCreationBase(worktreePath, branch, effectiveBase, options) } - // SSH parity: src/relay/git-handler-worktree-ops.ts addWorktreeOp mirrors this exact - // probe-and-write state machine. If you change the logic here, update - // the relay handler in lockstep so local and SSH paths stay aligned. - // - // Why: with --no-track there is no upstream until first push. Setting - // push.autoSetupRemote=true makes a plain `git push` from the terminal - // create origin/ and set it as upstream automatically — matching - // user expectations from modern git without requiring `-u`. Note that - // `--local` on a linked worktree writes to the shared common-dir config, - // so this affects the whole repo, not just this worktree. That is - // intentional and acceptable: the value is benign and idempotent, and - // every Orca-created worktree wants the same default. True per-worktree - // scope would require enabling extensions.worktreeConfig=true repo-wide, - // which is a larger change we deliberately avoid. - // - // Notes on the design: - // - push.autoSetupRemote is honored by git >= 2.37; older clients ignore - // the value, so `git push` falls back to the pre-2.37 "no upstream" - // error and the user runs `git push -u` once. - // - Failures here are warn-only: config writes are best-effort and a - // missing write degrades to the same fallback as old git. - // - The write is skipped when any value is already set (local, global, - // or system) so a deliberate user `false` is preserved. - // - Not rolled back on creation failure: addSparseWorktree's catch path - // removes the worktree but does not unset this config. That is consistent - // with the "benign and idempotent" rationale above — every Orca-created - // worktree wants this default, and a future creation will silently re-set - // it via the existing-value check anyway. + // SSH parity: relay's addWorktreeOp (src/relay/git-handler-worktree-ops.ts) mirrors this — change both in lockstep. + // Why: --no-track leaves no upstream until first push; push.autoSetupRemote=true lets a plain + // `git push` create+set origin/ (git >=2.37; older clients ignore it). `--local` on a + // linked worktree writes the shared common-dir config (whole repo) — intentional and idempotent, + // so it's warn-only and not rolled back on failure. try { - // Why: `--get` (not `--local --get`) so a value set at any scope - // (local/global/system) counts as "user already chose" and we don't - // overwrite it. + // Why: `--get` (not `--local --get`) so a value at any scope counts as "user already chose" and isn't overwritten. let alreadySet = false try { await gitExecFileAsync(['config', '--get', 'push.autoSetupRemote'], { @@ -1073,10 +993,7 @@ async function performAddWorktree( }) alreadySet = true } catch (readError) { - // Why: `git config --get` exits 1 only when the key is unset at every - // scope. Any other exit code means a real read failure (corrupt config, - // locked file, parse error) — surface that via the outer catch instead - // of silently overwriting whatever value the user actually has. + // Why: `git config --get` exits 1 only when unset at every scope; any other code is a real read failure — rethrow rather than overwrite the user's value. const code = (readError as { code?: unknown })?.code if (code !== 1) { throw readError @@ -1138,15 +1055,13 @@ export async function addSparseWorktree( try { await removeWorktree(repoPath, worktreePath, true, { deleteBranch: !options.checkoutExistingBranch, - // Why: rolling back a failed creation — the just-created branch has no - // user commits, so force-delete it rather than preserving an orphan. + // Why: failed-creation rollback — the fresh branch has no user commits, so force-delete rather than orphan it. forceBranchDelete: !options.checkoutExistingBranch, ...(options.wslDistro ? { wslDistro: options.wslDistro } : {}) }) } catch { wrapped.cleanupFailed = true - // Why: the user needs to know that manual cleanup may be required — - // otherwise a half-created worktree silently lingers on disk. + // Why: surface that manual cleanup may be needed, else a half-created worktree lingers silently on disk. wrapped.message = `${wrapped.message} (cleanup also failed — the partially created worktree at "${worktreePath}" may need manual removal)` } } @@ -1155,13 +1070,10 @@ export async function addSparseWorktree( } /** - * Move a worktree's directory to a new path with `git worktree move`, which - * relocates the working tree and rewrites git's gitdir pointers so the linkage - * stays intact — a raw `fs.rename` would corrupt the `.git` file and the - * `.git/worktrees//gitdir` back-pointer. Local worktrees only: the - * first-work folder rename skips SSH/remote, so there is no relay parity handler - * for this op. The caller owns migrating Orca's path-derived worktree identity - * after a successful move, and pre-checks that the destination is free. + * Move a worktree with `git worktree move` (not `fs.rename`, which corrupts the + * `.git` file and the `.git/worktrees//gitdir` back-pointer). Local-only, + * so there is no relay parity handler. Caller owns migrating Orca's + * path-derived worktree identity and pre-checks that the destination is free. */ export async function moveWorktree( repoPath: string, @@ -1184,10 +1096,7 @@ export async function removeWorktree( repoPath: string, worktreePath: string, force = false, - // Why: forceBranchDelete is for cleaning up a worktree this code just created - // (e.g. rollback of a failed creation) where the fresh branch has no user work - // and must be removed outright. User-initiated deletes leave it false so unmerged - // commits are preserved. + // forceBranchDelete: for failed-creation rollback (fresh branch, no user work); user deletes leave it false so unmerged commits survive. options: RemoveWorktreeOptions = {} ): Promise { try { @@ -1213,8 +1122,7 @@ async function performRemoveWorktree( const branchName = normalizeLocalBranchRef(removedWorktree?.branch ?? '') const branchHead = removedWorktree?.head ?? '' - // Why: callers outside the IPC/runtime preflight must not bypass Git's lock - // contract or depend on localized stderr to discover it after side effects. + // Why: callers outside the IPC/runtime preflight must not bypass Git's lock contract or rely on localized stderr after side effects. assertWorktreeUnlockedForRemoval(removedWorktree) const args = ['worktree', 'remove'] @@ -1228,9 +1136,7 @@ async function performRemoveWorktree( if (force || !isSubmoduleWorktreeRemovalRefusal(error)) { throw error } - // Why: Git refuses non-force removal of any worktree with an initialised - // submodule even when everything is clean. Re-prove cleanliness (parent - // status reports dirty submodule content as ` M `), then --force. + // Why: Git refuses non-force removal of a worktree with an initialised submodule even when clean; re-prove cleanliness, then --force. await assertWorktreeCleanForRemoval(worktreePath, false, options) await gitExecFileAsync( ['worktree', 'remove', '--force', worktreePath], @@ -1246,13 +1152,8 @@ async function performRemoveWorktree( } try { - // Why: `git worktree remove` only detaches the filesystem entry. Orca also - // drops the now-unused local branch here so delete-worktree does not leave - // behind orphaned feature branches unless another worktree still points at it. - // Use `-d` (not `-D`): Git refuses to delete a branch with commits not merged - // into its upstream or HEAD, so unpublished work is preserved instead of - // force-deleted. forceBranchDelete opts into `-D` for failed-creation rollback, - // where the fresh branch has no user work to protect. + // Why: also drop the now-orphaned branch so delete-worktree leaves none; `-d` (not `-D`) preserves + // unmerged work, and forceBranchDelete opts into `-D` for failed-creation rollback. const branchDeleteResult = await deleteLocalBranchAfterWorktreeRemoval( repoPath, branchName, @@ -1277,16 +1178,14 @@ async function performRemoveWorktree( return {} } } catch (alreadyMergedDeleteError) { - // Why: the worktree is already gone; a raced branch cleanup should - // degrade to the preserved-branch recovery path instead of failing delete. + // Why: worktree is already gone; a raced branch cleanup should degrade to preserved-branch recovery, not fail delete. console.warn( `[git] Failed to delete already-merged local branch "${branchName}" after removing worktree`, alreadyMergedDeleteError ) } } - // Expected when the branch still has unmerged/unpublished commits: keep it. - // Deleting a worktree must never silently discard commits. + // Keep an unmerged/unpublished branch: deleting a worktree must never silently discard commits. console.warn( `[git] Preserved local branch "${branchName}" after removing worktree (not fully merged)`, error @@ -1315,8 +1214,7 @@ async function deleteLocalBranchAfterWorktreeRemoval( } try { - // Why: `branch -d` is the cheap live-checkout guard. Only pay for - // `worktree prune` when a stale admin record may be the thing blocking it. + // Why: only pay for `worktree prune` when a stale admin record may be blocking `branch -d`. await gitExecFileAsync(['worktree', 'prune'], gitExecOptions(repoPath, options)) } catch (error) { console.warn(`[git] Failed to prune worktrees before deleting branch "${branchName}"`, error) @@ -1350,9 +1248,7 @@ async function deleteAlreadyMergedBranchAfterSafeDeleteFailure( }) const targetRefs = await getBranchCleanupTargetRefs(runGit, branchName) await refreshBranchCleanupTargetRefs(runGit, targetRefs) - // Why: squash merges rewrite commit IDs, so `branch -d` can reject a branch - // whose changes are already on the base ref. Delete only when Git can prove - // the branch contributes no tree changes to that base. + // Why: squash merges rewrite commit IDs, so `branch -d` rejects already-merged branches; delete only when Git proves no unmerged tree changes. if ( !(await branchHasNoUnmergedChangesOnAnyTarget( runGit, @@ -1389,8 +1285,7 @@ export async function forceDeleteLocalBranch( if (await isLocalBranchCheckedOut(repoPath, branchName, runGit)) { throw new Error(`Local branch "${branchName}" is checked out in another worktree.`) } - // Why: stale toast actions must not delete a branch that moved after Git - // preserved it. `update-ref` deletes only if the ref still has expectedHead. + // Why: stale toast actions must not delete a branch that moved; `update-ref -d` deletes only if the ref still == expectedHead. try { await runGit(['update-ref', '-d', `refs/heads/${branchName}`, expectedHead], repoPath) } catch { @@ -1412,8 +1307,7 @@ export async function forceDeleteLocalBranch( try { await runGit(['config', '--remove-section', `branch.${branchName}`], repoPath) } catch { - // Best-effort parity with `git branch -D`; stale config is harmless and - // should not make the already-deleted ref look like a failed delete. + // Best-effort parity with `git branch -D`; stale config is harmless. } } @@ -1493,22 +1387,8 @@ function translateWorktreePath( } async function detectSparseCheckout(worktreePath: string): Promise { - // Why: `listWorktrees` runs on every 3-second git-status poll and on every - // worktree refresh, so this probe fires N times per poll for N worktrees. - // The previous `git sparse-checkout list` subprocess made that N*poll extra - // git processes, which regressed app responsiveness on machines with many - // worktrees (see PR #1131 revert in #1290). A single fs.stat on the - // per-worktree sparse-checkout config file is ~two orders of magnitude - // cheaper and has the same truthiness semantics: Git writes this file when - // sparse checkout is enabled for the worktree and does not write it - // otherwise. - // - // Why per-worktree gitdir and not `/.git/info/sparse-checkout`: - // linked worktrees have a `.git` file that points at - // `/.git/worktrees/`, and that is where Git stores the - // worktree-local sparse-checkout config. `core.sparseCheckout` itself is - // shared across all worktrees, so the presence of the config file is the - // correct per-worktree signal. + // Why: fs.stat the per-worktree gitdir's sparse-checkout config instead of a per-poll `git sparse-checkout list` subprocess that regressed responsiveness (PR #1290); + // the file's presence is the per-worktree signal because core.sparseCheckout is shared across all worktrees. try { const gitDir = await resolveGitDir(worktreePath) const stats = await stat(join(gitDir, 'info', 'sparse-checkout')) diff --git a/src/main/github/client.ts b/src/main/github/client.ts index d9d41f4f653..53bf26614ff 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -1,5 +1,4 @@ -/* eslint-disable max-lines -- Why: co-locating all GitHub client functions keeps the -concurrency acquire/release pattern and error handling consistent across operations. */ +/* eslint-disable max-lines -- Why: co-locating all GitHub client functions keeps acquire/release and error handling consistent. */ import type { ClassifiedError, GitPushTarget, @@ -66,8 +65,7 @@ import { type LocalGitExecOptions, type OwnerRepo } from './gh-utils' -// Import the pure error-parsing helpers from the lightweight module, not -// `./gh-utils`, so tests that mock gh-utils still resolve the real functions. +// Why: import from the lightweight module (not ./gh-utils) so tests mocking gh-utils still get the real functions. import { extractExecError, parseRetryAfterMs } from '../git/exec-error' import { isCommitPartOfMergedPR, @@ -120,8 +118,7 @@ type HostedReviewLocalGitOptions = ReturnType() @@ -197,9 +194,7 @@ function prRefreshUpstreamError( message: safePRRefreshErrorMessage(errorType), fetchedAt: Date.now() } - // Why: a rate limit that carries a Retry-After is a real cooldown — surface it - // as the retry schedule so the renderer disables manual Retry until the reset - // and shows a truthful auto-retry time, instead of retrying into another 429. + // Why: a Retry-After is a real cooldown — surface it as the retry schedule so the renderer doesn't retry into another 429. if (errorType === 'rate_limited') { const retryAfterMs = parseRetryAfterMs(extractExecError(err).stderr) if (retryAfterMs !== null && retryAfterMs > 0) { @@ -268,9 +263,8 @@ function sanitizeRemoteName(owner: string, repo: string): string { } /** - * A fork push target plus the PR's `maintainer_can_modify` flag. The flag rides - * alongside the target (rather than inside {@link GitPushTarget}) so it never - * leaks into the persisted, validated push-target shape. + * A fork push target plus the PR's `maintainer_can_modify` flag, kept outside + * {@link GitPushTarget} so it never leaks into the persisted push-target shape. */ export type PullRequestPushTarget = { pushTarget: GitPushTarget @@ -309,9 +303,7 @@ export async function getPullRequestPushTarget( prStdout = stdout break } catch (error) { - // Why: in fork workflows `origin` is often the contributor fork while - // the PR number belongs to `upstream`; probe all known PR repos before - // deciding this PR number is unavailable. + // Why: origin is often the contributor fork while the PR belongs to upstream; probe all PR repos before giving up. if (isNotFoundGhError(error)) { continue } @@ -421,22 +413,17 @@ export async function getAuthenticatedViewer(): Promise { } } -// Why: main-process maps omit repoId because the IPC handler never receives -// a repo identifier beyond path. Exported because runtime consumers receive -// listWorkItems results before the renderer stamps repoId. +// Why: omit repoId — the main process only has the path; the renderer stamps repoId after IPC. export type MainWorkItem = Omit -// Why: issue numbers follow creation order within a repository. Pinning this -// sort keeps gh's rich PR rows aligned with numbered Search API issue pages. +// Why: issue numbers follow creation order, so this sort aligns gh's PR rows with numbered Search API issue pages. const WORK_ITEM_NUMBER_SORT_QUALIFIER = 'sort:created-desc' const WORK_ITEM_PR_LIST_JSON_FIELDS = 'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRefOid,headRepositoryOwner,reviewRequests' -// Why: these fields are intentionally excluded from `gh pr list` because -// statusCheckRollup/review decision/PR-specific merge metadata fan out into -// expensive GraphQL work across every row. Requested reviewers are kept in the -// list payload because the Tasks table renders that column on first paint. +// Why: kept out of `gh pr list` — statusCheckRollup/reviewDecision/merge metadata fan out into expensive per-row GraphQL. +// Requested reviewers stay in the list payload because Tasks renders that column on first paint. const WORK_ITEM_PR_DETAIL_JSON_FIELDS = 'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRefOid,headRepositoryOwner,additions,deletions,changedFiles,reviewDecision,reviewRequests,latestReviews,assignees,statusCheckRollup,mergeable,mergeStateStatus,autoMergeRequest,maintainerCanModify' @@ -464,12 +451,8 @@ function mapIssueWorkItem(item: Record): MainWorkItem { } /** - * Derive both the author login and its API avatar_url in one place so GHE - * avatars render (the login-only `github.com/{login}.png` URL 404s on GHE). - * - * REST exposes `user.avatar_url`; gh/GraphQL expose `author.avatarUrl`. `gh pr - * view` omits the avatar entirely, so authorAvatarUrl is left undefined and the - * UI falls back to the login URL then a placeholder. See #8784. + * Derive author login + avatar_url together so GHE avatars render — the login-only + * `{login}.png` URL 404s on GHE. REST uses `user.avatar_url`, gh/GraphQL `author.avatarUrl` (#8784). */ function authorFieldsFromUnknown( item: Record @@ -505,8 +488,7 @@ function extractHeadOwnerLogin(item: Record): string | null { } } } - // Why: when a fork is deleted or made inaccessible, GitHub can return - // `head.repo = null` but still include `head.user`/`head.label`. + // Why: a deleted/inaccessible fork returns head.repo = null but still has head.user/head.label. const user = head.user if (typeof user === 'object' && user !== null) { const login = (user as { login?: unknown }).login @@ -682,14 +664,9 @@ function mapPullRequestWorkItem( item: Record, baseOwnerRepo: OwnerRepo | null = null ): MainWorkItem { - // Why: fork PRs are disabled in the Start-from picker. We compare the PR head's - // owner to the selected repo's owner; when the base repo is unknown we default - // to false so non-picker call sites see the same shape as before. + // Why: fork PRs are disabled in the Start-from picker; compare head owner to the selected repo's owner. const headOwnerLogin = extractHeadOwnerLogin(item) - // Why: only emit isCrossRepository when we actually know the head owner. If - // the gh response lacks `headRepositoryOwner` (older callers, tests without - // that fixture, or gh not returning it), leave the field undefined instead - // of falsely claiming "not a fork". + // Why: leave isCrossRepository undefined when the head owner is unknown, rather than falsely claiming "not a fork". const isCrossRepository = headOwnerLogin !== null && baseOwnerRepo !== null ? headOwnerLogin !== baseOwnerRepo.owner @@ -787,8 +764,7 @@ async function hydrateWorkItemRepositoryMergeMetadata( if (!ownerRepo || !hasPullRequest) { return items } - // Why: merge method settings are repository-level, so one cached metadata - // probe can keep Tasks rows accurate without per-PR GraphQL fan-out. + // Why: merge settings are repo-level, so one cached probe keeps Tasks rows accurate without per-PR GraphQL fan-out. const mergeMetadata = await detectRepositoryMergeMetadata(ownerRepo, undefined, ghOptions) if (!mergeMetadata.mergeMethodSettings && mergeMetadata.autoMergeAllowed === null) { return items @@ -835,9 +811,7 @@ async function fetchIssueWorkItem( return mapIssueWorkItem(JSON.parse(stdout) as Record) } -// REST /pulls/{n} has requested_reviewers but not latestReviews. When the JSON -// `gh pr view` path fails, still pull review fields from gh so mobile/desktop -// reviewer lists (CodeRabbit COMMENTED, etc.) are not silently empty. +// Why: REST /pulls/{n} lacks latestReviews, so pull review fields from gh so reviewer lists aren't silently empty. const WORK_ITEM_PR_REVIEW_JSON_FIELDS = 'reviewRequests,latestReviews' async function fetchPullRequestReviewFields( @@ -896,9 +870,7 @@ async function fetchPullRequestWorkItem( ) const item = JSON.parse(stdout) as Record const mapped = mapPullRequestWorkItem(item, ownerRepo) - // Why: merge-metadata GraphQL is best-effort. A failure here must not fall - // through to the REST path below — that path drops latestReviews and blanks - // the mobile/desktop reviewer list for bots that only left a review. + // Why: merge-metadata GraphQL is best-effort — don't fall through to REST, which drops latestReviews and blanks bot-only reviewer lists. const baseRefName = typeof item.baseRefName === 'string' ? item.baseRefName : undefined try { const mergeMetadata = await detectRepositoryMergeMetadata(ownerRepo, baseRefName, ghOptions) @@ -1010,8 +982,7 @@ function buildWorkItemListRequest(args: { } } - // Why: search/issues omits the PR fields used by the Tasks management - // columns. Fetch through gh's rich PR list and slice its stable created sort. + // Why: search/issues omits the PR fields the Tasks columns need; use gh's rich PR list on a stable created sort. searchParts.push(WORK_ITEM_NUMBER_SORT_QUALIFIER) const out = [ 'pr', @@ -1030,11 +1001,7 @@ function buildWorkItemListRequest(args: { return { args: out, offset: (page - 1) * limit } } -// Why: internal shape shared by listRecentWorkItems / listQueriedWorkItems so -// listWorkItems can lift per-side errors into the IPC envelope. The issue-side -// error is the specific new class of silent wrongness introduced by #1076 — -// PR-side errors existed before and are explicitly out of scope for this -// feature per the parent design doc §6. +// Why: shared shape so listWorkItems can lift the issue-side error (#1076 silent wrongness) into the IPC envelope; PR errors out of scope (§6). type PartialWorkItemsResult = { items: MainWorkItem[] issuesError?: ClassifiedError @@ -1048,8 +1015,7 @@ function assertSshRepoHasResolvedGitHubSource(args: { if (!args.connectionId || args.issueOwnerRepo || args.prOwnerRepo) { return } - // Why: SSH repo paths are remote-only, so gh cannot use cwd to infer repo - // context. Without a resolved owner/repo, running gh would query local state. + // Why: SSH repo paths are remote-only, so without a resolved owner/repo gh would query local state. throw new Error(GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE) } @@ -1066,8 +1032,7 @@ async function resolvePrWorkItemSource( localGitOptions: LocalGitExecOptions = {} ): Promise { const [originCandidate, upstreamCandidate] = await Promise.all([ - // Why: this caller combines both remotes itself, so it needs origin - // specifically (getOwnerRepo is upstream-first since #7331). + // Why: this caller combines both remotes itself, so it needs origin specifically (getOwnerRepo is upstream-first since #7331). getOwnerRepoForRemote(repoPath, 'origin', connectionId, localGitOptions), getOwnerRepoForRemote(repoPath, 'upstream', connectionId, localGitOptions) ]) @@ -1077,10 +1042,8 @@ async function resolvePrWorkItemSource( } /** - * gh exec for calls that rely on gh's own cwd→repo resolution (no explicit - * owner/repo). Serves a remembered deterministic resolution failure without - * spawning, so a remote-less repo costs one gh spawn per config change/TTL - * instead of two per Tasks refresh. + * gh exec relying on gh's own cwd→repo resolution. Serves a remembered resolution failure + * without spawning, so a remote-less repo costs one gh spawn per config change/TTL, not two per refresh. */ async function ghCwdResolvedExec( context: GitHubRepoContext, @@ -1135,9 +1098,7 @@ async function listRecentWorkItems( issueRequest.args.splice(1, 2) } if (issueOwnerRepo || prOwnerRepo || requiresExplicitRepo) { - // Why: allSettled so a 403 on upstream issues doesn't zero out the origin - // PR half — the UI renders partial results plus a banner for the failing - // side, matching the parent design doc's partial-failure rule (§2). + // Why: allSettled so a 403 on the issue side doesn't zero the PR half — partial results + banner (parent doc §2). const [issuesSettled, prsSettled] = await Promise.allSettled([ issueOwnerRepo ? ghExecFileAsync(issueRequest.args, ghOptions) @@ -1155,9 +1116,7 @@ async function listRecentWorkItems( let issuesError: ClassifiedError | undefined if (issuesSettled.status === 'fulfilled') { issues = (JSON.parse(issuesSettled.value.stdout) as Record[]) - // Why: the GitHub search/issues endpoint may still return PRs with a - // pull_request marker even when queried with is:issue. Filter them out - // here to keep the issue and PR buckets clean. + // Why: search/issues can still return PRs (pull_request marker) even with is:issue; filter them out. .filter((item) => !('pull_request' in item)) .map(mapIssueWorkItem) } else { @@ -1175,15 +1134,8 @@ async function listRecentWorkItems( .map((item) => mapPullRequestWorkItem(item, prOwnerRepo)) prs = await hydrateWorkItemRepositoryMergeMetadata(prs, prOwnerRepo, ghOptions) } else { - // Why: PR-side failures must preserve the pre-diff behavior of - // Promise.all by re-throwing so the rejection propagates up through - // listWorkItems to the renderer's cross-repo aggregator (which counts - // the repo as failed). This feature is scoped to the issue-side silent - // wrongness from #1076; PR errors must not be silently swallowed here. - // Why: if the issue side ALSO failed, the classified issuesError would - // otherwise be silently dropped when we throw the PR reason. Log it so - // debugging both-sides-failed scenarios (e.g. 403 on both endpoints) - // isn't blind to the issue-side classification. + // Why: re-throw PR errors so the cross-repo aggregator counts the repo failed; this feature only fixes issue-side swallowing (#1076). + // Why: log issuesError first so a both-sides-failed case isn't blind to the classification we're about to drop. if (issuesError) { console.warn( 'listRecentWorkItems: both issue and PR sides failed; issuesError was classified:', @@ -1200,13 +1152,7 @@ async function listRecentWorkItems( } } - // Why: the fallback path (non-GitHub remote — neither issueOwnerRepo nor - // prOwnerRepo resolved) intentionally stays on Promise.all rather than the - // Promise.allSettled + per-side classification used above. There are no - // `sources` to surface on this branch and nothing for the partial-failure - // banner to render, so a single-side failure here means the whole call is - // effectively unusable for the feature — reject-all matches reality. If - // non-GitHub remotes ever grow source metadata, revisit this symmetry. + // Why: non-GitHub remotes have no sources for a partial-failure banner, so keep Promise.all (reject-all) instead of allSettled. const [issuesResult, prsResult] = await Promise.all([ ghCwdResolvedExec(repoContext, issueRequest.args, ghOptions), ghCwdResolvedExec(repoContext, prRequest.args, ghOptions) @@ -1249,9 +1195,7 @@ async function listQueriedWorkItems( let nonAvailabilityFailureCount = 0 let availabilityError: unknown - // Why: run the issue and PR fetches in parallel but surface the - // issue-side error separately so the IPC envelope can carry it up. PR-side - // failures retain the prior swallow-and-log behavior per parent doc §6. + // Why: surface the issue-side error separately for the IPC envelope; PR-side keeps prior swallow-and-log (parent doc §6). const issueFetch = (async (): Promise => { if (!issueScope) { return { items: [] } @@ -1327,9 +1271,7 @@ async function listQueriedWorkItems( const [issueResult, prItems] = await Promise.all([issueFetch, prFetch]) if (availabilityError && successfulRequestCount === 0 && nonAvailabilityFailureCount === 0) { - // Why: combined queries normally preserve either successful half. When - // every attempted half hit the same availability class, propagate one of - // those existing failures so Tasks can distinguish an outage from no data. + // Why: when every half hit the same availability failure, propagate it so Tasks can distinguish an outage from no data. throw availabilityError } return { @@ -1369,10 +1311,7 @@ export async function listWorkItems( const prOwnerRepo = prResolved.source await acquire() try { - // Why: errors propagate to IPC so the renderer's cross-repo aggregator can - // count this repo as failed and surface the partial-failure banner. A - // catch-all here would make an auth/network failure indistinguishable from - // an empty result and silently under-report per-repo failures. + // Why: let errors propagate to IPC — a catch-all would make failure indistinguishable from empty and under-report per-repo failures. const partial = !trimmedQuery ? await listRecentWorkItems( repoPath, @@ -1425,8 +1364,7 @@ function buildSearchQueryString( if (query.state === 'open') { parts.push('is:open') } else if (query.state === 'closed') { - // Why: GitHub search treats merged PRs as closed. Exclude merged so the - // "Closed" filter actually means closed-without-merge. + // Why: GitHub search treats merged PRs as closed; exclude merged so "Closed" means closed-without-merge. parts.push('is:closed') if (query.scope !== 'issue') { parts.push('-is:merged') @@ -1482,16 +1420,13 @@ async function countWorkItemsForQuery( ], ghOptions ) - // Why: over-counts gh cache hits, which is the safe direction — the search - // bucket is only 30/min and the next probe corrects the estimate. + // Why: over-counting cache hits is the safe direction — the next probe corrects the estimate. noteRateLimitSpend('search') return Number.parseInt(stdout.trim(), 10) || 0 } function sameOwnerRepo(left: OwnerRepo | null, right: OwnerRepo | null): boolean { - // Why: GitHub treats owner and repo names as case-insensitive, so remotes - // with different casing (StablyAI/Orca vs stablyai/orca) point at the same - // repo and should not split into two search queries. + // Why: GitHub owner/repo names are case-insensitive, so differently-cased remotes are the same repo (don't split into two searches). return ( left?.owner.toLowerCase() === right?.owner.toLowerCase() && left?.repo.toLowerCase() === right?.repo.toLowerCase() @@ -1512,9 +1447,7 @@ function defaultOpenWorkItemQuery(): ParsedTaskQuery { } } -// Why: uses GitHub's search API to get total_count without fetching items. -// This powers the pagination bar so the user sees total pages upfront. -// Cached for 120s to avoid burning the search rate limit (30 req/min). +// Why: cached 120s to avoid burning the 30/min search rate limit that backs the pagination total. export async function countWorkItems( repoPath: string, query?: string, @@ -1540,10 +1473,7 @@ export async function countWorkItems( const parsedQuery = trimmedQuery ? parseTaskQuery(trimmedQuery) : null const effectiveQuery = parsedQuery ?? defaultOpenWorkItemQuery() - // Why: counts are decorative (pagination totals). The search bucket is only - // 30/min, so a multi-repo Tasks page must stop counting when the budget is - // gone instead of converting the remaining repos into 403 spawns. getRateLimit - // is 30s-cached and single-flight, so priming here is one spawn per window. + // Why: counts are decorative, so stop when the 30/min search budget is gone rather than spawn into 403s (getRateLimit is 30s-cached). await getRateLimit() if (rateLimitGuard('search').blocked) { return 0 @@ -1562,9 +1492,7 @@ export async function countWorkItems( } const counts: Promise[] = [] - // Why: `draft`, `reviewRequested`, and `reviewedBy` are PR-only predicates. - // When present, the issue half would always return 0 and wastes a search - // API call — skip the issue half entirely in that case. + // Why: draft/reviewRequested/reviewedBy are PR-only, so the issue half would always return 0 — skip it to save a search call. const hasPrOnlyFilter = effectiveQuery.draft || effectiveQuery.reviewRequested !== null || @@ -1596,9 +1524,7 @@ export async function countWorkItems( ) ) } - // Why: allSettled so a single failing search (e.g. transient network, rate - // limit on one side) doesn't silently zero out the total; sum only the - // fulfilled halves instead. + // Why: allSettled so one failing search side doesn't zero the total; sum only fulfilled halves. const results = await Promise.allSettled(counts) let total = 0 for (const r of results) { @@ -1622,9 +1548,7 @@ export async function getRepoSlug( connectionId?: string | null, options: HostedReviewExecutionOptions = {} ): Promise<{ owner: string; repo: string } | null> { - // Why: the slug is the checkout's own identity (renderer display, icon - // autodetect), so it must stay origin-based — getOwnerRepo became - // upstream-first for PR reads in #7331. + // Why: the slug is the checkout's own identity, so it must stay origin-based (getOwnerRepo is upstream-first since #7331). return getOwnerRepoForRemote( repoPath, 'origin', @@ -1634,11 +1558,8 @@ export async function getRepoSlug( } /** - * Resolve a fork's upstream/parent owner/repo, or null when the repo is not a - * fork. Why: a fork's `origin` points at the personal copy, so repo identity - * (notably the avatar) should prefer the upstream. Fast-paths the `upstream` - * remote (offline); otherwise asks the GitHub API for the fork parent. The API - * call targets the explicit origin slug, so it works for SSH repos too. + * Resolve a fork's upstream/parent owner/repo, or null when not a fork. + * Why: a fork's `origin` is the personal copy, so repo identity (avatar) should prefer upstream. * Best-effort: any failure (offline, unauthed, non-GitHub) resolves to null. */ export async function getRepoUpstream( @@ -1648,8 +1569,7 @@ export async function getRepoUpstream( ): Promise { const localGitArgs = hostedReviewLocalGitOptionArgs(options) const localGitOptions = localGitArgs[0] ?? {} - // Why: must be origin specifically — the fork-parent fast-path below compares - // it against the upstream remote (getOwnerRepo is upstream-first since #7331). + // Why: must be origin — the fast-path compares it against upstream (getOwnerRepo is upstream-first since #7331). const origin = await getOwnerRepoForRemote(repoPath, 'origin', connectionId, ...localGitArgs) if (!origin) { return null @@ -1667,8 +1587,7 @@ export async function getRepoUpstream( try { const { stdout } = await ghExecFileAsync( ['repo', 'view', `${origin.owner}/${origin.repo}`, '--json', 'isFork,parent'], - // Why: best-effort fork lookup runs at add-time; cap latency so a stalled - // gh process can't hold up repo creation. + // Why: cap latency so a stalled gh process can't hold up repo creation. { ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), timeout: 10_000 @@ -1753,8 +1672,7 @@ function parseCreatePRPayload(stdout: string): { number: number; url: string } | } catch { // Fall through to URL parsing for older gh versions without --json support. } - // Why: gh prints the PR URL (not JSON) here; match any host, not just - // github.com, so a GitHub Enterprise Server URL still parses directly (#8312). + // Why: match any host (not just github.com) so a GHES PR URL still parses (#8312). const urlMatch = trimmed.match(/https?:\/\/[^\s/]+\/[^\s/]+\/[^\s/]+\/pull\/(\d+)/) if (!urlMatch) { return null @@ -1762,11 +1680,7 @@ function parseCreatePRPayload(stdout: string): { number: number; url: string } | return { number: Number(urlMatch[1]), url: urlMatch[0] } } -// Why: `gh --repo OWNER/REPO` resolves the shorthand against gh's default host -// (usually github.com), not the repo's remote — so a GHES repo would target a -// same-named github.com repo, or fail. Qualify with the host for GHES so gh hits -// the Enterprise server; this is the only host signal for SSH repos, which run -// gh with no cwd context (#8312). github.com keeps the bare shorthand. +// Why: bare OWNER/REPO resolves against gh's default host, so qualify with host for GHES; the only host signal for cwd-less SSH repos (#8312). function ghRepoArg(slug: { owner: string; repo: string; host?: string }): string { return slug.host && slug.host.toLowerCase() !== 'github.com' ? `${slug.host}/${slug.owner}/${slug.repo}` @@ -1860,11 +1774,8 @@ export async function createGitHubPullRequest( } } - // Why: github.com-only slug parsing returns null for GHES, so fall back to the - // enterprise resolver (gh-authenticated custom host) before giving up (#8312). - // Creation targets origin: the head branch is unqualified, so `gh pr create` - // must run against the repo the branch was pushed to, not the fork parent - // (getOwnerRepo became upstream-first for PR reads in #7331). + // Why: github.com-only slug parsing returns null for GHES; fall back to the enterprise resolver (#8312). + // Why: use origin, not the fork parent — the unqualified head branch was pushed there (getOwnerRepo upstream-first since #7331). const ownerRepo = (await getOwnerRepoForRemote( repoPath, @@ -2029,11 +1940,7 @@ export async function getWorkItem( return issue } } catch (err) { - // Why: a transient failure (5xx, rate limit, network flake) on issue #N would - // silently fall through to PR #N — potentially a completely unrelated item. - // Only fall through when the issue genuinely doesn't exist (404); re-throw everything - // else so the outer catch returns null and the caller sees a real failure instead of - // a wrong item. classifyGhError centralizes the 404/"not found" pattern-matching. + // Why: only fall through to PR #N on a genuine 404; re-throw transient errors so a flake can't surface an unrelated PR. const stderr = err instanceof Error ? err.message : String(err) if (classifyGhError(stderr).type !== 'not_found') { throw err @@ -2130,8 +2037,7 @@ const GITHUB_AUTO_MERGE_METHODS: Record TRACKED_UPSTREAM_SNAPSHOT_CACHE_MAX_ENTRIES) { const oldestKey = trackedUpstreamSnapshotCache.keys().next().value if (oldestKey === undefined) { @@ -2549,8 +2448,7 @@ async function getTrackedUpstreamBranch( if (result.upstreamsByBranchName.has(branchName)) { return result.upstreamsByBranchName.get(branchName) ?? null } - // Why: a concurrent snapshot may finish before this branch exists in git. - // Re-probe instead of returning a one-shot synthetic null. + // Why: a concurrent snapshot may finish before this branch exists in git; re-probe instead of returning a synthetic null. const retryInFlight = trackedUpstreamSnapshotInFlight.get(cacheKey) if (retryInFlight) { const retryResult = await retryInFlight @@ -2558,9 +2456,7 @@ async function getTrackedUpstreamBranch( } } - // Why: PR polling can ask about hundreds of local worktree branches at once. - // Read the branch upstream snapshot in one git process per repo/runtime - // instead of spawning one failing `branch@{upstream}` probe per branch. + // Why: PR polling asks about hundreds of branches at once; read all upstreams in one git process per repo/runtime, not one probe per branch. const probeGeneration = beginTrackedUpstreamSnapshotProbe(cacheKey) const probe = probeTrackedUpstreamSnapshot(repoPath, connectionId, localGitOptions) trackedUpstreamSnapshotInFlight.set(cacheKey, probe) @@ -2615,8 +2511,7 @@ async function probeTrackedUpstreamSnapshot( const gitConfigSignature = startingGitConfigSignature === endingGitConfigSignature ? endingGitConfigSignature : undefined return { - // Why: transient git failures must not cache an empty snapshot that forces - // every branch lookup to delete and re-probe on the next refresh tick. + // Why: don't cache an empty snapshot after a transient git failure, or every branch lookup re-probes on the next refresh tick. cacheable: !configSignatureChanged && !probeFailed, probeFailed, ...(gitConfigSignature ? { gitConfigSignature } : {}), @@ -2627,8 +2522,7 @@ async function probeTrackedUpstreamSnapshot( function getCacheableTrackedUpstreamSnapshot( upstreamsByBranchName: Map ): Map { - // Why: SSH/WSL cannot cheaply inspect remote .git/config here. The short TTL - // still bounds stale positives, while repeated PR refreshes share one scan. + // Why: SSH/WSL can't cheaply inspect remote .git/config here; the short TTL bounds stale positives while refreshes share one scan. return upstreamsByBranchName } @@ -2744,8 +2638,7 @@ async function lookupPRByBranchName(args: { args.ghOptions ) : await getFallbackPRListForBranch(candidate, args.branchName, args.ghOptions) - // Why: REST/list branch lookup identifies the PR cheaply; exact - // `gh pr view` carries review, merge queue, and auto-merge state. + // Why: REST/list branch lookup identifies the PR cheaply; exact `gh pr view` carries review, merge-queue, and auto-merge state. const data = await hydrateBranchLookupWithExactPR(candidate, branchData, args.ghOptions) if (data) { return { data, dataRepo: candidate } @@ -2777,8 +2670,7 @@ async function lookupPRByBranchName(args: { } } } - // Why: branch-list failures are ambiguous for fork discovery, but exact - // fallback-number recovery should still get a chance before surfacing error. + // Why: branch-list failures are ambiguous for fork discovery; give exact fallback-number recovery a chance before surfacing the error. return hasPendingError ? { data: null, dataRepo: null, pendingError } : { data: null, dataRepo: null } @@ -2837,8 +2729,7 @@ async function getPRByNumber( ghOptions ) } catch (err) { - // Why: deleted or manually edited linked PR metadata should fall back to - // branch discovery; quota/auth/network failures get one cheaper REST exact lookup. + // Why: deleted/edited linked PR metadata falls back to branch discovery; quota/auth/network failures get one cheaper REST exact lookup. if (isNotFoundGhError(err)) { return null } @@ -2892,8 +2783,7 @@ async function lookupPRByNumber(args: { } } catch (err) { if (isNoPullRequestError(err)) { - // Why: stale cached fallback numbers should not turn every poll into an - // error when the PR was deleted or belonged to a different repo. + // Why: stale cached fallback numbers shouldn't error every poll when the PR was deleted or belongs to another repo. return { data: null, dataRepo: null } } throw err @@ -2941,10 +2831,7 @@ export async function getPRForBranch( return outcome.kind === 'found' ? outcome.pr : null } -// Why: the exact-linked fallback (`gh pr view` with no resolved repo candidates) -// returns dataRepo=null, which would leave the merged-PR membership probe unable -// to run. Derive the PR's own repo from its web URL so a diverged merged linked -// PR can still be confirmed and cleared. Host-agnostic to cover GitHub Enterprise. +// Why: exact-linked fallback returns dataRepo=null; derive the PR's repo from its web URL (host-agnostic for GHE) so the merged-PR membership probe can run. function ownerRepoFromPullRequestUrl(url: string): OwnerRepo | null { const match = url.match(/^https?:\/\/[^/\s]+\/([^/\s]+)\/([^/\s]+)\/pull\/\d+/) return match ? { owner: match[1], repo: match[2] } : null @@ -2958,10 +2845,8 @@ export async function getPRForBranchOutcome( fallbackPRNumber?: number | null, options: GitHubPRBranchLookupOptions = {} ): Promise { - // Strip refs/heads/ prefix if present const branchName = branch.replace(/^refs\/heads\//, '') - // Why: detached HEAD cannot use branch lookup, but an exact linked/fallback - // PR number remains safe to query and keeps review state visible. + // Why: detached HEAD can't use branch lookup, but an exact linked/fallback PR number is still safe to query and keeps review state visible. if (!branchName && typeof linkedPRNumber !== 'number' && typeof fallbackPRNumber !== 'number') { return { kind: 'no-pr', fetchedAt: Date.now() } } @@ -3028,8 +2913,7 @@ export async function getPRForBranchOutcome( explicitCurrentHeadOid ) if (membership === 'not-contained') { - // explicitCurrentHeadOid is non-null here (guarded above); record the - // exact head so consumers only clear the worktree that actually diverged. + // explicitCurrentHeadOid is non-null here (guarded above); record the exact diverged head so consumers clear only that worktree. headDivergedFromMergedPRAtOid = explicitCurrentHeadOid } } @@ -3040,9 +2924,7 @@ export async function getPRForBranchOutcome( if (!candidate || !isMergedImplicitPR(candidate, linkedPRNumber)) { return false } - // Why: prefer the caller-supplied worktree HEAD; only shell out (against - // the main repo path) when no explicit oid is available, matching legacy - // behavior. This keeps merged-at-head PRs visible for secondary worktrees. + // Why: prefer the caller's worktree HEAD; only shell out (main repo path) when no explicit oid, keeping merged-at-head PRs visible for secondary worktrees. currentHeadOidForMergedImplicit ??= explicitCurrentHeadOid !== null ? explicitCurrentHeadOid @@ -3050,10 +2932,7 @@ export async function getPRForBranchOutcome( if (!shouldHideMergedImplicitPR(candidate, linkedPRNumber, currentHeadOidForMergedImplicit)) { return false } - // Why: a worktree can sit behind its own PR's final head (update-branch - // merges, web-committed suggestions). A head that is one of the PR's own - // commits is the same line of work, not a reused branch name — keep the - // merged PR visible instead of offering "create a pull request". + // Why: a head that is one of the PR's own commits (update-branch/web commits) is the same work, not a reused branch name — keep the merged PR visible. return ( (await mergedPRContainsHead(candidate, candidateRepo, currentHeadOidForMergedImplicit)) !== 'contained' @@ -3069,8 +2948,7 @@ export async function getPRForBranchOutcome( data = exactLookup.data dataRepo = exactLookup.dataRepo } else if (branchName) { - // During a rebase the worktree is in detached HEAD and branch is empty. - // An empty --head filter causes gh to return an arbitrary PR. + // During a rebase (detached HEAD) branch is empty; an empty --head filter makes gh return an arbitrary PR. const branchLookup = await lookupPRByBranchName({ candidates, headRepo, @@ -3084,8 +2962,7 @@ export async function getPRForBranchOutcome( hasPendingBranchLookupError = true } if (!data) { - // Why: the tracked upstream can identify the real PR head by branch - // name or by fork owner even when branch names match locally. + // Why: the tracked upstream identifies the real PR head by branch name or fork owner even when local branch names match. const upstreamBranch = await getTrackedUpstreamBranch( repoPath, branchName, @@ -3154,16 +3031,11 @@ export async function getPRForBranchOutcome( explicitCurrentHeadOid !== null && shouldHideMergedImplicitPR(data, linkedPRNumber, explicitCurrentHeadOid) && (await mergedPRContainsHead(data, dataRepo, explicitCurrentHeadOid)) !== 'contained' - // Why no lazy-HEAD re-check on preservation: fallback numbers come from - // callers that already gated them on head equality or confirmed - // containment; re-hiding against the main-repo HEAD would blank - // deleted-head merged PRs that are deliberately kept visible. + // Why no lazy-HEAD re-check: fallback numbers were already gated on head equality/containment; re-hiding would blank kept deleted-head merged PRs. const shouldPreserveMergedFallback = !explicitHeadHidesMergedImplicitPR && (fallbackConfirmedMergedBranch || options.acceptMergedFallbackPR === true) - // Why: a currently visible PR can be merged outside Orca; when the caller - // marks the fallback as visible review state, keep its lifecycle fresh even - // if GitHub no longer reports it by branch (for example deleted heads). + // Why: a visible PR can be merged outside Orca; keep a caller-marked fallback fresh even when GitHub no longer reports it by branch (e.g. deleted heads). if ((await hideMergedImplicitPR(data, dataRepo)) && !shouldPreserveMergedFallback) { return { kind: 'no-pr', fetchedAt: Date.now() } } @@ -3440,8 +3312,7 @@ function mapGraphQLPendingApprovalCheckSuite( name: getPendingApprovalCheckSuiteName(suite, headSha, index), status: 'completed', conclusion: 'action_required', - // Why: suite-only approval blockers have no check run; use the suite page - // as the actionable destination when GraphQL exposes one. + // Why: suite-only approval blockers have no check run; link the suite page when GraphQL exposes one. url: nullableString(suite.url) ?? (headSha ? getPendingApprovalCheckSuiteUrl(ownerRepo, headSha, suite.databaseId) : null) @@ -3472,8 +3343,7 @@ function mapGraphQLPRChecksResponse( .map((context) => nullableNumber(context.checkSuite?.databaseId)) .filter((id): id is number => id !== null) ) - // Why: mixed-CI repos expose Jenkins/Prow/Tide as legacy status contexts - // inside the same rollup as check runs. Keep check-run metadata on collisions. + // Why: mixed-CI repos expose Jenkins/Prow/Tide as legacy status contexts in the same rollup; keep check-run metadata on name collisions. const legacyStatuses = contexts .filter(isGraphQLStatusContext) .map(mapGraphQLStatusContext) @@ -3544,8 +3414,7 @@ async function getPRChecksViaRestFallback( .map(mapRestCommitStatus) .filter((check): check is PRCheckDetail => check !== null && !checkRunNames.has(check.name)) } catch (err) { - // Why: the REST fallback is already degraded; keep richer check-run rows - // if the legacy-status enrichment fails. + // Why: REST fallback is already degraded; keep the richer check-run rows if legacy-status enrichment fails. console.warn('getPRChecks REST status fallback failed:', err) } @@ -3612,8 +3481,7 @@ export async function getPRChecks( } const { stdout } = await ghExecFileAsync(fallbackArgs, ghOptions).catch((err: unknown) => { const { stderr } = extractExecError(err) - // Why: `gh pr checks` exits non-zero when a PR genuinely has no check - // runs yet. Treat that as an empty optional section, not a load failure. + // Why: `gh pr checks` exits non-zero when a PR has no check runs yet; treat that as empty, not a load failure. if (stderr.toLowerCase().includes('no checks reported')) { return { stdout: '[]', stderr } } @@ -3644,8 +3512,7 @@ export async function getPRChecks( if (canUseGraphQLRollup) { await acquire() try { - // Why: --cache 60s saves rate-limit budget during polling, but when the - // user explicitly clicks refresh we must skip it so gh fetches fresh data. + // Why: --cache 60s saves rate-limit budget during polling; explicit refresh skips it for fresh data. const cacheArgs = options?.noCache ? [] : ['--cache', '60s'] const { stdout } = await ghExecFileAsync( [ @@ -3672,8 +3539,7 @@ export async function getPRChecks( return checks } } catch (err) { - // Why: if GitHub's richer rollup query is unavailable, the older gh - // command still returns the combined check/status list for display. + // Why: fall back to older `gh pr checks` when GitHub's richer rollup query is unavailable. console.warn('getPRChecks via GraphQL rollup failed, falling back to gh pr checks:', err) } finally { release() @@ -3821,8 +3687,7 @@ async function attachFailedJobLogTails( }) .slice(0, PR_CHECK_LOG_TAIL_JOB_LIMIT) - // Why: failed workflows can have many jobs; cap log fetches so details remain - // a lazy, bounded follow-up request instead of a burst of hosted log downloads. + // Why: cap log fetches so failed-job details stay a bounded follow-up, not a burst of hosted log downloads. for (const job of failedJobs) { const cacheKey = getCheckJobLogTailCacheKey(job) if (!cacheKey) { @@ -4042,9 +3907,7 @@ export async function rerunPRChecks( } } -// Why: review thread resolution status and thread IDs are only available via -// GraphQL. The REST pulls/{n}/comments endpoint does not expose them, so we -// use GraphQL for review threads and REST for issue-level comments. +// Why: review thread resolution status + thread IDs are GraphQL-only (REST pulls/{n}/comments omits them). const REVIEW_THREADS_QUERY = ` query($owner: String!, $repo: String!, $pr: Int!) { repository(owner: $owner, name: $repo) { @@ -4114,15 +3977,11 @@ export async function getPRComments( await acquire() try { if (ownerRepo) { - // Why: --cache 60s saves rate-limit budget during normal loads, but when the - // user explicitly clicks refresh we must skip it so gh fetches fresh data. + // Why: --cache 60s saves rate-limit budget on normal loads; explicit refresh skips it for fresh data. const cacheArgs = options?.noCache ? [] : ['--cache', '60s'] const base = `repos/${ownerRepo.owner}/${ownerRepo.repo}` - // Why: use allSettled so a single failing endpoint (e.g. GraphQL - // permissions, transient network error) doesn't blank out all comments. - // Each source is parsed independently; failed sources contribute zero - // comments instead of aborting the entire fetch. + // Why: allSettled so one failing endpoint doesn't blank out all comments; failed sources contribute zero. const reviewThreadsGuard = rateLimitGuard('graphql') let reviewThreadsFetch: Promise<{ stdout: string; stderr: string } | null> if (reviewThreadsGuard.blocked) { @@ -4151,10 +4010,7 @@ export async function getPRComments( ghOptions ), reviewThreadsFetch, - // Why: review summaries (approve, request changes, general comments) live - // under pulls/{n}/reviews, not under issue comments or review threads. - // Without this, a reviewer who submits "LGTM" without inline threads - // would have their comment silently dropped from the panel. + // Why: review summaries (approve/request-changes/general) live under pulls/{n}/reviews, not issue comments or threads. ghExecFileAsync( ['api', ...cacheArgs, `${base}/pulls/${prNumber}/reviews?per_page=100`], ghOptions @@ -4260,9 +4116,7 @@ export async function getPRComments( threadId: thread.id, isResolved: thread.isResolved, isOutdated: thread.line == null, - // Why: GitHub nulls out line/startLine when the commented code is - // outdated (e.g. after a force-push). Fall back to originalLine which - // always preserves the line numbers from when the comment was created. + // Why: GitHub nulls line/startLine when the commented code is outdated (e.g. force-push); originalLine preserves the original numbers. line: thread.line ?? thread.originalLine ?? undefined, startLine: thread.startLine ?? thread.originalStartLine ?? undefined }) @@ -4274,8 +4128,7 @@ export async function getPRComments( } } - // Parse review summaries (REST) — only include reviews with a body, - // since empty-body reviews (e.g. approvals with no comment) add noise. + // Review summaries (REST); skip empty-body reviews (e.g. approvals with no comment) as noise. type RESTReview = { id: number user: { login: string; avatar_url: string; type?: string } | null @@ -4581,9 +4434,7 @@ export async function mergePR( return { ok: false, error: mergeBlocker } } - // Don't use --delete-branch: it tries to delete the local branch which - // fails when the user's worktree is checked out on it. Branch cleanup - // is handled by worktree deletion (local) and GitHub's auto-delete setting (remote). + // Don't use --delete-branch: it deletes the local branch, which fails while the worktree is checked out on it. const args = ['pr', 'merge', String(prNumber), `--${method}`] if (ownerRepo) { args.push('--repo', `${ownerRepo.owner}/${ownerRepo.repo}`) @@ -4636,9 +4487,7 @@ export async function setPRAutoMerge( } } -// Why: GitHub rejects enabling auto-merge on a PR that can already merge with -// "Pull request is in clean status". Surface the actionable next step (merge -// directly) instead of the raw GraphQL error. +// Why: GitHub rejects auto-merge on an already-mergeable PR ("clean status"); surface an actionable message instead of the raw error. function classifySetAutoMergeError(message: string): string { if (/in clean status/i.test(message)) { return 'This pull request can already be merged. Use Merge instead of auto-merge.' @@ -4734,8 +4583,7 @@ async function enablePRAutoMerge( if (pr.headRefOid) { args.push('-f', `expectedHeadOid=${pr.headRefOid}`) } - // Why: `gh pr merge --auto` can perform an immediate merge; this mutation - // only creates GitHub's auto-merge request and lets branch requirements gate it. + // Why: `gh pr merge --auto` can merge immediately; this mutation only creates the auto-merge request, letting branch requirements gate it. await ghExecFileAsync(args, { ...ghOptions, env: { ...process.env, GH_PROMPT_DISABLED: '1' } @@ -4769,8 +4617,7 @@ async function getPRMergeBlocker( if (pr.mergeQueueRequired === true) { return 'This pull request must be merged through GitHub merge queue. Use Merge when ready instead.' } - // Why: conflict summaries shell out to local git; SSH repo paths are remote-only - // until that helper is routed through the SSH git provider. + // Why: conflict summaries shell out to local git; skip for SSH repos until that helper routes through the SSH provider. if ( connectionId || pr.mergeable !== 'CONFLICTING' || @@ -4790,8 +4637,7 @@ async function getPRMergeBlocker( ) return formatMergeConflictBlocker(pr.baseRefName, summary) } catch { - // Why: conflict preflight should improve stale UI diagnostics, not make - // merge impossible when the lookup endpoint has a transient failure. + // Why: conflict preflight should improve stale UI diagnostics, not block merge on a transient lookup failure. return null } } @@ -4827,8 +4673,7 @@ export async function updatePRState( await acquire() try { const cmd = updates.state === 'closed' ? 'close' : 'reopen' - // Why: GitHub can reject REST pull state PATCHes for reopen paths with a - // generic 422; gh's PR commands use GitHub's supported reopen flow. + // Why: gh's PR commands use GitHub's supported reopen flow; REST state PATCH can 422 on reopen. await ghExecFileAsync( ['pr', cmd, String(prNumber), '--repo', `${ownerRepo.owner}/${ownerRepo.repo}`], { diff --git a/src/main/github/pr-refresh-coordinator.ts b/src/main/github/pr-refresh-coordinator.ts index 700cc32ecd2..592bea4870b 100644 --- a/src/main/github/pr-refresh-coordinator.ts +++ b/src/main/github/pr-refresh-coordinator.ts @@ -1,6 +1,4 @@ -/* eslint-disable max-lines -- Why: the coordinator keeps queueing, pacing, and -renderer broadcast rules together so freshness and rate-limit invariants are -reviewable in one place. */ +/* eslint-disable max-lines -- Why: queueing, pacing, and broadcast rules stay together so freshness and rate-limit invariants are reviewable in one place. */ import { webContents } from 'electron' import type { GitHubPRRefreshAlias, @@ -83,16 +81,13 @@ const queue = new Map() const backgroundStarts: number[] = [] const activeStartsByScope = new Map() const errorBackoff = new Map() -// Per-key manual-retry cooldown from a real secondary rate limit (Retry-After). -// refreshPRNow refuses a manual retry until this time so a stale renderer cannot -// bypass the gate the client advertised via `retryDisabledUntil`. +// Per-key manual-retry cooldown (secondary rate-limit Retry-After); refreshPRNow refuses manual retry until this time so a stale renderer can't bypass it. const manualRetryGates = new Map() let lastBackgroundStartAt = 0 /** * Track (or clear) the manual-retry cooldown for a key from a broadcast outcome. - * Only a rate-limit outcome carrying `retryDisabledUntil` sets a gate; any other - * settled outcome clears it. + * Only a rate-limit outcome carrying `retryDisabledUntil` sets a gate; any other settled outcome clears it. */ function noteManualRetryGate(key: string, outcome: PRRefreshOutcome): void { if (outcome.kind === 'upstream-error' && outcome.retryDisabledUntil !== undefined) { @@ -169,8 +164,7 @@ export function clearVisiblePRRefreshWindow(windowId: number): void { const hadVisibleRefreshes = visibleByWindow.delete(windowId) clearActiveBurstWindow(windowId) if (hadVisibleRefreshes) { - // Why: visible follow-ups are owned by the renderer that reported them. - // If that WebContents is destroyed, no later visibility report may arrive. + // Why: visible follow-ups are owned by the reporting renderer; if its WebContents is destroyed, no later visibility report arrives. removeInvisibleVisibleRefreshes() } } @@ -178,12 +172,7 @@ export function clearVisiblePRRefreshWindow(windowId: number): void { /** * Drop a removed worktree's aliases from every queue entry. * - * Why: many local branches/worktrees that resolve to the same PR coalesce into - * one queue entry whose `aliases` map keeps one entry per worktree. Aliases are - * only pruned when a candidate is re-enqueued as invalid — never when a worktree - * is simply removed. Over a long session with churning worktrees these maps grow - * unbounded, contributing to the renderer/process memory creep behind the OOM - * reports. Called from worktree removal so stale aliases are released promptly. + * Why: aliases are otherwise only pruned when a candidate is re-enqueued as invalid, so churning worktrees grow these maps unbounded (OOM creep). */ export function pruneWorktreePRRefreshAliases(worktreeId: string): void { for (const [key, entry] of queue) { @@ -203,8 +192,7 @@ export function pruneWorktreePRRefreshAliases(worktreeId: string): void { resetKeyRetryState(key) continue } - // Keep the entry alive but ensure its representative candidate is not a - // dangling reference to the removed worktree. + // Keep the entry alive but replace its representative so it isn't a dangling reference to the removed worktree. if (entry.candidate.worktreeId === worktreeId) { const replacementAlias = entry.aliases.values().next().value if (replacementAlias) { @@ -213,9 +201,7 @@ export function pruneWorktreePRRefreshAliases(worktreeId: string): void { cacheKey: replacementAlias.cacheKey, branch: replacementAlias.branch, worktreeId: replacementAlias.worktreeId, - // Why: the probe now represents the replacement worktree, so it must - // use that worktree's head — otherwise divergence is stamped for the - // removed worktree's head and the survivor's link is never cleared. + // Why: the probe now represents the replacement worktree, so use its head — else the removed worktree's link never clears. currentHeadOid: replacementAlias.currentHeadOid ?? null } } @@ -382,8 +368,7 @@ function setVisibleFollowUp(entry: QueueEntry): void { existing.aliases.set(alias.cacheKey, alias) } - // Why: a user activation can arrive while a background refresh is awaiting gh. - // The background follow-up must not overwrite that pending active/manual work. + // Why: a user activation can arrive while a background refresh awaits gh; the follow-up must not overwrite that pending active/manual work. if ( bypassesFreshnessDelay(existing.reason) || existing.priority > entry.priority || @@ -418,9 +403,7 @@ function removeQueuedAliasForInvalidCandidate(key: string, alias: GitHubPRRefres cacheKey: replacementAlias.cacheKey, branch: replacementAlias.branch, worktreeId: replacementAlias.worktreeId, - // Why: the probe now represents the replacement worktree, so it must use - // that worktree's head — otherwise divergence is stamped for the pruned - // candidate's head and the survivor's link is never cleared. + // Why: the probe now represents the replacement worktree, so use its head — else the survivor's link never clears. currentHeadOid: replacementAlias.currentHeadOid ?? null, isArchived: false, isBare: false @@ -430,8 +413,7 @@ function removeQueuedAliasForInvalidCandidate(key: string, alias: GitHubPRRefres /** * Advances the visible-key error backoff and returns the earliest retry time. - * The renderer surfaces this as `nextAutoRetryAt`; only visible keys auto-retry, - * so callers must gate on `isVisibleKey` before computing a schedule. + * Why: only visible keys auto-retry, so callers must gate on `isVisibleKey` first. */ function nextVisibleErrorRetryAt(key: string): number { const failures = (errorBackoff.get(key)?.failures ?? 0) + 1 @@ -442,23 +424,14 @@ function nextVisibleErrorRetryAt(key: string): number { } /** - * Stamps the auto-retry time onto an error outcome before broadcast so every - * alias receives identical timing. - * - * Why: this only sets `nextAutoRetryAt` (the "Orca will retry at {time}" hint). - * It must NOT set `retryDisabledUntil` — that field disables *manual* Retry and - * is reserved for a real rate-limit gate (the paused/breaker path in - * `refreshPRNow`, which maps `pausedUntil` into it). Deriving it from ordinary - * exponential backoff would disable a manual Retry that `refreshPRNow` would in - * fact accept. + * Stamps the auto-retry time onto an error outcome before broadcast. + * Why: sets only `nextAutoRetryAt`, never `retryDisabledUntil` — that disables *manual* Retry and is reserved for a real rate-limit gate. */ function withErrorSchedule(outcome: PRRefreshOutcome, retryAt: number): PRRefreshOutcome { if (outcome.kind !== 'upstream-error') { return outcome } - // Why: preserve a real Retry-After cooldown the client already stamped on the - // outcome (`retryDisabledUntil`); auto-retry no earlier than both the backoff - // and that cooldown so we do not retry into an active secondary rate limit. + // Why: honor a real Retry-After cooldown the client stamped (`retryDisabledUntil`) so we don't auto-retry into an active secondary rate limit. const cooldownUntil = outcome.retryDisabledUntil return { ...outcome, @@ -476,14 +449,12 @@ function scheduleVisibleFollowUp( options?: { pendingMergeabilityDelayMs?: number; plannedRetryAt?: number } ): void { if (!isVisibleKey(key)) { - // Why: manual/active refreshes can remove the queued visible retry after - // its owner window is gone, leaving the retry backoff without an owner. + // Why: manual/active refreshes can remove the queued visible retry after its owner window is gone, orphaning the backoff. resetKeyRetryState(key) return } if (outcome.kind === 'upstream-error') { - // Why: reuse the retry time already computed for the broadcast schedule so - // the same failure is not counted twice against the backoff. + // Why: reuse the retry time already computed for the broadcast so the same failure isn't counted twice against the backoff. const retryAt = options?.plannedRetryAt ?? nextVisibleErrorRetryAt(key) setVisibleFollowUp({ key, @@ -495,8 +466,7 @@ function scheduleVisibleFollowUp( queuedAt: nextQueueOrder(), windowId }) - // Why: this is a delayed retry, not active work; showing it as a spinner - // makes visible worktrees look stuck until the backoff expires. + // Why: this is a delayed retry, not active work; a spinner would make visible worktrees look stuck until backoff expires. scheduleDrain(retryAt - Date.now()) return } @@ -511,9 +481,7 @@ function scheduleVisibleFollowUp( pendingMergeabilityDueAt === null ? regularDueAt : Math.min(regularDueAt, pendingMergeabilityDueAt) - // Why: coalesced linked-PR refreshes may represent several local branches. - // Preserve every alias for the next visible follow-up so all cache entries - // keep receiving periodic updates. + // Why: a coalesced linked-PR refresh may represent several branches; preserve every alias so all cache entries keep getting updates. setVisibleFollowUp({ key, candidate: followUpCandidate, @@ -522,8 +490,7 @@ function scheduleVisibleFollowUp( priority, dueAt, queuedAt: nextQueueOrder(), - // Why: this manual one-shot fixes GitHub's transient UNKNOWN state; visible - // spacing would otherwise delay it past the intended prompt retry window. + // Why: this manual one-shot fixes GitHub's transient UNKNOWN state; visible spacing would delay it past the prompt retry window. bypassBackgroundBudget: pendingMergeabilityDueAt !== null, windowId }) @@ -543,8 +510,7 @@ function refreshIntervalForCandidate(candidate: GitHubPRRefreshCandidate): numbe candidate.cachedMergeable === 'UNKNOWN' && !hasResolvedMergeStateStatus(candidate.cachedMergeStateStatus) ) { - // Why: GitHub can return transient UNKNOWN mergeability while it computes - // the PR test merge; visible merge buttons need a prompt follow-up. + // Why: GitHub returns transient UNKNOWN mergeability while computing the test merge; visible merge buttons need a prompt follow-up. return MERGEABILITY_PENDING_REFRESH_MS } if (candidate.cachedChecksStatus === 'success') { @@ -573,9 +539,7 @@ function isMergeabilityPendingOutcome(outcome: PRRefreshOutcome): boolean { } function backgroundRefreshBuckets(): ('core' | 'graphql')[] { - // Why: branch refreshes prefer REST but can still fall back to `gh pr list` - // when local head-owner metadata is unavailable. Guard both buckets until the - // client exposes an exact per-lookup cost plan. + // Why: branch refreshes prefer REST but can fall back to `gh pr list`, so guard both buckets until the client exposes a per-lookup cost plan. return ['core', 'graphql'] } @@ -648,8 +612,7 @@ function activeOrder(a: QueueEntry, b: QueueEntry): number { if (activeBurstScope(a) !== activeBurstScope(b)) { return 0 } - // Why: activation churn should refresh the worktree the user lands on before - // stale transient selections from the same window/runtime scope. + // Why: refresh the worktree the user lands on before stale transient selections in the same window/runtime scope. return b.queuedAt - a.queuedAt } @@ -765,10 +728,7 @@ async function drainQueue(): Promise { ) if (isBackground(next.reason)) { - // Why: the probe only warms rateLimitGuard's cached snapshot, so a - // failed probe must fail open — GHES with rate limiting disabled 404s - // every probe (#7553). A genuinely broken gh surfaces per-key as typed - // fetch outcomes instead (visible keys additionally back off). + // Why: the probe only warms rateLimitGuard's cache, so it must fail open — GHES with rate limiting disabled 404s every probe (#7553). await getRateLimit() const buckets = backgroundRefreshBuckets() const blockedGuard = buckets @@ -791,8 +751,7 @@ async function drainQueue(): Promise { noteBackgroundStart() } if (next.reason === 'active') { - // Why: activation is high priority, but tab/worktree churn can enqueue - // many distinct active refreshes that each probe local Git. + // Why: tab/worktree churn can enqueue many distinct active refreshes that each probe local Git. noteActiveStart(next) } for (const bucket of buckets) { @@ -808,8 +767,7 @@ async function drainQueue(): Promise { next.candidate.linkedPRNumber == null ? (next.candidate.fallbackPRNumber ?? null) : null, ...hostedReviewOptionArgs(next.candidate) ) - // Why: compute the retry schedule before broadcasting so the outcome and - // its follow-up retry share one timing; only visible keys auto-retry. + // Why: compute the retry schedule before broadcasting so the outcome and its follow-up share one timing; only visible keys auto-retry. let plannedRetryAt: number | undefined let broadcastOutcome = outcome if (outcome.kind === 'upstream-error' && isVisibleKey(next.key)) { @@ -882,11 +840,7 @@ export function enqueuePRRefresh( existing.candidate = candidate existing.windowId = windowId ?? existing.windowId } else if (existing.candidate.worktreeId === candidate.worktreeId) { - // Why: a non-promoting coalesce (e.g. visible→visible) keeps the existing - // representative, but the representative drives the probe head. If its own - // worktree moved head/branch, refresh those probe inputs so divergence is - // stamped for the current head — otherwise the head-scoped clear never - // matches and a merged linked PR lingers after a branch switch. + // Why: the representative drives the probe head; refresh probe inputs when its worktree moved head/branch, else a merged linked PR lingers after a switch. existing.candidate = { ...existing.candidate, cacheKey: candidate.cacheKey, @@ -908,8 +862,7 @@ export function enqueuePRRefresh( windowId }) } - // Why: visible/SWR refreshes are background maintenance and may sit behind - // the budget queue. Only user/action-driven queueing should surface in UI. + // Why: visible/SWR are background maintenance behind the budget queue; only user/action-driven queueing should surface in UI. if (shouldBroadcastQueued(reason, dueAt)) { broadcast({ aliases: [alias], reason, status: 'queued' }) } @@ -968,11 +921,7 @@ export async function refreshPRNow(candidate: GitHubPRRefreshCandidate): Promise return outcome } - // Why: enforce the same rate-limit gate main advertises via `retryDisabledUntil` - // so a stale renderer cannot bypass it. A blocked primary bucket means gh would - // 403 anyway; a recorded secondary Retry-After cooldown means gh would 429 - // again. Refuse without spending quota until the later of the two and report - // the pause honestly. + // Why: enforce the rate-limit gate so a stale renderer can't bypass it; refuse without spending quota until the later of the two cooldowns. const manualBlockedGuard = backgroundRefreshBuckets() .map((bucket) => rateLimitGuard(bucket)) .find((guard) => guard.blocked) @@ -983,11 +932,7 @@ export async function refreshPRNow(candidate: GitHubPRRefreshCandidate): Promise ) if (gateUntil > Date.now()) { const retryAt = gateUntil - // Why: the paused broadcast maps `pausedUntil` into the renderer's - // `nextAutoRetryAt` ("Orca will retry at {time}"), so that auto-retry must be - // real. Requeue this candidate at the reset time and wake the drain then — - // mirroring the background paused path — instead of advertising an automatic - // retry that was never scheduled (finding 12). + // Why: paused maps `pausedUntil` into the renderer's auto-retry, so requeue at reset (finding 12) — don't advertise an unscheduled retry. queue.set(key, { key, candidate, @@ -1041,8 +986,7 @@ export async function refreshPRNow(candidate: GitHubPRRefreshCandidate): Promise ) scheduleVisibleFollowUp(key, candidate, outcome, 40, aliases, undefined, { plannedRetryAt, - // Why: GitHub often reports UNKNOWN immediately after `gh pr reopen`; - // do one prompt visible retry so conflicts replace the transient label. + // Why: GitHub reports UNKNOWN right after `gh pr reopen`; one prompt visible retry replaces the transient label. pendingMergeabilityDelayMs: MANUAL_MERGEABILITY_PENDING_REFRESH_MS }) return broadcastOutcome diff --git a/src/main/github/project-view.ts b/src/main/github/project-view.ts index 3a040d37520..8e2be18044e 100644 --- a/src/main/github/project-view.ts +++ b/src/main/github/project-view.ts @@ -1,9 +1,4 @@ -/* eslint-disable max-lines -- Why: ProjectV2 GraphQL has its own normalization -layer, retry policy (parent-field dance), paste-to-add parser, and discovery -pagination. Co-locating the read path keeps the retry/classify/normalize -contract reviewable as one surface. Lower-level plumbing (slug validation, -error classifier, runGraphql/runRest) lives in ./project-view/internals; the -slug-addressed write path lives in ./project-view/mutations. */ +/* eslint-disable max-lines -- Why: co-locate the ProjectV2 read path (normalize/retry/paste-parser/discovery) as one reviewable surface; plumbing lives in ./project-view/internals and mutations. */ import { acquire, release, @@ -52,8 +47,7 @@ import { isGitHubProjectRefInputTooLarge } from '../../shared/github-project-ref-input' -// Re-export the public API so existing call sites (`./project-view`) keep -// working unchanged. The split is internal-only. +// Re-export the public API so existing `./project-view` call sites keep working; the split is internal-only. export { isValidOwnerSlug, isValidRepoSlug, @@ -77,14 +71,7 @@ export { // ─── Constants ───────────────────────────────────────────────────────── -// Why: these defaults were deliberately shrunk from 50/50/100 to cut quota -// spend in the most expensive gh call reachable from TaskPage. Discovery -// walks viewer projects, then up to `DISCOVERY_MAX_ORGS` orgs × a nested -// `projectsV2(first:N)` query. The org loop dominates the cost and is the -// path that produced the user-visible HTTP 504 when one org was slow. Users -// with projects outside this window can still paste a URL to reach them — -// no functional loss. Prior values: MAX_ORGS=50, ORG_PAGE_SIZE=30, -// PROJECTS_PER_OWNER=100, nested projectsV2 first=50. +// Why: defaults deliberately shrunk to cut quota spend in discovery — the org loop dominates and produced the HTTP 504; overflow owners can paste a URL. const ITEM_PAGE_SIZE = 100 const MAX_ITEMS = 500 const VIEWS_PAGE_SIZE = 20 @@ -98,9 +85,7 @@ export const PROJECT_VIEW_OWNER_CACHE_MAX_ENTRIES = 512 // ─── Module-scope caches (reset on HMR — intentional) ────────────────── -// Why: pasted and discovered GitHub owners are user-controlled across a long -// main-process session; keep capability probes bounded to avoid unbounded -// retention while preserving the hot-owner fast path. +// Why: owners are user-controlled over a long session; bound cache entries to avoid unbounded retention while keeping the hot-owner fast path. function rememberProjectViewCacheEntry( cache: Map, key: K, @@ -129,20 +114,12 @@ function getProjectViewCacheEntry(cache: Map, key: K): V | undefined return value } -// Why: HMR reloading should re-probe capability. Both caches live as plain -// module locals so a dev-time code swap naturally re-runs capability probes -// instead of carrying a stale "unsupported" flag into fresh code. +// Why: plain module locals so HMR code swaps re-run capability probes instead of carrying a stale "unsupported" flag. const ownerTypeCache = new Map() -// Why: keyed by `${owner}\0${ownerType}` rather than a process-global flag so -// a single owner's capability gap (e.g. token without scope on org A) doesn't -// poison every subsequent fetch for unrelated owners that DO support -// Issue.parent. See bug-scan finding 2. +// Why: keyed per owner (not a process-global flag) so one owner's capability gap doesn't poison others that DO support Issue.parent (bug-scan finding 2). const parentFieldRetriedByOwner = new Map() const parentFieldWarningLoggedByOwner = new Map() -// Why: concurrent fetchAllItems calls for the same owner all observe the -// same "not-yet-retried" state, each issuing a duplicate first-page probe -// and racing to set the flag. Use an in-flight promise per owner so only -// one caller drives the probe; siblings await the same result. +// Why: in-flight promise per owner so concurrent fetchAllItems callers share one probe instead of each racing a duplicate first-page probe. const parentFieldProbeInFlight = new Map>() function ownerScopeKey(owner: string, ownerType: GitHubProjectOwnerType): string { @@ -392,8 +369,7 @@ export function normalizeFieldValue( } case undefined: default: - // Unknown __typename → forward-compat: drop silently, do not throw, - // do not classify as drift (see design §Error Handling). + // Unknown __typename → forward-compat: drop silently, don't classify as drift (see design §Error Handling). return null } } @@ -713,13 +689,7 @@ async function fetchViewFieldsContinuation( ): Promise< { ok: true; fields: RawProjectV2Field[] } | { ok: false; error: GitHubProjectViewError } > { - // Why: address the view directly via `node(id:)` instead of re-fetching the - // whole project + walking views every page. Previous shape paid an - // unnecessary `${VIEWS_PAGE_SIZE}` views fan-out per field-continuation - // page; the new shape is one round-trip per field page, which is the - // minimum possible cost. Field-paged views are rare (>50 fields), so this - // only matters for the few projects that hit it — but when they do, the - // savings compound across pagination loops. + // Why: address the view directly via node(id:) instead of re-walking all views each page — one round-trip per field page. const query = ` query($after:String!, $viewId:ID!) { node(id:$viewId) { @@ -844,9 +814,7 @@ type RawItemsPage = { nodes?: (RawItem | null)[] } -// Why: runGraphql returns the classified error but not the raw GraphQL -// errors; for the parent-field retry decision we need those. This variant -// returns the raw envelope so callers can re-inspect. +// Why: unlike runGraphql, return the raw GraphQL error envelope so the parent-field retry decision can re-inspect it. async function fetchItemsPageWithRaw(args: { owner: string ownerType: GitHubProjectOwnerType @@ -925,9 +893,7 @@ async function fetchItemsPageWithRaw(args: { try { parsed = JSON.parse(stdout) } catch { - // Why: when gh exits non-zero with no parseable JSON on stdout (network, - // auth, rate-limit, missing scope), classify against stderr so callers - // see the real cause instead of a synthesized drift/not-found. + // Why: gh exited non-zero with unparseable stdout; classify against stderr so callers see the real cause, not a synthesized drift/not-found. if (execFailed) { return { ok: false, @@ -943,9 +909,7 @@ async function fetchItemsPageWithRaw(args: { stderr } } - // Why: gh exec rejected but stdout still had a parseable error envelope — - // fall through to the parsed.errors branch below. If parsed has neither - // data nor errors, surface the stderr classification rather than not_found. + // Why: gh rejected but stdout parsed; fall through to parsed.errors below, else surface the stderr classification rather than not_found. if (execFailed && (!parsed.errors || parsed.errors.length === 0) && !parsed.data) { return { ok: false, @@ -987,26 +951,16 @@ async function fetchAllItems(args: { | { ok: true; rows: GitHubProjectRow[]; totalCount: number; parentFieldDropped: boolean } | { ok: false; error: GitHubProjectViewError; totalCount?: number } > { - // Why: caches keyed by (owner, ownerType) so a single owner's lack of - // Issue.parent capability (e.g. token without scope on org A) doesn't - // poison fetches for unrelated owners. See bug-scan finding 2. + // Why: keyed by (owner, ownerType) so one owner's missing Issue.parent capability doesn't poison unrelated owners. const scopeKey = ownerScopeKey(args.owner, args.ownerType) - // Why: if another caller for the SAME owner is currently probing whether - // Issue.parent is supported, await its decision so we don't fire a - // duplicate with-parent probe. We must re-read the retried flag AFTER - // awaiting because the probe may have flipped it. + // Why: await any in-flight same-owner probe so we don't duplicate it, then re-read the retried flag (the probe may have flipped it). const inFlight = parentFieldProbeInFlight.get(scopeKey) if (inFlight) { await inFlight.catch(() => {}) } let includeParent = !hasParentFieldRetried(scopeKey) let parentFieldDropped = !includeParent - // First page — single-flight the with-parent attempt PER OWNER so - // concurrent callers for the same owner observe one probe result instead - // of each issuing their own. We must assign the in-flight promise - // synchronously (no await between the get() check and the set()) so - // concurrent callers race on the same promise rather than each creating - // a duplicate probe. + // Single-flight the with-parent probe per owner; assign the in-flight promise synchronously (no await between get() and set()) so callers share one probe. let first: Awaited> let probePromise: Promise>> | null = null if (includeParent && !parentFieldProbeInFlight.has(scopeKey)) { @@ -1026,12 +980,7 @@ async function fetchAllItems(args: { after: null, includeParent: true }) - // Why: flip parentFieldRetriedByOwner BEFORE resolveProbe()/clearing - // parentFieldProbeInFlight so siblings that awoke on `inFlight.catch()` - // observe the updated flag. Doing this in the outer block (after - // `await probePromise`) leaves a gap where siblings see - // parentFieldProbeInFlight=null and parentFieldRetriedByOwner without - // the scopeKey, then issue duplicate with-parent probes. + // Why: set the retried flag BEFORE resolving/clearing the probe so siblings awoken on inFlight.catch() see it and don't fire duplicate with-parent probes. if (!result.ok && errorsIndicateParentField(result.rawErrors, result.stderr)) { markParentFieldRetried(scopeKey) } @@ -1054,9 +1003,7 @@ async function fetchAllItems(args: { }) } if (!first.ok && includeParent && errorsIndicateParentField(first.rawErrors, first.stderr)) { - // Retry the whole table without parent. Mark this owner as retried so - // subsequent fetches against the same owner skip the probe — but other - // owners are unaffected. + // Retry the whole table without parent; mark this owner retried so later fetches skip the probe (other owners unaffected). markParentFieldRetried(scopeKey) includeParent = false parentFieldDropped = true @@ -1255,16 +1202,7 @@ export async function getProjectViewTable( matchStrength = m } } - // Why: stop as soon as we have ANY match — including 'default' (first - // table view). Continuing to walk views pages for a default selector - // costs one extra GraphQL call per page with no upside: the default - // contract is "first table view we see", and view layouts don't change - // ordering between pages such that a later view would outrank the - // first table layout. Previously we kept walking on default match - // because the precedence comment hinted at re-ranking, but no real - // selector promotes a 'default' to a stronger match within the same - // selector input — those ranks only matter when the caller supplied - // a selector. Bail early on any non-null selectedRaw. + // Why: stop on ANY match (incl. 'default' = first table view); walking further pages costs a GraphQL call per page with no re-ranking upside. if (selectedRaw) { break } @@ -1300,15 +1238,11 @@ export async function getProjectViewTable( } const selectedView = finalized.view - // Why: an explicit empty-string override means "no filter"; treat undefined - // as "use the view's filter as-is". The override is ephemeral — never - // persisted to GitHub — so users can clear the search without mutating the - // view's stored filter. + // Why: empty-string override means "no filter"; undefined means "use the view's stored filter". The override is ephemeral, never persisted. const effectiveQuery = typeof args.queryOverride === 'string' ? args.queryOverride : selectedView.filter - // Unsupported layout: return without paginating items; attempt a cheap - // count-only query best-effort. + // Unsupported layout: skip item pagination; best-effort count-only query. if (selectedView.layout !== 'TABLE_LAYOUT') { const count = await fetchItemsCountOnly({ owner: args.owner, @@ -1389,10 +1323,7 @@ type RawViewerDiscovery = { export async function listAccessibleProjects(): Promise { const viewerProjects: GitHubProjectSummary[] = [] const orgProjects: GitHubProjectSummary[] = [] - // Why: per-org failures are collected so the picker can render a "some orgs - // didn't load" banner with the affected logins, instead of aborting the - // whole discovery on the first 504. Users with flaky org fetches still get - // viewer + other orgs in the list. + // Why: collect per-org failures so the picker shows a "some orgs didn't load" banner instead of aborting discovery on the first 504. const partialFailures: { owner: string; message: string }[] = [] let viewerLogin: string | null = null @@ -1423,10 +1354,7 @@ export async function listAccessibleProjects(): Promise(query, vars) if (!res.ok) { - // Why: a viewer-level failure is structural — if we can't list the - // user's own projects, we have nothing to build on. Propagate as a - // hard error instead of returning partial. Org-level errors below - // are non-fatal because the viewer slice is still useful on its own. + // Why: viewer-level failure is structural (no projects to build on), so propagate hard; org-level errors below are non-fatal. return { ok: false, error: res.error } } if (!res.data.viewer) { @@ -1463,13 +1391,7 @@ export async function listAccessibleProjects(): Promise(query, vars) if (!res.ok) { - // Why: the org-listing query itself failed (not a nested projectsV2). - // Record it as a partial failure against a synthetic `*` owner so the - // UI banner explains why additional orgs aren't listed, but keep any - // viewer projects we already collected. This is the critical 504 path - // the user reported in the ProjectPicker. + // Why: org-listing failed; record a synthetic '*' partial failure so the banner explains it, but keep collected viewer projects (the reported 504 path). partialFailures.push({ owner: '*', message: res.error.message }) break } @@ -1516,9 +1434,7 @@ export async function listAccessibleProjects(): Promise 0 && b.remaining < floor) { return { blocked: true, remaining: b.remaining, limit: b.limit, resetAt: b.resetAt } } @@ -143,12 +107,8 @@ export function rateLimitGuard(bucket: RateLimitBucketKind): } /** - * Decrement the cached `remaining` counter for a bucket after a successful - * spawn. Why: the canonical numbers come from the next probe, but between - * probes the cached snapshot would over-report budget if we didn't account - * for the work we just did. The decrement keeps the circuit breaker honest - * during a burst (e.g. paginating items) instead of waiting 30s for the cache - * to expire. + * Decrement the cached `remaining` for a bucket after a successful spawn. + * Why: between probes the snapshot would over-report budget, so this keeps the breaker honest during a burst instead of waiting for the TTL. */ export function noteRateLimitSpend(bucket: RateLimitBucketKind, cost = 1): void { if (!cached) { @@ -160,11 +120,7 @@ export function noteRateLimitSpend(bucket: RateLimitBucketKind, cost = 1): void } } -// Why: when the runner's breaker trips it only knows "blocked", not for how -// long. `gh api rate_limit` is exempt from limits, so one forced probe turns -// the fallback block into the bucket's real reset time (or clears a block -// that a stale fallback would otherwise keep alive). Single-flight: a 90-repo -// 403 burst must refine once, not 90 times. +// Why: the breaker only knows "blocked", not the reset time; one exempt probe refines it to the real reset or clears a stale block (single-flight so a 403 burst probes once). let resetRefinementInFlight: Promise | null = null function refineBreakerFromSnapshot(): void { @@ -193,9 +149,7 @@ function refineBreakerFromSnapshot(): void { registerGhRateLimitResetProbe(() => refineBreakerFromSnapshot()) -// Why: a 90-repo fan-out that primes the guard concurrently must resolve to -// one `gh api rate_limit` spawn, not one per repo — the TTL cache alone can't -// dedupe calls that all start before the first probe lands. +// Why: single-flight so a concurrent fan-out resolves to one probe — the TTL cache can't dedupe calls that start before the first lands. let probeInFlight: Promise | null = null export async function getRateLimit(options?: { force?: boolean }): Promise { diff --git a/src/main/github/work-item-details.ts b/src/main/github/work-item-details.ts index 406856c6b3e..87cdbe52eaa 100644 --- a/src/main/github/work-item-details.ts +++ b/src/main/github/work-item-details.ts @@ -1,6 +1,4 @@ -/* eslint-disable max-lines -- Why: the PR/Issue details service groups the - body/comments/files/checks fetch paths alongside the file-contents resolver - so the drawer's rate-limit and caching strategy lives in one place. */ +/* eslint-disable max-lines -- Why: groups the PR/Issue fetch paths and file-contents resolver so caching/rate-limit strategy lives in one place. */ import type { GitHubAssignableUser, GitHubPRFile, @@ -28,17 +26,12 @@ import { noteRateLimitSpend, rateLimitGuard } from './rate-limit' import { getPRReviewCommentLineNumbersFromPatch } from './pr-review-comment-lines' import { isMaxBufferOverflowError } from '../git/max-buffer-overflow' -// Why: a PR "changed file" listing returned by the REST endpoint is paginated -// at 100 per page; we cap at a reasonable total so a massive PR cannot starve -// the gh semaphore while we fetch file listings. +// Why: cap total PR files so a massive PR can't starve the gh semaphore while paging (100/page). const MAX_PR_FILES = 300 -// Why: issue timelines can be extremely noisy from automation and cross-links. -// Bound drawer detail work so one huge issue cannot monopolize gh/API time. +// Why: bound noisy issue timelines so one huge issue can't monopolize gh/API time. const MAX_ISSUE_TIMELINE_ITEMS = 300 const GITHUB_REST_PAGE_SIZE = 100 -// Why: hosted PR files must exceed the renderer's large-diff threshold before -// we give up on the raw fetch; otherwise the UI sees an empty diff instead of -// the safety fallback. +// Why: raw-fetch buffer must exceed the renderer's large-diff threshold, else the UI shows an empty diff instead of the fallback. const GITHUB_RAW_CONTENT_MAX_BUFFER_BYTES = 8 * 1024 * 1024 function localGitOptionArgs(options: LocalGitExecOptions = {}): [] | [LocalGitExecOptions] { @@ -75,12 +68,7 @@ const WORK_ITEM_PARTICIPANTS_QUERY = `query($owner: String!, $repo: String!, $nu } }` -// Why: a single GraphQL round-trip replaces three serial gh subprocesses on -// the issue path (REST issue + REST comments + GraphQL participants). The -// previous fan-out could spawn ~3 `gh` processes per drawer-open; this drops -// it to one. We still fall back to the legacy REST+GraphQL path if the -// collapsed query throws or returns missing data — see the strict-fallback -// branch in getWorkItemDetails. +// Why: one GraphQL round-trip replaces the 3 serial gh subprocesses (REST issue + comments + participants); falls back to the legacy path on failure. const ISSUE_DETAILS_QUERY = `query($owner: String!, $repo: String!, $number: Int!) { repository(owner: $owner, name: $repo) { issue(number: $number) { @@ -300,8 +288,7 @@ async function getIssueTimelineItems( ], ghOptions ) - // Why: --jq emits compact NDJSON while explicit pages let us stop once - // supported activity reaches the drawer cap. + // Why: --jq emits NDJSON and explicit paging lets us stop once we hit the drawer cap. const pageEvents = parseRestTimelineEventLines(stdout) for (const event of pageEvents) { const item = mapRestTimelineEvent(event) @@ -324,10 +311,9 @@ async function getIssueTimelineItems( } /** - * Fetch an issue's body, comments, assignees, participants, and timeline in a - * single GraphQL round-trip. Returns null on any partial error so the caller - * falls back to the strict REST path. Assignee/participant avatars are resolved - * here so GitHub Enterprise users don't render blank. + * Fetch an issue's body, comments, assignees, participants, and timeline in one GraphQL round-trip. + * Returns null on any partial error so the caller falls back to the strict REST path. + * Avatars are resolved here so GHE users don't render blank. */ async function getIssueDetailsViaGraphQL( repoPath: string, @@ -338,9 +324,7 @@ async function getIssueDetailsViaGraphQL( body: string comments: PRComment[] assignees: string[] - // Avatar-bearing assignees, kept separate from the `assignees` login list so - // callers that only need logins are unaffected; used to enrich assignee - // avatars that `gh` leaves blank (notably GitHub Enterprise users). + // Avatar-bearing assignees, kept separate from the login-only `assignees`; enriches avatars `gh` leaves blank (GHE). assigneeUsers: GitHubAssignableUser[] participants: GitHubAssignableUser[] timelineItems: GitHubIssueTimelineItem[] @@ -376,9 +360,7 @@ async function getIssueDetailsViaGraphQL( ) const parsed = JSON.parse(stdout) as GraphQLIssueDetailsResponse if (parsed.errors && parsed.errors.length > 0) { - // Why: any partial GraphQL error (permissions, unknown field on a fork) - // forces the strict REST fallback so the drawer never paints a half-built - // shell. The fallback path's behavior is the historical contract. + // Why: any partial GraphQL error forces the strict REST fallback so the drawer never paints a half-built shell. return null } const issue = parsed.data?.repository?.issue @@ -436,8 +418,7 @@ function mergeGitHubUsers(users: GitHubAssignableUser[]): GitHubAssignableUser[] const key = user.login.toLowerCase() const existing = byLogin.get(key) if (existing) { - // Why: avoid mutating caller-provided objects — return a new merged record - // so upstream references to `user`/`existing` stay unchanged. + // Why: return a new merged record instead of mutating caller-provided objects. byLogin.set(key, { login: existing.login, name: existing.name ?? user.name ?? null, @@ -486,14 +467,10 @@ function mapFileStatus(raw: string): GitHubPRFile['status'] { } } -// Why: GitHub's REST file listing does not explicitly flag binary files, but it -// omits the `patch` field for them. When a file has changes but no patch, we -// treat it as binary so the drawer's diff tab can show a placeholder instead of -// attempting to fetch contents that would render as noise in a text diff viewer. +// Why: REST doesn't flag binaries but omits `patch` for them; treat "changes but no patch" as binary so the diff tab shows a placeholder. function isBinaryHint(file: RESTPRFile): boolean { if (file.status === 'removed' || file.status === 'added') { - // A newly added or removed file with zero patch text but non-zero changes - // is almost always binary (images, lockfiles over the size cap, etc.). + // Added/removed file with changes but no patch is almost always binary (images, oversized lockfiles). return file.patch === undefined && file.changes > 0 } return file.patch === undefined && file.changes > 0 @@ -543,9 +520,7 @@ async function getPRHeadBaseSha( } } -// Why: null signals a failed/blocked fetch (rate limit, auth, unresolved -// remote) so the Files tab can show a retryable error instead of the -// misleading "No files changed." empty state; [] means a genuinely empty PR. +// Why: null = failed/blocked fetch (Files tab shows retry, not "No files changed."); [] = genuinely empty PR. async function getPRFiles( repoPath: string, prNumber: number, @@ -822,9 +797,7 @@ async function getWorkItemParticipants( connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - // Why: work items in a fork live on the upstream remote, so participants must - // be resolved via the same upstream-first resolvers the body/comment fetches - // use (getIssueOwnerRepo for issues, getOwnerRepo for PRs; see #7331). + // Why: fork work items live upstream, so resolve owner/repo with the same upstream-first resolvers as body/comments (#7331). const ownerRepo = item.type === 'issue' ? await getIssueOwnerRepo(repoPath, connectionId, ...localGitOptionArgs(localGitOptions)) @@ -895,8 +868,7 @@ async function getGitHubUsersByLogin( return [] } if (rateLimitGuard('graphql').blocked) { - // Why: surface the skip. Callers degrade silently to login-based avatar URLs - // (blank on GHE), so without this log "why are avatars missing" is untraceable. + // Why: log the skip — callers degrade silently to blank GHE avatars, so it's otherwise untraceable. console.warn( `getGitHubUsersByLogin skipped: GraphQL rate-limit budget exhausted (${uniqueLogins.length} logins unresolved)` ) @@ -945,15 +917,9 @@ async function getGitHubUsersByLogin( } /** - * Stamp GraphQL-resolved avatars onto a work item's author, reviewers, review - * requests, latest reviews, and assignees. + * Stamp GraphQL-resolved avatars onto a work item's author, reviewers, review requests, latest reviews, and assignees. * - * The work item's users come from `gh pr view`, which omits avatar_url. On - * GitHub Enterprise the login-based `github.com/{login}.png` URL 404s, so those - * avatars render blank while comment avatars (fetched via GraphQL) work. - * `knownUsers` (participants + the mention-author batch, which already includes - * the item's reviewers/assignees) carries the resolved avatars, so we apply - * them without any extra round-trip. See #8784. + * Why: `gh pr view` omits avatar_url, so login-based avatars 404 on GHE; knownUsers carries the GraphQL-resolved ones. See #8784. */ function enrichItemDisplayAvatars( item: Omit, @@ -968,16 +934,11 @@ function enrichItemDisplayAvatars( if (avatarByLogin.size === 0) { return item } - // Why: prefer the GraphQL-resolved avatar over whatever `gh` returned. `gh pr - // view` often yields an empty avatar or the default `u/0` placeholder for - // enterprise users; the resolved avatar is the authoritative one for that - // login. Fall back to the original only when the lookup found nothing. + // Why: prefer the GraphQL-resolved avatar — `gh pr view` returns empty/`u/0` placeholders for enterprise users; fall back to the original only when the lookup is empty. const avatarFor = (login: string): string | undefined => avatarByLogin.get(login.toLowerCase()) const resolvedAvatar = (login: string, existing?: string | null): string | undefined => avatarFor(login) || existing || undefined - // Callers coalesce a missing result to the field's "no avatar" sentinel ('' - // for GitHubAssignableUser, null for GitHubPRReviewSummary); GitHubUserAvatar - // treats both as falsy and falls back to the login URL then initials. + // Callers coalesce a missing result to each field's "no avatar" sentinel ('' or null); GitHubUserAvatar falls back to login URL then initials. const authorAvatarUrl = (item.author ? avatarFor(item.author) : undefined) || item.authorAvatarUrl return { ...item, @@ -1020,17 +981,8 @@ async function getMentionParticipants( connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - // Why: resolve mention authors AND the item's reviewers/assignees in ONE - // aliased GraphQL round-trip. Folding the display users in here means the - // caller can reuse this result to stamp avatars (see enrichItemDisplayAvatars) - // without a second, separately rate-limited lookup — which is what made GHE - // reviewer/assignee avatars resolve intermittently. See #8784. - // Why: order the always-visible display users (author, reviewers, assignees) - // before comment authors so the 40-login cap in getGitHubUsersByLogin never - // drops a reviewer/assignee avatar on a PR with many commenters. On a PR with - // 40+ distinct participants the trailing comment authors are dropped from this - // batch and lose only their @mention-suggestion avatar; rendered comment - // avatars are unaffected (they carry their own avatarUrl from the comment). + // Why: resolve mention authors + display users (reviewers/assignees) in one aliased trip so avatars reuse it without a second rate-limited lookup (#8784). + // Why: order display users before comment authors so the 40-login cap in getGitHubUsersByLogin can't drop a reviewer/assignee avatar. const visibleLogins = [ item.author ?? '', ...(item.reviewRequests ?? []).map((user) => user.login), @@ -1065,8 +1017,7 @@ async function getPRChecksForDetails( ...localGitOptionArgs(localGitOptions) ) } catch (err) { - // Why: checks are auxiliary PR metadata; a gh CLI edge case must not block - // the user from opening the PR review drawer and reading the files/comments. + // Why: checks are auxiliary — a gh failure must not block opening the PR drawer. console.warn('getWorkItemDetails PR checks failed:', err) return [] } @@ -1079,9 +1030,7 @@ export async function getWorkItemDetails( connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - // Why: getWorkItem already handles acquire/release. We call it first (outside - // our semaphore) so the known-cheap lookup doesn't compete with the richer - // detail fetches that follow. + // Why: getWorkItem self-manages acquire/release; call it outside our semaphore so the cheap lookup doesn't block detail fetches. const item: Omit | null = await getWorkItem( repoPath, number, @@ -1096,13 +1045,7 @@ export async function getWorkItemDetails( await acquire() try { if (item.type === 'issue') { - // Why: try the collapsed single-GraphQL path first — body, assignees, - // participants, and comments all return in one round-trip. On any - // failure (permissions, partial errors, non-GitHub remote), strictly - // fall back to the legacy REST+GraphQL fan-out so historical behavior - // is preserved. The GraphQL `participants` connection includes every - // commenter, so we skip the extra `getMentionParticipants` aliased - // user-hydration trip when the collapsed path succeeds. + // Why: one GraphQL round-trip returns everything (its participants include commenters, so getMentionParticipants is skipped); fall back to REST+GraphQL on any failure. const collapsed = await getIssueDetailsViaGraphQL( repoPath, item.number, @@ -1111,9 +1054,7 @@ export async function getWorkItemDetails( ) if (collapsed) { return { - // Include assigneeUsers: a non-participating assignee is absent from - // the participants connection, so without them item.assignees keeps a - // blank/placeholder avatar (common for GitHub Enterprise users). + // Include assigneeUsers: non-participating assignees are absent from participants and keep a blank avatar (GHE). item: enrichItemDisplayAvatars(item, [ ...collapsed.participants, ...collapsed.assigneeUsers @@ -1125,8 +1066,7 @@ export async function getWorkItemDetails( timelineItems: collapsed.timelineItems } } - // Why: fall back to body/comments and GraphQL participants in parallel; - // the mention-participant merge is a cheap local operation afterward. + // Fallback: fetch body/comments and participants in parallel. const [{ body, comments, assignees, timelineItems }, participants] = await Promise.all([ getIssueBodyAndComments(repoPath, item.number, connectionId, localGitOptions), getWorkItemParticipants(repoPath, item, connectionId, localGitOptions) @@ -1165,9 +1105,7 @@ export async function getWorkItemDetails( getWorkItemParticipants(repoPath, item, connectionId, localGitOptions) ]) - // Why: run the mention-author GraphQL lookup in parallel with the final - // checks fetch instead of serially — both depend only on data from the - // Promise.all above, so there's no ordering requirement between them. + // Why: mention-author lookup and checks are independent, so run them in parallel. const [mentionParticipants, checks] = await Promise.all([ getMentionParticipants(repoPath, item, comments, participants, connectionId, localGitOptions), getPRChecksForDetails(repoPath, item.number, shas?.headSha, connectionId, localGitOptions) @@ -1181,8 +1119,7 @@ export async function getWorkItemDetails( baseSha: shas?.baseSha, pullRequestId: viewedStates?.pullRequestId, checks, - // Why: distinguish a failed file fetch (null) from an empty PR so the - // Files tab surfaces a retry instead of "No files changed." + // Why: null (failed fetch) vs empty PR — Files tab shows retry, not "No files changed." files: files === null ? undefined : mergePRFileViewedStates(files, viewedStates), filesUnavailable: files === null, participants: mentionParticipants @@ -1192,10 +1129,7 @@ export async function getWorkItemDetails( } } -// Why: base64-decoded contents at specific commits are needed to feed Orca's -// Monaco-based DiffViewer (which expects original/modified text, not unified -// diff patches). Fetching via gh api --cache keeps rate-limit usage bounded -// during rapid file-expand clicks in the drawer. +// Why: Monaco DiffViewer needs original/modified text (not patches); --cache bounds rate-limit spend on rapid file-expands. async function fetchContentAtRef(args: { repoPath: string connectionId?: string | null @@ -1222,9 +1156,7 @@ async function fetchContentAtRef(args: { maxBuffer: GITHUB_RAW_CONTENT_MAX_BUFFER_BYTES } ) - // Raw content response: Electron's execFile returns string in utf-8. If the - // file is binary, the string will contain replacement characters — we treat - // anything with a NUL byte in the first 2KB as binary and skip rendering. + // Heuristic: a NUL byte in the first 2KB means binary (execFile decodes as lossy utf-8). const sample = stdout.slice(0, 2048) if (sample.includes('\u0000')) { return { content: '', isBinary: true } @@ -1265,9 +1197,7 @@ export async function getPRFileContents(args: { await acquire() try { - // Why: for added files there's no original content at the base ref; for - // removed files there's no modified content at the head ref. Skipping the - // redundant fetches keeps latency down and avoids spurious 404 warnings. + // Why: added files have no base-ref original, removed files no head-ref modified; skip those to avoid spurious 404s. const needsOriginal = args.status !== 'added' const needsModified = args.status !== 'removed' const originalRef = args.baseSha diff --git a/src/main/gitlab/client.ts b/src/main/gitlab/client.ts index eef57b3aaba..a179ea4be86 100644 --- a/src/main/gitlab/client.ts +++ b/src/main/gitlab/client.ts @@ -1,6 +1,4 @@ -/* eslint-disable max-lines -- Why: parallel to src/main/github/client.ts — -co-locating GitLab MR/issue/work-item operations keeps the concurrency -acquire/release pattern obvious across operations. */ +/* eslint-disable max-lines -- co-locates GitLab MR/issue/work-item operations sharing one acquire/release pattern. */ import type { ClassifiedError, GitLabAssignableUser, @@ -48,8 +46,7 @@ import { type HostedReviewExecutionOptions } from '../source-control/hosted-review-git-options' -// Why: glab REST API addresses projects by URL-encoded path. Centralized -// so call sites don't forget the slash escapes for nested groups. +// Why: glab REST addresses projects by URL-encoded path; escapes slashes for nested groups. function encodedProject(projectPath: string): string { return encodeURIComponent(projectPath) } @@ -67,9 +64,8 @@ function hostedReviewLocalGitOptionArgs( } /** - * Get the authenticated GitLab viewer. Mirrors getAuthenticatedViewer - * from the GitHub client — returns null when glab is unavailable, the - * user is unauthenticated, or the lookup fails. + * Get the authenticated GitLab viewer. + * Returns null when glab is unavailable, unauthenticated, or the lookup fails. */ export async function getAuthenticatedViewer(): Promise { await acquire() @@ -202,8 +198,7 @@ function rememberGitLabRateLimitSnapshot( snapshot: GitLabRateLimitSnapshot ): void { pruneGitLabRateLimitCache() - // Why: self-managed GitLab hostnames come from repo config; keep this - // process cache bounded even across many transient hosts. + // Why: self-managed hostnames come from repo config; keep this cache bounded across many transient hosts. gitLabRateLimitCache.delete(cacheKey) gitLabRateLimitCache.set(cacheKey, snapshot) pruneGitLabRateLimitCache() @@ -223,9 +218,7 @@ export async function getRateLimit(options?: { await acquire() try { - // Why: GitLab.com and self-managed GitLab instances expose REST budget - // headers inconsistently. Query a cheap authenticated endpoint and report - // the headers when present; a null bucket means this host omitted them. + // Why: GitLab exposes REST budget headers inconsistently; a null bucket means this host omitted them. const args = host ? ['--hostname', host, 'user'] : ['user'] const { headers } = await glabApiWithHeaders(args) const snapshot = parseGitLabRateLimitSnapshot(headers, host) @@ -239,10 +232,7 @@ export async function getRateLimit(options?: { } } -/** - * Resolve a project's full GitLab project ref (host + path). Mirrors - * github/getRepoSlug. Returns null for non-GitLab remotes. - */ +/** Resolve a project's full GitLab project ref (host + path); null for non-GitLab remotes. */ export async function getProjectSlug( repoPath: string, connectionId?: string | null, @@ -254,9 +244,8 @@ export async function getProjectSlug( } /** - * Fetch a single merge request with the pipeline status rolled up. - * Returns null when the MR doesn't exist or glab fails — callers - * decide whether to surface "not found" UI. + * Fetch a single merge request with pipeline status rolled up. + * Returns null when the MR doesn't exist or glab fails. */ export async function getMergeRequest( repoPath: string, @@ -285,9 +274,7 @@ export async function getMergeRequest( head_pipeline?: { status?: string } | null pipeline?: { status?: string } | null } - // Why: GitLab's MR detail surfaces the head pipeline directly. - // Older instances expose `pipeline` instead of `head_pipeline` — try - // both. If neither is set the rollup falls back to neutral. + // Why: older GitLab instances expose `pipeline` instead of `head_pipeline`; try both. const pipelineStatus = derivePipelineStatus(data.head_pipeline ?? data.pipeline ?? null) return mapMRInfo(data, pipelineStatus) } catch { @@ -298,11 +285,8 @@ export async function getMergeRequest( } /** - * Find the merge request whose source branch matches the given branch - * name. Mirrors github/getPRForBranch — returns the most recently - * updated MR for the branch, or null when none exists. The branch is the - * local checkout's current ref (Orca strips refs/heads/ prefix upstream - * so we don't need to here). + * Find the merge request whose source branch matches the given name. + * Returns the most recently updated MR for the branch, or null when none exists. */ export async function getMergeRequestForBranch( repoPath: string, @@ -310,9 +294,7 @@ export async function getMergeRequestForBranch( linkedMRIid?: number | null, connectionId?: string | null, options: HostedReviewExecutionOptions = {}, - // Why: the existing-review lookup behind Create must distinguish a real glab - // transport/auth failure from an accepted "no MR". When true, a failed lookup - // throws instead of collapsing to null so callers never report false not_found. + // Why: when true, a failed lookup throws instead of returning null, so callers never report a false not_found. throwOnFailure = false ): Promise { const branchName = branch.replace(/^refs\/heads\//, '') @@ -343,8 +325,7 @@ export async function getMergeRequestForBranch( })[] if (Array.isArray(data) && data.length > 0) { const raw = data[0] - // Why: older GitLab list payloads expose `pipeline` instead of - // `head_pipeline`, matching the detail endpoint compatibility path. + // Why: older GitLab list payloads expose `pipeline` instead of `head_pipeline`. const pipelineStatus = derivePipelineStatus(raw.head_pipeline ?? raw.pipeline ?? null) return mapMRInfo(raw, pipelineStatus) } @@ -352,9 +333,7 @@ export async function getMergeRequestForBranch( if (typeof linkedMRIid !== 'number') { return null } - // Why: create-from-MR worktrees may use a fresh local branch name rather - // than the MR source branch. Fall back to the durable linked iid so the - // core review status still follows the workspace. + // Why: create-from-MR worktrees may rename the branch; fall back to the durable linked iid. const { stdout } = await glabExecFileAsync( [ 'api', @@ -380,9 +359,7 @@ export async function getMergeRequestForBranch( } /** - * Existing-review lookup that surfaces glab transport/auth failures instead of - * collapsing them to null, so a failed lookup becomes - * `reviewLookupOutcome: 'unavailable'` rather than a false "No merge request found". + * Like getMergeRequestForBranch but throws glab failures instead of returning null, so callers report 'unavailable' not a false "not found". */ export function getMergeRequestForBranchOrThrow( repoPath: string, @@ -407,10 +384,7 @@ function mrListStateFlags(state: MRListState): string[] { } } -/** - * List merge requests for a project. Uses glab CLI pagination because - * it handles self-hosted auth and project selection consistently. - */ +/** List merge requests for a project via glab CLI, which handles self-hosted auth and project selection. */ export async function listMergeRequests( repoPath: string, state: MRListState = 'opened', @@ -422,10 +396,7 @@ export async function listMergeRequests( localGitOptions: LocalGitExecOptions = {} ): Promise { const knownHosts = await getGlabKnownHosts(connectionId, localGitOptions) - // Why: MRs sit on `origin` in the fork model (the user's fork is where - // they push branches and submit MRs). Mirror github's `getOwnerRepo` - // call site by going through the upstream/origin preference resolver - // so cross-fork workflows reuse the same plumbing. + // Why: MRs live on the user's fork (origin); route through the preference resolver so fork workflows share plumbing. const { source: projectRef } = await resolveIssueSource( repoPath, preference, @@ -435,8 +406,7 @@ export async function listMergeRequests( ) if (!projectRef) { if (connectionId) { - // Why: SSH-backed repos have no local cwd for glab to infer from. - // Running cwd-less could resolve an unrelated local project instead. + // Why: SSH-backed repos have no local cwd; a cwd-less glab could resolve an unrelated project. return { items: [], page, @@ -449,14 +419,9 @@ export async function listMergeRequests( } } } - // Why: fallback — let glab infer project from cwd, same as listIssues. - // Used when the repo's remote host is not in getGlabKnownHosts(connectionId) - // (e.g. a fresh self-hosted instance), but glab itself can still - // resolve it from the local git config. + // Why: fallback — let glab infer the project from cwd when the host isn't in getGlabKnownHosts. const stateFlag = mrListStateFlags(state) - // Why: the cwd-inferred fallback must honor the same search the API path - // does, otherwise typing a query against a self-hosted / unresolved-projectRef - // repo silently returns the unfiltered list (the original #6263 symptom). + // Why: apply the same search as the API path, else queries are silently ignored on cwd-inferred repos (#6263). const searchFlag = query?.trim() ? ['--search', query.trim()] : [] await acquire() try { @@ -484,8 +449,7 @@ export async function listMergeRequests( items: data.map((d) => mapMRToWorkItem(d, 'unknown')), page, perPage, - // Why: the CLI doesn't return x-total headers, so totals are - // approximate. For the Tasks UI this is acceptable. + // Why: the CLI omits x-total headers, so totals are approximate. totalCount: data.length, totalPages: data.length < perPage ? page : page + 1 } @@ -503,8 +467,7 @@ export async function listMergeRequests( release() } } - // Why: 'all' is exposed as the picker filter but GitLab's API expects - // no state param to mean "any state". Drop the param when 'all'. + // Why: GitLab's API uses an absent state param to mean "any"; drop it for 'all'. const stateParam = state === 'all' ? '' : `&state=${state}` const searchParam = query?.trim() ? `&search=${encodeURIComponent(query.trim())}` : '' const path = @@ -524,8 +487,7 @@ export async function listMergeRequests( page, perPage, totalCount: parseHeaderInt(headers['x-total'], 0), - // Why: when 'all' state is requested or the per_page is large, - // GitLab may not include x-total-pages; fall back to ceil(total/perPage). + // Why: GitLab may omit x-total-pages for 'all' or large per_page; fall back to ceil(total/perPage). totalPages: parseHeaderInt(headers['x-total-pages'], 0) || Math.max(1, Math.ceil(parseHeaderInt(headers['x-total'], 0) / perPage)) @@ -554,10 +516,8 @@ function parseHeaderInt(value: string | undefined, fallback: number): number { } /** - * Fetch a work item (MR or issue) given an explicit project ref + - * iid + type. Mirrors github/getWorkItemByOwnerRepo — used by the - * paste-URL flow in the picker where the URL determines the project - * directly rather than going through the local repo's remotes. + * Fetch a work item (MR or issue) by explicit project ref + iid + type. + * Used by the paste-URL flow, where the URL determines the project directly. */ export async function getWorkItemByProjectRef( repoPath: string, @@ -573,8 +533,7 @@ export async function getWorkItemByProjectRef( const { stdout } = await glabExecFileAsync( [ 'api', - // Why: pasted GitLab URLs carry an explicit host; preserve it even for - // local/runtime-local repos so cwd remotes cannot redirect the lookup. + // Why: preserve the pasted URL's explicit host so cwd remotes can't redirect the lookup. ...(projectRef.host ? ['--hostname', projectRef.host] : []), `projects/${encodedProject(projectRef.path)}/${resource}/${iid}` ], @@ -593,9 +552,7 @@ export async function getWorkItemByProjectRef( } function mrStateToIssueState(state: MRListState): IssueListState | null { - // Why: GitLab issues don't have a 'merged' state. When the user is - // filtering MRs to merged, return null so listWorkItems can skip the - // issues fetch entirely instead of mis-mapping to opened/closed. + // Why: issues have no 'merged' state; return null so the issues fetch is skipped, not mis-mapped. switch (state) { case 'opened': return 'opened' @@ -640,16 +597,8 @@ export async function listWorkItems( } } } - // Why: fan out the two read calls so the response time is the slower - // of the two, not their sum. Errors classify per-side; an MR-side - // failure with a successful issues fetch still surfaces issues with - // an error envelope. - // - // Why we don't go through `listIssues` here: that function returns - // IssueInfo, which deliberately strips the raw glab fields (notably - // `updated_at`). The combined sort needs updatedAt, so we read the - // raw issues API directly and run mapIssueToWorkItem against the - // raw payload instead. + // Why: fan out both reads so latency is the slower of the two, not the sum. + // Why read the raw issues API (not listIssues): IssueInfo strips updated_at, which the combined sort needs. const [mrs, issues] = await Promise.all([ listMergeRequests( repoPath, @@ -680,20 +629,13 @@ export async function listWorkItems( const merged = [...mrs.items, ...issues.items].sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? '') ) - // Why: combine error envelopes — the renderer's banner cares about - // any failed fetch, not which one. MR-side error wins because it's - // strictly more informative than an issues-side error in most - // permission scenarios (issues can be disabled per project). + // Why: MR-side error wins — issues can be disabled per project, making its error less informative. const error: ClassifiedError | undefined = mrs.error ?? issues.error return { items: merged, page, perPage, - // Why: approximate totals — an exact combined-pagination total would - // require a server-side ordering primitive across two distinct - // resources, which the GitLab API doesn't offer. MR total is the - // right direction; the UI's "Page X of Y" reads as a hint, not a - // strict count. + // Why: approximate totals — GitLab can't paginate across MRs+issues as one set, so MR totals stand in. totalCount: mrs.totalCount, totalPages: mrs.totalPages, ...(error ? { error } : {}) @@ -739,14 +681,7 @@ export async function fetchIssuesAsWorkItems( /** * List the authenticated user's GitLab todos (gitlab.com/dashboard/todos). - * Cross-project — `glab api todos` is user-scoped so the cwd doesn't - * affect the result; callers may pass any registered repo path so the - * IPC handler's path-validation guard has something to check. - * - * Why: GitLab's todos surface is the closest GitLab-native analogue of - * GitHub's notifications/inbox. Surfacing it in Orca lets users start - * work directly from a mention/assignment without going to gitlab.com - * first. + * User-scoped, so cwd is irrelevant; repoPath only satisfies the IPC handler's path-validation guard. */ export async function listTodos( repoPath: string, @@ -764,9 +699,7 @@ export async function listTodos( } await acquire() try { - // Why: per_page=50 keeps this user-scoped cross-project view cheap. The UI - // shows the highest-priority todos first, so avoid walking every pending - // todo page from large GitLab accounts. + // Why: per_page=50 keeps this cross-project view cheap; the UI only shows top-priority todos. const { stdout } = await glabExecFileAsync( [ 'api', @@ -805,9 +738,7 @@ export async function listTodos( state: t.state === 'done' ? 'done' : 'pending' })) } catch { - // Why: silent empty-list on auth/network failures matches the rest - // of the read-side surface (`listLabels`, `listAssignableUsers`). - // The caller's banner / loading-state UI signals connectivity issues. + // Why: silent empty-list on auth/network failure matches the read-side surface; caller UI signals connectivity. return [] } finally { release() @@ -815,9 +746,6 @@ export async function listTodos( } // ── MR mutations ────────────────────────────────────────────────── -// Why: mirror the GitHub-side actions (mergePR, updatePRTitle, close -// via gh issue close, etc.) for the GitLab dialog footer. All take a -// repoPath + iid and resolve the project ref via the existing helper. async function withProjectRef( repoPath: string, @@ -875,9 +803,7 @@ export async function closeMR( return { ok: true } } catch (err) { const msg = err instanceof Error ? err.message : String(err) - // Why: glab returns a non-zero exit when the MR is already - // closed — treat that as success since the desired state is - // reached. + // Why: glab exits non-zero when the MR is already closed — treat as success. if (msg.toLowerCase().includes('already')) { return { ok: true } } @@ -951,9 +877,7 @@ export async function mergeMR( async (projectRef, repoFlag) => { await acquire() try { - // Why: glab mr merge accepts --squash and --rebase flags; - // omitting both does a regular merge commit. Map our union - // to the right glab flag. + // Why: omitting both --squash and --rebase yields a regular merge commit. const methodFlag = method === 'squash' ? ['--squash'] : method === 'rebase' ? ['--rebase'] : [] await glabExecFileAsync( @@ -1148,8 +1072,7 @@ export async function resolveMRDiscussion( } await acquire() try { - // Why: GitLab resolves/reopens the whole discussion thread, not a single - // note; this mirrors GitHub's thread-level resolve mutation. + // Why: GitLab resolves/reopens the whole discussion thread, not a single note. await glabExecFileAsync( [ 'api', @@ -1429,7 +1352,5 @@ export { updateIssue } from './issues' -// Why: surface the upstream-aware project-ref helper so non-issue call -// sites that need the resolved project (e.g. the paste-URL UI) don't -// have to import from gl-utils directly. +// Re-exported so paste-URL call sites don't import getProjectRefForRemote from gl-utils directly. export { getProjectRefForRemote } diff --git a/src/main/opencode/hook-service.test.ts b/src/main/opencode/hook-service.test.ts index 2ad00cf5baa..f3956519ee7 100644 --- a/src/main/opencode/hook-service.test.ts +++ b/src/main/opencode/hook-service.test.ts @@ -46,10 +46,7 @@ describe('OpenCode hook plugin source', () => { }) it('resolves hook coords from the endpoint file before falling back to process.env', () => { - // Why: a long-running OpenCode session was fork()ed with the prior Orca's - // PORT/TOKEN frozen into process.env. The plugin must prefer the on-disk - // endpoint file (rewritten on every Orca start()) over env, otherwise it - // keeps posting to a dead port after an Orca restart. + // Why: a forked session freezes the prior Orca's PORT/TOKEN in env; prefer the on-disk endpoint file or it posts to a dead port after restart. const source = _internals.getOpenCodePluginSource() expect(source).toContain('function readEndpointFile()') @@ -71,12 +68,7 @@ describe('OpenCode hook plugin source', () => { }) it('caches the parsed endpoint file on mtime+size+inode to skip re-reads per post', () => { - // Why: message.part.updated fires many times per second during a streaming - // assistant reply. Each post() calls resolveHookCoords() which reads the - // endpoint file — without the cache we'd readFileSync + parse on every - // streamed Part. The cache key combines mtime + size + inode so renameSync - // (writeEndpointFile's atomic swap) invalidates the cache via the ino - // change even when mtime resolution is coarse and size happens to match. + // Why: cache the parse to avoid a read per streamed Part; inode in the key lets renameSync invalidate it even when mtime/size collide. const source = _internals.getOpenCodePluginSource() expect(source).toContain('let cachedEndpointKey = "";') @@ -91,14 +83,7 @@ describe('OpenCode hook plugin source', () => { }) it('forwards question.asked as AskUserQuestion so the pane flips to waiting', () => { - // Why: OpenCode exposes two separate plugin events for human-in-the-loop - // moments — `permission.asked` (blocks on tool approval) and - // `question.asked` (the agent called an ask-the-user tool). The plugin - // must forward both so the server-side normalizer can map each to - // `waiting` and render the red indicator. Dropping `question.asked` - // leaves the pane stuck in `working` while the agent is actually idle, - // waiting on a human reply — exactly the bug other OpenCode integrations - // also handle. + // Why: forward question.asked too (not just permission.asked), else the pane stays "working" while the agent idles on a human reply. const source = _internals.getOpenCodePluginSource() expect(source).toContain('if (event.type === "question.asked")') @@ -118,10 +103,7 @@ describe('OpenCode hook plugin source', () => { }) it('guards endpoint-file parse warnings with a process-lifetime latch', () => { - // Why: ENOENT is the normal pre-install case and must stay silent, but a - // malformed/unreadable file (EACCES, EIO, parse error) would otherwise - // spam stderr once per hook post. The latch keeps the warning to once per - // OpenCode process — mirrors server.ts's warnedVersions/warnedEnvs intent. + // Why: ENOENT is normal pre-install (stay silent), but a bad file would spam stderr per post; latch warns once per process. const source = _internals.getOpenCodePluginSource() expect(source).toContain('let warnedBadEndpoint = false;') @@ -132,11 +114,7 @@ describe('OpenCode hook plugin source', () => { describe('OpenCode id safety guard', () => { it('accepts the daemon-path sessionId shape (worktreeId@@uuid with ::/...)', () => { - // Why: after the daemon-parity refactor (#1148) pty.ts mints sessionIds - // like `@@` where worktreeId contains "::" and a - // filesystem path. The previous strict regex rejected every real id and - // silently dropped OPENCODE_CONFIG_DIR. Lock in that such ids are now - // accepted so the plugin dir is actually written. + // Why: after #1148 pty.ts mints sessionIds like @@ with "::" and a path; the old strict regex rejected every real id. const daemonSessionId = '50c010a2-bc8e-4eb1-8847-5812133ad6df::/Users/thebr/ghostx/workspaces/noqa/autoheal@@a1b2c3d4' expect(isUsableId(daemonSessionId)).toBe(true) @@ -152,8 +130,7 @@ describe('OpenCode id safety guard', () => { }) it('rejects non-string runtime values even though the type says string', () => { - // Why: the typeof guard is defense-in-depth for any-typed callers; - // without a test, a future refactor could delete the guard silently. + // Why: the typeof guard is defense-in-depth for any-typed callers; a test keeps a refactor from silently deleting it. expect(isUsableId(undefined as unknown as string)).toBe(false) expect(isUsableId(null as unknown as string)).toBe(false) expect(isUsableId(42 as unknown as string)).toBe(false) @@ -176,11 +153,7 @@ describe('OpenCode id safety guard', () => { }) describe('OpenCodeHookService buildPtyEnv / clearPty round-trip', () => { - // Why: the primitives above only prove the helpers work in isolation. This - // suite exercises the public surface against a real filesystem so a future - // regression — e.g. re-tightening the id guard or desyncing the path used by - // writeLegacyPluginConfig vs clearPty — fails loudly. Before #1148 the service - // silently returned {} for daemon-shaped ids; these tests lock that in. + // Why: exercise the public surface against a real filesystem so regressions fail loudly (before #1148 daemon-shaped ids silently returned {}). const daemonSessionId = '50c010a2-bc8e-4eb1-8847-5812133ad6df::/Users/thebr/ghostx/workspaces/noqa/autoheal@@a1b2c3d4' const plainUuidId = 'c0ffee00-0000-4000-8000-000000000000' @@ -244,9 +217,7 @@ describe('OpenCodeHookService buildPtyEnv / clearPty round-trip', () => { }) it('buildPtyEnv preserves a user-set OPENCODE_CONFIG_DIR when the id is unusable', () => { - // Why: defense-in-depth — if the bounds guard rejects the id, we still - // must not blow away the user's own OPENCODE_CONFIG_DIR. The status - // plugin is forfeited, but the user's plugins/auth/keymap keep loading. + // Why: even when the id is rejected, don't blow away the user's own OPENCODE_CONFIG_DIR. const service = new OpenCodeHookService() const userDir = mkdtempSync(join(tmpdir(), 'orca-opencode-userdir-')) try { @@ -271,11 +242,7 @@ describe('OpenCodeHookService buildPtyEnv / clearPty round-trip', () => { }) describe('OpenCodeHookService overlay mode (user OPENCODE_CONFIG_DIR set)', () => { - // Why: locks in docs/opencode-config-dir-collision.md — when the user has - // their own OPENCODE_CONFIG_DIR (e.g. a company-wide opencode config repo), - // Orca must mirror it into a source-scoped overlay rather than `delete` its - // own injection or overwrite the user's value. The user's auth/models/keymap - // and Orca's status plugin both load via a single OPENCODE_CONFIG_DIR. + // Why: with a user-set OPENCODE_CONFIG_DIR, mirror it into an overlay rather than overwrite it (docs/opencode-config-dir-collision.md). const ptyId = 'overlay-pty-1' let userDataDir: string let userConfigDir: string @@ -350,18 +317,14 @@ describe('OpenCodeHookService overlay mode (user OPENCODE_CONFIG_DIR set)', () = it.skipIf(process.platform === 'win32')( 'mirrors top-level entries via symlinks so plugins/ is a real directory', () => { - // Why: only the plugins/ subtree needs entry-by-entry mirroring so Orca - // can drop a sibling file alongside the user's plugins. Other top-level - // entries (auth.json, opencode.json) are mirrored as a single symlink so - // user edits propagate live on POSIX. + // Why: only plugins/ needs per-entry mirroring (Orca drops a sibling); other entries are single symlinks so user edits propagate live. const service = new OpenCodeHookService() const env = service.buildPtyEnv(ptyId, userConfigDir) const overlay = env.OPENCODE_CONFIG_DIR! expect(lstatSync(join(overlay, 'opencode.json')).isSymbolicLink()).toBe(true) expect(lstatSync(join(overlay, 'auth.json')).isSymbolicLink()).toBe(true) - // plugins/ must be a real directory in the overlay so Orca can write - // its sibling status plugin into it. + // plugins/ must be a real dir in the overlay so Orca can drop its sibling plugin. expect(lstatSync(join(overlay, 'plugins')).isDirectory()).toBe(true) expect(lstatSync(join(overlay, 'plugins')).isSymbolicLink()).toBe(false) // user-plugin.js inside plugins/ is mirrored entry-by-entry. @@ -370,11 +333,7 @@ describe('OpenCodeHookService overlay mode (user OPENCODE_CONFIG_DIR set)', () = ) it("does not overwrite a user plugin file with the same filename as Orca's plugin", () => { - // Why: the failure mode this guards against — a user-owned plugin file - // happens to be named orca-opencode-status.js. Without the per-entry - // skip in mirrorUserConfig, the file would be linked into the overlay - // and Orca's writeFileSync would write through the symlink, destroying - // the user's content on their real filesystem. + // Why: a user plugin named orca-opencode-status.js must not be symlinked into the overlay, or writeFileSync would clobber it. const userOrcaSentinel = 'USER OWNED ORCA-NAMED PLUGIN — DO NOT CLOBBER' writeFileSync(join(userConfigDir, 'plugins', 'orca-opencode-status.js'), userOrcaSentinel) @@ -399,15 +358,12 @@ describe('OpenCodeHookService overlay mode (user OPENCODE_CONFIG_DIR set)', () = it.skipIf(process.platform === 'win32')( 'does not write through a symlinked plugins/ directory into the user filesystem', () => { - // Why: if plugins/ is a symlink (common dotfiles pattern), writing Orca's - // status plugin through it would land in the user's real filesystem — - // exactly the failure mode docs/opencode-config-dir-collision.md rejects. + // Why: writing Orca's plugin through a symlinked plugins/ would leak into the user's fs (docs/opencode-config-dir-collision.md). const realPluginsDir = mkdtempSync(join(tmpdir(), 'orca-real-plugins-')) try { writeFileSync(join(realPluginsDir, 'real-plugin.js'), 'REAL USER PLUGIN') - // Replace the userConfigDir/plugins dir created by beforeEach with a - // symlink pointing at the external "real" plugins dir. + // Swap plugins/ for a symlink to the external "real" dir (dotfiles pattern). rmSync(join(userConfigDir, 'plugins'), { recursive: true, force: true }) symlinkSync(realPluginsDir, join(userConfigDir, 'plugins'), 'dir') @@ -416,15 +372,13 @@ describe('OpenCodeHookService overlay mode (user OPENCODE_CONFIG_DIR set)', () = // The user's real filesystem must NOT receive Orca's status plugin. expect(existsSync(join(realPluginsDir, 'orca-opencode-status.js'))).toBe(false) - // Overlay's plugins/ must be a real directory, not a symlink that - // would write through to the user's filesystem. + // Overlay's plugins/ must be a real dir, else writes leak into the user's filesystem. expect(lstatSync(join(env.OPENCODE_CONFIG_DIR!, 'plugins')).isSymbolicLink()).toBe(false) // Orca's status plugin lands in the overlay only. expect( existsSync(join(env.OPENCODE_CONFIG_DIR!, 'plugins', 'orca-opencode-status.js')) ).toBe(true) - // The user's sentinel plugin is reachable through the overlay (mirrored - // entry-by-entry after resolving the symlink target). + // User's real plugin is reachable via the overlay (plugins mirrored entry-by-entry after resolving the symlink target). expect( readFileSync(join(env.OPENCODE_CONFIG_DIR!, 'plugins', 'real-plugin.js'), 'utf8') ).toBe('REAL USER PLUGIN') @@ -435,10 +389,7 @@ describe('OpenCodeHookService overlay mode (user OPENCODE_CONFIG_DIR set)', () = ) it("preserves the user's OPENCODE_CONFIG_DIR when the path does not exist", () => { - // Why: typoed user path — overriding it with an Orca-owned dir would let - // Orca's status plugin "succeed" while silently hiding the user's typo. - // The design rejects that: leave the user's value alone and let OpenCode - // surface the typo on its own. + // Why: leave a nonexistent user path alone so OpenCode surfaces the typo instead of Orca silently hiding it. const service = new OpenCodeHookService() const missingPath = join(tmpdir(), `orca-opencode-nope-${Date.now()}`) expect(existsSync(missingPath)).toBe(false) @@ -456,11 +407,7 @@ describe('OpenCodeHookService overlay mode (user OPENCODE_CONFIG_DIR set)', () = }) it("preserves the user's OPENCODE_CONFIG_DIR when the mirror step fails", async () => { - // Why: mock the shared mirrorEntry helper to throw on the first symlink - // (e.g. Windows without developer mode → EPERM). The hook service must - // catch and fall back to { OPENCODE_CONFIG_DIR: existingConfigDir } — - // the user's plugins/auth/models keep loading; only Orca's status plugin - // is forfeited. + // Why: if mirroring fails (e.g. Windows EPERM), fall back to the user's OPENCODE_CONFIG_DIR so their config still loads. const overlayMirror = await import('../pty/overlay-mirror') const mirrorSpy = vi.spyOn(overlayMirror, 'mirrorEntry').mockImplementation(() => { throw new Error('simulated EPERM on symlink') @@ -469,8 +416,7 @@ describe('OpenCodeHookService overlay mode (user OPENCODE_CONFIG_DIR set)', () = const service = new OpenCodeHookService() const env = service.buildPtyEnv(ptyId, userConfigDir) expect(env).toEqual({ OPENCODE_CONFIG_DIR: userConfigDir }) - // Overlay cleanup is deliberately not on the fallback path; it may hold - // OpenCode-created runtime files on Windows. + // Overlay cleanup is deliberately off the fallback path; it may hold OpenCode runtime files on Windows. const overlayDir = join( userDataDir, 'opencode-config-overlays', @@ -486,8 +432,7 @@ describe('OpenCodeHookService overlay mode (user OPENCODE_CONFIG_DIR set)', () = it.skipIf(process.platform === 'win32')( 'clearPty leaves the source overlay off the teardown hot path', () => { - // Why: OpenCode may populate OPENCODE_CONFIG_DIR with runtime files. - // PTY teardown must not recursively delete that tree on Electron main. + // Why: OpenCode may fill the overlay with runtime files; PTY teardown must not recursively delete it on Electron main. const service = new OpenCodeHookService() service.buildPtyEnv(ptyId, userConfigDir) @@ -509,10 +454,7 @@ describe('OpenCodeHookService overlay mode (user OPENCODE_CONFIG_DIR set)', () = ) it('rebuilding the overlay for the same ptyId does not corrupt the user dir', () => { - // Mirrors the daemon cold-restore code path that calls buildPtyEnv with - // the same sessionId across restarts. Each rebuild must refresh the prior - // overlay safely (no symlink-walk into user data) and keep both user files - // and Orca's plugin reachable. + // Mirrors daemon cold-restore: rebuilding for the same sessionId must refresh the overlay without symlink-walking into user data. const service = new OpenCodeHookService() service.buildPtyEnv(ptyId, userConfigDir) service.buildPtyEnv(ptyId, userConfigDir) diff --git a/src/main/opencode/hook-service.ts b/src/main/opencode/hook-service.ts index 4195e4a0732..c2baa488dc9 100644 --- a/src/main/opencode/hook-service.ts +++ b/src/main/opencode/hook-service.ts @@ -1,7 +1,4 @@ -/* eslint-disable max-lines -- Why: this file contains a multi-line inline - JS plugin source emitted into OpenCode's plugins directory as a single - file; splitting the plugin source across TS modules would obscure the - runtime artifact and scatter tightly coupled string-template logic. */ +/* eslint-disable max-lines -- Why: holds an inline JS plugin source emitted as one file; splitting across TS modules would scatter tightly coupled string-template logic. */ import { app } from 'electron' import { join } from 'node:path' import { @@ -28,33 +25,13 @@ type OpenCodeOverlayManifest = { pluginEntries: string[] } -// Why: the id passed in by pty.ts's daemon path is a sessionId shaped like -// "@@" where worktreeId itself contains "::" and a -// filesystem path (slashes, colons). Earlier the id was a simple numeric -// counter, so rejecting anything with "/" or ":" was a safe guard against -// path traversal. After the daemon-parity refactor (#1148) the sessionId -// shape changed, and the old regex silently rejected every legitimate id, -// leaving OPENCODE_CONFIG_DIR unset and the plugin never loading. -// -// Keep an input-bounds guard (non-empty, bounded length) for defense in -// depth, and derive the on-disk directory name via hash so any caller's id — -// including ones containing path separators — produces a short, stable, -// filesystem-safe name. Hashing also eliminates path-traversal risk at the -// source: the directory name is always 32 hex chars, never a prefix/suffix -// of the caller's input. -// Why: 1024 is a generous sanity cap — daemon-shaped ids embed a worktree -// filesystem path plus "@@", and this bound prevents pathological inputs -// from burning CPU in the SHA-256 step. Since the id is hashed anyway, 1024 -// is decoupled from PATH_MAX. +// Why: bounds-check only — the id is a daemon sessionId with path separators, hashed downstream to a filesystem-safe name (an old regex rejecting "/"/":" broke every such id, #1148); 1024 just caps pathological hash input. function isUsableId(id: string): boolean { return typeof id === 'string' && id.length > 0 && id.length <= 1024 } function toSafeDirName(id: string): string { - // Why: SHA-256 truncated to 32 hex chars (128 bits) is ample for a - // per-session directory name — collisions require ~2^64 concurrent sessions - // to become likely, far beyond any real workload. Hex keeps the name - // portable across all filesystems (no base64 padding, no `/`). + // Why: 32 hex chars (128 bits) makes collisions negligible and stays filesystem-portable (no base64 padding or `/`). return createHash('sha256').update(id).digest('hex').slice(0, 32) } @@ -63,13 +40,7 @@ export function getOpenCodePluginSource(): string { } export function getOpenCodeFamilyPluginSource(hookPathname: string): string { - // Why: the plugin runs inside the OpenCode Node process and POSTs to the - // unified agent-hooks server shared with Claude/Codex/Gemini. It reads the - // same ORCA_PANE_KEY / ORCA_TAB_ID / ORCA_WORKTREE_ID / ORCA_AGENT_HOOK_* - // env vars that Orca injects into every PTY, so OpenCode panes flow into - // agentStatusByPaneKey via the same IPC path as every other agent. Event - // mapping is done plugin-side (SessionBusy / SessionIdle / PermissionRequest) - // so the server-side normalizer can keep its one-event-per-case switch shape. + // Why: plugin runs in OpenCode's Node process and POSTs Orca's ORCA_* PTY env to the shared agent-hooks server; events are mapped plugin-side to fit the server's per-case switch. return [ '// Why: process-lifetime guard so a recurring parse error on a malformed', "// endpoint file does not spam OpenCode's stderr once per hook post.", @@ -404,35 +375,20 @@ export function getOpenCodeFamilyPluginSource(hookPathname: string): string { ].join('\n') } -// Why: OpenCode hooks used to run their own loopback HTTP server + IPC -// channel (pty:opencode-status). That pathway produced a synthetic terminal -// title but never entered agentStatusByPaneKey, so the unified dashboard -// never saw OpenCode sessions. The service now only installs the plugin -// file into OPENCODE_CONFIG_DIR — the plugin POSTs directly to the shared -// agent-hooks server (/hook/opencode), so OpenCode rides the same status -// pipeline as Claude/Codex/Gemini. +// Why: installs the plugin into OPENCODE_CONFIG_DIR so it POSTs to the shared agent-hooks server, unifying OpenCode status with Claude/Codex/Gemini (the old loopback-IPC path never reached agentStatusByPaneKey). export class OpenCodeHookService { clearPty(_ptyId: string): void { - // Why: OpenCode can materialize thousands of plugin runtime files under - // OPENCODE_CONFIG_DIR. This teardown runs on Electron's main process hot - // path, so recursive deletion here can freeze the whole app on Windows - // while Node, antivirus, or indexing still holds file handles. - // - // Current builds use app/source-scoped config dirs, not PTY-scoped dirs, - // so there is no live PTY-owned OpenCode filesystem state to remove. + // Why: no-op — config dirs are app/source-scoped now, and recursive delete on the main-process hot path could freeze on Windows. } buildPtyEnv(ptyId: string, existingConfigDir?: string | undefined): Record { if (!isUsableId(ptyId)) { - // Why: defense-in-depth. If the id fails the bounds guard, a user-set - // OPENCODE_CONFIG_DIR should still be preserved so OpenCode loads the - // user's own config — only the Orca status plugin is forfeited. + // Why: on a bad id, still preserve a user-set OPENCODE_CONFIG_DIR; only the Orca status plugin is forfeited. return existingConfigDir ? { OPENCODE_CONFIG_DIR: existingConfigDir } : {} } if (!existingConfigDir) { - // Why: OpenCode may install plugin dependencies under this root. Sharing - // it prevents per-terminal node_modules churn and teardown freezes. + // Why: share one config root so OpenCode's plugin deps don't churn node_modules per terminal. const configDir = this.writeSharedPluginConfig() if (!configDir) { return {} @@ -440,10 +396,7 @@ export class OpenCodeHookService { return { OPENCODE_CONFIG_DIR: configDir } } - // Why: do NOT `mkdir -p` the user's typoed path — overriding it with an - // Orca-owned dir is the exact config-replacement failure mode documented in - // docs/opencode-config-dir-collision.md. Let OpenCode surface the typo on - // its own; we only forfeit our status plugin for this pane. + // Why: don't mkdir the user's (possibly typoed) path — that's the config-replacement failure mode in docs/opencode-config-dir-collision.md; let OpenCode surface it. if (!existsSync(existingConfigDir)) { return { OPENCODE_CONFIG_DIR: existingConfigDir } } @@ -455,11 +408,7 @@ export class OpenCodeHookService { this.mirrorUserConfig(existingConfigDir, overlayDir) this.writePluginIntoOverlay(overlayDir) } catch { - // Why: overlay creation is best-effort. Symlink-creation can fail on - // Windows without developer mode (EPERM), userData can be read-only on - // locked-down corporate machines, etc. In every case, preserve the - // user's OPENCODE_CONFIG_DIR — a missing status plugin is a vastly - // smaller harm than silently dropping the user's auth/models/keymap. + // Why: best-effort — symlink creation needs Windows developer mode (else EPERM) and userData may be read-only; preserve the user's config over dropping their auth/models/keymap. return { OPENCODE_CONFIG_DIR: existingConfigDir } } @@ -513,17 +462,10 @@ export class OpenCodeHookService { } } - // Why: walks the user's OPENCODE_CONFIG_DIR top-level entries. The - // `plugins/` subdirectory gets created as a real directory in the overlay - // so Orca can drop a sibling file alongside the user's plugins; everything - // else (opencode.json, auth.json, themes/, etc.) is mirrored as a single - // top-level entry via symlink/junction so user edits propagate live on - // POSIX (and on Windows-with-developer-mode) without copying files. + // Why: mirror user config entries as symlinks so edits propagate live; only plugins/ becomes a real overlay dir so Orca can drop a sibling plugin file. private mirrorUserConfig(sourceDir: string, overlayDir: string): void { const previousManifest = this.readOverlayManifest(overlayDir) - // Why: source-scoped overlays persist across terminals. Only remove paths - // Orca previously mirrored, so deleted/replaced user config cannot stay - // stale while OpenCode-owned runtime dirs such as node_modules survive. + // Why: overlays persist across terminals; remove only Orca-mirrored paths so stale user config clears but OpenCode runtime dirs (node_modules) survive. this.clearManifestEntries(overlayDir, previousManifest) const nextManifest: OpenCodeOverlayManifest = { topLevelEntries: [], pluginEntries: [] } @@ -532,43 +474,25 @@ export class OpenCodeHookService { const sourcePath = join(sourceDir, entry.name) if (entry.name === 'plugins') { - // Why: check isSymbolicLink BEFORE isDirectory — a Windows junction - // can report both as true on a Dirent, and we must take the symlink - // branch so the per-entry mirroring (not a single mirrorEntry call - // that would create a symlink at /plugins) handles it. + // Why: check isSymbolicLink before isDirectory — a Windows junction reports both, and the symlink branch must win. const isSymlink = entry.isSymbolicLink() let isLinkPointingToDir = false if (isSymlink) { try { isLinkPointingToDir = statSync(sourcePath).isDirectory() } catch { - // Why: broken symlink (target missing) or permission error — fall - // through to the default mirrorEntry path so the dangling link is - // mirrored verbatim rather than write-through-resolved. + // Why: broken/inaccessible symlink — mirror the dangling link verbatim instead of resolving through it. isLinkPointingToDir = false } } if ((!isSymlink && entry.isDirectory()) || isLinkPointingToDir) { - // Why: when the user's plugins/ is a symlink-to-dir, resolve to the - // real target so readdir returns the actual entries and child paths - // join against the resolved root. mirrorEntry then creates symlinks - // pointing into the resolved real plugins (not back through the - // user's link), and /plugins itself stays a real dir so - // writePluginIntoOverlay can never write through to the user's FS. + // Why: resolve a symlinked plugins/ to its real target so /plugins stays a real dir and writePluginIntoOverlay can't write through the user's link. const resolvedSource = isLinkPointingToDir ? realpathSync(sourcePath) : sourcePath const overlayPluginsDir = join(overlayDir, 'plugins') mkdirSync(overlayPluginsDir, { recursive: true }) for (const pluginEntry of readdirSync(resolvedSource, { withFileTypes: true })) { - // Why: skip a user file with the same filename as Orca's plugin — - // mirroring it here would either resolve a same-named target via - // symlink (writePluginIntoOverlay then clobbers the user's file - // through the link) or collide on Windows with the directory entry - // about to be created by writePluginIntoOverlay. Either way the - // user's plugin would be lost. Skipping yields the desired - // semantics: Orca's status plugin runs and the user's same-named - // plugin is shadowed for this PTY only — their source file on disk - // is untouched. + // Why: skip a user plugin sharing Orca's filename; mirroring it would let writePluginIntoOverlay clobber the user's file. if (pluginEntry.name === ORCA_OPENCODE_PLUGIN_FILE) { continue } @@ -589,13 +513,7 @@ export class OpenCodeHookService { this.writeOverlayManifest(overlayDir, nextManifest) } - // Why: write Orca's status plugin into the overlay's plugins/ dir. The - // pre-write unlink is the load-bearing part — POSIX writeFileSync over a - // symlink writes through to the link target, so without it a user-owned - // plugin with this filename would be clobbered through a mirrored link. - // Skipping the same-named user file in mirrorUserConfig already prevents - // the link from being created, but the unlink keeps this function safe - // even if a stale overlay slips through with the link still in place. + // Why: pre-write unlink guards against POSIX writeFileSync writing through a mirrored symlink and clobbering a same-named user plugin. private writePluginIntoOverlay(overlayDir: string): void { const pluginsDir = join(overlayDir, 'plugins') mkdirSync(pluginsDir, { recursive: true }) @@ -603,8 +521,7 @@ export class OpenCodeHookService { try { unlinkSync(pluginPath) } catch { - // No-op: file may not exist on a fresh overlay. Any persistent failure - // (e.g. permissions) will surface on the writeFileSync below. + // File may not exist on a fresh overlay; a real failure surfaces on writeFileSync below. } writeFileSync(pluginPath, getOpenCodePluginSource()) } @@ -616,9 +533,7 @@ export class OpenCodeHookService { mkdirSync(pluginsDir, { recursive: true }) writeFileSync(join(pluginsDir, ORCA_OPENCODE_PLUGIN_FILE), getOpenCodePluginSource()) } catch { - // Why: on Windows, userData directories can be locked by antivirus or - // indexers (EPERM/EBUSY). Plugin config is non-critical — the PTY should - // still spawn without the OpenCode status plugin. + // Why: userData can be locked on Windows (EPERM/EBUSY); plugin is non-critical, so spawn without it. return null } return configDir diff --git a/src/main/ports/advertised-url-watcher.ts b/src/main/ports/advertised-url-watcher.ts index b5a9876b55d..befc11ab94f 100644 --- a/src/main/ports/advertised-url-watcher.ts +++ b/src/main/ports/advertised-url-watcher.ts @@ -1,42 +1,26 @@ -/* eslint-disable no-control-regex, max-lines -- Why: ANSI/OSC stripping must match raw - * control sequences in PTY output, same as src/main/runtime/orca-runtime.ts; - * URL parsing, host classification, cache lifecycle, and cross-worktree lookup - * are tightly coupled and kept in one file to keep the rules in lockstep. */ -// Watches PTY output for HTTP(S) URLs that dev servers (Vite, Next, etc.) -// print on startup. Maintains a per-{worktreeId, port} cache so the workspace -// ports panel can show the tool-advertised origin instead of the kernel bind -// (e.g. `https://local.getmontecarlo.com:3001/` rather than `127.0.0.1:3001`). -// -// Why a separate stateful buffer per PTY: ANSI escape sequences and URLs can -// both straddle PTY write boundaries, so we cannot strip ANSI and scan one -// chunk at a time. We accumulate raw bytes, finalize only at newline-anchored -// boundaries, then run a stateless strip-and-scan on the finalized prefix. +/* eslint-disable no-control-regex, max-lines -- control-sequence regexes need raw matching; URL parsing, host classification, cache lifecycle, and cross-worktree lookup stay in one file to keep the rules in lockstep. */ +// Watches PTY output for HTTP(S) URLs dev servers print on startup, caching the +// advertised origin per {worktreeId, port} for the ports panel (vs the kernel bind). +// Why a separate stateful buffer per PTY: ANSI sequences and URLs can straddle PTY +// write boundaries, so we accumulate raw bytes and strip-and-scan only at newlines. const PER_PTY_BUFFER_LIMIT = 4096 const PENDING_PRE_BIND_LIMIT = 16 * 1024 -/** Cap on distinct never-bound PTY IDs. Spawn-failure paths can leave a - * pending entry that never gets bindPty'd — without an upper bound, a - * long-running app would slowly leak one entry per failure. */ +/** Cap on distinct never-bound PTY IDs; spawn-failure paths never bindPty, so without a bound they'd leak one entry each. */ const MAX_PENDING_ENTRIES = 32 const MAX_CACHE_ENTRIES = 256 const URL_CANDIDATE_LIMIT = 2048 -// ANSI/OSC strippers mirror the runtime normalizer, with URL-specific cursor -// move handling below to avoid fusing text that a real terminal would skip. +// ANSI/OSC strippers mirror the runtime normalizer in src/main/runtime/orca-runtime.ts, plus URL-specific cursor-move handling to avoid fusing skipped text. const OSC_PATTERN = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g -// Why: cursor moves in differential redraws can skip cells that are already on -// screen. Replacing them with a URL-invalid guard skips the damaged candidate. +// Why: cursor moves in differential redraws skip on-screen cells; a URL-invalid guard drops the damaged candidate. const CURSOR_MOVE_PATTERN = /\x1b\[[0-?]*[ -/]*[CDGHf]/g const CURSOR_MOVE_URL_GUARD = '[' const CSI_PATTERN = /\x1b\[[0-?]*[ -/]*[@-~]/g const SINGLE_ESC_PATTERN = /\x1b[@-_]/g const CONTROL_PATTERN = /[\x00-\x08\x0b-\x1f\x7f]/g -// Why: this is a permissive candidate matcher. Real validation happens via -// `new URL()` below. Stopping at characters that cannot appear in a URL -// (whitespace, quotes, angle brackets, backtick) avoids absorbing terminal -// punctuation. Trailing punctuation like `.,;)` is trimmed before parsing -// because URLs are commonly followed by sentence punctuation in human text. +// Permissive matcher (real validation is `new URL()` below); stops at non-URL chars so terminal punctuation isn't absorbed. const URL_CANDIDATE_PATTERN = /\bhttps?:\/\/[^\s<>"'`]+/gi export type HostKind = 'custom' | 'loopback' | 'private-ip' | 'public-ip' @@ -49,11 +33,8 @@ export type AdvertisedUrl = { port: number ptyId: string lastSeenAt: number - /** PID of the listening process that this advertised URL was validated - * against on a previous scan. Set by lookup APIs when a current PID is - * supplied; a later mismatch evicts the entry. Captured on first scan - * rather than at capture time because the PTY shell PID is not the - * listener PID. */ + /** Listener PID this URL was validated against on a prior scan; a later mismatch evicts the entry. + * Captured on first scan (not at capture time) because the PTY shell PID isn't the listener PID. */ validatedListenerPid?: number } @@ -82,9 +63,7 @@ function worktreeIdFromCacheKey(key: CacheKey, port: number): string { class PtyBuffer { private raw = '' - /** Append a chunk and return the cleaned, finalized text (everything up to - * and including the last newline). Whatever follows the last newline stays - * buffered so that a URL or ANSI sequence split across chunks survives. */ + /** Append a chunk; return cleaned text up to the last newline. The tail stays buffered so a URL or ANSI sequence split across chunks survives. */ ingest(chunk: string): string { const chunkHasLineBreak = chunk.includes('\n') || chunk.includes('\r') this.raw += chunk @@ -105,8 +84,7 @@ class PtyBuffer { } function lastLineBreak(text: string): number { - // Both \n and \r are accepted; \r\n is handled by stripping \r in - // stripTerminalControls — but we still want either one as a finalize point. + // Accept either \n or \r as a finalize point (\r\n is normalized later in stripTerminalControls). for (let i = text.length - 1; i >= 0; i--) { const ch = text.charCodeAt(i) if (ch === 0x0a || ch === 0x0d) { @@ -162,8 +140,7 @@ function parseUrl(candidate: string): URL | null { } export function classifyHost(hostname: string): HostKind { - // Why: Node's URL.hostname returns IPv6 literals with brackets ("[::1]"), - // while the public API for this function should accept either form. + // Why: strip IPv6 brackets so this public API accepts both "[::1]" (Node's form) and bare literals. const lower = hostname.toLowerCase().replace(/^\[|\]$/g, '') if (lower === 'localhost' || lower === '127.0.0.1' || lower === '::1') { return 'loopback' @@ -230,10 +207,7 @@ function isPrivateIpv6(value: string): boolean { } function hostKindScore(kind: HostKind): number { - // Codex's preference: custom DNS > loopback > private IP > public IP. - // A custom-configured host (e.g. `local.getmontecarlo.com`) is almost always - // the one the user wants opened, and loopback beats LAN for cert/cookie - // reasons on a single-machine setup. + // Prefer custom DNS > loopback > private IP > public IP: loopback beats LAN for cert/cookie reasons on one machine. switch (kind) { case 'custom': return 3 @@ -310,8 +284,7 @@ export class AdvertisedUrlWatcher { if (entry.ptyId !== ptyId) { continue } - // Why: SSH forward enrichment has no listener PID to validate against, - // so PTY teardown is the only reliable expiry signal for that cache row. + // Why: SSH forward enrichment has no listener PID, so PTY teardown is the only reliable expiry signal. this.cache.delete(key) this.validationBaselines.delete(key) this.startupAbsentAllowances.delete(key) @@ -324,8 +297,7 @@ export class AdvertisedUrlWatcher { } forgetWorktree(worktreeId: string): void { - // Why: worktree IDs are reused for the same repo/path, so removed - // worktrees must not leave scan baselines for a future workspace. + // Why: worktree IDs are reused, so a removed worktree must not leave scan baselines for a future one. for (const [ptyId, boundWorktreeId] of this.ptyToWorktree) { if (boundWorktreeId !== worktreeId) { continue @@ -357,14 +329,10 @@ export class AdvertisedUrlWatcher { } const worktreeId = this.ptyToWorktree.get(ptyId) if (!worktreeId) { - // Why: data can arrive on daemon-backed PTYs before the spawn handler - // resolves and we learn the worktreeId (see comment at - // src/main/ipc/pty.ts:1318-1323). Buffer until bindPty replays. + // Why: daemon PTY data can arrive before the spawn handler resolves the worktreeId (src/main/ipc/pty.ts:1318-1323); buffer until bindPty replays. const prior = this.pending.get(ptyId) ?? '' const merged = (prior + chunk).slice(-PENDING_PRE_BIND_LIMIT) - // Why: drop+reinsert touches insertion order so this acts as an LRU - // (Map iterates in insertion order). Combined with the eviction below, - // we keep at most MAX_PENDING_ENTRIES distinct unbound PTYs. + // Why: drop+reinsert refreshes Map insertion order (LRU) so the eviction below drops the oldest unbound PTY. this.pending.delete(ptyId) this.pending.set(ptyId, merged) while (this.pending.size > MAX_PENDING_ENTRIES) { @@ -398,18 +366,12 @@ export class AdvertisedUrlWatcher { return } const hostname = url.hostname - // Why: dev servers sometimes print their bind address verbatim - // (`http://0.0.0.0:3000/`). Those wildcard hosts cannot be opened in a - // browser — the OS scanner already normalizes wildcards to `localhost` - // for the connect host, so refuse the advertised value rather than let - // it overwrite that sensible default. + // Why: wildcard bind hosts (0.0.0.0, ::) can't be opened in a browser; keep the scanner's localhost default instead. if (isUnspecifiedHost(hostname)) { return } const hostKind = classifyHost(hostname) - // Why: store origin only — no path, query, fragment, or userinfo. A - // terminal line containing an OAuth callback or token must not get - // surfaced in the port panel. + // Why: store origin only (no path/query/fragment/userinfo) so an OAuth callback or token can't leak to the panel. const origin = `${protocol}://${formatHostForOrigin(url)}${ isDefaultPort(protocol, port) ? '' : `:${port}` }` @@ -430,17 +392,14 @@ export class AdvertisedUrlWatcher { if (baseline) { this.validationBaselines.set(key, baseline) if (baseline.kind === 'absent') { - // Why: the advertised URL can arrive between the process printing its - // banner and the scanner seeing the listener; allow one settling scan. + // Why: the URL can arrive between the banner print and the scanner seeing the listener; allow one settling scan. this.startupAbsentAllowances.add(key) } else { this.startupAbsentAllowances.delete(key) } } else { this.validationBaselines.delete(key) - // Why: PTY output can arrive before the scanner has produced any - // snapshot for the worktree; give that startup race the same one-scan - // absent allowance as a known-absent baseline. + // Why: PTY output can arrive before any scan snapshot exists; grant the same one-scan absent allowance. this.startupAbsentAllowances.add(key) } const changedEvents = this.enforceCacheLimit() @@ -496,9 +455,7 @@ export class AdvertisedUrlWatcher { if (entry.validatedListenerPid === undefined) { entry.validatedListenerPid = currentListenerPid } else if (entry.validatedListenerPid !== currentListenerPid) { - // Why: the process that printed this URL is gone and a different - // process is now listening on the port. Drop the cached URL — the - // new listener may be unrelated to the captured banner. + // Why: a different process now listens on this port, so the captured banner may be unrelated — drop it. this.cache.delete(key) this.validationBaselines.delete(key) this.startupAbsentAllowances.delete(key) @@ -519,9 +476,8 @@ export class AdvertisedUrlWatcher { } } - /** Reconcile the URL cache with a scanner snapshot for a worktree scope. - * Unvalidated URLs are tied to the listener state observed around capture, - * so a later absent port or changed PID cannot be lazily blessed. */ + /** Reconcile the URL cache with a scanner snapshot. Unvalidated URLs stay tied to the + * listener state seen at capture, so a later absent port or changed PID can't lazily bless them. */ reconcileScan( worktreeIds: readonly string[], observations: readonly AdvertisedUrlListenerObservation[] @@ -582,8 +538,7 @@ export class AdvertisedUrlWatcher { } if (baseline?.kind === 'absent' && current.kind === 'present') { this.startupAbsentAllowances.delete(key) - // Why: dev servers can print their URL before the OS listener scan sees - // the port. Let that first present scan validate the startup banner. + // Why: dev servers print their URL before the listener scan sees the port; let this first present scan validate it. return false } return ( @@ -593,17 +548,10 @@ export class AdvertisedUrlWatcher { ) } - /** Find the best advertised URL for `port` across multiple worktrees. SSH - * connections host several worktrees but the remote-side port scanner - * returns ports for the whole connection, so we need to scan all the - * worktrees attached to that connection. Returns the highest-scoring - * entry by hostKind (custom DNS > loopback > private IP > public IP), - * with HTTPS and recency as tie-breakers — same rule as `shouldReplace` - * uses on insert. - * - * When `currentListenerPid` is supplied each candidate entry is run - * through PID filtering first. Already-pinned mismatches are evicted, - * then only the returned winner is pinned to the current listener. */ + /** Find the best advertised URL for `port` across worktrees, scored via `shouldReplace`. + * Scans all worktrees on the connection because an SSH port scanner reports ports for the + * whole connection, not per-worktree. With `currentListenerPid`, mismatched pinned entries + * are evicted and only the winner is pinned. */ lookupBest( worktreeIds: readonly string[], port: number, @@ -663,15 +611,11 @@ function isDefaultPort(protocol: 'http' | 'https', port: number): boolean { return (protocol === 'http' && port === 80) || (protocol === 'https' && port === 443) } -/** Process-wide singleton. The runtime feeds it from `onPtyData`; the scanner - * enrichment reads it. Tests should instantiate their own - * `AdvertisedUrlWatcher` rather than relying on this. */ +/** Process-wide singleton fed by the runtime and read by scanner enrichment. Tests should instantiate their own instead. */ export const advertisedUrlWatcher = new AdvertisedUrlWatcher() function formatHostForOrigin(url: URL): string { - // Why: Node returns IPv6 hostnames pre-bracketed ("[::1]"). Some other JS - // runtimes strip the brackets. Accept both: re-bracket if a bare IPv6 - // literal slips through. + // Why: some JS runtimes strip the IPv6 brackets Node adds; re-bracket a bare IPv6 literal. const h = url.hostname if (h.startsWith('[') && h.endsWith(']')) { return h @@ -691,8 +635,7 @@ function observedListenersByPort( if (!observed.has(observation.port)) { observed.set(observation.port, observation.pid) } else if (existing !== observation.pid) { - // Multiple host-specific listeners on one port make PID attribution - // ambiguous for our port-keyed URL cache; preserve presence only. + // Multiple host-specific listeners on one port make PID attribution ambiguous; keep presence only. observed.set(observation.port, undefined) } } diff --git a/src/main/source-control/hosted-review-creation.ts b/src/main/source-control/hosted-review-creation.ts index a0271d0ea3d..9d95f6ddf02 100644 --- a/src/main/source-control/hosted-review-creation.ts +++ b/src/main/source-control/hosted-review-creation.ts @@ -1,5 +1,4 @@ -/* eslint-disable max-lines -- Why: provider detection, eligibility, and creation - preflight share one boundary so renderer and main-process gating cannot drift. */ +/* eslint-disable max-lines -- Why: detection, eligibility, and creation preflight share one boundary so gating can't drift. */ import type { CreateHostedReviewInput, CreateHostedReviewResult, @@ -43,9 +42,7 @@ import { type HostedReviewCreationEligibilityInput = HostedReviewCreationEligibilityArgs & { connectionId?: string | null - // Why: only the create-time preflight enforces base-on-remote as a hard block; - // the renderer's eligibility probe passes the base as a candidate and relies on - // Change 1 to correct a local-only parent, so it must never set this. + // Why: only the create-time preflight sets this; the renderer's probe leaves it unset to auto-correct a local-only parent. enforceBaseOnRemote?: boolean } & HostedReviewExecutionOptions @@ -65,11 +62,7 @@ async function isGitHubAuthenticated( connectionId?: string | null, options: HostedReviewExecutionOptions = {} ): Promise { - // Why: a GHES remote is only routed to the GitHub provider once detection has - // confirmed gh is authenticated to its enterprise host, so a non-null slug - // already means authenticated — skip a redundant, rate-limited gh probe. - // Reaching the github.com check below therefore means the remote is github.com - // (its own custom host would have resolved above) (#8312). + // Why: a non-null enterprise slug already means gh is authenticated there, so skip a redundant probe (#8312). if (await getEnterpriseGitHubRepoSlug(repoPath, connectionId, options)) { return true } @@ -142,14 +135,7 @@ async function getDefaultBaseRef( * Whether the candidate base resolves to a remote-tracking branch on the * executing host. * - * Why: a stacked worktree's `worktree.baseRef` is typically a bare parent - * branch name with no remote qualifier, so the probe must match the branch - * under *any* configured remote rather than assume `origin` — otherwise fork - * workflows (`upstream/main`) would be missed. Runs through - * `runGitForHostedReview` so it evaluates on the same host that will run the - * provider create (native/WSL/SSH/relay). This reads the local remote-tracking - * snapshot, not the live remote; see the design doc's Open Questions for the - * ls-remote/staleness tradeoff left as a follow-up. + * Why: matches under *any* remote (not just origin) and reads the local tracking snapshot, not the live remote. */ async function baseRefExistsOnRemote( candidate: string, @@ -165,18 +151,13 @@ async function baseRefExistsOnRemote( runGitForHostedReview(repoPath, argv, connectionId, options) const patterns = [`refs/remotes/*/${base}`] - // Non-origin remote-qualified candidate (e.g. `fork/main`): the wildcard glob - // above only matches a branch literally named that because `*` does not cross `/`. - // Include the exact tracking ref directly. + // `*` does not cross `/`, so a remote-qualified candidate (e.g. `fork/main`) needs its exact tracking ref too. if (base.includes('/')) { patterns.push(`refs/remotes/${base}`) } try { - // for-each-ref exits 0 whether or not the pattern matches, so a clean empty - // result is an authoritative "absent" while a thrown error is a transport - // failure. Never conflate the two: on an unreachable host, preserve the - // candidate rather than silently demoting a legitimately-pushed parent base. + // for-each-ref exits 0 on no match: empty means absent, a thrown error means transport failure (preserve the candidate). const { stdout } = await run(['for-each-ref', '--count=1', '--format=%(refname)', ...patterns]) return stdout.trim().length > 0 } catch { @@ -210,15 +191,13 @@ async function hasUncommittedChanges( 'Remote connection dropped. Click Reconnect on the SSH target before retrying.' ) } - // Why: the relay intentionally restricts generic git.exec. Use the - // structured status RPC for SSH dirty checks instead of raw `git status`. + // Why: the relay restricts generic git.exec, so use the structured status RPC for SSH dirty checks. return (await provider.getStatus(repoPath)).entries.length > 0 } const { stdout } = await gitExecFileAsync(['status', '--porcelain'], { cwd: repoPath, ...getHostedReviewLocalGitOptions(options), - // Why: create-PR validation should not take Git's optional index lock while - // the user may be running fetch/pull/rebase from a terminal. + // Why: don't take Git's optional index lock while the user may be running fetch/pull/rebase in a terminal. env: gitOptionalLocksDisabledEnv() }) return stdout.trim().length > 0 @@ -237,8 +216,7 @@ async function getHostedReviewUpstreamStatus( throw new Error('Remote connection dropped. Click Reconnect on the SSH target before retrying.') } try { - // Why: SSH exposes upstream divergence through a dedicated relay RPC; - // generic git.exec intentionally does not allow rev-list/status plumbing. + // Why: the relay blocks generic git.exec, so use its dedicated upstream RPC for SSH divergence. return await provider.getUpstreamStatus(repoPath) } catch (error) { if (isNoUpstreamError(error)) { @@ -431,16 +409,11 @@ async function validateCurrentBranchCanCreateReview( ahead: upstreamStatus.ahead, behind: upstreamStatus.behind, connectionId, - // Why: this is the last gate before the provider create, which targets the - // submitted base verbatim — enforce that the base exists on the remote here. + // Why: last gate before the create, which targets the submitted base verbatim — enforce it exists on the remote. enforceBaseOnRemote: true, ...options }) - // Why: never call the provider create API when the existing-review lookup - // could not prove the branch has no review. An `unavailable` outcome means a - // real MR/PR might exist that a failed lookup could not see; refuse - // inconclusively (design invariant 8 / submission boundary) so we never - // create a duplicate. The composed title/body are preserved by the caller. + // Why: an unavailable lookup might hide a real PR — refuse rather than risk a duplicate (design invariant 8). if (eligibility.reviewLookupOutcome === 'unavailable') { return { ok: false, @@ -448,8 +421,7 @@ async function validateCurrentBranchCanCreateReview( error: `Create ${copy.shortLabel} failed: Orca could not confirm whether this branch already has a ${copy.reviewLabel}. Retry once the ${copy.providerName} lookup succeeds.` } } - // Why: renderer eligibility can be stale by submit time; the main process - // is the last chance to avoid creating a PR from an out-of-date remote head. + // Why: renderer eligibility can be stale by submit time; main process is the last gate before an out-of-date create. return blockedEligibilityToCreateResult(eligibility, submittedBase) } catch (error) { console.warn('Hosted review creation preflight failed:', error) @@ -470,11 +442,7 @@ export async function getHostedReviewCreationEligibility( connectionId: args.connectionId, ...hostedReviewExecutionContext(args) }) - // Why: an incoming base is only a *candidate* for the default merge target. A - // stacked worktree's parent base resolves on the remote only when it was - // actually pushed; a local-only parent must fall back to the repo default so - // the PR targets a ref the remote can resolve. Never regress to "no base" — - // keep the candidate if the repo default itself is unavailable. + // Why: the base is only a candidate; fall back to repo default so a local-only parent targets a remote-resolvable ref. const candidateBase = args.base?.trim() || null const candidateBaseOnRemote = candidateBase != null && @@ -488,9 +456,7 @@ export async function getHostedReviewCreationEligibility( } const baseBranch = defaultBaseRef ? normalizeHostedReviewBaseRef(defaultBaseRef) : null let review: Awaited> = null - // Why: a swallowed lookup failure must not masquerade as authoritative - // no-review evidence. Track it so the renderer can distinguish an accepted - // no-PR result (`not_found`) from a local-blocker fallback (`unavailable`). + // Why: track lookup failure so a swallowed error isn't mistaken for authoritative no-review evidence. let lookupFailed = false try { review = await getHostedReviewForBranch({ @@ -506,12 +472,7 @@ export async function getHostedReviewCreationEligibility( ...hostedReviewExecutionContext(args) }) } catch (error) { - // Why: a failed existing-review lookup can never authorize Create — it might - // be hiding a real PR. Record `unavailable` and fall through so any local - // blocker (dirty/no-upstream/behind) still supplies primary guidance, but the - // clean-branch happy path below is forced to `canCreate: false`. Never rethrow - // here: a thrown lookup must not collapse into a false `not_found` and must - // stay a structured, honest empty state for the renderer. + // Why: a failed lookup might hide a real PR, so record unavailable and fall through rather than rethrow. lookupFailed = true console.warn('Hosted review lookup failed; treating existing-review as unavailable:', error) } @@ -580,12 +541,7 @@ export async function getHostedReviewCreationEligibility( if ((args.ahead ?? 0) > 0) { return { ...baseResult, canCreate: false, blockedReason: 'needs_push', nextAction: 'push' } } - // Why: at create-time, `gh pr create` (and the other providers) target the - // submitted base verbatim — Change 1 only corrects the *default*, not a stale - // renderer's submitted value. Block a local-only submitted base here so it - // fails with actionable copy instead of the provider's opaque error. Only the - // create-time preflight enforces this; the renderer's eligibility probe leaves - // enforceBaseOnRemote unset so a local-only parent is silently auto-corrected. + // Why: providers target the submitted base verbatim; block a local-only base here with actionable copy. if (args.enforceBaseOnRemote && candidateBase && !candidateBaseOnRemote) { return { ...baseResult, @@ -594,9 +550,7 @@ export async function getHostedReviewCreationEligibility( nextAction: null } } - // Why: a lookup that failed leaves review existence unproven, so the clean, - // in-sync happy path must not claim `canCreate` — that would open Create - // against a branch that may already have a review. Stays `unavailable`. + // Why: a failed lookup leaves review existence unproven, so the happy path must not claim canCreate. return { ...baseResult, canCreate: lookupFailed ? false : Boolean(baseBranch), diff --git a/src/main/speech/model-manager.ts b/src/main/speech/model-manager.ts index c8f918f1526..2b8bca8bbca 100644 --- a/src/main/speech/model-manager.ts +++ b/src/main/speech/model-manager.ts @@ -46,24 +46,13 @@ type DownloadTotals = { totalBytes: number } type ContentRange = { start: number; end: number; totalBytes?: number } const DOWNLOAD_IDLE_TIMEOUT_MS = 120_000 -// Why: flaky networks and scanning proxies commonly kill long CDN transfers -// near the end (reported as "stuck at 90%, then fails"); resuming with a Range -// request lets those connections finish instead of failing the same way on -// every full restart. +// Why: flaky networks/proxies often kill long CDN transfers near the end; Range-resume lets them finish. const DOWNLOAD_RETRY_DELAYS_MS = [1_000, 2_000, 4_000] -// Why: abandon a download only after this many CONSECUTIVE attempts make no -// forward progress. A resume that keeps advancing (even across many mid-stream -// drops) is never given up on — that is the whole point of resumable downloads -// for large models over flaky networks, and a fixed all-time failure budget -// would wrongly kill a steadily-progressing large download. +// Why: count only CONSECUTIVE no-progress attempts, so a download still advancing across drops is never abandoned. const MAX_NO_PROGRESS_ATTEMPTS = DOWNLOAD_RETRY_DELAYS_MS.length + 1 -// Why: an absolute backstop so a server handing back pathologically tiny (or -// zero-net) segments cannot loop forever; sized to cover the largest (~1GB) -// model even if a proxy caps each range response near ~256KB, far below any -// real CDN chunk. +// Why: absolute backstop against a tiny-segment server; 4096 covers the ~1GB model even at a proxy's ~256KB min range. const MAX_TOTAL_DOWNLOAD_REQUESTS = 4_096 -// Why: a longer server window should be surfaced for a later manual retry, -// rather than leaving the settings download looking stuck for minutes. +// Why: cap honored Retry-After; a longer server window is surfaced for manual retry, not a multi-minute stall. const MAX_RETRY_AFTER_MS = 120_000 const RETRYABLE_NET_ERROR = /net::ERR_(CONTENT_LENGTH_MISMATCH|INCOMPLETE_CHUNKED_ENCODING|CONNECTION_(RESET|CLOSED|ABORTED|REFUSED|TIMED_OUT)|EMPTY_RESPONSE|NETWORK_CHANGED|TIMED_OUT|INTERNET_DISCONNECTED|ADDRESS_UNREACHABLE|NAME_NOT_RESOLVED|SOCKET_NOT_CONNECTED|HTTP2_PROTOCOL_ERROR|QUIC_PROTOCOL_ERROR)\b/ @@ -172,8 +161,7 @@ export class ModelManager { const prepared = this.prepareModelsDir(requestedModelsDir) this.modelsDir = prepared.modelsDir this.migrationSourceDir = prepared.migrationSourceDir - // Why: migrating a non-ASCII cache copies large model files; run it off the - // main thread and gate model-state reads on it so the UI stays responsive. + // Why: migration copies large model files, so run it async and gate state reads on it to keep the UI responsive. this.migrationReady = migrateSpeechModelCacheIfNeeded( prepared.migrationSourceDir, prepared.modelsDir @@ -181,8 +169,7 @@ export class ModelManager { } setProgressCallback(cb: ProgressCallback): () => void { - // Why: concurrent settings windows can observe the same download; a - // returned unsubscribe prevents one window from replacing another. + // Why: return an unsubscribe so concurrent settings windows don't replace each other's callback. this.progressCallbacks.add(cb) return () => { this.progressCallbacks.delete(cb) @@ -274,10 +261,7 @@ export class ModelManager { } async downloadModel(modelId: string): Promise { - // Why: no migration await here — migration only copies dirs already present - // in the old cache (surfaced as ready via getModelState before download is - // offered), so it never races a download, and awaiting would defer the - // synchronous request setup that cancelDownload relies on. + // Why: no migration await — it never races a download, and awaiting would defer setup cancelDownload relies on. if (this.activeDownloads.has(modelId)) { return } @@ -302,8 +286,7 @@ export class ModelManager { this.updateState(modelId, 'downloading', 0) const archivePath = join(this.modelsDir, `${modelId}.tar.bz2`) - // Why: resume appends to the partial archive from this download run only; - // a leftover file from a crashed run would corrupt the resumed archive. + // Why: resume appends, so a leftover archive from a crashed run would corrupt the download. try { if (existsSync(archivePath)) { rmSync(archivePath) @@ -317,8 +300,7 @@ export class ModelManager { const handle: DownloadHandle = { abort: () => { aborted = true - // Why: a stalled HTTPS request may never deliver another data chunk; - // cancellation must tear down the request immediately. + // Why: a stalled HTTPS request may never deliver another chunk, so tear it down immediately. abortController.abort() } } @@ -355,9 +337,7 @@ export class ModelManager { } if (!this.validateModelFiles(manifest, modelDir)) { - // Why: some archives nest files inside a subdirectory matching the - // archive name. If the expected files aren't at the top-level model - // dir, scan one level down and move them up. + // Why: some archives nest files in a subdir; scan one level down and move them up. await this.flattenNestedDir(modelDir, manifest) } @@ -378,8 +358,7 @@ export class ModelManager { } this.cleanup(modelId, archivePath) if (!aborted) { - // Why: the settings UI awaits this promise to show download failures; - // cancellation stays quiet, but real failures must reach the caller. + // Why: the settings UI awaits this to surface failures; stay quiet on cancellation, rethrow real errors. throw err } } finally { @@ -416,9 +395,7 @@ export class ModelManager { if (existsSync(modelDir)) { await rm(modelDir, { recursive: true, force: true }) } - // Why: also delete the pre-migration copy (awaited above, so the copy has - // finished) — otherwise the next launch re-migrates it and resurrects the - // model the user just deleted. + // Why: also delete the pre-migration copy, or the next launch re-migrates it and resurrects the model. if (this.migrationSourceDir) { const sourceModelDir = this.getSafeModelDir(modelId, this.migrationSourceDir) if (existsSync(sourceModelDir)) { @@ -436,8 +413,7 @@ export class ModelManager { ): void { const state: SpeechModelState = { id: modelId, status, progress, error } this.modelStates.set(modelId, state) - // Why: notify the renderer on every state change (not just download - // progress) so the UI updates for extracting/ready/error transitions. + // Why: notify on every state change (not just progress) so extracting/ready/error transitions reach the UI. const progressValue = progress ?? (status === 'extracting' ? 0.95 : -1) for (const callback of this.progressCallbacks) { callback(modelId, progressValue) @@ -466,13 +442,11 @@ export class ModelManager { for (;;) { requestCount += 1 const offset = this.getPartialArchiveBytes(archivePath) - // Why: the transport can fail after the final byte reached disk; the - // caller's SHA-256 check is the authoritative completion test. + // Why: transport can fail after the last byte hits disk; the SHA-256 check is the real completion test. if (offset === totals.totalBytes) { return } - // Why: absolute backstop against a server that never lets the download - // finish; the stall and abort paths handle the common cases first. + // Why: absolute backstop against a server that never lets the download finish. if (requestCount > MAX_TOTAL_DOWNLOAD_REQUESTS) { throw describeInterruptedDownload( new Error('too many download requests'), @@ -482,8 +456,7 @@ export class ModelManager { ) } try { - // Why: each attempt restarts from the canonical URL rather than the - // last redirect target, because CDN redirect URLs are signed and expire. + // Why: restart from the canonical URL, not the last redirect, because signed CDN redirect URLs expire. await this.downloadFile( url, archivePath, @@ -508,10 +481,7 @@ export class ModelManager { `Model download response ended at ${receivedBytes} of ${totals.totalBytes} bytes` ) if (receivedBytes > offset) { - // Why: some proxies return a valid but bounded range segment; request - // the next segment immediately instead of failing checksum or waiting. - // Forward progress resets the stall counter; the total-request ceiling - // at the loop top bounds a pathological tiny-segment server. + // Why: some proxies cap each range segment; request the next immediately and reset the stall counter. noProgressStreak = 0 continue } @@ -530,8 +500,7 @@ export class ModelManager { if (!isRetryableDownloadError(err)) { throw err } - // Why: give up only on a genuine stall (repeated no forward progress); - // a download that keeps advancing across drops is allowed to continue. + // Why: give up only on a genuine stall; a download still advancing across drops keeps going. if (noProgressStreak >= MAX_NO_PROGRESS_ATTEMPTS) { throw describeInterruptedDownload(err, receivedBytes, totals.totalBytes, requestCount) } @@ -697,8 +666,7 @@ export class ModelManager { (parsedLength <= 0 || parsedLength === contentRange.end - contentRange.start + 1) if (resumeOffset > 0 && response.statusCode === 206 && !resumed) { - // Why: appending an unverified range can silently corrupt the archive; - // discard it so the canonical URL is retried from byte zero. + // Why: appending an unverified range can silently corrupt the archive; discard and retry from byte zero. try { rmSync(dest) } catch { @@ -716,8 +684,7 @@ export class ModelManager { if (response.statusCode !== 200 && !resumed) { if (response.statusCode === 416) { - // Why: the server rejected our resume offset; drop the partial so - // the retry loop can restart this download from scratch. + // Why: 416 means the server rejected our resume offset; drop the partial to restart from scratch. try { rmSync(dest) } catch { @@ -729,17 +696,14 @@ export class ModelManager { statusError.httpStatusCode = response.statusCode statusError.retryAfterMs = parseRetryAfterMs(response.headers['retry-after']) rejectOnce(statusError) - // Why: retrying must not leave a prior error body draining without - // cancellation or idle-timeout ownership. + // Why: abort so a retry doesn't leave the error-response body draining unowned. activeRequest?.abort() return } - // Why: a 200 despite our Range request means the server restarted the - // transfer from byte zero, so the partial file must be overwritten. + // Why: a 200 to our Range request means the server restarted from byte zero, so overwrite the partial. const progressBase = resumed ? resumeOffset : 0 - // Why: Content-Length on a 206 describes only this segment; when - // Content-Range uses '*', retain the known complete archive size. + // Why: Content-Length on a 206 is only this segment; on Content-Range '*' keep the known full size. const totalSize = resumed ? (contentRange?.totalBytes ?? totals?.totalBytes ?? expectedSize) : parsedLength > 0 @@ -789,8 +753,7 @@ export class ModelManager { request.setHeader('Range', `bytes=${resumeOffset}-`) } - // Why: Electron's net stack honors app proxy settings, unlike Node's - // https client, but it does not expose request.setTimeout(). + // Why: Electron net honors app proxy settings (unlike Node https) but exposes no setTimeout, so time out manually. resetIdleTimeout() request.on('error', onRequestError) request.on('response', onResponse) @@ -838,8 +801,7 @@ export class ModelManager { const onEnd = (): void => { const actualSha256 = hash.digest('hex') if (actualSha256 !== expectedSha256.toLowerCase()) { - // Why: these archives feed native model parsers; filename checks do - // not protect against compromised or redirected release assets. + // Why: archives feed native parsers, so verify contents against compromised/redirected release assets. settleReject(new Error('Downloaded model archive failed integrity verification')) return } @@ -862,10 +824,7 @@ export class ModelManager { mkdirSync(modelDir, { recursive: true }) return new Promise((resolve, reject) => { - // Why: spawn instead of exec because exec buffers all stdout/stderr - // (1MB default maxBuffer). bzip2 decompression is slow (~1-5 min for - // 170MB archives) and exec can silently kill the process if stderr - // exceeds the buffer. spawn streams output without buffering. + // Why: spawn (not exec) so slow bzip2 stderr can't overflow exec's 1MB maxBuffer and silently kill the process. const tarExecutable = resolveTarExecutable() const child = spawn( tarExecutable, @@ -929,8 +888,7 @@ export class ModelManager { }, 600_000) abortPoll = setInterval(() => { if (isAborted()) { - // Why: if the extraction child wedges and never emits close/error, - // the abort poller must still clear itself when we reject. + // Why: a wedged child may never emit close/error, so abort must kill it here. fail(new Error('Aborted'), true) } }, 250) diff --git a/src/main/ssh/ssh-connection.ts b/src/main/ssh/ssh-connection.ts index d38b0ab359c..eb2f79cf13a 100644 --- a/src/main/ssh/ssh-connection.ts +++ b/src/main/ssh/ssh-connection.ts @@ -51,15 +51,10 @@ type SshRemoteFileOptions = { hostPlatform?: RemoteHostPlatform } -// Upper bound on waiting, after an abort, for the in-flight open callback or -// for an aborted late-opened channel to finish closing before rejecting -// anyway. Normal opens and closes complete in one network round-trip. +// Upper bound on waiting for an aborted channel's open/close to settle before rejecting anyway. const ABORTED_CHANNEL_CLOSE_GRACE_MS = 5_000 -// Why: on session-limited servers (MaxSessions), a channel open can be refused -// transiently — e.g. our next CHANNEL_OPEN reaching sshd a few microseconds -// before it finishes processing the previous channel's close. A refused open -// never started the command, so retrying is always safe. +// Why: MaxSessions servers can transiently refuse a channel open; a refused open never ran the command, so retry is safe. const SESSION_LIMIT_OPEN_RETRIES = 4 const SESSION_LIMIT_OPEN_RETRY_DELAY_MS = 150 @@ -131,12 +126,7 @@ export class SshConnection { this.callbacks = callbacks } - // Why: exposes whether a passphrase/password is already cached in-memory for - // this connection. Used by ssh:needsPassphrasePrompt so callers can decide - // whether a manual-reconnect will prompt or go through silently. Without this, - // lastRequiredPassphrase stays true across the session even after the user - // has entered the credential once, causing redundant "enter passphrase" - // prompts on disconnect→reconnect cycles within a single app session. + // Why: lets ssh:needsPassphrasePrompt skip redundant passphrase prompts on reconnect when the credential is already cached in-memory. hasCachedCredential(): boolean { return this.cachedPassphrase != null || this.cachedPassword != null } @@ -247,10 +237,7 @@ export class SshConnection { unconfirmedOpenError = Object.assign(error, { sshChannelCloseConfirmed: false }) return unconfirmedOpenError } - // Why: rejecting the instant the signal aborts lets the caller proceed - // while the in-flight channel open completes in the background and holds - // a server-side session slot (MaxSessions). Mark the abort and settle - // from the open callback, after the late resource has been closed. + // Why: an in-flight open holds a MaxSessions slot; reject the caller now, then settle from the open callback once the late channel closes. let abortRequested = false let abortDeadlineTimer: NodeJS.Timeout | undefined const cleanup = (): void => { @@ -260,8 +247,7 @@ export class SshConnection { } const onAbort = (): void => { abortRequested = true - // Why: a hung socket may never invoke the open callback — bound the - // aborted caller's wait instead of pinning it for CONNECT_TIMEOUT_MS. + // Why: a hung socket may never invoke the open callback; bound the aborted caller's wait instead of pinning it for CONNECT_TIMEOUT_MS. abortDeadlineTimer = setTimeout(() => { settled = true cleanup() @@ -288,8 +274,7 @@ export class SshConnection { if (onClose) { emitter.once?.('close', onClose) } - // Why: ssh2 can withhold CHANNEL_CLOSE while discarded exec streams - // remain unread, and teardown errors have no other owner. + // Why: ssh2 withholds CHANNEL_CLOSE while discarded exec streams remain unread, and teardown errors have no other owner. emitter.resume?.() emitter.stderr?.resume?.() try { @@ -320,14 +305,12 @@ export class SshConnection { } done() } - // Why: bounded — a remote that never confirms the close must not hang - // the aborted operation forever. + // Why: bounded so a remote that never confirms the close can't hang the aborted operation forever. const closeGraceTimer = setTimeout(done, ABORTED_CHANNEL_CLOSE_GRACE_MS) if (typeof emitter.once === 'function') { emitter.once('close', confirmAndDone) } - // Why: ssh2 withholds the 'close' event until the channel's streams - // are drained; nobody else will ever read this discarded channel. + // Why: ssh2 withholds 'close' until the channel's streams are drained; nobody else will read this discarded channel. discardLateValue(value) if (typeof emitter.once !== 'function') { done() @@ -335,9 +318,7 @@ export class SshConnection { } const finish = (error: Error | undefined, value?: T): void => { if (settled) { - // Why: ssh2 can invoke the open callback after our timeout has - // rejected. Close that late resource so the remote channel is not - // left open with no owner. + // Why: ssh2 can invoke the open callback after our timeout rejected; close that late channel so it isn't left open with no owner. if (!error && value !== undefined) { discardLateValue(value, () => { if (unconfirmedOpenError) { @@ -372,8 +353,7 @@ export class SshConnection { signal?.addEventListener('abort', onAbort, { once: true }) try { - // Why: higher-level channel timers start only after ssh2 invokes its - // open callback. A stale SSH socket can otherwise keep exec/sftp stuck. + // Why: higher-level channel timers start only after ssh2's open callback; a stale SSH socket can otherwise keep exec/sftp stuck. register(finish) } catch (error) { finish(error instanceof Error ? error : new Error(String(error))) @@ -386,8 +366,7 @@ export class SshConnection { remoteDir: string, options?: SshRemoteFileOptions & { signal?: AbortSignal } ): Promise { - // Why: relay deployment timeout and connection teardown are independent; - // either owner must stop a transfer that can otherwise outlive its lock. + // Why: relay-deploy timeout and connection teardown are independent owners; either must stop a transfer that could outlive its lock. const linkedSignal = createLinkedSshFileTransferSignal( [this.systemOperationAbortController.signal, options?.signal].filter( (signal): signal is AbortSignal => signal !== undefined @@ -463,8 +442,7 @@ export class SshConnection { close: () => sftp.end() } } - // Why: disconnect replaces the connection controller; an existing import - // session must stay bound to the signal and SSH config it opened with. + // Why: disconnect replaces the connection controller, so an existing import session must stay bound to the signal and SSH config it opened with. const signal = this.systemOperationAbortController.signal const buildArgsOptions = this.getSystemSshBuildArgsOptions() return { @@ -590,8 +568,7 @@ export class SshConnection { } catch (err) { lastError = err instanceof Error ? err : new Error(String(err)) - // Why: a concurrent disconnect() already set 'disconnected'; a cancelled - // attempt's late error must not overwrite it with auth-failed/error. + // Why: a concurrent disconnect() already set 'disconnected'; a cancelled attempt's late error must not overwrite it with auth-failed/error. if (this.disposed) { throw lastError } @@ -630,10 +607,7 @@ export class SshConnection { await this.doSystemSshProbeWithControlMasterRetry(connectGeneration, resolved) return } - // Why: ssh2 has no gssapi-with-mic support, so hosts that explicitly - // request GSSAPIAuthentication try Kerberos SSO via the system OpenSSH - // binary first. Restrict the probe to GSSAPI so missing tickets fall through - // to Orca's existing key and credential-prompt path. + // Why: ssh2 lacks gssapi-with-mic; GSSAPIAuthentication hosts try Kerberos SSO via system OpenSSH first, then fall through to key/credential auth. if (this.target.gssapiAuthentication === true) { try { await this.doSystemSshProbeWithControlMasterRetry(connectGeneration, resolved, true) @@ -644,9 +618,7 @@ export class SshConnection { } } } - // Why: a synchronous spawn throw (no system ssh binary) bypasses the probe's - // own catch, so the ssh2 fall-through must clear all system-transport state - // itself — otherwise exec/sftp keep routing through the failed transport. + // Why: a synchronous spawn throw bypasses the probe's catch, so clear system-transport state here or exec/sftp keep routing through the failed transport. this.systemSshResolvedConfig = null this.systemSshControlMasterDisabledForSession = false this.systemSshGssapiOnlyForSession = false @@ -654,8 +626,7 @@ export class SshConnection { const config = buildConnectConfig(this.target, resolved) - // Why: ssh2 doesn't support ProxyCommand/ProxyJump natively. Spawn the - // resolved proxy and pipe its stdin/stdout as config.sock. + // Why: ssh2 doesn't support ProxyCommand/ProxyJump natively; spawn the resolved proxy and pipe its stdin/stdout as config.sock. const effectiveProxy = resolveEffectiveProxy(this.target, resolved) if (effectiveProxy) { const proxy = spawnProxyCommand(effectiveProxy, config.host!, config.port!, config.username!) @@ -683,8 +654,7 @@ export class SshConnection { this.proxyProcess?.kill() this.proxyProcess = null try { - // Why: on macOS, per-app network policy can block Orca's direct - // TCP socket while the system OpenSSH binary is still allowed. + // Why: on macOS, per-app network policy can block Orca's direct TCP socket while the system OpenSSH binary is still allowed. await this.doSystemSshProbeWithControlMasterRetry(connectGeneration, resolved) return } catch { @@ -700,16 +670,13 @@ export class SshConnection { let passphrasePromptHandled = false let credentialRetryConfig = config - // Why: ssh2 parses encrypted privateKey values before it tries agent - // auth. When an agent is available, give it the first attempt and only - // fall back to direct key parsing after agent auth fails. + // Why: ssh2 parses encrypted privateKey before agent auth; when an agent exists, let it try first and fall back to direct key parsing only if it fails. if (isAgentFallbackError(authError) && config.agent && !config.privateKey) { const keyConfig = buildConnectConfig(this.target, resolved, { includeAgent: false, includePrivateKey: true }) - // Why: if the agent path failed, password/passphrase retries should not - // go back through the same agent-only config. + // Why: if the agent path failed, password/passphrase retries must not reuse the same agent-only config. credentialRetryConfig = keyConfig if (this.cachedPassphrase) { keyConfig.passphrase = this.cachedPassphrase @@ -729,10 +696,7 @@ export class SshConnection { throw keyErr } authError = keyErr - // Why: when the effective config enables GSSAPI, let the reactive - // system-ssh probe (below) try a Kerberos ticket before prompting - // for the key passphrase; the general passphrase prompt still runs - // if that probe fails, since passphrasePromptHandled stays false. + // Why: with GSSAPI enabled, let the reactive system-ssh probe try a Kerberos ticket before prompting for the passphrase; the prompt still runs if it fails. if ( isPassphraseError(authError) && !this.cachedPassphrase && @@ -757,8 +721,7 @@ export class SshConnection { } } - // Why: a Kerberos ticket may authenticate where keys did not; try the - // system ssh binary before falling back to interactive prompts. + // Why: a Kerberos ticket may authenticate where keys did not; try the system ssh binary before falling back to interactive prompts. if (isGssapiSystemSshFallbackCandidate(authError, this.target, resolved)) { this.proxyProcess?.kill() this.proxyProcess = null @@ -771,9 +734,7 @@ export class SshConnection { this.systemSshGssapiOnlyForSession = false this.useSystemSshTransport = false } - // Why: if a disconnect/reconnect superseded this attempt mid-probe, throw - // the cancellation error — not the stale ssh2 authError — so connect() - // does not post auth-failed after the target was deliberately disconnected. + // Why: if a disconnect/reconnect superseded this attempt mid-probe, throw the cancellation error (not the stale authError) so connect() doesn't post auth-failed. if (this.disposed || !this.isCurrentConnectAttempt(connectGeneration)) { throw this.createCancelledConnectAttemptError() } @@ -785,8 +746,7 @@ export class SshConnection { throw authError } - // Why: prompt for passphrase on encrypted-key error, then retry with - // a fresh proxy socket (ssh2 may have destroyed the original). + // Why: prompt for passphrase on encrypted-key error, then retry with a fresh proxy socket (ssh2 may have destroyed the original). if (isPassphraseError(authError) && !this.cachedPassphrase && !passphrasePromptHandled) { const detail = this.target.identityFile || resolved?.identityFile?.[0] || '(unknown)' const val = await this.callbacks.onCredentialRequest(this.target.id, 'passphrase', detail) @@ -798,8 +758,7 @@ export class SshConnection { return } } - // Why: an agent socket failure can still be recovered by password auth, - // but the retry must use the no-agent config selected above. + // Why: an agent socket failure can still be recovered by password auth, but the retry must use the no-agent config selected above. if (isAgentFallbackError(authError) && !this.cachedPassword) { const val = await this.callbacks.onCredentialRequest( this.target.id, @@ -828,9 +787,7 @@ export class SshConnection { clearTimeout(this.reconnectTimer) this.reconnectTimer = null } - // Why: OS sleep/wake can leave ssh2 thinking a dead TCP socket is still - // connected. Tear down the local transport and run the normal reconnect - // path so the relay session can reattach remote PTYs after wake. + // Why: OS sleep/wake can leave ssh2 thinking a dead TCP socket is still connected; tear down and reconnect so the relay can reattach remote PTYs. this.closeTransportsForReconnect() this.state.reconnectAttempt = 0 this.setState('reconnecting') @@ -843,8 +800,7 @@ export class SshConnection { this.proxyProcess?.kill() this.proxyProcess = null - // Why: this probe runs before remote platform detection. A raw echo works - // under POSIX shells, cmd.exe, and PowerShell; `/bin/sh` wrapping does not. + // Why: this probe runs before remote platform detection; a raw echo works under POSIX shells, cmd.exe, and PowerShell, but `/bin/sh` wrapping does not. const channel = this.spawnTrackedSystemSshCommand('echo ORCA-SYSTEM-SSH-OK', { wrapCommand: false }) @@ -1006,8 +962,7 @@ export class SshConnection { reject(this.createCancelledConnectAttemptError()) } const onReady = (): void => { - // Why: direct system SSH startup has the same late-ready race as ssh2; - // disconnect/reconnect must own the generation before state can flip. + // Why: direct system SSH has the same late-ready race as ssh2; disconnect/reconnect must own the generation before state flips. if (!this.isCurrentConnectAttempt(connectGeneration)) { settle(cancelStartup) return @@ -1101,8 +1056,7 @@ export class SshConnection { return options } - // Why: ssh2 may destroy the proxy socket on auth failure, so credential - // retries need a fresh proxy process and Duplex stream. + // Why: ssh2 may destroy the proxy socket on auth failure, so credential retries need a fresh proxy process and Duplex stream. private respawnProxy( config: ConnectConfig, proxy: ReturnType | null | undefined @@ -1126,8 +1080,7 @@ export class SshConnection { client.off('error', onStartupError) } const swallowLateStartupError = (): void => { - // Why: ssh2 can emit another socket error while a failed or cancelled - // pre-handshake client is being destroyed, after startup has settled. + // Why: ssh2 can emit another socket error while destroying a settled pre-handshake client. } const guardStartupDestroy = (): void => { client.on('error', swallowLateStartupError) @@ -1137,9 +1090,7 @@ export class SshConnection { if (settled) { return } - // Why: connect() completion races with explicit disconnect(). Once a - // newer connect attempt or disconnect bumps the generation/disposed - // state, this late ready event must not resurrect the torn-down client. + // Why: connect() completion races with disconnect(); a late ready must not resurrect a torn-down client after generation/disposed changes. if (this.disposed || connectGeneration !== this.connectGeneration) { settled = true guardStartupDestroy() @@ -1154,15 +1105,7 @@ export class SshConnection { this.proxyProcess = null this.setupDisconnectHandler(client) cleanupStartupListeners() - // Why: ssh2 leaves Nagle's algorithm on by default. For single-byte - // keystrokes through a remote PTY this stacks with the kernel's - // delayed-ACK timer and adds up to ~40 ms per keystroke. OpenSSH's - // `ssh` sets TCP_NODELAY whenever a PTY is allocated; we mirror that - // because every channel we open over this connection (PTY data, - // JSON-RPC requests, port-scan probes) is latency-sensitive. No-op - // for proxy-command / proxy-jump connections where _sock is a custom - // Duplex; that case relies on the proxy program's own TCP behavior, - // same as native ssh. + // Why: ssh2 leaves Nagle on; enable TCP_NODELAY so keystrokes don't stack with delayed-ACK (~40ms each). No-op for proxy sockets. const sock = (client as unknown as { _sock?: { setNoDelay?: unknown } })._sock if (sock instanceof net.Socket) { console.warn(`[ssh] TCP_NODELAY enabled for ${this.target.label}`) @@ -1191,8 +1134,7 @@ export class SshConnection { }) } - // Why: guard on identity so a late event from the old client doesn't - // null out a successful reconnect. + // Why: guard on identity so a late event from the old client can't null out a successful reconnect. private setupDisconnectHandler(client: SshClient): void { const onDrop = () => { if (this.disposed || this.client !== client) { @@ -1234,8 +1176,7 @@ export class SshConnection { private async runReconnectAttempt(attempt: number): Promise { try { - // Why: reset reconnectAttempt before attemptConnect so setState('connected') - // broadcasts reconnectAttempt=0, which ssh.ts uses to trigger relay re-establishment. + // Why: reset before connecting so the 'connected' broadcast carries reconnectAttempt=0, which ssh.ts uses to trigger relay re-establishment. this.state.reconnectAttempt = 0 await this.attemptConnect() } catch (err) { @@ -1316,9 +1257,7 @@ export class SshConnection { this.systemSsh = proc this.useSystemSshTransport = true this.setState('connected') - // Why: register reconnection handler only after the initial handshake - // succeeds. The onExit registered above guards with `settled` so it - // won't fire a duplicate for exits during the handshake phase. + // Why: register the reconnect handler only after handshake succeeds (the onExit above guards with `settled`). proc.onExit(() => { if (!this.disposed && this.systemSsh === proc) { this.systemSsh = null diff --git a/src/main/ssh/ssh-relay-deploy.ts b/src/main/ssh/ssh-relay-deploy.ts index 56779b87f0f..2d1a1a446fc 100644 --- a/src/main/ssh/ssh-relay-deploy.ts +++ b/src/main/ssh/ssh-relay-deploy.ts @@ -1,8 +1,5 @@ import { join } from 'node:path' -/* eslint-disable max-lines -- Why: the relay-deploy module owns one cohesive - contract — version detection, install-locked deploy, native-deps probe, - relay launch, and GC — and splitting risks drift between the install - sequence and the GC's live-socket invariant. */ +/* eslint-disable max-lines -- Why: one cohesive contract (version detect, install-locked deploy, native-deps probe, launch, GC); splitting risks install/GC drift. */ import { existsSync } from 'node:fs' import { app } from 'electron' import type { SshConnection } from './ssh-connection' @@ -101,15 +98,7 @@ function execHostCommand( } /** - * Deploy the relay to the remote host and launch it. - * - * Steps: - * 1. Detect remote OS/arch via `uname -sm` - * 2. Check if correct relay version is already deployed - * 3. If not, SCP the relay package - * 4. Launch relay via exec channel - * 5. Wait for ORCA-RELAY sentinel on stdout - * 6. Return the transport (relay's stdin/stdout) for multiplexer use + * Deploy the relay to the remote host and launch it, returning the transport (relay's stdin/stdout) for multiplexer use. */ export async function deployAndLaunchRelay( conn: SshConnection, @@ -118,8 +107,7 @@ export async function deployAndLaunchRelay( relayInstanceId?: string ): Promise { let timeoutHandle: ReturnType - // Why: Promise.race does not cancel its loser. Stop a contended install-lock - // waiter so it cannot acquire and mutate the relay after this call times out. + // Why: Promise.race doesn't cancel its loser; abort so a contended install-lock waiter can't mutate the relay after this call times out. const deployAbortController = new AbortController() const timeoutPromise = new Promise((_resolve, reject) => { timeoutHandle = setTimeout(() => { @@ -145,12 +133,9 @@ export async function deployAndLaunchRelay( } /** - * Resolve the remote home, derive the versioned relay directory, and check - * whether the relay is already installed there. + * Resolve the remote home, derive the versioned relay dir, and check whether the relay is installed there. * - * Why: extracted so the deploy can run this chain concurrently with node-path - * resolution (the two are independent). Home and install-check stay sequential - * here because the install-check needs the resolved directory. + * Why: extracted to run concurrently with node-path resolution; home and install-check stay sequential because the check needs the resolved dir. */ async function resolveRemoteInstallState( conn: SshConnection, @@ -158,17 +143,14 @@ async function resolveRemoteInstallState( fullVersion: string, options?: { rethrowSessionLimitErrors?: boolean; signal?: AbortSignal } ): Promise<{ remoteHome: string; remoteRelayDir: string; alreadyInstalled: boolean }> { - // Why: SFTP does not expand `~`, so we must resolve the remote home - // explicitly with the host's native shell and normalize it before use. + // Why: SFTP doesn't expand `~`, so resolve the remote home explicitly via the host's native shell and normalize it. const remoteHome = normalizeRemoteHome( await execHostCommand(conn, hostPlatform, readRemoteHomeCommand(hostPlatform), { signal: options?.signal }), hostPlatform ) - // Why: we only interpolate $HOME into single-quoted shell strings later, so - // this validation only needs to reject obviously unsafe control characters. - // Allow spaces and non-ASCII so valid home directories are not rejected. + // Why: $HOME is only used inside single-quoted shell strings, so validation only rejects control chars — spaces and non-ASCII stay valid. if (!validateRemoteHome(remoteHome, hostPlatform)) { throw new Error(`Remote home is not a valid path: ${remoteHome.slice(0, 100)}`) } @@ -264,8 +246,7 @@ function isAbortError(err: unknown): boolean { } /** - * Detect the remote platform, resolve install state and node path, install the - * relay if it is not already present, then launch it and return the transport. + * Detect platform, resolve install state + node path, install if absent, launch, and return the transport. * Inner implementation wrapped by `deployAndLaunchRelay` with an overall timeout. */ async function deployAndLaunchRelayInner( @@ -289,8 +270,7 @@ async function deployAndLaunchRelayInner( if (!(err instanceof RelayDirectoryGcConflictError)) { throw err } - // Why: GC atomically moves the old install aside. Wait for its stable - // sibling claim to clear, then recompute install state from scratch. + // Why: GC atomically moves the old install aside; wait for its sibling claim to clear, then recompute install state. await waitForRelayGcClaimRelease(conn, err.remoteRelayDir, err.hostPlatform, deploySignal) } } @@ -321,19 +301,11 @@ async function deployAndLaunchRelayAttempt( `This may be a packaging issue — try reinstalling Orca.` ) } - // Why: read the content-hashed full version from the local build's .version - // file. Used as both the remote dir name and the wire-handshake version. - // Throws on missing/empty rather than silently falling back — see - // docs/ssh-relay-versioned-install-dirs.md "Data Flow: Upstream Error". + // Why: content-hashed version doubles as remote dir name and wire-handshake version; throws on missing rather than falling back (see docs/ssh-relay-versioned-install-dirs.md). const fullVersion = readLocalFullVersion(localRelayDir) onProgress?.('Checking existing relay...') - // Why: the remote-home -> install-check chain and node resolution are - // independent (both only need hostPlatform, not each other's results), yet - // each is a separate SSH exec round trip. Run them concurrently so the deploy - // pays one round trip instead of two for this phase. Most failures stay - // fail-fast; remotes that reject overlapping session channels retry the old - // sequential order so restrictive SSH servers keep working. + // Why: install-check and node resolution are independent; run concurrently to save a round trip, with sequential fallback for restrictive SSH servers. const { remoteHome, remoteRelayDir, alreadyInstalled, nodePath } = await resolveRelayBootstrapState(conn, hostPlatform, fullVersion, deploySignal) console.log(`[ssh-relay] Remote dir: ${remoteRelayDir}`) @@ -354,14 +326,11 @@ async function deployAndLaunchRelayAttempt( launchGcClaimToken = launchFence.gcClaimToken deploySignal?.throwIfAborted() } else { - // Why: serialize concurrent first-installs of the same version against - // each other via a host-native exclusive lock. The losing caller polls and either - // re-checks `alreadyInstalled` (now true) or steals a stale lock. + // Why: serialize concurrent first-installs via a host-native exclusive lock; the loser polls to re-check installed or steal a stale lock. await acquireInstallLock(conn, remoteRelayDir, hostPlatform, { signal: deploySignal }) ownsInstallLock = true try { - // Re-probe after acquiring the lock — a sibling installer may have - // finished while we were waiting. + // Re-probe after acquiring the lock — a sibling installer may have finished while we waited. if ( !(await isRelayAlreadyInstalled(conn, remoteRelayDir, hostPlatform, { signal: deploySignal @@ -384,18 +353,15 @@ async function deployAndLaunchRelayAttempt( ) console.log('[ssh-relay] Native deps installed') - // Why: mark complete but retain the lock until launch makes daemon - // liveness observable to cross-version GC. + // Why: mark complete but retain the lock until launch makes daemon liveness observable to cross-version GC. await finalizeInstall(conn, remoteRelayDir, hostPlatform, { signal: deploySignal, releaseLock: false }) } } catch (err) { - // Why: leave a partial install dir in place (no `.install-complete`) - // so the next deploy detects the partial and re-runs upload + install. - // Keep the lock if remote command termination was not confirmed; stale - // recovery is safer than overlapping a still-running npm process. + // Why: leave a partial install dir (no .install-complete) so the next deploy re-runs upload + install. + // Why: keep the lock if remote termination was unconfirmed — stale recovery beats overlapping a still-running npm. if (!isUnconfirmedSshCommandTermination(err)) { await abandonInstall(conn, remoteRelayDir, hostPlatform) ownsInstallLock = false @@ -421,23 +387,18 @@ async function deployAndLaunchRelayAttempt( ) launchLivenessObserved = true } finally { - // Why: older clients understand only the install lock. If launch never - // becomes live, retain it for stale recovery so their GC cannot race a - // concurrent caller that was waiting behind this owner. + // Why: older clients understand only the install lock; if launch never goes live, keep it so their GC can't race a caller waiting behind this owner. if (ownsInstallLock && launchLivenessObserved) { await abandonInstall(conn, remoteRelayDir, hostPlatform) } - // The detached start may outlive a timed-out SSH command. Preserve either - // fence on failed launch until stale recovery can prove the handoff ended. + // The detached start may outlive a timed-out SSH command; keep the fence on failed launch until stale recovery proves the handoff ended. if (launchGcClaimToken && launchLivenessObserved) { await releaseRelayGcClaimWithRetry(conn, remoteRelayDir, launchGcClaimToken, hostPlatform) } } console.log('[ssh-relay] Relay started successfully') - // Why: best-effort cleanup of unreferenced sibling version dirs. Errors - // are logged inside gcOldRelayVersions and never propagate, so a GC failure - // can never block the user from connecting. + // Why: best-effort GC of unreferenced sibling version dirs; errors are swallowed so a GC failure never blocks connecting. void gcOldRelayVersions(conn, remoteHome, remoteRelayDir, hostPlatform, { windowsNodePath: launched.nodePath, windowsSockNames: [relaySocketNameForInstanceId(relayInstanceId)] @@ -470,14 +431,12 @@ async function uploadRelay( ) } - // Create remote directory await execHostCommand(conn, hostPlatform, makeRemoteDirectoryCommand(hostPlatform, remoteDir), { signal }) await uploadDirectoryForConnection(conn, localRelayDir, remoteDir, hostPlatform, signal) - // Make the node binary executable if (!isWindowsRemoteHost(hostPlatform)) { await execHostCommand( conn, @@ -487,9 +446,7 @@ async function uploadRelay( ) } - // Why: write `.version` via SFTP rather than shell to avoid quoting issues - // with content-hashed version strings. The remote daemon reads this same - // file on startup so the wire-handshake validates against it. + // Why: write .version via SFTP not shell to avoid quoting content-hashed versions; the daemon reads it to validate the wire handshake. await writeRemoteFile( conn, hostPlatform, @@ -535,8 +492,7 @@ async function writeRemoteFile( try { await new Promise((resolve, reject) => { const ws = sftp.createWriteStream(remotePath) - // .once: a session 'error' arriving after we've already resolved/rejected - // would otherwise become an unhandled error and crash main. + // .once: a late session 'error' after resolve/reject would otherwise be unhandled and crash main. sftp.once('error', reject) ws.once('close', resolve) ws.once('error', reject) @@ -556,15 +512,13 @@ type RelayNativeDepName = keyof typeof RELAY_NATIVE_DEPS const RELAY_NATIVE_DEP_NAMES = Object.keys(RELAY_NATIVE_DEPS) as RelayNativeDepName[] const NATIVE_DEPS_MISSING_PREFIX = 'ORCA-NATIVE-DEPS-MISSING:' -// Why: npm 12 blocks dependency lifecycle scripts unless each exact package -// version is approved, even when ignore-scripts is explicitly disabled. +// Why: npm 12 blocks dependency lifecycle scripts unless each exact package version is approved, even with ignore-scripts disabled. const RELAY_NATIVE_DEP_SCRIPT_ALLOWLIST = Object.fromEntries( Object.entries(RELAY_NATIVE_DEPS).map(([name, version]) => [`${name}@${version}`, true]) ) function nativeDepsProbeJs(successToken: string): string { - // Why: node-pty's Windows wrapper defers loading conpty.node until first - // spawn, so require("node-pty") alone cannot prove the binding is healthy. + // Why: node-pty's Windows wrapper defers conpty.node until first spawn, so require("node-pty") alone can't prove the binding is healthy. const loadNodePty = 'require("node-pty"); require("node-pty/lib/utils").loadNativeModule(process.platform==="win32"&&Number(require("os").release().split(".")[2])>=18309?"conpty":"pty")' return `(()=>{const missing=[];try{${loadNodePty}}catch{missing.push("node-pty")}try{require("@parcel/watcher")}catch{missing.push("@parcel/watcher")}if(missing.length){console.log("${NATIVE_DEPS_MISSING_PREFIX}"+missing.join(","));process.exitCode=1}else{console.log(${JSON.stringify(successToken)})}})()` @@ -644,8 +598,7 @@ async function repairInstalledNativeDeps( throw err } if (!stillInstalled) { - // Why: GC may finish its rename before our lock attempt recreates the - // original path. Never trust probes made before this locked recheck. + // Why: GC may finish its rename before our lock recreates the path; never trust probes made before this locked recheck. await abandonInstall(conn, remoteDir, hostPlatform) throw new RelayDirectoryGcConflictError(remoteDir, hostPlatform) } @@ -655,16 +608,11 @@ async function repairInstalledNativeDeps( ? await acquireRelayLaunchGcFence(conn, remoteDir, hostPlatform, signal) : undefined if (initialProbe.available) { - // Why: even a healthy reconnect must stay fenced until launch liveness is - // observable; otherwise cross-version GC can rename after this probe. + // Why: even a healthy reconnect stays fenced until launch liveness is observable, or cross-version GC can rename after this probe. return { ownsInstallLock: lockResult === 'acquired', gcClaimToken } } - // Why: an already-installed relay can launch in degraded mode (fs/git/preflight - // still work; native-backed ops fail), so native-deps repair is best-effort: - // lock contention and repair failures must not abort the connection. Pre-repair - // this path used require.resolve (which passed for a present-but-unloadable - // binding), so a fatal repair here would be a straight regression. + // Why: an already-installed relay can launch degraded, so native-deps repair is best-effort — lock contention and failures must not abort the connection. console.warn(`[ssh-relay] Repairing missing native deps at ${remoteDir}`) if (lockResult === 'busy' || lockResult === 'error') { console.warn( @@ -673,8 +621,7 @@ async function repairInstalledNativeDeps( return { ownsInstallLock: false, gcClaimToken } } try { - // Why: older complete relay dirs were created before @parcel/watcher was - // installed. Re-probe under the lock so only one reconnect mutates the dir. + // Why: older complete relay dirs predate @parcel/watcher; re-probe under the lock so only one reconnect mutates the dir. const probe = await probeRequiredNativeDeps(conn, remoteDir, hostPlatform, nodePath, signal) if (!probe.available) { await installNativeDeps( @@ -691,9 +638,8 @@ async function repairInstalledNativeDeps( return { ownsInstallLock: true } } catch (err) { const terminationUnconfirmed = isUnconfirmedSshCommandTermination(err) - // Why: keep a confirmed-failure lock through degraded launch so GC cannot - // move the relay before liveness is visible. Unconfirmed remote mutation - // keeps its stale-recoverable lock beyond this connection. + // Why: hold a confirmed-failure lock through degraded launch so GC can't move the relay before liveness is visible. + // Why: unconfirmed remote mutation keeps its stale-recoverable lock beyond this connection. console.warn( `[ssh-relay] Native deps repair failed at ${remoteDir}; launching degraded: ${ err instanceof Error ? err.message : String(err) @@ -723,8 +669,7 @@ async function acquireRelayLaunchGcFence( if (!stillInstalled) { throw new RelayDirectoryGcConflictError(remoteDir, hostPlatform) } - // Why: a caller that cannot own the install lock still needs its own - // durable fence; never borrow another connection's lock through launch. + // Why: a caller without the install lock still needs its own durable fence; never borrow another connection's lock through launch. return token } catch (err) { await releaseRelayGcClaimWithRetry(conn, remoteDir, token, hostPlatform) @@ -732,14 +677,8 @@ async function acquireRelayLaunchGcFence( } } -// Why: node-pty and @parcel/watcher are native addons that can't be bundled by -// esbuild. They must be installed on the remote host against its Node.js version -// and OS so dynamic imports/require calls resolve from the relay dir. -// -// TODO(#1693): VS Code ships per-platform tarballs with node-pty pre-built -// from CI and skips `npm install` on the remote entirely. That approach -// eliminates the whole class of bugs around npm/compiler/network failures -// on the remote. Worth doing once we're past the immediate fix. +// Why: node-pty and @parcel/watcher are native addons esbuild can't bundle; install them on the remote against its Node/OS. +// TODO(#1693): ship per-platform tarballs with node-pty prebuilt from CI to skip remote npm install. async function installNativeDeps( conn: SshConnection, remoteDir: string, @@ -749,14 +688,9 @@ async function installNativeDeps( signal?: AbortSignal, resetDeps: RelayNativeDepName[] = [] ): Promise { - // Why: commandWithNodePath puts node's bin directory in PATH for npm's child processes. - // npm install runs node-pty's prebuild script (`node scripts/prebuild.js`) - // which spawns `node` as a child — if node isn't in PATH, that child - // fails with exit 127 even though we invoked npm via its full path. - // npm init -y rejects '+' in derived package names (content-hashed dir - // names like relay-0.1.0+abc123). Bypass it with a fixed minimal - // package.json. type:commonjs pins module resolution against Node default - // flips or a remote ~/.npmrc setting type=module. + // Why: node-pty's prebuild spawns `node` as a child, so node must be in PATH (commandWithNodePath) or it fails exit 127. + // Why: npm init -y rejects '+' in content-hashed dir names, so write a fixed minimal package.json instead. + // Why: type:commonjs pins module resolution against Node default flips or a remote ~/.npmrc type=module. const pkgJson = `${JSON.stringify({ name: 'orca-relay', version: '1.0.0', @@ -777,8 +711,7 @@ async function installNativeDeps( const installArgs = Object.entries(RELAY_NATIVE_DEPS) .map(([dep, version]) => shellEscape(`${dep}@${version}`)) .join(' ') - // Why: npm reports a present package as up to date even when one packaged - // native file was deleted. Reset only dependencies the probe found broken. + // Why: npm reports a present package as up to date even if a native file was deleted; reset only deps the probe found broken. const resetCommand = resetNativeDepsCommand(hostPlatform, resetDeps) const resetPrefix = resetCommand ? `${resetCommand}; ` : '' const command = isWindowsRemoteHost(hostPlatform) @@ -807,17 +740,12 @@ async function installNativeDeps( throw err } signal?.throwIfAborted() - // Don't write .install-complete on hard fail; reconnect retries on a - // partial install. Greppable token so user bug reports paste something - // searchable. + // Don't write .install-complete on hard fail so reconnect retries the partial install; greppable token aids bug reports. const msg = (err as Error).message console.warn( `[ssh-relay][NATIVE-DEPS-INSTALL-FAIL] npm install native deps failed at ${remoteDir} (${platform}): ${msg}` ) - // Why: on Linux node-pty has no prebuild and must compile, so a missing - // C/C++ toolchain is the dominant first-connect failure (#1693). Probe the - // remote and replace node-gyp's opaque `not found: make` with an actionable - // install hint instead of leaking the raw npm output to the user. + // Why: on Linux node-pty compiles, so a missing C/C++ toolchain is the dominant first-connect failure (#1693); probe to give an actionable install hint. if (platform.startsWith('linux') && shouldProbeBuildToolchainAfterNativeDepsFailure(msg)) { const toolchain = await probeBuildToolchain(conn, hostPlatform, signal) if (toolchain?.toolchainMissing) { @@ -831,8 +759,7 @@ async function installNativeDeps( let probe = await probeInstalledNativeDeps(conn, remoteDir, hostPlatform, nodePath, signal) if (!probe.available) { - // Why: npm treats an already-present package as up to date, so enabling - // lifecycle scripts on install cannot repair a binding skipped earlier. + // Why: npm treats an already-present package as up to date, so re-enabling lifecycle scripts on install can't repair a skipped binding. console.warn(`[ssh-relay] Rebuilding unloadable native deps at ${remoteDir}`) let rebuilt = false try { @@ -854,8 +781,7 @@ async function installNativeDeps( } } - // MISSING is non-fatal by design: the relay still serves fs/git/preflight; - // only native-backed operations fail on hosts that cannot build the addons. + // MISSING is non-fatal by design: the relay still serves fs/git/preflight; only native-backed ops fail on hosts that can't build the addons. if (!probe.available) { console.warn( `[ssh-relay][NPTY-MISSING] native deps installed but require() failed at ${remoteDir} (${platform}). stdout=${probe.output.trim().slice(-200)} stderr=${probe.stderr.trim().slice(-500)}` @@ -937,8 +863,7 @@ async function makeNodePtySpawnHelperExecutable( if (isWindowsRemoteHost(hostPlatform)) { return } - // SFTP doesn't preserve execute bits; node-pty's spawn-helper prebuild - // must be +x for posix_spawnp. + // SFTP doesn't preserve execute bits; node-pty's spawn-helper must be +x for posix_spawnp. await execHostCommand( conn, hostPlatform, @@ -959,8 +884,7 @@ async function probeInstalledNativeDeps( output: string stderr: string }> { - // require() catches unloadable installs (wrong arch, missing prebuild, or a - // skipped lifecycle script) that require.resolve() and test -d both miss. + // require() catches unloadable installs (wrong arch, missing prebuild, skipped lifecycle script) that require.resolve() and test -d miss. const PROBE_OK = 'ORCA-NPTY-PROBE-OK' const stderrFile = joinRemotePath(hostPlatform, remoteDir, '.npty-probe.stderr') const escapedStderr = shellEscape(stderrFile) @@ -1016,8 +940,7 @@ export function getLocalRelayCandidates(platform: RelayPlatform): string[] { candidates.push(join(process.env.ORCA_RELAY_PATH, platform)) } - // Why: electron-builder copies extraResources next to the app bundle, while - // app.getAppPath() points at app.asar in packaged builds. + // Why: electron-builder copies extraResources next to the app bundle, but app.getAppPath() points at app.asar in packaged builds. if (process.resourcesPath) { candidates.push(join(process.resourcesPath, 'relay', platform)) candidates.push(join(process.resourcesPath, 'app.asar.unpacked', 'out', 'relay', platform)) @@ -1041,13 +964,7 @@ async function launchRelay( relayInstanceId?: string, signal?: AbortSignal ): Promise<{ transport: MultiplexerTransport; nodePath: string; sockPath: string }> { - // Why: Phase 1 of the plan requires Node.js on the remote. We use the - // system `node` rather than bundling a node binary, keeping the relay - // package small (~100KB JS vs ~60MB with embedded node). - // Non-login SSH shells may not have node in PATH, so we source the - // user's profile to pick up nvm/fnm/brew PATH entries. - // Why: graceTimeSeconds originates from user-editable SshTarget config. - // Clamping to integer prevents shell injection if the type ever loosened. + // Why: graceTimeSeconds comes from user-editable SshTarget config; floor+clamp to an integer prevents shell injection if the type ever loosened. const requestedGraceTime = Math.floor(graceTimeSeconds ?? DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS) const graceTime = requestedGraceTime === 0 @@ -1058,9 +975,7 @@ async function launchRelay( ) const escapedDir = shellEscape(remoteDir) const escapedNode = shellEscape(nodePath) - // Why: remoteRelayDir is shared by every Orca target for the same remote - // account. Hashing the target ID into the socket name prevents one target - // from attaching to another target's live relay. + // Why: remoteRelayDir is shared across Orca targets for one account; hashing the target ID into the socket name stops cross-target attach. const sockName = relaySocketNameForInstanceId(relayInstanceId) const sockFile = relayEndpointForHost(hostPlatform, remoteDir, sockName) const endpointDir = relayHookEndpointDirForHost(hostPlatform, remoteDir, sockFile) @@ -1094,10 +1009,7 @@ async function launchRelay( ) } - // Why: after an app restart a relay may still be running in its grace - // period with live PTY sessions. We check for its Unix socket and - // launch in --connect mode to bridge the new SSH channel to the - // existing relay process — preserving PTY state and scrollback. + // Why: after a restart the relay may still be alive in its grace period; --connect to its socket preserves PTY state and scrollback. try { const probeOutput = await execCommand( conn, @@ -1121,8 +1033,7 @@ async function launchRelay( '[ssh-relay] Socket reconnect failed, launching fresh relay:', err instanceof Error ? err.message : String(err) ) - // Why: stale socket from a crashed relay — remove it so the - // fresh launch can bind a new socket at the same path. + // Why: stale socket from a crashed relay — remove it so the fresh launch can bind at the same path. await execCommand(conn, `rm -f ${shellEscape(sockFile)}`, { signal }).catch( (cleanupErr) => { if (isUnconfirmedSshCommandTermination(cleanupErr)) { @@ -1141,41 +1052,21 @@ async function launchRelay( // Probe failed — fall through to fresh launch } - // Why: the relay must outlive the SSH connection so PTY sessions survive - // app restarts. nohup prevents SIGHUP death, ${shellEscape(logFile)} 2>&1 {}) launchChannel.on('error', () => {}) launchChannel.stderr.on('data', () => {}) launchChannel.stderr.on('error', () => {}) - // Why: the shell exits quickly (nohup ... &), but the SSH channel stays - // open until all child fds close. Explicitly closing it after the poll - // loop prevents channel accumulation across relay restarts, which would - // eventually hit the server's MaxSessions limit. + // Why: the SSH channel stays open until all child fds close; close it after the poll or channels accumulate and hit the server's MaxSessions limit. launchChannel.on('close', () => {}) - // Why: the backgrounded relay needs time to bind its Unix socket. We - // poll rather than sleep a fixed duration because remote host speed - // varies widely (CI vs. Raspberry Pi). - // Why: checking `test -S` only verifies the inode exists, not that the - // relay is listening. After a stale socket removal + fresh launch, the - // old inode can linger briefly. We probe with a connect-and-close to - // confirm the socket is actually accepting connections. + // Why: poll rather than fixed sleep — remote host speed varies widely (CI vs. Raspberry Pi). + // Why: test -S only proves the inode exists, not that the relay is listening; a connect-and-close confirms it accepts connections. const POLL_INTERVAL_MS = 200 const POLL_TIMEOUT_MS = 10_000 const pollStart = Date.now() @@ -1183,10 +1074,7 @@ async function launchRelay( try { while (Date.now() - pollStart < POLL_TIMEOUT_MS) { try { - // Why: node is guaranteed to exist on the remote (we just deployed - // the relay with it). Using it to probe the socket is more portable - // than python3/socat/perl which may not be installed. The socket - // path is passed as argv[1] to avoid shell quoting issues with -e. + // Why: probe via node (guaranteed present) not python3/socat/perl; pass the socket path as argv[1] to dodge -e quoting issues. const result = await execCommand( conn, `${escapedNode} -e 'var s=require("net").connect(process.argv[1]);s.on("connect",function(){s.destroy();process.stdout.write("READY")});s.on("error",function(){process.stdout.write("WAITING")})' ${shellEscape(sockFile)} 2>/dev/null || (test -S ${shellEscape(sockFile)} && echo READY || echo WAITING)`, @@ -1216,10 +1104,7 @@ async function launchRelay( throw new Error(`Relay failed to start within ${POLL_TIMEOUT_MS / 1000}s. Log:\n${logOutput}`) } - // Why: the backgrounded relay's stdout goes to a log file, not the exec - // channel. We connect via --connect which bridges this new channel's - // stdin/stdout to the relay's Unix socket — same path used for reconnect - // after app restart. + // Why: backgrounded relay's stdout goes to a log file, not the exec channel; --connect bridges this channel to its Unix socket. const channel = await conn.exec( `cd ${escapedDir} && ${escapedNode} relay.js --connect --sock-path ${shellEscape(sockFile)}`, { signal } @@ -1302,8 +1187,7 @@ async function rememberWindowsActiveRelayEndpoint( { signal } ).catch((err) => { signal?.throwIfAborted() - // Why: fallback pipe names are deterministic, so losing this marker does - // not force the next deploy to orphan an undiscoverable relay. + // Why: fallback pipe names are deterministic, so losing this marker won't orphan an undiscoverable relay. console.warn( `[ssh-relay] Failed to persist Windows active relay pipe at ${markerPath}: ${err instanceof Error ? err.message : String(err)}` ) @@ -1353,11 +1237,8 @@ async function launchWindowsRelay( err instanceof Error ? err.message : String(err) ) if (opts.reconnectFallback) { - // Why: an existing Windows named pipe cannot be unlinked like a Unix - // socket; use a deterministic fallback pipe so marker write failures - // remain recoverable on the next deploy. - // Keep activePipeMarkerPath keyed by the original target sock name; - // the marker records the active pipe for that target, fallback or not. + // Why: a Windows named pipe can't be unlinked like a Unix socket; a deterministic fallback pipe keeps the next deploy recoverable. + // Why: spread keeps activePipeMarkerPath at the original target sock name — the marker records that target's active pipe, fallback or not. launchOpts = { ...opts, ...opts.reconnectFallback } } } @@ -1489,8 +1370,7 @@ function windowsRelayLaunchCommand( errFile: string ): string { const relayScript = joinRemotePath(hostPlatform, remoteDir, 'relay.js') - // Why: Windows sshd kills the exec channel's process tree when the channel - // closes. WMI re-parents the detached relay so the named pipe stays alive. + // Why: Windows sshd kills the exec channel's process tree on close; WMI re-parents the detached relay to survive. const quoted = (value: string): string => `"${value.replace(/"/g, '\\"')}"` const relayCommandLine = [ quoted(nodePath), @@ -1502,8 +1382,7 @@ function windowsRelayLaunchCommand( quoted(sockPath), '--endpoint-dir', quoted(endpointDir), - // Why: in-process rotation owns relay.log (the tail-diagnostics target); - // the shell redirects remain for pre-JS boot/crash output. + // Why: --log-file owns rotation; shell redirects still capture pre-JS boot/crash output. '--log-file', quoted(logFile), `1>${quoted(logFile)}`, diff --git a/src/main/ssh/ssh-relay-native-deps-install.test.ts b/src/main/ssh/ssh-relay-native-deps-install.test.ts index f38a2b1065b..fdc7ff3e73e 100644 --- a/src/main/ssh/ssh-relay-native-deps-install.test.ts +++ b/src/main/ssh/ssh-relay-native-deps-install.test.ts @@ -1,8 +1,4 @@ -// Why: regression coverage for the install-probe contract. The original -// "node-pty is not available" bug shipped because every layer that should -// have caught it (chained shell, swallowing catch, resolve-only probe) was -// silent. Tests below pin the parts that, individually, would have caught -// it. +// Why: regression coverage for the install-probe contract — the "node-pty is not available" bug shipped because every guard layer was silent. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -84,8 +80,7 @@ import type { SshConnection } from './ssh-connection' type SftpWriteCapture = { paths: string[] contents: Record - // Number of execCommand calls observed at the moment ws.end() ran for each - // captured path. Used to pin "package.json was written before npm install". + // execCommand call count observed when ws.end() ran, per path — pins "package.json written before npm install". execCallCountAtWrite: Record } @@ -115,8 +110,7 @@ function makeMockConnection(capture: SftpWriteCapture): SshConnection { } }) } - // Why: production code uses ws.once('close', ...). The 'once' wrapper - // delegates to the same handler-table as 'on' for the test mock. + // Why: production uses ws.once('close'); the mock delegates 'once' to the same handler table as 'on'. return Object.assign(stub, { once: stub.on }) }), end: vi.fn() @@ -141,17 +135,8 @@ function decodePowerShellCommand(command: string): string | null { return match ? Buffer.from(match[1], 'base64').toString('utf16le') : null } -// Exec call order under our mocks (deploy happy path): -// 1: uname 2: $HOME 3: mkdir remoteDir (uploadRelay) -// 4: chmod +x node 5: npm install 6: chmod prebuilds -// 7: probe (cd && node -e require) -// [8: cat stderr — only when probe stdout is MISSING (graceful path)] -// 8 or 9: rm probe-stderr (best-effort cleanup; runs whenever probe resolved) -// [next: npm rebuild → chmod → second probe when the first probe is MISSING] -// next: socket DEAD next: socket READY -// -// When the probe rejects (SSH channel close or cd-failure when the install -// dir vanished), the catch path skips both stderr-capture and the rm. +// Happy-path exec order: uname, $HOME, mkdir, chmod node, npm install, chmod prebuilds, probe, [cat stderr + rm if MISSING], [rebuild → chmod → re-probe if MISSING], DEAD, READY. +// When the probe rejects (SSH channel close or vanished install dir), the catch skips both stderr-capture and the rm. function makeExecResponses(opts: { npmInstall: 'ok' | { reject: string } // 'ok' : probe resolves with the sentinel; rm runs once @@ -159,19 +144,14 @@ function makeExecResponses(opts: { // 'dir-gone': probe rejects (cd-failure), exec rejects directly // { reject }: probe rejects with custom error (e.g. SSH channel) probe: 'ok' | 'missing' | 'dir-gone' | { reject: string } - // Override probe stdout for shell-noise pressure tests. If set, replaces - // the load-test stdout entirely (useful for testing pollution prefixes). + // Override probe stdout entirely for shell-noise/pollution-prefix pressure tests. probeStdoutOverride?: string - // Result after the automatic rebuild. Defaults to missing so legacy tests - // continue to exercise the final degraded-mode warning. + // Result after the automatic rebuild; defaults to missing so legacy tests still exercise the degraded-mode warning. repairProbe?: 'ok' | 'missing' - // Raw stdout for the build-toolchain probe that runs in installNativeDeps' - // catch when `npm install` rejects on Linux. Defaults to a fully-present - // toolchain so the original npm error propagates unchanged. + // Raw stdout for the toolchain probe in installNativeDeps' catch; defaults to a full toolchain so the original npm error propagates unchanged. toolchainProbe?: string }): ExecResponse[] { - // npm install failure aborts the deploy after the catch probes the remote's - // build toolchain — no chmod/probe/launch slots are reached. + // npm install failure aborts after the catch probes the toolchain; no chmod/probe/launch slots are reached. if (opts.npmInstall !== 'ok') { return [ '__ORCA_REMOTE_PLATFORM__ Linux x86_64', @@ -234,10 +214,7 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { beforeEach(() => { vi.clearAllMocks() - // Tests that throw mid-deploy leave unconsumed `mockResolvedValueOnce` - // entries queued. Without resetting, the next test's first await consumes - // a leaked response. clearAllMocks doesn't drop the queue (it only clears - // .mock.calls), so we explicitly mockReset. + // mockReset because clearAllMocks keeps queued mockResolvedValueOnce entries, so a leaked response would bleed into the next test. vi.mocked(execCommand).mockReset() vi.mocked(uploadDirectory).mockResolvedValue(undefined) sftpCapture.paths.length = 0 @@ -247,9 +224,7 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { for (const k of Object.keys(sftpCapture.execCallCountAtWrite)) { delete sftpCapture.execCallCountAtWrite[k] } - // Re-prime: factory mockReturnValue / mockResolvedValue survive - // clearAllMocks, so this is just defense-in-depth in case a test does its - // own resetAllMocks. + // Re-prime as defense-in-depth: factory mocks survive clearAllMocks but a test's own resetAllMocks would drop them. vi.mocked(parseUnameToRelayPlatform).mockReturnValue('linux-x64') vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(false) warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) @@ -285,8 +260,7 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { expect(parsed.name).toBe('orca-relay') expect(parsed.version).toBe('1.0.0') expect(parsed.private).toBe(true) - // Why: pin commonjs so a future Node default flip doesn't silently - // break `require('node-pty')`. + // Why: pin commonjs so a future Node default flip can't break require('node-pty'). expect(parsed.type).toBe('commonjs') expect(parsed.dependencies).toEqual({ '@parcel/watcher': '2.5.6', 'node-pty': '1.1.0' }) expect(parsed.allowScripts).toEqual({ @@ -300,10 +274,7 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { ) expect(npmInstallIdx).toBeGreaterThanOrEqual(0) expect(execCalls[npmInstallIdx]).toContain('--ignore-scripts=false') - // Pin actual ordering: number of execCommand calls observed at the moment - // ws.end() ran for package.json must be < the index of `npm install`. - // Catches a future refactor that fires SFTP-write and npm install via - // Promise.all (where the final-state assertions above would still pass). + // Pin write-before-install ordering to catch a Promise.all refactor where the final-state assertions above still pass. const writeObservedAt = sftpCapture.execCallCountAtWrite[pkgPath as string] expect(writeObservedAt).toBeLessThanOrEqual(npmInstallIdx) }) @@ -319,8 +290,7 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { await expect(deployAndLaunchRelay(conn)).rejects.toThrow(/npm ERR/) - // The crucial regression: `.install-complete` must NOT have been written. - // Previously the catch swallowed the throw and finalizeInstall ran anyway. + // Regression: previously the catch swallowed the throw and finalizeInstall ran anyway. expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) @@ -333,8 +303,7 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { makeExecResponses({ npmInstall: { reject: 'gyp ERR! stack Error: not found: make' }, probe: 'ok', - // No HAVE lines → make/g++ absent; apk present → tailored hint must - // come from the remote probe rather than a hardcoded apt fallback. + // No HAVE lines + apk present: the tailored hint must come from the remote probe, not a hardcoded apt fallback. toolchainProbe: 'PKG apk' }) ) @@ -362,14 +331,12 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { makeExecResponses({ npmInstall: { reject: 'npm ERR! network ETIMEDOUT' }, probe: 'ok', - // Even if the host also lacks build tools, a network error should stay - // a network error instead of being relabeled as an install-tools fix. + // A network error must stay a network error even if the host also lacks build tools. toolchainProbe: 'PKG apt-get' }) ) - // The npm output is something else (network, registry), so surface the - // real error rather than a misleading "install build tools". + // Non-toolchain npm error (network/registry): surface the real error, not a misleading "install build tools". const error = await deployAndLaunchRelay(conn).catch((e: Error) => e) expect((error as Error).message).toContain('npm ERR! network ETIMEDOUT') expect((error as Error).message).not.toContain('build tools') @@ -405,11 +372,7 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { await deployAndLaunchRelay(conn) - // Probe failure is non-fatal by design (see docs/ssh-relay-versioned- - // install-dirs.md): relay still serves fs/git/preflight, only pty.spawn - // fails at runtime. Throwing here would loop reconnects forever on - // hosts where node-pty truly cannot build (Alpine without compiler, - // glibc too old). + // Probe failure is non-fatal by design (docs/ssh-relay-versioned-install-dirs.md): throwing would loop reconnects forever where node-pty can't build. const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) expect(warnMessages.some((m) => m.includes('[ssh-relay][NPTY-MISSING]'))).toBe(true) @@ -439,9 +402,7 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { }) it('propagates an SSH-channel failure from the post-rebuild re-probe', async () => { - // Why: the rebuild itself degrades gracefully, but the verification probe - // after it must still surface transport death — conflating a dead channel - // with "native deps missing" would finalize a half-repaired install. + // Why: rebuild degrades gracefully, but the post-rebuild probe must surface transport death, else a dead channel finalizes a half-repaired install. const conn = makeMockConnection(sftpCapture) feed([ '__ORCA_REMOTE_PLATFORM__ Linux x86_64', // uname @@ -517,23 +478,18 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { await expect(deployAndLaunchRelay(conn)).rejects.toThrow(/SSH channel/) - // Pin that the rejection actually came from the PROBE call (not some - // earlier/later exec). Drift in slot ordering would otherwise let this - // test pass while exercising a different failure path. + // Pin the rejection to the PROBE call so slot-ordering drift can't pass this test via a different failure path. const execCalls = vi.mocked(execCommand).mock.calls.map(([, c]) => c) const probeCallIdx = execCalls.findIndex((c) => c.includes('require("node-pty")')) const npmInstallIdx = execCalls.findIndex( (c) => c.includes('npm install') && c.includes('node-pty') && c.includes('@parcel/watcher') ) expect(probeCallIdx, 'probe must have been invoked').toBeGreaterThanOrEqual(0) - // Probe must come strictly AFTER `npm install` — otherwise we'd be - // probing into an empty install dir and this whole failure mode - // wouldn't represent the real-world race. + // Probe must come strictly after npm install, else it probes an empty dir and doesn't represent the real-world race. expect(probeCallIdx).toBeGreaterThan(npmInstallIdx) const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) - // Channel failure must NOT be conflated with "node-pty missing" or with - // "npm install failed". + // Channel failure must not be conflated with "node-pty missing" or "npm install failed". expect(warnMessages.some((m) => m.includes('[ssh-relay][NPTY-MISSING]'))).toBe(false) expect(warnMessages.some((m) => m.includes('[ssh-relay][NATIVE-DEPS-INSTALL-FAIL]'))).toBe( false @@ -548,17 +504,10 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { const conn = makeMockConnection(sftpCapture) feed(makeExecResponses({ npmInstall: 'ok', probe: 'dir-gone' })) - // The probe shape `cd ${dir} && (node -e ... || echo MISSING)` short- - // circuits on cd-failure (`&&`), so the whole exec rejects rather than - // resolving with the MISSING sentinel. Conflating "dir vanished" with - // "node-pty missing" would mark the version installed and strand the - // user in degraded mode forever. + // Why: cd-failure short-circuits the probe's && so it rejects instead of resolving MISSING; conflating "dir gone" with "node-pty missing" strands the user in degraded mode. await expect(deployAndLaunchRelay(conn)).rejects.toThrow(/cd:/) - // Pin that the rejection came from the probe slot specifically, not - // some earlier exec — otherwise a future refactor could move probe - // before npm install and this test would still pass for the wrong - // reason. + // Pin the rejection to the probe slot so a refactor moving probe before npm install can't pass this test for the wrong reason. const execCalls = vi.mocked(execCommand).mock.calls.map(([, c]) => c) const probeIdx = execCalls.findIndex((c) => c.includes('require("node-pty")')) const npmInstallIdx = execCalls.findIndex( @@ -584,16 +533,11 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { .mock.calls.map(([, c]) => c) .filter((c) => c.includes(`require("node-pty")`)) - // Why: the probe shape must invoke the deployed node binary against - // require('node-pty'). A weaker probe (test -d) could pass even when - // the native binding load is broken. + // Why: probe must require('node-pty') via the node binary; a weaker test -d passes even when the native binding load is broken. expect(probeCmds.length).toBeGreaterThan(0) expect(probeCmds[0]).toMatch(/node['"]?\s+-e/) - // Pin the full installNativeDeps exec sequence: npm install → chmod - // prebuilds → probe. A refactor that moves chmod-prebuilds after the - // probe would silently break spawn-helper bits; one that probes before - // npm install would test an empty dir. + // Pin order npm install → chmod prebuilds → probe: chmod-after-probe breaks spawn-helper bits; probe-before-install tests an empty dir. const all = vi.mocked(execCommand).mock.calls.map(([, c]) => c) const npmIdx = all.findIndex( (c) => c.includes('npm install') && c.includes('node-pty') && c.includes('@parcel/watcher') @@ -613,10 +557,7 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { it('matches the sentinel even with bashrc/MOTD noise prefixed to probe stdout', async () => { const conn = makeMockConnection(sftpCapture) - // Some remotes have customized .bashrc that prints to stdout on every - // non-interactive shell exec (corporate MOTD, NVM/conda init banners). - // Production uses .includes(PROBE_OK) with stderr redirected to a file, - // so noise on stdout BEFORE the sentinel must still resolve to OK. + // Why: noisy .bashrc/MOTD can prefix probe stdout; production uses .includes(PROBE_OK) so pre-sentinel noise still resolves OK. feed( makeExecResponses({ npmInstall: 'ok', @@ -644,8 +585,7 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { await deployAndLaunchRelay(conn) - // Absence of PROBE_OK is what triggers the warn, regardless of what - // appears around it. finalize still runs (degraded-mode by design). + // Absence of PROBE_OK triggers the warn regardless of surrounding noise; finalize still runs by design. const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) expect(warnMessages.some((m) => m.includes('[ssh-relay][NPTY-MISSING]'))).toBe(true) expect(vi.mocked(finalizeInstall)).toHaveBeenCalledTimes(1) @@ -708,8 +648,7 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { }) it('includes the platform tuple in NPTY-MISSING and native install failure logs', async () => { - // Platform tuple lets bug reports be triaged for prebuild availability - // without asking the user to dig out their arch. + // Platform tuple lets bug reports be triaged for prebuild availability without asking the user for their arch. const conn = makeMockConnection(sftpCapture) feed(makeExecResponses({ npmInstall: 'ok', probe: 'missing' })) await deployAndLaunchRelay(conn) @@ -783,10 +722,7 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { }) it('launches an already-installed relay in degraded mode when repair throws', async () => { - // Why: a repair failure on a completed dir (e.g. offline/proxy-locked npm) - // must not block the connection — the relay still serves fs/git/preflight. - // Pre-fix this rethrew and aborted the whole deploy. The next reconnect - // retries, so a transient failure self-heals. + // Why: a repair failure on a completed dir must not block the connection — relay still serves fs/git/preflight; next reconnect retries. vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(true) const conn = makeMockConnection(sftpCapture) feed([ @@ -849,8 +785,7 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { installSignal?.addEventListener( 'abort', () => { - // Mirrors execCommand's bounded close grace when ssh2 never - // confirms that the remote npm process stopped. + // Mirrors execCommand's bounded close grace when ssh2 never confirms npm stopped. setTimeout( () => reject( @@ -960,8 +895,7 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { }) it.each(['busy', 'error'] as const)('launches degraded when lock is %s', async (lockResult) => { - // Why: lock contention/wedge must not block a completed relay from launching - // in degraded mode — repair is best-effort and we hold no lock to release. + // Why: lock contention/wedge must not block a completed relay from launching in degraded mode. vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(true) vi.mocked(tryAcquireRelayRepairLock).mockResolvedValueOnce(lockResult) const conn = makeMockConnection(sftpCapture) diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index b9c41c84c1d..0428a4d3086 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -1,12 +1,5 @@ /* oxlint-disable max-lines */ -// Why: consolidates all relay lifecycle state (multiplexer, providers, abort -// controller, initialization flag) into a single class per SSH target. -// Previously this state was scattered across 5 module-level Maps/Sets in -// ssh.ts and ssh-relay-helpers.ts, with 3 separate code paths for initial -// connect, network-blip reconnect, and cleanup — each partially duplicating -// provider registration/teardown logic. This class is the single authority -// for relay session state, eliminating the class of bugs where one path -// forgets a step that another path handles. +// Why: single authority for all relay lifecycle state per SSH target (previously scattered across module Maps/Sets with duplicated paths). import type { BrowserWindow } from 'electron' import { deployAndLaunchRelay } from './ssh-relay-deploy' @@ -132,8 +125,7 @@ async function resolveRemoteGrokHome( ): Promise { const fallback = defaultRemoteGrokHome(remoteHome) try { - // Why: remote PTYs start login shells, so probe the same profile-derived - // environment instead of the relay service or local Electron environment. + // Why: remote PTYs start login shells, so probe that profile-derived env, not the relay service or local Electron env. const shellOutput = await execCommand(connection, "printenv SHELL || printf '/bin/sh\\n'", { timeoutMs: REMOTE_GROK_HOME_PROBE_TIMEOUT_MS }) @@ -141,8 +133,7 @@ async function resolveRemoteGrokHome( if (!shell?.startsWith('/') || hasControlCharacter(shell)) { return fallback } - // Why: this runs inside the user's actual login shell, which may be fish or - // tcsh. External commands avoid shell-specific variable/conditional syntax. + // Why: runs in the user's login shell (maybe fish/tcsh), so use external commands to avoid shell-specific syntax. const probe = `printenv GROK_HOME | head -c ${REMOTE_GROK_HOME_MAX_LENGTH + 1}` const output = await execCommand(connection, loginShellCommand(shell, probe), { wrapCommand: false, @@ -204,20 +195,11 @@ export class SshRelaySession { private mux: SshChannelMultiplexer | null = null private abortController: AbortController | null = null private muxDisposeCleanup: (() => void) | null = null - // Why: store the notification-handler disposer so teardownProviders can - // release it on reconnect/shutdown. Symmetric with muxDisposeCleanup; while - // the old mux's handler array is GC'd along with the mux today, holding the - // disposer is cheap insurance against future code that retains the old mux. + // Why: hold the notification-handler disposer so teardownProviders can release it on reconnect/shutdown (symmetric with muxDisposeCleanup). private muxNotificationCleanup: (() => void) | null = null - // Why: when the relay exec channel closes but the SSH connection stays - // up, the onStateChange reconnect path never fires. This callback lets - // ssh.ts wire up relay-level reconnect from outside the session. + // Why: onStateChange never fires when the relay channel closes but SSH stays up; this callback lets ssh.ts drive relay-level reconnect. private _onRelayLost: ((targetId: string) => void) | null = null - // Why: a wire-handshake mismatch is terminal — the daemon and client are at - // different versions, no amount of backoff retry will reconcile them. This - // separate callback lets ssh.ts surface the failure to the user and skip - // the relay-lost backoff loop entirely. Distinct from _onRelayLost because - // _onRelayLost expects a recoverable transport drop. + // Why: version mismatch is terminal, so it needs a separate callback from _onRelayLost (which expects a recoverable transport drop). private _onTerminalRelayError: | ((targetId: string, err: RelayVersionMismatchError) => void) | null = null @@ -271,11 +253,7 @@ export class SshRelaySession { return this._state } - // Why: TypeScript narrows _state after control-flow checks and then - // rejects comparisons like `this._state === 'disposed'` inside async - // methods where it "knows" the state was e.g. 'deploying'. But dispose() - // can mutate _state from another call stack between await points. This - // helper defeats narrowing so the disposed checks compile correctly. + // Why: dispose() can mutate _state across await points, so defeat TS's control-flow narrowing that would otherwise reject the 'disposed' check. private isDisposed(): boolean { return (this._state as RelaySessionState) === 'disposed' } @@ -320,9 +298,7 @@ export class SshRelaySession { mux.notify(SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD, { graceTimeSeconds: 0 }) } - // Why: single entry point for relay setup — used by both initial connect - // and app-restart reconnect. Having one path eliminates the risk of - // forgetting a registration step. + // Why: single entry point for relay setup (initial connect + app-restart reconnect) so no path forgets a registration step. async establish(conn: SshConnection, graceTimeSeconds?: number): Promise { if (this._state !== 'idle') { throw new Error(`Cannot establish relay session in state: ${this._state}`) @@ -347,10 +323,7 @@ export class SshRelaySession { } : null - // Why: dispose() can fire during the await above (e.g. user clicks - // disconnect while relay is deploying). If so, the session is already - // cleaned up — creating a mux and registering providers would leak - // resources with no owner to dispose them. + // Why: dispose() can fire during the await above; if it did, creating a mux/providers now would leak with no owner to dispose them. if (this.isDisposed()) { const orphanMux = new SshChannelMultiplexer(transport) orphanMux.dispose() @@ -361,12 +334,7 @@ export class SshRelaySession { this.mux = mux const ownsAttempt = (): boolean => this.mux === mux && !this.isDisposed() - // Why: verify the relay is actually responsive before registering - // providers. In --connect mode the bridge may have already closed - // (e.g. the grace-period relay exited because it had no PTYs), and - // registerRelayRoots would silently swallow all mux errors, leaving - // the session in 'ready' state with a dead mux. A round-trip request - // here fails fast so doConnect() can report the real error. + // Why: round-trip the relay before registering providers so a closed --connect bridge fails fast instead of leaving a 'ready' session on a dead mux. await mux.request('session.resolveHome', { path: '~' }) const registered = await this.registerProviders(mux, ownsAttempt) @@ -377,11 +345,7 @@ export class SshRelaySession { throw new Error('Session disposed during establish') } - // Why: the mux's transport can close during registerProviders (e.g. - // the --connect bridge exits). registerRelayRoots swallows mux errors - // (notifications no-op when disposed, git.listWorktrees requests are - // try/caught), so establish would otherwise reach 'ready' with a dead - // mux. Checking isDisposed catches this silent failure. + // Why: registerProviders swallows mux errors, so an isDisposed check catches a transport that closed mid-registration before we reach 'ready'. if (mux.isDisposed()) { throw new Error('Relay connection lost during provider registration') } @@ -391,8 +355,7 @@ export class SshRelaySession { throw new Error('Session disposed during establish') } - // Why: explicit disconnect keeps PTY ownership so a later manual connect - // must reattach those remote PTYs through the fresh relay connection. + // Why: explicit disconnect keeps PTY ownership, so a later manual connect must reattach those remote PTYs. await this.reattachKnownPtys(ownsAttempt) if (!ownsAttempt()) { @@ -405,22 +368,12 @@ export class SshRelaySession { this.startPortScanning() this._onReady?.(this.targetId) } catch (err) { - // Why: if deployAndLaunchRelay succeeded but registerProviders threw - // partway through, the mux is live and some providers may be partially - // registered. teardownProviders cleans up everything so a subsequent - // establish() call starts from a clean slate. + // Why: registerProviders can throw with a live mux and partial registration — tear everything down so a retry starts clean. if (!this.isDisposed()) { this.teardownProviders('connection_lost') this._state = 'idle' } - // Why: a wire-handshake mismatch on the FIRST connect is also terminal - // — the deployed relay binary on disk does not match a still-running - // daemon (typically because a legacy daemon from before the - // versioned-dir change is still alive). Notify the terminal-error - // callback so ssh.ts surfaces an actionable message and the caller's - // catch path doesn't conflate this with a transient deploy failure. - // We still rethrow so doConnect's existing failure path runs (clean up - // the SSH connection); ssh.ts's handler is idempotent. + // Why: a version mismatch on first connect is terminal (deployed binary vs. a still-running legacy daemon); notify the callback but still rethrow. if (isRelayVersionMismatchError(err)) { console.warn( `[ssh-relay-session] Terminal relay version mismatch on initial connect for ${this.targetId}: ${err.message}` @@ -431,14 +384,9 @@ export class SshRelaySession { } } - // Why: network-blip reconnect path. Tears down the old provider stack, - // deploys a fresh relay, and re-attaches any PTYs that survived the - // relay's grace window. Guarded by an AbortController so overlapping - // reconnect attempts (fast connection flaps) cancel the stale one. + // Why: network-blip reconnect; AbortController-guarded so overlapping attempts from fast flaps cancel the stale one. async reconnect(conn: SshConnection, graceTimeSeconds?: number): Promise { - // Why: only allow reconnect from 'ready' or 'reconnecting'. Calling - // reconnect from 'deploying' would tear down a mux that establish() is - // concurrently using. 'idle' means no session was established yet. + // Why: reconnect only from 'ready'/'reconnecting' — from 'deploying' it would tear down a mux establish() is still using; 'idle' has no session yet. if (this._state !== 'ready' && this._state !== 'reconnecting') { return } @@ -451,8 +399,7 @@ export class SshRelaySession { this._state = 'reconnecting' this.currentConnection = conn - // Why: stop scanning before teardownProviders so the polling timer doesn't - // fire against a disposed multiplexer. + // Why: stop scanning before teardownProviders so the poll timer can't fire against a disposed multiplexer. this.stopPortScanning() await this.portForwardManager.removeAllForwards(this.targetId) this.broadcastEmptyLists() @@ -476,9 +423,7 @@ export class SshRelaySession { : null if (abortController.signal.aborted || this.isDisposed()) { - // Why: the relay is already running on the remote. Creating a temporary - // multiplexer and immediately disposing it sends a clean shutdown to the - // relay process so it doesn't linger until its grace timer expires. + // Why: relay is already running remotely — a throwaway mux we immediately dispose sends a clean shutdown so it doesn't linger until grace expires. const orphanMux = new SshChannelMultiplexer(transport) orphanMux.dispose() return @@ -492,9 +437,7 @@ export class SshRelaySession { !abortController.signal.aborted && !this.isDisposed() - // Why: same health check as establish() — verify the relay is - // responsive before registering providers so a dead --connect bridge - // fails fast instead of silently producing a dead mux. + // Why: same health check as establish() — round-trip the relay before registering providers so a dead --connect bridge fails fast. await mux.request('session.resolveHome', { path: '~' }) if (!ownsAttempt()) { if (!mux.isDisposed()) { @@ -515,10 +458,7 @@ export class SshRelaySession { throw new Error('Relay connection lost during provider registration') } - // Why: dispose() can fire during registerProviders or the attach loop - // below. If it did, providers and mux were already cleaned up by - // dispose() — but this.mux was reassigned above, so the new mux - // would leak. Clean it up and bail. + // Why: dispose() during registration/attach already cleaned up, but this.mux was reassigned above — clean up the new mux so it doesn't leak. if (!ownsAttempt()) { if (this.mux === mux) { this.teardownProviders('shutdown') @@ -540,16 +480,11 @@ export class SshRelaySession { this.startPortScanning() this._onReady?.(this.targetId) } catch (err) { - // Why: clean up the mux if it was created but registration failed - // partway through. Without this, the mux's keepalive/timeout timers - // continue running on a half-initialized session. + // Why: tear down a partially-registered mux so its keepalive/timeout timers don't keep running on a half-initialized session. if (this.abortController === abortController && !this.isDisposed()) { this.teardownProviders('connection_lost') } - // Why: a version-mismatch is terminal. Fire the typed callback so - // ssh.ts can surface a "please reconnect manually" notice and skip the - // relay-lost backoff loop entirely. We do NOT keep state at - // 'reconnecting' — there's no transient drop to recover from. + // Why: version-mismatch is terminal — fire the typed callback and drop out of 'reconnecting' since backoff retry can't reconcile it. if (isRelayVersionMismatchError(err)) { console.warn( `[ssh-relay-session] Terminal relay version mismatch for ${this.targetId}: ${err.message}` @@ -560,17 +495,12 @@ export class SshRelaySession { this._onTerminalRelayError?.(this.targetId, err) return } - // Why: stay in 'reconnecting' rather than reverting to 'ready', because - // the provider stack is already torn down. The SSH connection manager - // will fire another onStateChange when it reconnects again. + // Why: stay in 'reconnecting' (not 'ready') since the provider stack is torn down; the SSH manager will fire another onStateChange to retry. console.warn( `[ssh-relay-session] Failed to re-establish relay for ${this.targetId}: ${err instanceof Error ? err.message : String(err)}` ) if (this.abortController === abortController && !this.isDisposed()) { - // Why: non-not-found PTY attach failures are usually transient mux or - // relay transport failures. Treat them like relay loss so ssh.ts's - // bounded backoff retries instead of stranding the session forever in - // reconnecting. + // Why: treat non-not-found attach failures as relay loss so ssh.ts's bounded backoff retries instead of stranding the session in 'reconnecting'. this._onRelayLost?.(this.targetId) } } finally { @@ -586,8 +516,7 @@ export class SshRelaySession { } this.abortController?.abort() this.stopPortScanning() - // Why: fire-and-forget — nothing rebinds after dispose, so we don't - // need to wait for the OS to release ports. + // Why: fire-and-forget — nothing rebinds after dispose, so no need to await port release. void this.portForwardManager.removeAllForwards(this.targetId) this.broadcastEmptyLists() this.teardownProviders('shutdown') @@ -603,9 +532,7 @@ export class SshRelaySession { this.abortController?.abort() this.stopPortScanning() this.broadcastEmptyLists() - // Why: app/window disconnect is non-destructive for remote PTYs. The relay - // owns the grace timer, so Orca must unregister local providers without - // clearing PTY ownership needed for reattach. + // Why: window disconnect is non-destructive — unregister local providers but keep PTY ownership so reattach works (relay owns the grace timer). this.teardownProviders('connection_lost') this.store.markSshRemotePtyLeases(this.targetId, 'detached') this.currentConnection = null @@ -614,11 +541,7 @@ export class SshRelaySession { // ── Private ─────────────────────────────────────────────────────── - // Why: when the relay exec channel closes (e.g. --connect bridge exits, - // relay process crashes) but the SSH connection stays up, there is no - // automatic recovery — onStateChange only fires on SSH-level reconnects. - // This watcher detects relay-level channel loss and fires onRelayLost - // so ssh.ts can trigger session.reconnect() with the still-live SSH conn. + // Why: onStateChange only fires on SSH-level reconnects, so watch for relay-channel loss while SSH stays up and fire onRelayLost. private watchMuxForRelayLoss(mux: SshChannelMultiplexer): void { this.muxDisposeCleanup?.() this.muxDisposeCleanup = mux.onDispose((reason) => { @@ -631,8 +554,7 @@ export class SshRelaySession { }) } - // Why: shared by establish() and reconnect() — the exact same provider - // registration sequence, eliminating the duplication that caused bugs. + // Why: shared by establish() and reconnect() so both use the exact same registration sequence. private async registerProviders( mux: SshChannelMultiplexer, shouldContinue?: () => boolean @@ -655,10 +577,7 @@ export class SshRelaySession { try { await this.installRemoteOrcaCliLauncher() } catch (error) { - // Why: the remote `orca` CLI launcher is a convenience bridge. On session- - // limited remotes (MaxSessions=1) the relay bridge holds the only slot, - // so this raw-connection install can fail — that must not fail the - // whole connection, matching the managed-hook install above. + // Why: on MaxSessions=1 remotes the relay holds the only slot, so this raw-connection install can fail — don't fail the whole connection. console.warn( `[ssh-relay-session] remote orca CLI launcher install failed for ${this.targetId}: ${ error instanceof Error ? error.message : String(error) @@ -679,8 +598,7 @@ export class SshRelaySession { connection.usesSystemSshTransport?.() === true ? undefined : (options?: { signal?: AbortSignal }) => this.requireReadyConnection().sftp(options) - // Why: getHostPlatform() falls back to this.hostPlatform when the full - // remote CLI bridge env is incomplete, so path rules still match the host. + // Why: getHostPlatform() falls back to this.hostPlatform when bridge env is incomplete, so path rules still match the host. const hostPlatform = this.getHostPlatform() ?? undefined const fsProvider = new SshFilesystemProvider( this.targetId, @@ -728,11 +646,7 @@ export class SshRelaySession { }) } - // Why: the relay can inject ORCA_AGENT_HOOK_* env into SSH PTYs, but - // hook-script agents (Claude/Codex/Gemini/etc.) still need their config - // files on the remote host to call Orca's managed script. Install those - // configs before registering the PTY provider so newly spawned agent panes - // report status from their first prompt. + // Why: hook-script agents need remote config files (relay env alone isn't enough); install before the PTY provider so first prompts report status. private async installManagedHooksOnRemote(mux: SshChannelMultiplexer): Promise { if (!isRemoteAgentHooksEnabled() || !this.areAgentStatusHooksEnabled()) { return @@ -741,8 +655,7 @@ export class SshRelaySession { this.remoteCliBridgeEnv?.hostPlatform && isWindowsRemoteHost(this.remoteCliBridgeEnv.hostPlatform) ) { - // Why: managed hook installers currently emit POSIX hook scripts and paths. - // Windows remotes still get relay-injected env plus plugin overlays. + // Why: managed hook installers emit POSIX-only scripts/paths; Windows remotes rely on relay-injected env + plugin overlays instead. return } @@ -848,17 +761,7 @@ export class SshRelaySession { }) } - // Why: ship the OpenCode plugin / Pi extension source bodies to the relay - // so it can materialize overlay dirs and inject OPENCODE_CONFIG_DIR - // / PI_CODING_AGENT_DIR into spawn env. The strings change as we add agent - // events (recent additions: cursor, pi); pinning them to the relay binary - // would force a relay redeploy on every Orca update. See - // docs/design/agent-status-over-ssh.md §4 + §8 (commit #7). - // - // Best-effort: a -32601 from an older relay (no handler installed) is - // swallowed; the user just doesn't get OpenCode/Pi status reporting until - // they upgrade. Hook-script-based agents use a separate explicit remote - // installer flow because that mutates user-owned agent config files. + // Why: ship plugin/extension source from Orca so agent-event changes don't force a relay redeploy (agent-status-over-ssh.md §4/§8). Best-effort. private async installPluginsOnRelay(mux: SshChannelMultiplexer): Promise { if (!isRemoteAgentHooksEnabled() || !this.areAgentStatusHooksEnabled()) { return @@ -870,11 +773,7 @@ export class SshRelaySession { ompExtensionSource: getPiAgentStatusExtensionSource('omp') }) } catch (err) { - // Why: -32601 = older relay without the handler (treat as soft skip). - // CONNECTION_LOST / DISPOSED come from the multiplexer when it tears - // down mid-flight (routine on session shutdown / reconnect race) — not - // a real failure to surface; suppress to avoid log spam on every clean - // disconnect. + // Why: -32601 = older relay without the handler; CONNECTION_LOST/DISPOSED = routine mid-flight teardown — swallow both. const code = (err as { code?: unknown })?.code if (code === -32601 || code === 'CONNECTION_LOST' || code === 'DISPOSED') { return @@ -901,25 +800,12 @@ export class SshRelaySession { }) } - // Why: route the relay's `agent.hook` JSON-RPC notification into Orca's - // shared `agentHookServer` via `ingestRemote`. The wire envelope carries - // `connectionId: null` (the relay does not know Orca's local handle); we - // stamp the real value here from `this.targetId` so the renderer can drop - // in-flight events for connections that have torn down. After wiring is - // in place we kick off a request-driven replay so any cached payload from - // before the channel was up survives the reconnect — see §5 Path 3. - // - // The Orca-side mux's `notificationHandlers` is a flat array — each - // handler must filter by method name itself. + // Why: relay sends connectionId:null, so stamp this.targetId here so the renderer can drop events from torn-down connections. private wireUpAgentHookEvents(mux: SshChannelMultiplexer): void { if (!isRemoteAgentHooksEnabled()) { return } - // Why: capture the disposer so teardownProviders can release the - // notification handler symmetrically with muxDisposeCleanup. Even though - // the disposed mux's handler array is GC'd along with it today, retaining - // the disposer makes "registerProviders called twice on the same mux" - // safe by future-proofing against duplicate handler registration. + // Why: capture the disposer so teardownProviders can release this handler and re-wiring can't double-register it. this.muxNotificationCleanup?.() this.muxNotificationCleanup = mux.onNotification((method, params) => { if (method !== AGENT_HOOK_NOTIFICATION_METHOD) { @@ -946,11 +832,7 @@ export class SshRelaySession { if (typeof envelope.paneKey !== 'string') { return } - // Why: forward env/version verbatim so Orca's warn-once cross-build / - // dev-vs-prod diagnostics fire on remote events the same as on local - // ones — see docs/design/agent-status-over-ssh.md §3 ("Replay / - // version mismatch") and the relay's wire envelope at - // src/shared/agent-hook-relay.ts. + // Why: forward env/version verbatim so cross-build warn-once diagnostics fire on remote events too (agent-status-over-ssh.md §3). agentHookServer.ingestRemote( { paneKey: envelope.paneKey, @@ -979,12 +861,7 @@ export class SshRelaySession { ) }) - // Why: ask the relay to replay every cached paneKey it remembers. Issued - // *after* the handler is wired so the request-driven replay shape - // strictly trails our subscription on the dispatcher's single write - // callback. Best-effort: a relay that does not know the method - // (e.g. older relay binary) returns -32601; CONNECTION_LOST / DISPOSED - // arise from mux teardown mid-flight on routine reconnect/shutdown. + // Why: request replay of cached paneKeys only after the handler is wired, so replayed events can't arrive before we subscribe. Best-effort. void mux.request(AGENT_HOOK_REQUEST_REPLAY_METHOD).catch((err) => { const code = (err as { code?: unknown })?.code if (code === -32601 || code === 'CONNECTION_LOST' || code === 'DISPOSED') { @@ -993,9 +870,7 @@ export class SshRelaySession { if (mux.isDisposed()) { return } - // Why: a normal disconnect/teardown rejects the in-flight request with - // "Multiplexer disposed"; suppress the warn for that path so reconnect - // cycles aren't noisy. + // Why: suppress the warn when a normal teardown rejects the in-flight request, so reconnect cycles aren't noisy. if (mux.isDisposed()) { return } @@ -1020,8 +895,7 @@ export class SshRelaySession { if (reason === 'shutdown') { clearPtyOwnershipForConnection(this.targetId) } else { - // Why: handlers are detached above, so no late event can recreate a - // stamped status between this clear and reconnect replay. + // Why: handlers detached above, so no late event can re-stamp status between this clear and reconnect replay. agentHookServer.clearStatusEntriesForConnection(this.targetId) } @@ -1039,10 +913,7 @@ export class SshRelaySession { unregisterSshGitProvider(this.targetId) } - // Why: kept for back-compat with old relay binaries during the upgrade - // window — those still gate FS ops on registered roots. New relays no-op - // these notifications. Tracked for removal once the relay-version floor - // moves past the cutover (see docs/relay-fs-allowlist-removal.md). + // Why: back-compat for old relays that gate FS ops on registered roots; removable post-cutover (docs/relay-fs-allowlist-removal.md). private async registerRelayRoots(mux: SshChannelMultiplexer): Promise { const remoteRepos = this.store.getRepos().filter((r) => r.connectionId === this.targetId) @@ -1069,9 +940,7 @@ export class SshRelaySession { ) } - // Why: extracted so establish() and reconnect() share exactly the same - // event wiring. Previously forgetting to wire onReplay on one path - // caused silent terminal blanking after reconnect. + // Why: shared by establish()/reconnect() so both paths reset renderer lists the same way. private broadcastEmptyLists(): void { const win = this.getMainWindow() if (!win || win.isDestroyed()) { @@ -1091,17 +960,13 @@ export class SshRelaySession { if (!this.mux || this.isDisposed()) { return } - // Why: each scan walks /proc/*/fd on the remote host, so the scanner skips - // ticks entirely while no window can show the results (hidden to tray or - // minimized overnight) and rescans immediately when the window returns. + // Why: each scan walks /proc/*/fd remotely, so skip ticks while the window is hidden and rescan when it returns. const scanner = new PortScanner({ isWindowVisible: () => isMainWindowVisible(this.getMainWindow()), onWindowBecameVisible: onMainWindowBecameVisible }) this.portScanner = scanner - // Why: capture the scanner instance so that a late ports.detect callback - // from a previous relay session (before reconnect replaced it) is silently - // discarded instead of publishing stale results into the new session. + // Why: guard against a late ports.detect callback from a pre-reconnect scanner publishing stale results into the new session. scanner.startScanning(this.targetId, this.mux, (targetId, ports, platform) => { if (this.portScanner !== scanner) { return @@ -1131,11 +996,7 @@ export class SshRelaySession { if (!win || win.isDestroyed()) { return } - // Why: hidden-delivery gate parity with ipc/pty.ts — runtime ingestion - // above already consumed the chunk; gated renderer delivery is dropped - // and one out-of-band pty:modelRestoreNeeded signal latches - // model-restore-needed for reveal. Never an in-band pty:data sentinel: - // OSC-9999-only chunks legitimately strip to empty in the renderer. + // Why: hidden-delivery gate parity with ipc/pty.ts — latch model-restore out-of-band, never an in-band pty:data sentinel (OSC-9999-only chunks strip to empty). const store = this.store as { getSettings?: Store['getSettings'] } if (shouldDropHiddenRendererPtyData(payload.id, store.getSettings?.())) { const drop = recordHiddenRendererPtyDataDrop(payload.id, rawLength) @@ -1210,9 +1071,7 @@ export class SshRelaySession { .getSshRemotePtyLeases(this.targetId) .filter((lease) => lease.state !== 'terminated' && lease.state !== 'expired') const leasedPtyIds = activeLeases.map((lease) => lease.ptyId) - // Why: carry each lease's pane identity into the attach so the relay can - // reject cross-generation id collisions even between split panes in one tab. - // Older leases may lack leafId, so tabId remains the compatibility fallback. + // Why: pass pane identity so the relay can reject cross-generation id collisions; tabId falls back for pre-leafId leases. const expectedIdentityByPtyId = new Map( activeLeases .map((lease): [string, ExpectedPtyIdentity] | null => { @@ -1221,8 +1080,7 @@ export class SshRelaySession { }) .filter((entry): entry is [string, ExpectedPtyIdentity] => entry !== null) ) - // Why: after app restart, ptyOwnership is empty but durable SSH leases - // still describe remote PTYs that survived in the relay grace window. + // Why: after app restart ptyOwnership is empty, but durable SSH leases still describe grace-window survivors. const ptyIds = Array.from( new Set([ ...getPtyIdsForConnection(this.targetId).map((ptyId) => @@ -1274,9 +1132,7 @@ export class SshRelaySession { deletePtyOwnership(appPtyId) this.forwardedReattachReplayByPty.delete(appPtyId) this.store.markSshRemotePtyLease(this.targetId, ptyId, 'expired') - // Why: if the new relay cannot reattach this id, the remote backing - // process is gone. Tell the renderer so it clears stale pane bindings - // instead of keeping a cursor-only terminal. + // Why: reattach failure means the remote process is gone; tell the renderer to clear the stale pane. const win = this.getMainWindow() if (win && !win.isDestroyed()) { win.webContents.send('pty:exit', { id: appPtyId, code: -1 }) diff --git a/src/main/ssh/ssh-relay-versioned-install.ts b/src/main/ssh/ssh-relay-versioned-install.ts index b211fec4dd0..def8840ca57 100644 --- a/src/main/ssh/ssh-relay-versioned-install.ts +++ b/src/main/ssh/ssh-relay-versioned-install.ts @@ -1,12 +1,6 @@ -// Versioned-install plumbing for the remote relay. -// -// Why this exists: the relay used to install into a single shared directory -// (~/.orca-remote/relay-v0.1.0) which the deploy step would overwrite in place -// on every cross-version push. A daemon already loaded into memory then served -// new clients off rewritten on-disk code, producing protocol drift and a -// reconnect loop. We now install each (RELAY_VERSION + content-hash) bundle -// into its own directory and never mutate it after the install finishes, -// matching VS Code's `~/.vscode-server/bin//` layout. +// Versioned-install plumbing for the remote relay: each (RELAY_VERSION + content-hash) +// bundle installs into its own immutable dir (like VS Code's ~/.vscode-server/bin//) +// so an in-memory daemon never serves new clients off overwritten on-disk code. // // See: docs/ssh-relay-versioned-install-dirs.md @@ -44,17 +38,10 @@ import { windowsRelayPipePathsForSocketName } from './ssh-relay-endpoints' import { isUnconfirmedSshCommandTermination } from './ssh-relay-exec-command' import { isSshSessionLimitError } from './ssh-session-limit-error' -// Why: the GC pass and the version-dir parser must agree on what counts as a -// relay install dir. Single source of truth for both. The pattern matches the -// new layout `relay-${RELAY_VERSION}+${hash}` and the legacy `relay-v${VERSION}` -// so the GC eventually drains the old layout once its daemons idle out. +// Single source of truth for GC and the version-dir parser; matches both the new and legacy relay-dir layouts. const RELAY_VERSION_DIR_REGEX = /^relay-(v?\d+\.\d+\.\d+(\+[0-9a-f]+)?)$/ -// Why: legacy dirs from before `.install-complete` was introduced (i.e. the -// `relay-v0.1.0` shape with no content-hash suffix). They are missing the -// install-complete sentinel by definition and need a separate liveness-only -// GC check so they actually drain after the legacy daemon dies, instead of -// living on remote disks forever. +// Legacy dirs predate `.install-complete`; they need a liveness-only GC check so they eventually drain. const LEGACY_RELAY_DIR_REGEX = /^relay-v\d+\.\d+\.\d+$/ const INSTALL_COMPLETE_NAME = '.install-complete' @@ -78,11 +65,9 @@ function execHostCommand( } /** - * Read the local relay's content-hashed version (e.g. "0.1.0+0a5fe134d020") - * from `${localRelayDir}/.version`. Throws on missing/empty so the caller - * never silently falls back to a path where a daemon from a different code - * generation may already be running — that fallback is the failure mode the - * versioned-install design exists to prevent. + * Read the local relay's content-hashed version (e.g. "0.1.0+0a5fe134d020") from + * `${localRelayDir}/.version`. Throws on missing/empty so the caller can't + * silently fall back to a path where a stale-generation daemon may be running. */ export function readLocalFullVersion(localRelayDir: string): string { const versionFile = join(localRelayDir, '.version') @@ -119,11 +104,9 @@ export function computeRemoteRelayDir( } /** - * Probe whether a fully-installed relay already exists at remoteRelayDir. - * - * "Fully installed" means: the directory contains relay.js, its isolated - * relay-watcher.js child, and the .install-complete sentinel written at the - * end of a successful install. Missing artifacts force a complete re-deploy. + * Probe whether a fully-installed relay exists at remoteRelayDir — meaning + * relay.js, its relay-watcher.js child, and the .install-complete sentinel are + * all present. Missing artifacts force a complete re-deploy. */ export async function isRelayAlreadyInstalled( conn: SshConnection, @@ -149,9 +132,9 @@ export async function isRelayAlreadyInstalled( } /** - * Mark the install as complete, then normally release the lock. Deploy keeps - * the lock through first launch so cross-version GC cannot move the directory - * between finalization and daemon liveness becoming observable. + * Mark the install complete, then normally release the lock. Deploy keeps the + * lock through first launch so cross-version GC can't move the dir before daemon + * liveness is observable. */ export async function finalizeInstall( conn: SshConnection, @@ -173,9 +156,8 @@ export async function finalizeInstall( } /** - * Release the install lock without writing the completion sentinel. Called - * from the failure path so the dir remains a recoverable partial that the - * next deploy detects (alreadyInstalled=false) and re-runs upload+install. + * Release the install lock without writing the completion sentinel, leaving the + * dir as a recoverable partial the next deploy re-installs. */ export async function abandonInstall( conn: SshConnection, @@ -187,17 +169,9 @@ export async function abandonInstall( } /** - * Garbage-collect old version directories. Removes a sibling dir under - * `${remoteHome}/${RELAY_REMOTE_DIR}/` only if ALL of: - * - * - it matches the relay-version-dir regex (allowlist) - * - it is NOT the current version dir - * - it has no live `relay-*.sock` (pgrep + connectability probe) - * - it contains `.install-complete` (a fully-installed dir, not a partial) - * - it does NOT contain `.install-lock` (no in-progress install) - * - * Best-effort: any error is logged and swallowed; GC must never block the - * user from connecting. + * Garbage-collect old version directories: remove an idle, fully-installed, + * unlocked sibling version dir (never the current one). Best-effort — errors + * are swallowed so GC never blocks the user from connecting. */ export async function gcOldRelayVersions( conn: SshConnection, @@ -242,8 +216,7 @@ export async function gcOldRelayVersions( kept.push(name) continue } - // Why: the claim is a sibling, so it survives moving/deleting the - // candidate and lets installers back out before mutating the old path. + // Why: the claim is a sibling, so it survives moving/deleting the candidate and lets installers back out first. const gcClaimToken = await tryAcquireRelayGcClaim(conn, dir, host) if (!gcClaimToken) { kept.push(name) @@ -252,8 +225,7 @@ export async function gcOldRelayVersions( let preserveGcClaim = false let gcClaimReleaseNeeded = true try { - // Recheck under the stable claim. New installers probe the claim both - // before and after creating their in-tree lock, closing both orders. + // Recheck under the stable claim; installers probe it before and after creating their lock, closing both orders. if (!(await isCandidateSafeToRemove(conn, dir, name, host, options))) { kept.push(name) continue @@ -268,8 +240,7 @@ export async function gcOldRelayVersions( kept.push(name) continue } - // Once renamed, a fresh install at the original path is isolated from - // deletion of the tombstone, so the sibling claim can be released. + // Once renamed, a fresh install at the original path is isolated from the tombstone's deletion, so release the claim. const release = await releaseRelayGcClaimWithRetry(conn, dir, gcClaimToken, host) gcClaimReleaseNeeded = release === 'unknown' await execHostCommand(conn, host, removeRemoteTreeCommand(host, tombstone)) @@ -326,22 +297,14 @@ async function isCandidateSafeToRemove( const locked = lockState === 'LOCKED' if (locked) { - // Why: a locked dir is normally unsafe to remove — but a STALE lock - // (remote age older than INSTALL_LOCK_STALE_MS) means the previous installer - // crashed and is never coming back. If the dir also has the - // .install-complete sentinel (touch succeeded but the rm-lock at the - // end of finalizeInstall failed), removing the dir is safe — no - // installer is racing us, and the daemon (if any) keeps running off - // its already-loaded code regardless of disk state. + // Why: stale lock = crashed installer; finalize can leave a dir .install-complete yet locked (lock-rm failed), so it's reclaimable. if (!(await isRelayInstallLockStale(conn, lockDir, host))) { return false } process.stderr.write?.(`[ssh-relay] GC: lock at ${lockDir} is stale; treating as recoverable\n`) } - // Legacy dirs (relay-v0.1.0) predate .install-complete. Skip the sentinel - // check for them and rely solely on the live-socket probe — that's the - // only signal we have that a legacy daemon is still serving clients. + // Legacy dirs predate .install-complete; skip the sentinel and rely on the live-socket probe alone. if (!isLegacy) { const completePath = joinRemotePath(host, dir, INSTALL_COMPLETE_NAME) const completeProbe = await execHostCommand( @@ -372,10 +335,7 @@ async function hasLiveRelaySocket( } ): Promise { try { - // Why: `ls -1 dir/relay-*.sock 2>/dev/null` lists socket files. For each, - // we test -S to confirm it's a socket inode. We do NOT attempt to open - // the socket here — `test -S` is sufficient for the GC decision and a - // connect-and-close probe would race with a daemon that's about to idle. + // Why: `test -S` only — a connect-and-close probe would race with a daemon about to idle. const windowsOptions = isWindowsRemoteHost(host) && options?.windowsNodePath ? { From 0f7250879ae3791512e3d00095aba91f797c0dec Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 20 Jul 2026 03:18:32 -0700 Subject: [PATCH 50/54] refactor(comments): slim verbose comments in renderer state (store/hooks/lib) (#9545) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collapse multi-line explanatory comment blocks into single-line "why" statements per AGENTS.md ("Document the Why, Briefly"): drop restatements of the code and mechanism narration; keep the non-obvious reason, external refs, and directives. Comments-only — verified no code changed via a Babel/esbuild comment-strip token-equality gate against origin/main; typecheck and oxlint clean. Area: renderer — store, hooks, lib, runtime. 36 files changed, 1673 insertions(+), 5677 deletions(-). Co-authored-by: Orca --- src/renderer/src/App.tsx | 562 ++++------------ .../src/hooks/useAutoAckViewedAgent.ts | 101 +-- src/renderer/src/hooks/useComposerState.ts | 427 +++--------- .../src/hooks/useEditorExternalWatch.ts | 220 ++----- src/renderer/src/hooks/useIpcEvents.ts | 491 ++++---------- src/renderer/src/lib/agent-status.test.ts | 81 +-- src/renderer/src/lib/agent-status.ts | 59 +- .../src/lib/launch-agent-in-new-tab.ts | 94 +-- .../pane-terminal-output-scheduler.ts | 176 ++--- .../terminal-arabic-shaping-joiner.ts | 92 +-- src/renderer/src/lib/telemetry.ts | 65 +- src/renderer/src/lib/use-tab-agent.ts | 122 +--- src/renderer/src/lib/workspace-session.ts | 88 +-- src/renderer/src/lib/worktree-activation.ts | 132 +--- src/renderer/src/lib/worktree-status.ts | 69 +- .../src/runtime/sync-runtime-graph.ts | 93 +-- .../src/runtime/web-runtime-session.ts | 75 +-- .../src/runtime/web-session-tabs-sync.ts | 164 ++--- src/renderer/src/store/slices/agent-status.ts | 476 +++----------- src/renderer/src/store/slices/browser.ts | 124 +--- src/renderer/src/store/slices/diffComments.ts | 110 +--- src/renderer/src/store/slices/editor.ts | 484 +++----------- src/renderer/src/store/slices/github.ts | 497 ++++---------- src/renderer/src/store/slices/repos.ts | 190 ++---- .../src/store/slices/store-cascades.test.ts | 69 +- .../slices/store-session-cascades.test.ts | 112 +--- src/renderer/src/store/slices/tabs.test.ts | 125 +--- src/renderer/src/store/slices/tabs.ts | 180 ++--- .../store/slices/terminals-hydration.test.ts | 57 +- src/renderer/src/store/slices/terminals.ts | 615 ++++-------------- src/renderer/src/store/slices/ui.ts | 280 ++------ .../src/store/slices/worktree-nav-history.ts | 65 +- .../src/store/slices/worktrees.test.ts | 84 +-- src/renderer/src/store/slices/worktrees.ts | 543 ++++------------ src/renderer/src/web/web-preload-api.ts | 141 ++-- src/renderer/src/web/web-runtime-client.ts | 87 +-- 36 files changed, 1673 insertions(+), 5677 deletions(-) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 977b32d30f6..faf1b7bb9ba 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -195,18 +195,13 @@ import { subscribeBackgroundTerminalWorktreeMountRequests } from './components/terminal/background-terminal-worktree-mount' -// Why: agents alive during a hard kill (crash, forced update install) need a -// reasonably fresh resume record on disk; one minute bounds the lost window -// without measurable per-tick cost (the capture skips unchanged records). +// Why: bound the resume-record loss window on a hard kill to ~1 min; capture skips unchanged records so per-tick cost is negligible. const SLEEPING_AGENT_RESUME_CAPTURE_INTERVAL_MS = 60_000 const isMac = navigator.userAgent.includes('Mac') const isWindows = !isMac && navigator.userAgent.includes('Windows') const shortcutPlatform: NodeJS.Platform = isMac ? 'darwin' : isWindows ? 'win32' : 'linux' -// Why: Windows and Linux both run with the native title bar removed (Windows -// via titleBarStyle: 'hidden', Linux via frame: false), so the renderer draws -// its own logo/menu anchor and min/max/close controls on both. Paired web -// clients run in a browser tab, so they must not render desktop window chrome. +// Why: Windows and Linux remove the native title bar so the renderer draws its own chrome; paired web clients run in a browser tab and must not. const hasCustomTitleBar = shouldRenderDesktopWindowChrome({ platform: shortcutPlatform, isWebClient: isPairedWebClientWindow() @@ -229,9 +224,7 @@ function getKeybindingContext(target: EventTarget | null): KeybindingContext { : 'app' } -// Abstraction over a real KeyboardEvent and a synthetic double-tap gesture so a -// single dispatch path serves both. KeybindingInput-compatible (key/code + -// modifier flags) so it flows straight into keybindingMatchesAction. +// Abstraction over a real KeyboardEvent and a synthetic double-tap gesture so one dispatch path serves both; KeybindingInput-compatible. type ShortcutDispatchInput = { key?: string code?: string @@ -245,16 +238,11 @@ type ShortcutDispatchInput = { preventDefault: () => void } -// Why: Windows ('hidden' titleBarStyle) and Linux (frame: false) both remove -// the native OS title bar, so we render our own minimize/maximize/close -// buttons. These SVG icons match the Fluent/Win11 style: thin 10×10 paths on a -// 40×30 hit area. +// Why: Windows and Linux both remove the native title bar, so we render our own min/max/close buttons (Fluent/Win11-style SVGs). function WindowControls(): React.JSX.Element { const [maximized, setMaximized] = useState(false) useEffect(() => { - // Why: window:maximize-changed only fires on transitions, so a window - // restored to a maximized state at startup would render the wrong icon - // until the user first clicks the button. Seed from main on mount. + // Why: maximize-changed only fires on transitions; seed from main on mount so a startup-maximized window shows the right icon. let cancelled = false void window.api.ui.isMaximized().then((value) => { if (!cancelled) { @@ -302,10 +290,7 @@ function WindowControls(): React.JSX.Element {

- {/* aria-label says "Dismiss" — the action persists silent opt-in, - not just hides the UI. */} + {/* aria-label says "Dismiss" but this persists silent opt-in, not just hides the UI. */}
) : filesUnavailable && files.length === 0 ? ( - // Why: the file fetch failed (rate limit, auth, unresolved - // remote); offer a retry instead of implying the PR is empty. + // Why: fetch failed (rate limit/auth/unresolved remote); offer retry instead of implying the PR is empty.
{translate( diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index 727c0a44970..9d08575e625 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -1,7 +1,4 @@ -/* eslint-disable max-lines -- Why: the tasks page keeps the repo selector, -task source controls, and GitHub task list co-located so the wiring between the -selected repo, the task filters, and the work-item list stays readable in one -place while this surface is still evolving. */ +/* eslint-disable max-lines -- Why: repo selector, task-source controls, and task list stay co-located so their wiring reads in one place. */ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useShallow } from 'zustand/react/shallow' @@ -512,10 +509,7 @@ function getTaskPageRepoCacheInput(repo: Repo): { } } -// Why: the sticky header's bg must be opaque (GITHUB_TASK_ROW_SURFACE_CLASS, not -// bg-muted/50) or vertically-scrolled rows bleed through it. These left-sticky -// cells additionally need a ::before gap-cover so horizontally-scrolled header -// columns don't show through the row's px-3 padding strip. +// Why: sticky header bg must be opaque or scrolled rows bleed through; the ::before gap-cover keeps horizontally-scrolled columns off the px-3 padding strip. const GITHUB_TASK_STICKY_ID_HEADER_CLASS = cn( 'sticky left-3 z-30 before:absolute before:-left-3 before:top-0 before:bottom-0 before:w-3 before:bg-inherit', GITHUB_TASK_ROW_SURFACE_CLASS @@ -550,8 +544,7 @@ function isPRFocusedTaskView(preset: TaskViewPresetId | null, query: string): bo } function normalizeGitHubTaskPreset(preset: TaskViewPresetId | null | undefined): TaskViewPresetId { - // Why: the split Issues/PRs tabs no longer have a mixed "All" view, so - // legacy saved defaults should land on the first tab instead of mixing rows. + // Why: the split Issues/PRs tabs dropped the mixed "All" view, so legacy saved defaults land on the first tab instead of mixing rows. return !preset || preset === 'all' ? 'issues' : preset } @@ -576,9 +569,7 @@ function scopeGitHubTaskSearch(query: string, kind: GitHubTaskKind): string { return `${inferredKind === 'prs' ? 'is:pr' : 'is:issue'} ${trimmed}` } -// Why: Intl.RelativeTimeFormat allocation is non-trivial, and previously we -// built a new formatter per work-item row render. Hoisting to module scope -// means all rows share one instance — zero per-row allocation cost. +// Why: Intl.RelativeTimeFormat allocation is non-trivial; hoist to module scope so all rows share one instance instead of allocating per render. const relativeTimeFormatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }) function formatRelativeTime(input: string): string { @@ -1149,8 +1140,7 @@ function GHStatusCell({ const resolvedStatusStateDraft = resolveTaskPageGitHubStatusStateDraft(statusStateDraft, item) if (resolvedStatusStateDraft !== statusStateDraft) { - // Why: item rows can refresh from the GitHub cache while this cell is still - // mounted; reconcile before paint instead of showing one stale status frame. + // Why: item rows can refresh from the cache while this cell is mounted; reconcile before paint to avoid one stale status frame. setStatusStateDraft(resolvedStatusStateDraft) } const localState = resolvedStatusStateDraft.localState @@ -1181,8 +1171,7 @@ function GHStatusCell({ updateLocalState(newState) patchWorkItem(item.id, { state: newState }, item.repoId, { sourceContext }) const target = getActiveRuntimeTarget(sourceSettings) - // Why: issue rows can be sourced by owner/repo URL instead of the local - // repo context; slug-aware writes preserve close reasons and duplicates. + // Why: issue rows can be sourced by owner/repo URL, not local repo context; slug-aware writes preserve close reasons and duplicates. const updatePromise = parsedOwnerRepo ? target.kind === 'environment' ? callRuntimeRpc<{ ok?: boolean; error?: { message?: string } | string }>( @@ -1521,9 +1510,7 @@ function ReviewChipAvatar({ reviewer: GitHubPRPrimaryReviewer | null }): React.JSX.Element { if (reviewer?.login) { - // Why: `gh pr list --json reviewRequests` can return only logins. Prefer the - // API avatar_url so GHE renders; GitHubUserAvatar falls back to the login URL - // and then an initials placeholder when no avatar loads. See #8784. + // Why: gh pr list --json reviewRequests can return only logins; prefer API avatar_url so GHE renders (falls back to login URL, then initials). See #8784. return ( => { - // Close the popover immediately so the UI feels responsive; the GitHub - // request/remove runs in the background and toasts on completion. + // Close the popover immediately for responsiveness; the GitHub request/remove runs in the background and toasts on completion. setOpen(false) setReviewerInput('') await (selectedReviewerLogins.has(reviewer.login.toLowerCase()) @@ -2929,9 +2914,7 @@ function PRMergeCell({ ) } -// Why: builds the page number array with ellipsis gaps, matching GitHub's -// pagination pattern: always show first page, last page, and a window of -// pages around the current page with "..." gaps between distant ranges. +// Builds the page-number array with ellipsis gaps: first, last, and a window around the current page. function getPageNumbers(current: number, total: number): (number | 'ellipsis')[] { if (total <= 9) { return Array.from({ length: total }, (_, i) => i) @@ -3035,20 +3018,14 @@ function PaginationBar({ ) } -// Why: type-guard predicate used to filter `perRepoSourceState` down to rows -// whose issue-source and PR-source slugs differ. Hoisted to module scope so -// the predicate isn't re-allocated on every TaskPage render. +// Why: hoisted to module scope so the type-guard predicate isn't re-allocated on every TaskPage render. const hasDivergentSources = ( s: TaskPageRepoSourceState ): s is TaskPageRepoSourceState & { sources: { issues: GitHubOwnerRepo; prs: GitHubOwnerRepo } } => !!s.sources?.issues && !!s.sources.prs && !sameGitHubOwnerRepo(s.sources.issues, s.sources.prs) -// Why: the selector keeps rendering even after the user picks 'upstream' (which -// makes effective `sources.prs` point at upstream). Raw-candidate divergence is the -// right render gate — a repo that has an `upstream` remote pointing somewhere -// different from origin is always a candidate for the toggle, regardless of -// the current effective preference. +// Why: gate on raw origin/upstream candidate divergence, not effective sources, so the toggle keeps rendering after the user picks 'upstream'. const hasUpstreamCandidateDivergence = ( s: TaskPageRepoSourceState ): s is TaskPageRepoSourceState & { @@ -3081,10 +3058,7 @@ export default function TaskPage(): React.JSX.Element { const fetchPRChecks = useAppStore((s) => s.fetchPRChecks) const getCachedWorkItems = useAppStore((s) => s.getCachedWorkItems) const setIssueSourcePreference = useAppStore((s) => s.setIssueSourcePreference) - // Why: bumped by `setIssueSourcePreference` after cache eviction so the - // fetch effect below re-runs and repopulates work-items against the new - // source. Eviction alone isn't enough because the effect's deps don't - // include `workItemsCache`. + // Why: bumped after cache eviction to re-run the fetch effect — eviction alone won't, since its deps don't include workItemsCache. const workItemsInvalidationNonce = useAppStore((s) => s.workItemsInvalidationNonce) const linearStatus = useAppStore((s) => s.linearStatus) const linearStatusChecked = useAppStore((s) => s.linearStatusChecked) @@ -3135,11 +3109,7 @@ export default function TaskPage(): React.JSX.Element { const submitShortcutLabel = getScreenSubmitShortcutLabel() const eligibleRepos = useMemo(() => repos.filter((repo) => isGitRepoKind(repo)), [repos]) - // Why: initial selection resolution honors (1) an explicit preselection from - // the caller, (2) the persisted defaultRepoSelection (null = sticky-all, - // array = curated subset, empty after filter = fall back to all), (3) fall - // back to "all eligible". An explicit preselection wins so "open tasks for - // this specific repo" entry points still land on a single-repo view. + // Why: initial selection precedence — explicit preselection > persisted defaultRepoSelection > all eligible; preselection wins so "open tasks for this repo" lands single-repo. const resolvedInitialSelection = useMemo>(() => { const preferred = pageData.preselectedRepoId if (preferred && eligibleRepos.some((repo) => repo.id === preferred)) { @@ -3151,9 +3121,7 @@ export default function TaskPage(): React.JSX.Element { if (filtered.length > 0) { return normalizeTaskRepoSelection(eligibleRepos, new Set(filtered)) } - // Why: empty after filtering (e.g. all persisted repos were removed) - // falls through to the automatic default so the page never renders with - // an empty selection — see the multi-combobox invariant. + // Why: empty after filtering (all persisted repos removed) falls through to the automatic default so the page never renders an empty selection. } return getDefaultTaskRepoSelection(eligibleRepos) }, [eligibleRepos, pageData.preselectedRepoId, settings?.defaultRepoSelection]) @@ -3168,10 +3136,7 @@ export default function TaskPage(): React.JSX.Element { [taskPickerGroups] ) - // Why: prune selection when a previously-selected repo is removed, and - // preserve sticky-all (when the selection equaled every logical project - // pre-change, keep it equal to every logical project post-change). Recreating - // the Set every time eligibleRepos changes would churn the fetch effect. + // Why: prune removed repos and preserve sticky-all (selection == all projects stays == all), without recreating the Set each time and churning the fetch effect. const prevTaskPickerCountRef = useRef(taskPickerRepos.length) useEffect(() => { const prevCount = prevTaskPickerCountRef.current @@ -3205,9 +3170,7 @@ export default function TaskPage(): React.JSX.Element { [eligibleRepos, repoSelection] ) - // Why: many affordances (new-issue dialog default, item dialog repo path lookup, - // optimistic stub) need *a* repo. First selected is used as the default; - // cross-repo dialogs still let the user override per-action. + // Why: many affordances need *a* repo; use the first selected as default, while cross-repo dialogs still let the user override per-action. const primaryRepo = selectedRepos[0] ?? null const linearWorkspaces = linearStatus.workspaces ?? [] const selectedLinearWorkspaceId = @@ -3268,8 +3231,7 @@ export default function TaskPage(): React.JSX.Element { const visibleWithoutProvider = preferredVisibleTaskProviders.filter( (visibleProvider) => visibleProvider !== provider ) - // Why: an empty provider list normalizes back to "all providers"; keep - // one other source visible so opting out actually hides this provider. + // Why: an empty provider list normalizes to "all providers", so keep one other source visible or hiding this one has no effect. const nextVisibleTaskProviders: TaskProvider[] = visibleWithoutProvider.length > 0 ? visibleWithoutProvider : ['github'] const nextDefaultTaskSource = resolveVisibleTaskProvider( @@ -3291,11 +3253,7 @@ export default function TaskPage(): React.JSX.Element { [defaultTaskSource, preferredVisibleTaskProviders, updateSettings] ) - // Why: seed the preset + query from the user's saved default synchronously - // so the first fetch effect issues exactly one request keyed to the final - // query. Previously a separate effect "re-seeded" these after mount, which - // caused a throwaway empty-query fetch followed by a second fetch for the - // real default — doubling the time-to-first-paint of the list. + // Why: seed preset + query synchronously so the first fetch issues one request; a prior post-mount re-seed caused a throwaway empty-query fetch, doubling time-to-first-paint. const defaultTaskViewPreset = normalizeGitHubTaskPreset(settings?.defaultTaskViewPreset ?? 'all') const initialTaskQuery = getTaskPresetQuery(defaultTaskViewPreset) @@ -3391,8 +3349,7 @@ export default function TaskPage(): React.JSX.Element { if (parsed?.kind !== 'runtime') { continue } - // Why: task sources can span multiple runtime hosts; each runtime owns - // its own gh/glab installation and auth state. + // Why: task sources can span multiple runtime hosts; each runtime owns its own gh/glab install and auth state. void callRuntimeRpc( { kind: 'environment', environmentId: parsed.environmentId }, 'preflight.check', @@ -3680,9 +3637,7 @@ export default function TaskPage(): React.JSX.Element { const jiraSearchPersistReadyRef = useRef(false) const [taskResumeApplied, setTaskResumeApplied] = useState(false) - // Why: pageData.taskSource changes when the user clicks a specific source - // icon in the sidebar while the task page is already open. useState only - // initializes once, so sync from the store when the value changes. + // Why: useState only inits once, so sync taskSource from the store when a sidebar source-icon click changes pageData.taskSource. useEffect(() => { const pageTaskSourceChanged = lastPageTaskSourceRef.current !== pageData.taskSource lastPageTaskSourceRef.current = pageData.taskSource @@ -3700,9 +3655,7 @@ export default function TaskPage(): React.JSX.Element { if (taskSourceManuallyChangedRef.current) { return } - // Why: GitLab/Linear availability hydrates after mount. If the saved - // default was unavailable during the first render, restore it once the - // relevant check proves the provider can be shown. + // Why: GitLab/Linear availability hydrates after mount; restore the saved default once its provider check proves it can be shown. if (visibleTaskProviders.includes(preferredTaskSource) && taskSource !== preferredTaskSource) { setTaskSource(preferredTaskSource) } @@ -3714,30 +3667,21 @@ export default function TaskPage(): React.JSX.Element { } }, [settings?.defaultTaskSource, taskSource, visibleTaskProviders]) - // Why: Project mode is a sub-tab within the GitHub source. Visible whenever - // the user is on the GitHub task source — actual entry into Project mode is - // gated on a non-null `activeProject` once they pick one. + // Why: Project mode is a GitHub sub-tab — visible on the GitHub source, but actual entry is gated on a non-null activeProject. const projectModeVisible = taskSource === 'github' const [githubMode, setGithubMode] = useState<'items' | 'project'>('items') // ── GitLab task-source state ────────────────────────────────────── - // Why: parallel to Linear's slim per-source state. Skips workItemsCache - // and cross-repo aggregation in v1 — the GitLab list fetches directly - // from `window.api.gl.listMRs` / `listIssues` for the primary repo. + // Why: parallel to Linear's slim per-source state — skips workItemsCache and cross-repo aggregation; fetches directly via window.api.gl for the primary repo. const [gitlabFilter, setGitlabFilter] = useState('opened') const [gitlabItems, setGitlabItems] = useState([]) const [gitlabLoading, setGitlabLoading] = useState(false) const [gitlabError, setGitlabError] = useState(null) const [gitlabRefreshNonce, setGitlabRefreshNonce] = useState(0) - // Why: opens GitLabItemDialog when a row is clicked. Separate state from - // gitlabItems so the dialog target survives a list refresh that might - // remove the item from the visible filter (e.g. closing an MR while - // it's open in the dialog). + // Why: separate from gitlabItems so the dialog target survives a list refresh that removes the item from the visible filter (e.g. closing an MR). const [gitlabDialogItem, setGitlabDialogItem] = useState(null) - // Why: GitLab tab has two sub-views — the project's MR/issue list, - // and the user's cross-project Todos (gitlab.com/dashboard/todos). - // 'project' is default; 'todos' fetches a separate stream. + // Why: GitLab tab has two sub-views — the project MR/issue list and the user's cross-project Todos (a separate stream). const [gitlabView, setGitlabView] = useState<'issues' | 'mrs' | 'todos'>('mrs') const [gitlabTodos, setGitlabTodos] = useState([]) const [gitlabTodosLoading, setGitlabTodosLoading] = useState(false) @@ -3758,8 +3702,7 @@ export default function TaskPage(): React.JSX.Element { ? isGitLabMRFilter(gitlabFilter) : true const activeGitlabFilter = gitlabFilterIsValid ? gitlabFilter : 'opened' - // Why: Issues and MRs expose different filter sets; repair before commit so - // fetch Effects never issue `glab` with a stale filter from the other view. + // Why: Issues and MRs expose different filter sets; repair before commit so fetch effects never run glab with a stale filter from the other view. if (!gitlabFilterIsValid) { setGitlabFilter('opened') } @@ -3784,40 +3727,26 @@ export default function TaskPage(): React.JSX.Element { const [tasksRefreshing, setTasksRefreshing] = useState(false) const [tasksFiltering, setTasksFiltering] = useState(false) const [tasksError, setTasksError] = useState(null) - // Why: per-repo failure count surfaced through the "N of M" banner. IPC-level - // rejections populate tasksError instead — the two are mutually exclusive so - // a successful-with-partial-failure read and a hard-reject don't double-show. + // Why: per-repo failure count for the "N of M" banner; IPC rejections use tasksError instead so partial-failure and hard-reject don't double-show. const [failedCount, setFailedCount] = useState(0) - // Why: when every selected refresh fails because GitHub is unreachable - // (outage / network / rate limit), attribute it to GitHub instead of making - // an empty or stale cached list look current. + // Why: when every refresh fails (GitHub outage/network/rate limit), attribute it to GitHub instead of showing an empty or stale list as current. const [githubUnavailable, setGithubUnavailable] = useState(false) const [taskRefreshNonce, setTaskRefreshNonce] = useState(0) - // Why: the fetch effect uses this to detect when a nonce bump is from the - // user clicking the refresh button (force=true) vs. re-running for any - // other reason — e.g. a repo change while the nonce happens to be > 0. + // Why: lets the fetch effect tell a user refresh-click nonce bump (force=true) from a re-run for another reason (e.g. repo change with nonce > 0). const lastFetchedNonceRef = useRef(-1) - // Why: analogous to `lastFetchedNonceRef` for the invalidation nonce. A - // preference flip should force the dispatch past fetch-dedupe (same repos + - // same query, cache just evicted — without `force: true` the fan-out could - // collapse onto a stale in-flight request that resolved against the - // pre-flip source). + // Why: invalidation-nonce analog of lastFetchedNonceRef; a preference flip must force past fetch-dedupe or the fan-out collapses onto a stale in-flight request from the pre-flip source. const lastFetchedInvalidationNonceRef = useRef(0) const paginationGenerationRef = useRef(0) - // Why: entering Tasks with fresh cache should still verify remote status - // once, but the result is reconciled into existing rows to avoid a full - // table shuffle when only status/key fields changed. + // Why: entering Tasks with fresh cache still verifies remote status once, reconciled into existing rows to avoid a full table shuffle. const landingGitHubRefreshKeysRef = useRef>(new Set()) - // Why: divide the display budget between repos so one provider page maps to - // one UI page without truncating rows that later provider pages cannot return. + // Why: split the display budget across repos so one provider page maps to one UI page without truncating rows later pages can't return. const githubPerRepoPageLimit = getTaskPagePerRepoLimit( selectedRepos.length, PER_REPO_FETCH_LIMIT, CROSS_REPO_DISPLAY_LIMIT ) const githubPageSize = githubPerRepoPageLimit * Math.max(1, selectedRepos.length) - // Why: null entries are pages not fetched yet. Numbered provider pages let a - // high-page click load that page directly without rate-limiting intermediate reads. + // Why: null entries are pages not fetched yet; numbered provider pages let a high-page click load directly without reading intermediate pages. const [pages, setPages] = useState<(GitHubWorkItem[] | null)[]>(() => { const trimmed = initialTaskQuery.trim() const merged: GitHubWorkItem[] = [] @@ -3852,10 +3781,7 @@ export default function TaskPage(): React.JSX.Element { setLoadingTargetPage(null) }, [selectedRepos, appliedTaskSearch, workItemsInvalidationNonce]) - // Why: clicking a GitHub row (or completing the create-issue flow) opens - // this dialog for a read/review surface. The dialog's "Use" button routes - // through the same direct-launch flow as the row-level "Use" CTA so - // behavior is consistent regardless of entry point. + // Why: the dialog's "Use" button routes through the same direct-launch flow as the row-level "Use" CTA so behavior is consistent regardless of entry point. const githubTaskDrawerWorkItem = useAppStore((s) => s.githubTaskDrawerWorkItem) const setGithubTaskDrawerWorkItem = useAppStore((s) => s.setGithubTaskDrawerWorkItem) const [dialogInitialTab, setDialogInitialTab] = useState('conversation') @@ -3878,12 +3804,7 @@ export default function TaskPage(): React.JSX.Element { ) ) - // Why: derive the dialog's work item from the store cache so it reflects - // optimistic patches (e.g. table-cell status toggle). Falls back to the - // snapshot stored at click time for newly-created stubs not yet in the cache. - // Disambiguates by repoId so issues with the same number fetched from - // multiple repos (e.g. fork + non-fork, both routed through the same - // upstream) resolve to the clicked row's repo, not the first one scanned. + // Why: derive the dialog item from the cache for optimistic patches, falling back to the click-time snapshot for new stubs; key by repoId so same-number issues across repos resolve to the clicked row. const cachedDialogWorkItem = useAppStore((s) => findTaskPageDialogWorkItem(s.workItemsCache, dialogWorkItemKey) ) @@ -4024,15 +3945,8 @@ export default function TaskPage(): React.JSX.Element { [patchTaskPageWorkItemRows] ) - // Why: feature 1 — render the "Issues from {owner}/{repo}" indicator per - // selected repo whose issue-source and PR-source slugs differ, and surface - // a per-repo retryable banner when the issue-side fetch failed. Both derive - // from the same `workItemsCache` entry the list already consumes, so no - // extra IPC round-trip is needed. The `TaskPageRepoSourceState` shape lives - // with the cache selectors so the render and guard code share one contract. - // Why: subscribe only to the cache entries this page can render. The selector - // returns entry references so Zustand shallow equality filters unrelated - // cache writes before they re-render the full tasks page. + // Why: the per-repo issue-source indicator and retry banner both derive from the same workItemsCache entry, so no extra IPC. + // Why: subscribe only to entries this page renders; the selector returns entry refs so shallow equality filters unrelated cache writes. const perRepoSourceState = useMemo( () => buildTaskPageRepoSourceState(selectedRepos, selectedWorkItemsCacheEntries), [selectedRepos, selectedWorkItemsCacheEntries] @@ -4042,18 +3956,13 @@ export default function TaskPage(): React.JSX.Element { if (taskSource !== 'github' || githubMode !== 'items') { return } - // Why: inline/dialog edits patch `workItemsCache`; the paged table renders - // from a local snapshot so it needs the patched row objects copied across. + // Why: inline/dialog edits patch workItemsCache; the paged table renders from a local snapshot, so copy patched rows across. setPages((current) => reconcileTaskPagePagesWithWorkItemsCache(current, selectedWorkItemsCacheEntries) ) }, [githubMode, selectedWorkItemsCacheEntries, taskSource]) - // Why: surface a one-time toast per session per repo when the user's - // preferred `'upstream'` is no longer configured and we fell back to - // origin. Gated on a ref-backed set so repeated list refreshes don't - // re-toast. We deliberately do NOT auto-reset the preference — the user - // may re-add `upstream` later and expect it to pick up again. + // Why: one-time toast per repo when the 'upstream' preference fell back to origin (ref-gated); deliberately don't auto-reset the preference so re-adding upstream later still applies. const fellBackToastedRef = useRef>(new Set()) useEffect(() => { if (taskSource !== 'github') { @@ -4081,14 +3990,7 @@ export default function TaskPage(): React.JSX.Element { } }, [selectedRepos, selectedWorkItemsCacheEntries, taskSource]) - // Why: on a partial-failure retry the cache still holds successful-side - // data, so `tasksLoading` (which is gated on `anyUncached`) never flips - // true and the Retry button would otherwise give no feedback. Track - // retry-in-flight per selected source so that clicking Retry - // on one banner only flips that source's button into its "Retrying…" - // state — other still-failing banners stay in their "Retry" state rather - // than misleadingly flipping in lockstep. The fetch effect clears the set - // when the nonce-driven refresh settles. + // Why: partial-failure retry leaves the cache populated so tasksLoading never flips, giving no feedback; track retry-in-flight per source so only the clicked banner shows "Retrying…". const [retryingSourceKeys, setRetryingSourceKeys] = useState>(() => new Set()) const handleRetryIssuesFetch = useCallback( @@ -4097,12 +3999,7 @@ export default function TaskPage(): React.JSX.Element { if (!source) { return } - // Why: bumping the shared refresh nonce reuses the Tasks list's - // single fetch path — nonce changes are treated as force=true so - // retry doesn't silently dedupe onto a still-failing in-flight request. - // The nonce bump refreshes ALL selected repos, but the Retrying… - // state is scoped to the clicked source so other banners stay in their - // "Retry" state rather than misleadingly flipping to "Retrying…". + // Why: nonce bump reuses the fetch path as force=true so retry doesn't dedupe onto a still-failing in-flight request (refreshes all repos; Retrying… stays scoped to the clicked source). setRetryingSourceKeys((prev) => { const next = new Set(prev) next.add(source.sourceKey) @@ -4123,20 +4020,11 @@ export default function TaskPage(): React.JSX.Element { const [newIssueAssignees, setNewIssueAssignees] = useState([]) const [newIssueSubmitting, setNewIssueSubmitting] = useState(false) const [newIssueRepoId, setNewIssueRepoId] = useState(null) - // Why: session-only draft slice backs recovery of an in-progress issue after - // an accidental dismissal (outside click/Escape/Cancel) and across a Tasks - // view unmount. Component `useState` stays the inputs' immediate source; the - // store is the durable-across-remount backing. See task-page-new-issue-draft. - // The draft value is read imperatively at open time (getState) rather than - // subscribed: it's only consumed in the `+` open handler, and the write-through - // rewrites it on every keystroke — subscribing would re-render all of TaskPage - // per keystroke while the modal is open. Actions are stable refs (no churn). + // Why: session-only draft recovers an in-progress issue across dismissal/remount; read imperatively (not subscribed) so per-keystroke writes don't re-render all of TaskPage. const setNewIssueDraft = useAppStore((s) => s.setNewIssueDraft) const clearNewIssueDraft = useAppStore((s) => s.clearNewIssueDraft) - // Why: resolve the target repo from the user's choice, falling back to the - // first selected repo if the chosen id drops out of the selection while the - // dialog is open — keeps submit always landing on a valid repo. + // Why: fall back to the first selected repo if the chosen id drops from the selection mid-dialog, so submit always has a valid target. const newIssueTargetRepo = useMemo( () => selectedRepos.find((r) => r.id === newIssueRepoId) ?? selectedRepos[0] ?? null, [selectedRepos, newIssueRepoId] @@ -4177,13 +4065,7 @@ export default function TaskPage(): React.JSX.Element { { runtimeEnvironmentId: newIssueOpen ? (newIssueRuntimeTarget?.environmentId ?? null) : null } ) - // Why: repo-scoped labels/assignees can't cross repos. A reactive clear keyed - // on the derived target id can't tell a restore apart from a user switch, so - // it would wipe just-restored fields and corrupt the recovery draft via the - // write-through below. Decompose by cause instead: this guard only handles the - // "chosen repo vanished from the selection" case (removed/deselected). A - // genuine user switch clears imperatively in the repo Select's handler; a - // restore always seeds an in-selection repoId, so neither path fires here. + // Why: only handles the "chosen repo vanished" case; a reactive clear keyed on target id can't tell a restore from a user switch and would wipe the recovery draft. useEffect(() => { const reset = resolveVanishedNewIssueRepoReset( newIssueRepoId, @@ -4197,10 +4079,7 @@ export default function TaskPage(): React.JSX.Element { setNewIssueRepoId(reset.repoId) }, [newIssueRepoId, selectedRepos]) - // Why: mirror the live fields into the session draft while the modal is open - // so an accidental dismissal doesn't lose input. Content-gate the write so an - // untouched open never pins a meaningless draft (repoId alone is not content), - // and clear any stale draft once the form is emptied back out. + // Why: content-gated mirror of live fields into the session draft while the modal is open, so dismissal doesn't lose input. useEffect(() => { if (!newIssueOpen) { return @@ -4239,9 +4118,7 @@ export default function TaskPage(): React.JSX.Element { useState(null) const [selectedLinearIssueCanFloat, setSelectedLinearIssueCanFloat] = useState(false) - // Why: the Linear list keeps its own fetched array, while cell edits patch - // the shared caches. Subscribing to just the Linear caches lets the list and - // inline detail reflect optimistic mutations without a second durable cache. + // Why: subscribe to just the Linear caches so list and inline detail reflect optimistic cell edits without a second cache. const linearCacheSnapshot = useAppStore( useShallow((s) => ({ issueCache: s.linearIssueCache, @@ -4717,8 +4594,7 @@ export default function TaskPage(): React.JSX.Element { setJiraSearchInput(jiraQuery) setAppliedJiraSearch(jiraQuery) - // Why: settings and persisted UI hydrate asynchronously. Apply the restored - // Tasks context exactly once so later source/filter clicks remain local. + // Why: settings/UI hydrate async; apply the restored Tasks context exactly once so later source/filter clicks stay local. taskResumeAppliedRef.current = true setTaskResumeApplied(true) }, [ @@ -4825,9 +4701,7 @@ export default function TaskPage(): React.JSX.Element { taskSource ]) - // Why: fetch the full team list from the Linear API so the selector shows - // all teams the user belongs to, not just teams with issues in the current - // fetch window. Fetched once when the Linear tab is active and connected. + // Why: fetch the full Linear team list so the selector shows all teams, not just those with issues in the fetch window. const [availableTeams, setAvailableTeams] = useState([]) const [linearTeamRefreshNonce, setLinearTeamRefreshNonce] = useState(0) @@ -4843,9 +4717,7 @@ export default function TaskPage(): React.JSX.Element { const cachedTeams = getCachedLinearTeams(selectedLinearWorkspaceId, { sourceContext: linearTaskSourceContext }) - // Why: workspace switches must not leave the prior workspace's teams - // available for new-issue creation while the replacement fetch is pending, - // but a workspace-scoped cache can keep the selector usable immediately. + // Why: on a workspace switch, drop the prior workspace's teams during the pending fetch but seed from the workspace-scoped cache. setAvailableTeams(cachedTeams ?? []) void listLinearTeams(selectedLinearWorkspaceId, { sourceContext: linearTaskSourceContext }) .then((teams) => { @@ -4916,10 +4788,7 @@ export default function TaskPage(): React.JSX.Element { jiraTaskSourceContext ]) - // Why: stable key for `selectedRepos` so the GitLab fetch effect below - // doesn't re-run on every parent re-render just because the array - // reference changed. The memoized string keys off id + path + - // connectionId — the only fields the effect actually reads. + // Why: stable string key for selectedRepos so the GitLab effect doesn't re-run on every parent render from a new array ref. const selectedReposKey = useMemo( () => selectedRepos @@ -4928,10 +4797,7 @@ export default function TaskPage(): React.JSX.Element { [selectedRepos] ) - // Why: GitLab task-source data fetch. Issues and MRs are fetched - // separately (mirrors GitHub's separate Issues / PRs endpoints) so - // errors are isolated per tab and the backend doesn't need a combined - // merge+sort that can hide failures. + // Why: fetch GitLab Issues and MRs separately so errors stay isolated per tab (mirrors GitHub's split endpoints). useEffect(() => { if (taskSource !== 'gitlab') { return @@ -4949,8 +4815,7 @@ export default function TaskPage(): React.JSX.Element { ) { return } - // Why: folder-mode repos have no remotes to derive a GitLab project from; - // SSH-backed Git repos go through the same provider-aware IPC path. + // Why: folder-mode repos lack remotes to derive a GitLab project from; SSH-backed repos use the same provider-aware IPC path. const eligibleRepos = selectedRepos if (eligibleRepos.length === 0) { setGitlabItems([]) @@ -4980,9 +4845,7 @@ export default function TaskPage(): React.JSX.Element { items: GitLabWorkItem[] error?: { type?: string; message: string } } - // Why: not_found just means "this repo isn't a GitLab project" - // (e.g. a GitHub-only repo in a mixed selection). Drop it - // silently so the GitLab list doesn't show false errors. + // Why: not_found just means the repo isn't a GitLab project (mixed selection); drop it so the list shows no false errors. const error = typed.error?.type === 'not_found' ? undefined : typed.error return { repoId: repo.id, items: typed.items, error } }) @@ -5027,9 +4890,7 @@ export default function TaskPage(): React.JSX.Element { } merged.sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? '')) setGitlabItems(merged) - // Why: only surface an error banner when EVERY eligible repo failed. - // Mixed selections often include non-GitLab repos, and a partial - // banner would hide the working rows. + // Why: only banner when every eligible repo failed; a partial one would hide working rows in a mixed (non-GitLab) selection. if (errs.length > 0 && merged.length === 0) { setGitlabError(errs[0]) } @@ -5045,9 +4906,7 @@ export default function TaskPage(): React.JSX.Element { // eslint-disable-next-line react-hooks/exhaustive-deps -- selectedReposKey encodes the only selectedRepos fields read above; keying off the array ref would re-run on every parent render. }, [taskSource, gitlabView, activeGitlabFilter, gitlabRefreshNonce, selectedReposKey]) - // Why: Todos fetch lives in its own effect — different trigger - // condition from the project view (no chip filter dependence) and a - // different data path (`gl.todos` is user-scoped, not repo-scoped). + // Why: Todos fetch has its own effect — different trigger (no chip filter) and data path (gl.todos is user-scoped, not repo-scoped). useEffect(() => { if (taskSource !== 'gitlab' || gitlabView !== 'todos') { return @@ -5208,8 +5067,7 @@ export default function TaskPage(): React.JSX.Element { return teams.sort((a, b) => a.name.localeCompare(b.name)) }, [displayedLinearIssues]) - // Why: the full Linear team fetch is async and can temporarily be empty. - // Keep the selector usable from issue metadata until the complete list lands. + // Why: the full team fetch is async and briefly empty; keep the selector usable from issue metadata until the list lands. const linearTeamOptions = useMemo(() => { if (availableTeams.length === 0) { return linearIssueTeams @@ -5226,8 +5084,7 @@ export default function TaskPage(): React.JSX.Element { }) }, [availableTeams, linearIssueTeams]) - // Why: team IDs belong to one Linear workspace. Switching workspaces while a - // saved subset exists must not leave the task list filtered by stale team IDs. + // Why: team IDs belong to one workspace, so a workspace switch must not leave the list filtered by stale team IDs. useEffect(() => { if (linearTeamOptions.length === 0) { return @@ -5247,8 +5104,7 @@ export default function TaskPage(): React.JSX.Element { ) const applyLinearAttributeFilter = useCallback((next: LinearIssueAttributeFilter) => { - // Why: batch filter + limit/page reset in one transition so the fetch - // effect never issues an old expanded-limit request for the new filter. + // Why: batch filter + limit/page reset so the fetch effect never issues an old expanded-limit request for the new filter. setLinearAttributeFilter(next) setLinearIssueLimit(LINEAR_ITEM_LIMIT) setLinearIssuePage(0) @@ -5272,8 +5128,7 @@ export default function TaskPage(): React.JSX.Element { if (previousId === null || previousId === nextId) { return } - // Why: status/assignee/labels are team-scoped; clearing them is a filter change - // and must reset limit/page via applyLinearAttributeFilter (R6), not a bare set. + // Why: team-scoped facets; clearing them is a filter change, so reset limit/page via applyLinearAttributeFilter (R6), not a bare set. const next = teamDerivedFacetsForPrimaryTeamChange(linearAttributeFilter) if ( linearIssueAttributeFilterSignature(linearAttributeFilter) === @@ -5292,8 +5147,7 @@ export default function TaskPage(): React.JSX.Element { if (activeLinearIssueContextLabel) { return displayedLinearIssues } - // Why: team options can be derived after issue rows render. Treat an - // empty selection as "all" until reconciliation has a concrete team set. + // Why: team options can arrive after issue rows render; treat an empty selection as "all" until reconciliation sets teams. if (displayedLinearIssues.length > 0 && linearTeamSelection.size === 0) { return displayedLinearIssues } @@ -5374,9 +5228,7 @@ export default function TaskPage(): React.JSX.Element { return } - // Why: unlike GitHub's cursor pages, Linear reads are cached as an - // expanded prefix. Jumping to a new page first expands the prefix, then - // commits the page when the fetch returns enough rows. + // Why: Linear reads are cached as an expanded prefix; a page jump expands it and commits once enough rows arrive. setActiveLinearIssueLoadingTargetPage(page) ensureActiveLinearIssueLimit((page + 1) * LINEAR_ITEM_LIMIT) }, @@ -5410,9 +5262,7 @@ export default function TaskPage(): React.JSX.Element { return } - // Why: Linear can return more backend rows without immediately filling the - // next visible page after local team filtering. Keep expanding the prefix - // until the requested page exists or Linear reports exhaustion. + // Why: local filtering can leave the next page short, so keep expanding the prefix until the page exists or Linear is exhausted. ensureActiveLinearIssueLimit(activeLinearIssueLimit + LINEAR_ITEM_LIMIT) }, [ activeLinearIssueCanRequestMore, @@ -5461,8 +5311,7 @@ export default function TaskPage(): React.JSX.Element { next.delete(groupedProperty) } - // Why: a Team column repeats the same value when one team is selected. - // Keep it hidden until the user explicitly opts back into that property. + // Why: a Team column repeats the same value when one team is selected; keep it hidden until the user opts back in. if (linearTeamSelection.size <= 1 && !linearTeamPropertyTouched) { next.delete('team') } else if (linearTeamSelection.size > 1 && !linearTeamPropertyTouched) { @@ -5795,8 +5644,7 @@ export default function TaskPage(): React.JSX.Element { } }) return () => { - // Why: project lists are workspace-scoped; stale responses must not - // populate the composer after a team/workspace switch. + // Why: project lists are workspace-scoped; stale responses must not populate the composer after a team/workspace switch. cancelled = true } }, [ @@ -5809,8 +5657,7 @@ export default function TaskPage(): React.JSX.Element { ]) useEffect(() => { - // Why: the selected team can change indirectly when the available Linear - // teams/workspace list refreshes, even if the explicit picker value did not. + // Why: the selected team can change indirectly when the Linear teams/workspace list refreshes, even if the picker value didn't. setNewLinearIssueStateId(null) setNewLinearIssueAssigneeId(null) setNewLinearIssuePriority(0) @@ -6132,8 +5979,7 @@ export default function TaskPage(): React.JSX.Element { } }) return () => { - // Why: create fields are scoped to project + issue type; ignore late - // responses after the user switches either selector. + // Why: create fields are scoped to project + issue type; ignore late responses after switching either selector. cancelled = true } }, [ @@ -6145,9 +5991,7 @@ export default function TaskPage(): React.JSX.Element { jiraTaskSourceContext ]) - // Why: defense-in-depth safety net applied to the current page's items. - // The active tab scopes requests to issues or PRs, and this keeps stale - // cache rows from leaking across the split tabs. + // Why: defense-in-depth — keep stale cache rows from leaking across the issue/PR split tabs. const applyTypeFilter = useCallback( (items: GitHubWorkItem[]) => { return items.filter((item) => { @@ -6250,8 +6094,7 @@ export default function TaskPage(): React.JSX.Element { lastLoadedPageIndex = index } } - // Why: when counts fail, a full loaded page is enough evidence to expose one - // more page without pretending unfetched sparse entries are empty results. + // Why: when counts fail, a full loaded page is enough evidence to expose one more page without faking empty results. const lastLoadedPageFull = (pages[lastLoadedPageIndex]?.length ?? 0) >= Math.max(1, githubPageSize) const fallbackTotalPages = lastLoadedPageFull @@ -6262,8 +6105,7 @@ export default function TaskPage(): React.JSX.Element { ? Math.max(pages.length, countedTotalPages) : fallbackTotalPages - // Why: numbered provider pages support random access. Load only the clicked - // page so a high-page jump does not exhaust GitHub's Search API rate bucket. + // Why: load only the clicked page so a high-page jump doesn't exhaust GitHub's Search API rate bucket. const handleLoadNextPage = useCallback( async (targetPage?: number) => { if (paginationLoading || selectedRepos.length === 0) { @@ -6346,12 +6188,7 @@ export default function TaskPage(): React.JSX.Element { githubSearchPersistReadyRef.current = true return } - // Why: persist the debounced applied query regardless of the active - // preset. The preset-click handler writes the canonical query for that - // preset, so persisting again here is at worst idempotent. When the - // user types into the search box `handleTaskSearchChange` clears the - // preset, but persisting unconditionally also covers paths that change - // appliedTaskSearch without going through that handler. + // Why: persist the applied query unconditionally to cover paths that change appliedTaskSearch outside the preset handler. setTaskResumeState({ githubItemsPreset: activeTaskPreset, githubItemsQuery: appliedTaskSearch.trim() @@ -6362,11 +6199,7 @@ export default function TaskPage(): React.JSX.Element { if (!taskResumeApplied) { return } - // Why: both early-return branches must clear `retryingSourceKeys` — if the - // user clicks Retry and then switches `taskSource` away from 'github' (or - // somehow ends up with zero repos selected) before the fetch dispatches, - // neither the `.then` nor the `.catch` below will fire, and the Retry - // button would stay stuck in its disabled/Retrying state indefinitely. + // Why: both early-return branches must clear retryingSourceKeys — if they fire, neither .then nor .catch runs and Retry stays stuck. if (taskSource !== 'github' || githubMode !== 'items') { setRetryingSourceKeys(new Set()) setTasksRefreshing(false) @@ -6380,16 +6213,11 @@ export default function TaskPage(): React.JSX.Element { return } // unreachable — multi-combobox forbids empty - // Why: `repo:owner/name` qualifiers are silently dropped before fan-out - // because in cross-repo mode they would pin every per-repo fetch to a - // single repo and zero out the rest. See stripRepoQualifiers. + // Why: strip repo:owner/name qualifiers before fan-out — cross-repo they'd pin every fetch to one repo. See stripRepoQualifiers. const q = stripRepoQualifiers(appliedTaskSearch.trim()) let cancelled = false - // Why: paint cached rows synchronously before awaiting the fan-out so - // selection changes don't leave the previous selection's rows on screen - // for a frame. Any repo without a cache entry simply contributes nothing - // to this pre-paint; the fetch will fill it in. + // Why: paint cached rows synchronously before the fan-out so a selection change doesn't leave the prior rows on screen for a frame. const preMerged: GitHubWorkItem[] = [] let anyUncached = false let anyRepoCached = false @@ -6408,9 +6236,7 @@ export default function TaskPage(): React.JSX.Element { preMerged.push(...cached) } } - // Why: always replace — if preMerged is empty (e.g. query just changed and - // no repo has a cache entry for it), we clear the previous query's rows - // rather than leaving them on screen under the spinner. + // Why: always replace — an empty preMerged clears the previous query's rows instead of leaving them under the spinner. const page0 = preMerged.length > 0 ? sortWorkItemsByNumber(preMerged).slice(0, githubPageSize) : [] setPages([page0]) @@ -6424,10 +6250,7 @@ export default function TaskPage(): React.JSX.Element { // Preserve the existing nonce-gated force behavior. const forceRefresh = taskRefreshNonce !== lastFetchedNonceRef.current lastFetchedNonceRef.current = taskRefreshNonce - // Why: a preference flip bumps `workItemsInvalidationNonce`. Treat that - // bump as a forced refresh so the fan-out bypasses the in-flight dedupe - // map — otherwise an overlapping request started before the flip could - // resolve the new fetch and repopulate the cache with pre-flip data. + // Why: treat a preference-flip nonce bump as a forced refresh so it bypasses the dedupe map and can't reuse pre-flip data. const preferenceInvalidated = workItemsInvalidationNonce !== lastFetchedInvalidationNonceRef.current lastFetchedInvalidationNonceRef.current = workItemsInvalidationNonce @@ -6447,27 +6270,16 @@ export default function TaskPage(): React.JSX.Element { landingRefreshKey ]) } - // Why: manual refresh keeps cached rows visible, so the normal - // `tasksLoading` flag may stay false. Track the forced fetch separately - // so the toolbar still shows a refresh-in-progress affordance. + // Why: manual refresh keeps cached rows (tasksLoading stays false), so track forced fetch separately for the toolbar spinner. setTasksRefreshing(forcedFetch) - // Why: snapshot the retrying source keys at effect-dispatch so overlapping - // retries don't clear each other's pending state. An earlier cancelled - // effect settling after a newer retry starts would otherwise wipe the - // newer retry's source from the set. Clearing only the keys captured - // when this effect dispatched preserves later additions. + // Why: snapshot retrying keys at dispatch so an earlier settling effect doesn't wipe a newer retry's pending source. const dispatchedRetrySourceKeys = retryingSourceKeys void fetchWorkItemsAcrossRepos(repoArgs, githubPerRepoPageLimit, githubPageSize, q, { ...deriveTaskPageGitHubWorkItemsFetchOptions(forcedFetch, shouldProbeOnLanding) }) .then(({ items, failedCount: failed, githubUnavailable: unavailable }) => { - // Why: clear only the sources this effect was responsible for - // retrying (the snapshot captured at dispatch time). Overlapping - // retries — a second click while a prior fetch is still in flight - // — must not clear the newer source from the set, so we can't just - // reset the whole set here. The early-return branches above reset - // the whole set because those branches won't dispatch a fetch. + // Why: clear only the dispatch-time snapshot keys so an overlapping retry's newer source isn't wiped. setRetryingSourceKeys((prev) => { if (dispatchedRetrySourceKeys.size === 0) { return prev @@ -6499,14 +6311,8 @@ export default function TaskPage(): React.JSX.Element { setTasksFiltering(false) }) .catch((err) => { - // Why: fetchWorkItemsAcrossRepos swallows per-repo failures, so a - // reject here means an IPC-level or programmer error — surface it. - // Clear only the sources this effect was responsible for retrying - // (the snapshot captured at dispatch time). Overlapping retries — - // a second click while a prior fetch is still in flight — must - // not clear the newer source from the set, so we can't just reset - // the whole set here. The early-return branches above reset the - // whole set because those branches won't dispatch a fetch. + // Why: fetchWorkItemsAcrossRepos swallows per-repo failures, so a reject here is IPC/programmer error — surface it. + // Why: clear only the dispatch-time snapshot keys so an overlapping retry's newer source isn't wiped. setRetryingSourceKeys((prev) => { if (dispatchedRetrySourceKeys.size === 0) { return prev @@ -6528,9 +6334,7 @@ export default function TaskPage(): React.JSX.Element { setTasksFiltering(false) }) - // Why: fire-and-forget count query in parallel with the items fetch. - // The search API is cached 120s server-side so this doesn't add - // meaningful latency or rate-limit pressure. + // Why: fire-and-forget count query alongside the items fetch; the search API is cached 120s server-side so it adds little cost. void countWorkItemsAcrossRepos( selectedRepos.map((r) => ({ repoId: r.id, @@ -6549,10 +6353,7 @@ export default function TaskPage(): React.JSX.Element { return () => { cancelled = true } - // Why: getCachedWorkItems and fetchWorkItemsAcrossRepos are stable zustand - // selectors; depending on them would re-run the effect on unrelated store - // updates. `workItemsInvalidationNonce` is explicitly included so a - // preference flip (which only evicts cache) re-dispatches this effect. + // Why: store selectors are stable (omit from deps); workItemsInvalidationNonce included so a preference flip re-dispatches. // eslint-disable-next-line react-hooks/exhaustive-deps }, [ selectedRepos, @@ -6567,9 +6368,7 @@ export default function TaskPage(): React.JSX.Element { const applyPRFilterChange = useCallback( (change: PRFilterChange): void => { let next = scopeGitHubTaskSearch(taskSearchInput, activeGithubTaskKind) - // Why: each filter dropdown contributes an independent qualifier patch. - // `withQualifier` round-trips through parseTaskQuery so prior filters and - // free-text are preserved. + // Why: withQualifier round-trips through parseTaskQuery so each dropdown's patch preserves prior filters and free-text. if ('author' in change) { next = withQualifier(next, 'author', change.author ?? null) } @@ -6589,9 +6388,7 @@ export default function TaskPage(): React.JSX.Element { next = withQualifier(next, 'draft', change.draft ? 'true' : 'false') } if ('reviewer' in change) { - // Why: the two reviewer qualifiers (review-requested vs reviewed-by) are - // mutually exclusive in the PR-list UX — clear the other axis whenever - // one is set so the chip's visible state matches the actual query. + // Why: the two reviewer qualifiers are mutually exclusive — clear the other whenever one is set so the chip matches the query. const reviewer = change.reviewer ?? null if (reviewer === null) { next = withQualifier(next, 'reviewRequested', null) @@ -6608,9 +6405,7 @@ export default function TaskPage(): React.JSX.Element { setAppliedTaskSearch(next) setActiveTaskPreset(null) setTaskResumeState({ githubItemsPreset: null, githubItemsQuery: next }) - // Why: filter changes replace the meaning of every row. Showing stale - // rows while the filtered query resolves reads like the filter did - // nothing, so render the same skeleton used for an uncached initial load. + // Why: a filter change replaces every row's meaning; show the load skeleton so stale rows don't read as if the filter did nothing. setTasksFiltering(true) setTaskRefreshNonce((current) => current + 1) }, @@ -6633,8 +6428,7 @@ export default function TaskPage(): React.JSX.Element { const scoped = scopeGitHubTaskSearch(next, activeGithubTaskKind) setTaskSearchInput(next) setActiveTaskPreset(null) - // Why: the visible rows are keyed by appliedTaskSearch, not the draft - // input. Hide stale rows as soon as the draft would change the query. + // Why: visible rows are keyed by appliedTaskSearch, not the draft input; hide stale rows once the draft changes the query. setTasksFiltering(scoped !== appliedTaskSearch) }, [activeGithubTaskKind, appliedTaskSearch] @@ -6642,9 +6436,7 @@ export default function TaskPage(): React.JSX.Element { const handleSetDefaultTaskPreset = useCallback( (presetId: TaskViewPresetId): void => { - // Why: the default task view is a durable preference, so right-clicking a - // preset updates the persisted settings instead of only changing the - // current page state. + // Why: the default task view is a durable preference, so persist it instead of only changing page state. void updateSettings({ defaultTaskViewPreset: presetId }).catch(() => { toast.error( translate('auto.components.TaskPage.fe380f306c', 'Failed to save default task view.') @@ -6870,8 +6662,7 @@ export default function TaskPage(): React.JSX.Element { labels: newIssueLabels, assignees: newIssueAssignees.map((assignee) => assignee.login) }, - // Why: oversized-body recovery can require two 30-second writes - // after GitHub rejects the initial create request. + // Why: oversized-body recovery can need two 30s writes after GitHub rejects the initial create. { timeoutMs: 65_000 } ) : await window.api.gh.createIssue({ @@ -6913,8 +6704,7 @@ export default function TaskPage(): React.JSX.Element { } setNewIssueOpen(false) if (result.bodySaveWarning) { - // Why: retain the unsaved body for recovery, but clear the title so - // reopening the composer cannot repeat the create with one click. + // Why: keep the unsaved body for recovery but clear the title so reopening can't one-click repeat the create. setNewIssueTitle('') setNewIssueDraft({ title: '' }) } else { @@ -6922,16 +6712,13 @@ export default function TaskPage(): React.JSX.Element { setNewIssueBody('') setNewIssueLabels([]) setNewIssueAssignees([]) - // Why: only a complete success discards the recovery draft; a partial - // body save keeps the original text available without another create. + // Why: only a complete success discards the recovery draft; a partial body save keeps the text for recovery. clearNewIssueDraft() } // Why: bump the nonce so the list refetches and shows the new issue. setTaskRefreshNonce((current) => current + 1) - // Why: auto-open the new issue in the dialog so the user sees - // exactly what was filed. Use an optimistic stub first so the dialog - // has immediate content, then refine with the full `workItem` fetch. + // Why: auto-open the new issue with an optimistic stub for immediate content, then refine with the full workItem fetch. const stub: GitHubWorkItem = { id: `issue:${String(result.number)}`, repoId: newIssueTargetRepo.id, @@ -6971,10 +6758,7 @@ export default function TaskPage(): React.JSX.Element { void fullIssuePromise .then((full) => { if (full) { - // Why: `full` is `Omit` (IPC shape). - // Cast through unknown: spreading a discriminated union loses the - // discriminant, so `{ ...full, repoId }` doesn't typecheck as - // GitHubWorkItem. The runtime shape is correct by construction. + // Why: cast through unknown — spreading the discriminated union loses the discriminant, so { ...full, repoId } won't typecheck. const withRepoId = { ...full, repoId: stubRepoId } as unknown as GitHubWorkItem setDialogWorkItem(withRepoId) } @@ -7155,8 +6939,7 @@ export default function TaskPage(): React.JSX.Element { setLinearRefreshNonce((n) => n + 1) useAppStore.getState().recordFeatureInteraction('linear-tasks') - // Why: auto-select the new issue in the inline workspace so the user - // sees exactly what was filed, mirroring the GitHub create-issue flow. + // Why: auto-select the new issue so the user sees exactly what was filed (mirrors the GitHub create-issue flow). void linearGetIssue( linearTaskSourceContext ?? settings, result.id, @@ -7255,8 +7038,7 @@ export default function TaskPage(): React.JSX.Element { return } if (full) { - // Why: the list cache may still be fresh after create; insert the - // new row locally before selecting it so the inspector stays open. + // Why: list cache may still be fresh after create; insert the new row locally before selecting so the inspector stays open. setJiraIssues((prev) => [full, ...prev.filter((issue) => issue.key !== full.key)]) setSelectedJiraIssue(full) } @@ -7308,9 +7090,7 @@ export default function TaskPage(): React.JSX.Element { return } - // Why: Esc should first dismiss the focused control so users can back - // out of text entry without accidentally closing the whole page. - // Once focus is already outside an input, Esc closes the tasks page. + // Why: Esc first blurs a focused input so it doesn't accidentally close the whole page; only closes once focus is outside an input. if ( target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || @@ -7363,8 +7143,7 @@ export default function TaskPage(): React.JSX.Element { refreshPreflightStatus ]) - // Why: debounce the Linear search input so we don't fire a request on every - // keystroke — matches the 300ms cadence used for GitHub search. + // Why: debounce the Linear search input so we don't fire a request per keystroke (300ms, matching GitHub search). useEffect(() => { if (!taskResumeApplied) { return @@ -7399,8 +7178,7 @@ export default function TaskPage(): React.JSX.Element { taskSource ]) - // Why: fetch Linear issues when the tab is active and connected. Empty search - // uses the plain `all` list with optional server-side attribute filters. + // Why: fetch Linear issues when the tab is active and connected; empty search uses the `all` list with server-side filters. useEffect(() => { if (!taskResumeApplied) { return @@ -7480,8 +7258,7 @@ export default function TaskPage(): React.JSX.Element { ]) } - // Why: cached rows should remain visible on navigation. Only an explicit - // refresh or a true cache miss needs the blocking loading state. + // Why: keep cached rows visible on navigation; only explicit refresh or a true cache miss shows the blocking loading state. setLinearLoading(forceRefresh || cachedResult === null) const request = @@ -7542,8 +7319,7 @@ export default function TaskPage(): React.JSX.Element { return () => { cancelled = true } - // Why: searchLinearIssues and listLinearIssues are stable zustand selectors; - // depending on them would re-run the effect on unrelated store updates. + // Why: searchLinearIssues/listLinearIssues are stable selectors; adding them would re-run the effect on unrelated store updates. // eslint-disable-next-line react-hooks/exhaustive-deps }, [ taskSource, @@ -7729,8 +7505,7 @@ export default function TaskPage(): React.JSX.Element { const force = linearRefreshNonce > 0 setLinearCustomViewsLoading(force || !allCached) setLinearCustomViewsError(null) - // Why: the Views tab already has a Model column, so listing both view - // models avoids a second, redundant Issues/Projects switch. + // Why: the Views tab already has a Model column, so list both models rather than add a redundant Issues/Projects switch. void Promise.all( LINEAR_CUSTOM_VIEW_MODELS.map((model) => listLinearCustomViews(model, LINEAR_ITEM_LIMIT, undefined, { @@ -7843,10 +7618,7 @@ export default function TaskPage(): React.JSX.Element { return } - // Why: the corrected Linear surface is list-first. Keep an open inspector - // only while its issue remains in the current filter instead of auto-opening - // the first row and turning the list back into navigation chrome. Related - // sub-issue navigation is allowed to stay open because it is user-directed. + // Why: list-first — keep an open inspector only while its issue stays in the filter, not auto-open row 1; user-directed sub-issue nav stays. if ( selectedLinearIssueId && !selectedLinearIssueCanFloat && @@ -7992,10 +7764,7 @@ export default function TaskPage(): React.JSX.Element { taskSource ]) - // Why: for Linear issues the "Use" flow opens the composer with the issue - // info adapted to the LinkedWorkItemSummary shape. Linear identifiers are - // strings (e.g. "ENG-123") so we use 0 as a placeholder number since the - // provider-generic work item shape still expects numeric issue metadata. + // Why: Linear ids are strings (e.g. "ENG-123") but the provider-generic shape needs a numeric number, so the adapter uses 0 as placeholder. const openComposerForLinearItem = useCallback( (issue: LinearIssue): void => { const linkedWorkItem = buildLinearIssueLinkedWorkItem(issue) @@ -8011,10 +7780,7 @@ export default function TaskPage(): React.JSX.Element { const handleUseLinearItem = useCallback( (issue: LinearIssue): void => { - // Why: same rationale as handleUseWorkItem — open the New Workspace - // dialog pre-filled rather than yolo-creating the worktree, so the - // user can confirm name / agent / setup before the worktree lands in - // the sidebar. Telemetry attribution flows via openComposerForLinearItem. + // Why: like handleUseWorkItem — open the pre-filled dialog instead of creating the worktree directly, so the user confirms name/agent/setup. useAppStore.getState().recordFeatureInteraction('linear-tasks') openComposerForLinearItem(issue) }, @@ -8129,12 +7895,7 @@ export default function TaskPage(): React.JSX.Element { return (
- {/* Why: pt-1.5 vertically centers this row's 32px icon cluster (X + - source toggles) with the sidebar's "Tasks" nav row. Sidebar Tasks - center sits 22px below the titlebar (pt-2 + py-1.5 + half size-4 - icon). Matching that here needs 6px top padding above the 32px - cluster (6 + 16 = 22). The previous pt-3 placed the cluster 6px - too low, breaking the visual band across the top chrome. */} + {/* Why: pt-1.5 (6px) aligns this 32px icon cluster's center with the sidebar Tasks row, 22px below the titlebar. */}
- {/* Why: Close is anchored left in the same row as the - source icons so the top chrome is one compact band. - Left-aligned keeps it clear of the app sidebar on the - right edge. */} + {/* Why: Close is anchored left with the source icons for one compact band, clear of the app sidebar on the right. */}