From e218dfbf8b064324314532aa5c1ed868d64d9d28 Mon Sep 17 00:00:00 2001 From: hoonsbory <53922603+hoonsbory@users.noreply.github.com> Date: Tue, 21 Jul 2026 07:05:23 +0900 Subject: [PATCH 01/58] fix:Keep Jira linked work items attached when the composer project changes (#7770) * Keep Jira linked work items attached when the composer project changes Repo/project switches in the new-workspace composer cleared every linked work item except Linear, so starting a workspace from a Jira task and then picking the actual implementation project silently dropped the ticket link. Route all three switch paths through a shared isRepoScopedLinkedWorkItem predicate: GitHub/GitLab sources stay repo-scoped and clear on a switch, Linear/Jira issues stay attached. * test(new-workspace): drop dead isLinearLinkedWorkItem, harden preserve-predicate coverage Follow-up to the Jira-preservation fix (review findings): - Remove isLinearLinkedWorkItem: no production consumer remains after all three composer switch paths route through shouldPreserveWorkspaceSourceOnRepoChange. - Pin the clear cases in workspace-source.test.ts (GitLab explicit + inferred, null) that both delegating paths depend on, not just GitHub. Co-authored-by: Orca --------- Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Co-authored-by: Orca --- ...poserState-host-context-boundaries.test.ts | 28 +++- src/renderer/src/hooks/useComposerState.ts | 19 +-- .../new-workspace/workspace-source.test.ts | 41 +++++ ...rkspace-linked-item-project-switch.spec.ts | 141 ++++++++++++++++++ 4 files changed, 219 insertions(+), 10 deletions(-) create mode 100644 tests/e2e/new-workspace-linked-item-project-switch.spec.ts diff --git a/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts b/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts index c46bd3ea5c2..3960141bacc 100644 --- a/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts +++ b/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts @@ -124,6 +124,32 @@ describe('useComposerState host-context boundaries', () => { expect(section).toMatch(/\.catch\(\(error: unknown\) =>/) }) + it('clears only repo-scoped linked work items when the repo or project changes', () => { + // Why: Linear and Jira issues are workspace-scoped context — a repo or + // project switch must keep them attached. Jira used to be dropped because + // this path special-cased Linear only. + const repoChangeSection = sourceBetween( + HOOK_SOURCE, + 'const handleRepoChange', + 'const handleFolderSourceRepoChange' + ) + expect(repoChangeSection).toContain( + '!shouldPreserveWorkspaceSourceOnRepoChange(linkedWorkItem)' + ) + + const folderSourceSection = sourceBetween( + HOOK_SOURCE, + 'const handleFolderSourceRepoChange', + 'const handleProjectHostSetupChange' + ) + expect(folderSourceSection).toContain('!shouldPreserveWorkspaceSourceOnRepoChange(current)') + + // No switch path may gate the linked-item clear on a Linear-only predicate + // again. (isLinearLinkedWorkItem itself may still appear — it drives the + // separate Linear branch-name feature — but never the preservation decision.) + expect(HOOK_SOURCE).not.toContain('if (!preserveLinearLinkedWorkItem)') + }) + it('does not use local SSH gates for runtime-owned folder targets', () => { const targetSection = sourceBetween( HOOK_SOURCE, @@ -568,7 +594,7 @@ describe('useComposerState host-context boundaries', () => { 'const handleProjectChange = useCallback', 'const handleSmartGitHubItemSelect' ) - expect(section).toContain("linkedProvider !== 'linear' && linkedProvider !== 'jira'") + expect(section).toContain('!shouldPreserveWorkspaceSourceOnRepoChange(linkedWorkItem)') }) it('resolves quick-create base refs through the worktree-create precedence helper', () => { diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index accc351ca50..7afcbc308af 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -149,7 +149,8 @@ import type { SmartNameMode } from '@/components/new-workspace/smart-workspace-s import { getForkPushWarning } from './fork-push-warning' import { buildWorkspaceSourceSelection, - shouldApplyWorkspaceSourceAutoName + shouldApplyWorkspaceSourceAutoName, + shouldPreserveWorkspaceSourceOnRepoChange } from '../../../shared/new-workspace/workspace-source' import { CONTEXTUAL_TOUR_ENABLE_AUTO_WORKSPACE_NAME_EVENT } from '@/components/contextual-tours/contextual-tour-composer-events' import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed' @@ -2525,8 +2526,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS setLinkedPR(null) setLinkedGitLabIssue(null) setLinkedGitLabMR(null) - // Why: a repo change invalidates repo-scoped sources, but a Linear issue is workspace-scoped and must survive choosing the implementation project. - if (!preserveLinearLinkedWorkItem) { + // Why: a repo change invalidates repo-scoped sources, but Linear and + // Jira issues are workspace-scoped and must survive choosing the + // implementation project — not just Linear. + if (linkedWorkItem && !shouldPreserveWorkspaceSourceOnRepoChange(linkedWorkItem)) { setLinkedWorkItem(null) } } @@ -2559,10 +2562,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS } setRepoId(value) smartGitHubPrStartPointSelectionRef.current = null - setLinkedWorkItem((current) => { - const provider = current ? getLinkedWorkItemProvider(current) : null - return provider === 'github' || provider === 'gitlab' ? null : current - }) + setLinkedWorkItem((current) => + current && !shouldPreserveWorkspaceSourceOnRepoChange(current) ? null : current + ) setLinkedIssue('') setLinkedPR(null) setLinkedGitLabIssue(null) @@ -2607,8 +2609,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS setLinkedPR(null) setLinkedGitLabIssue(null) setLinkedGitLabMR(null) - const linkedProvider = linkedWorkItem ? getLinkedWorkItemProvider(linkedWorkItem) : null - if (linkedWorkItem && linkedProvider !== 'linear' && linkedProvider !== 'jira') { + if (linkedWorkItem && !shouldPreserveWorkspaceSourceOnRepoChange(linkedWorkItem)) { setLinkedWorkItem(null) } setSparseEnabled(false) diff --git a/src/shared/new-workspace/workspace-source.test.ts b/src/shared/new-workspace/workspace-source.test.ts index ca1ea34b3a8..1f9a50f8c45 100644 --- a/src/shared/new-workspace/workspace-source.test.ts +++ b/src/shared/new-workspace/workspace-source.test.ts @@ -34,6 +34,26 @@ describe('workspace source policy', () => { it('preserves global work-item sources across repo changes', () => { expect(shouldPreserveWorkspaceSourceOnRepoChange(linear)).toBe(true) + expect( + shouldPreserveWorkspaceSourceOnRepoChange({ + provider: 'jira', + type: 'issue', + number: 0, + title: 'Workspace scoped', + url: 'https://acme.atlassian.net/browse/FUS-1' + }) + ).toBe(true) + // Why: Jira items picked from smart search may arrive without an explicit + // provider; preservation must still hold via URL/identifier inference. + expect( + shouldPreserveWorkspaceSourceOnRepoChange({ + type: 'issue', + number: 0, + title: 'Inferred Jira', + url: 'https://acme.atlassian.net/browse/FUS-1', + jiraIdentifier: 'FUS-1' + }) + ).toBe(true) expect( shouldPreserveWorkspaceSourceOnRepoChange({ provider: 'github', @@ -43,6 +63,27 @@ describe('workspace source policy', () => { url: 'https://github.com/o/r/issues/1' }) ).toBe(false) + // Why: GitLab is repo-scoped; pin both the explicit MR and the + // URL-inferred shape clear, since folder-source/project-group paths delegate here. + expect( + shouldPreserveWorkspaceSourceOnRepoChange({ + provider: 'gitlab', + type: 'mr', + number: 2, + title: 'Repo scoped MR', + url: 'https://gitlab.com/o/r/-/merge_requests/2' + }) + ).toBe(false) + expect( + shouldPreserveWorkspaceSourceOnRepoChange({ + type: 'issue', + number: 3, + title: 'Inferred GitLab', + url: 'https://gitlab.example.com/g/p/-/work_items/3' + }) + ).toBe(false) + // Why: a null source (branch-only) has nothing to preserve; callers guard on this. + expect(shouldPreserveWorkspaceSourceOnRepoChange(null)).toBe(false) }) it('shares provider inference, selection labels, and auto-name gates', () => { diff --git a/tests/e2e/new-workspace-linked-item-project-switch.spec.ts b/tests/e2e/new-workspace-linked-item-project-switch.spec.ts new file mode 100644 index 00000000000..5500bb223a2 --- /dev/null +++ b/tests/e2e/new-workspace-linked-item-project-switch.spec.ts @@ -0,0 +1,141 @@ +/** + * E2E: linked work item behavior when switching the composer project. + * + * Why: Jira/Linear issues are workspace-scoped context — switching the + * implementation project must keep them attached. They used to be dropped, + * leaving only the derived name in the smart field. GitHub/GitLab sources + * are repo-scoped and must still clear on a project switch. + * + * Why E2E: the preservation logic lives in useComposerState behind the real + * ProjectCombobox interaction and main-process repo resolution — a store + * slice unit test cannot reach the combobox → handleProjectChange → smart + * field pill re-render path. + */ + +import { execFileSync } from 'node:child_process' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import type { Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import type { LinkedWorkItemSummary } from '../../src/renderer/src/lib/new-workspace' + +const SECOND_PROJECT_NAME = 'linked-item-second-project' + +function runGit(repoPath: string, args: string[]): void { + execFileSync('git', args, { cwd: repoPath, stdio: 'pipe' }) +} + +function createGitRepo(repoPath: string): void { + mkdirSync(repoPath, { recursive: true }) + runGit(repoPath, ['init']) + runGit(repoPath, ['config', 'user.email', 'e2e@test.local']) + runGit(repoPath, ['config', 'user.name', 'E2E Test']) + writeFileSync(path.join(repoPath, 'README.md'), '# Linked item project switch E2E\n') + runGit(repoPath, ['add', '-A']) + runGit(repoPath, ['commit', '-m', 'Initial commit']) +} + +async function addSecondProject(page: Page, repoPath: string): Promise { + await page.evaluate(async (targetRepoPath) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + const addedRepo = await store.getState().addRepoPath(targetRepoPath) + if (!addedRepo) { + throw new Error(`Failed to add repo at ${targetRepoPath}`) + } + }, repoPath) +} + +async function openComposerWithLinkedWorkItem( + page: Page, + linkedWorkItem: LinkedWorkItemSummary, + prefilledName: string +): Promise { + await page.evaluate( + ({ linkedWorkItem, prefilledName }) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + store.getState().openModal('new-workspace-composer', { linkedWorkItem, prefilledName }) + }, + { linkedWorkItem, prefilledName } + ) +} + +async function switchComposerProject(page: Page, projectName: string): Promise { + const composer = page.getByRole('dialog') + const combobox = composer.locator('button[data-project-combobox-root="true"]') + await combobox.click() + await page.getByRole('option', { name: new RegExp(projectName) }).click() + await expect(combobox).toContainText(projectName) +} + +test.describe('New workspace composer linked item across project switches', () => { + let tempRoot: string + let secondRepoPath: string + + test.beforeEach(async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + tempRoot = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-linked-item-')) + secondRepoPath = path.join(tempRoot, SECOND_PROJECT_NAME) + createGitRepo(secondRepoPath) + await addSecondProject(orcaPage, secondRepoPath) + }) + + test.afterEach(() => { + rmSync(tempRoot, { recursive: true, force: true }) + }) + + test('keeps a Jira issue linked when the project changes', async ({ orcaPage }) => { + await openComposerWithLinkedWorkItem( + orcaPage, + { + type: 'issue', + provider: 'jira', + number: 0, + title: 'RDG-344 Migrate homepage from NuxtJS to NextJS', + url: 'https://example.atlassian.net/browse/RDG-344', + jiraIdentifier: 'RDG-344' + }, + 'rdg-344-nuxtjs-nextjs' + ) + + const composer = orcaPage.getByRole('dialog') + await expect(composer).toBeVisible() + const sourcePill = composer.locator('[data-workspace-source-pill="true"]') + await expect(sourcePill).toContainText('RDG-344 Migrate homepage from NuxtJS to NextJS') + + await switchComposerProject(orcaPage, SECOND_PROJECT_NAME) + + await expect(sourcePill).toContainText('RDG-344 Migrate homepage from NuxtJS to NextJS') + }) + + test('clears a repo-scoped GitHub issue when the project changes', async ({ orcaPage }) => { + await openComposerWithLinkedWorkItem( + orcaPage, + { + type: 'issue', + provider: 'github', + number: 41, + title: 'Fix crash on launch', + url: 'https://github.com/acme/app/issues/41' + }, + 'fix-crash-on-launch' + ) + + const composer = orcaPage.getByRole('dialog') + await expect(composer).toBeVisible() + const sourcePill = composer.locator('[data-workspace-source-pill="true"]') + await expect(sourcePill).toContainText('#41 Fix crash on launch') + + await switchComposerProject(orcaPage, SECOND_PROJECT_NAME) + + await expect(sourcePill).toHaveCount(0) + }) +}) From d19cb8bc475e686dd123615246917a00a2cff987 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:44:03 -0700 Subject: [PATCH 02/58] feat(codex): remove "Not now" from account-switch notice (#9613) * feat(codex): drop "Not now" collapse from account-switch notice The Codex account-switch overlay had a "Not now" button that collapsed the loud dialog into a corner chip via component-local state. Remove the button and all of its collapse state: the useState/useEffect, the CollapsedRestartChip, and the now-dead collapse helpers. buildCodexRestartNoticeKey still backs the focus effect, so the module is renamed codex-restart-overlay-collapse.ts -> codex-restart-notice-key.ts and its tests trimmed to match. The orphaned "Not now" i18n key is pruned from all locale catalogs. * test(codex): lock restart notice actions --- .../src/components/CodexRestartChip.tsx | 72 +------------------ .../src/components/codex-restart-chip.test.ts | 5 +- .../codex-restart-notice-key.test.ts | 13 ++++ .../components/codex-restart-notice-key.ts | 6 ++ .../codex-restart-overlay-collapse.test.ts | 62 ---------------- .../codex-restart-overlay-collapse.ts | 32 --------- src/renderer/src/i18n/locales/en.json | 3 +- src/renderer/src/i18n/locales/es.json | 3 +- src/renderer/src/i18n/locales/ja.json | 3 +- src/renderer/src/i18n/locales/ko.json | 3 +- src/renderer/src/i18n/locales/zh.json | 3 +- 11 files changed, 30 insertions(+), 175 deletions(-) create mode 100644 src/renderer/src/components/codex-restart-notice-key.test.ts create mode 100644 src/renderer/src/components/codex-restart-notice-key.ts delete mode 100644 src/renderer/src/components/codex-restart-overlay-collapse.test.ts delete mode 100644 src/renderer/src/components/codex-restart-overlay-collapse.ts diff --git a/src/renderer/src/components/CodexRestartChip.tsx b/src/renderer/src/components/CodexRestartChip.tsx index a4f11bdc0bb..522a47fc03e 100644 --- a/src/renderer/src/components/CodexRestartChip.tsx +++ b/src/renderer/src/components/CodexRestartChip.tsx @@ -1,4 +1,4 @@ -import { useEffect, useId, useMemo, useRef, useState } from 'react' +import { useEffect, useId, useMemo, useRef } from 'react' import { useShallow } from 'zustand/react/shallow' import { RefreshCw } from 'lucide-react' import { Button } from '@/components/ui/button' @@ -6,11 +6,7 @@ import { useAppStore } from '../store' import { selectCodexRestartInputs } from './codex-restart-chip-inputs' import { translate } from '@/i18n/i18n' import { shouldFocusMobileDriverAction } from './terminal-pane/mobile-driver-overlay-focus' -import { - buildCodexRestartNoticeKey, - createCodexRestartOverlayCollapseState, - getCodexRestartOverlayCollapseState -} from './codex-restart-overlay-collapse' +import { buildCodexRestartNoticeKey } from './codex-restart-notice-key' const EMPTY_TABS: { id: string }[] = [] @@ -99,16 +95,6 @@ export default function CodexRestartChip({ const clearCodexRestartNotice = useAppStore((s) => s.clearCodexRestartNotice) const noticeKey = restartNotice ? buildCodexRestartNoticeKey(restartNotice) : null - const [collapseState, setCollapseState] = useState(() => - createCodexRestartOverlayCollapseState(noticeKey) - ) - - const currentCollapseState = getCodexRestartOverlayCollapseState(collapseState, noticeKey) - useEffect(() => { - // Why: a new account switch must reopen loud mode without updating state - // during render, which can trip React's external-store snapshot guard. - setCollapseState((state) => getCodexRestartOverlayCollapseState(state, noticeKey)) - }, [noticeKey]) if (staleWorktreePtyIds.length === 0 || !restartNotice) { return null @@ -122,22 +108,11 @@ export default function CodexRestartChip({ dismissStaleWorktreePtyIds(staleWorktreePtyIds, clearCodexRestartNotice) } - if (currentCollapseState.collapsed) { - return ( - setCollapseState(createCodexRestartOverlayCollapseState(noticeKey))} - onRestart={handleRestart} - /> - ) - } - return ( setCollapseState({ noticeKey, collapsed: true })} onDismiss={handleDismiss} onRestart={handleRestart} /> @@ -148,14 +123,12 @@ function LoudRestartOverlay({ isVisible, noticeKey, restartNotice, - onCollapse, onDismiss, onRestart }: { isVisible: boolean noticeKey: string | null restartNotice: RestartNotice - onCollapse: () => void onDismiss: () => void onRestart: () => void }): React.JSX.Element { @@ -218,9 +191,6 @@ function LoudRestartOverlay({ - - - - ) -} diff --git a/src/renderer/src/components/codex-restart-chip.test.ts b/src/renderer/src/components/codex-restart-chip.test.ts index af542372bdd..468bb7c58df 100644 --- a/src/renderer/src/components/codex-restart-chip.test.ts +++ b/src/renderer/src/components/codex-restart-chip.test.ts @@ -92,7 +92,7 @@ describe('CodexRestartChip helpers', () => { expect(clearCodexRestartNotice).toHaveBeenCalledTimes(2) }) - it('renders a stale Codex restart notice without an external-store update loop', async () => { + it('renders only account-resolution actions without an external-store update loop', async () => { useAppStore.setState({ tabsByWorktree: { 'worktree-1': [ @@ -124,5 +124,8 @@ describe('CodexRestartChip helpers', () => { }) expect(container.textContent).toContain('Codex is still signed in as old@example.com') + expect( + Array.from(container.querySelectorAll('button'), (button) => button.textContent?.trim()) + ).toEqual(['Keep old account', 'Restart']) }) }) diff --git a/src/renderer/src/components/codex-restart-notice-key.test.ts b/src/renderer/src/components/codex-restart-notice-key.test.ts new file mode 100644 index 00000000000..17fbca689d9 --- /dev/null +++ b/src/renderer/src/components/codex-restart-notice-key.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest' +import { buildCodexRestartNoticeKey } from './codex-restart-notice-key' + +describe('codex restart notice key', () => { + it('builds a stable notice key from account labels', () => { + expect( + buildCodexRestartNoticeKey({ + previousAccountLabel: 'Account A', + nextAccountLabel: 'Account B' + }) + ).toBe('Account A\u0000Account B') + }) +}) diff --git a/src/renderer/src/components/codex-restart-notice-key.ts b/src/renderer/src/components/codex-restart-notice-key.ts new file mode 100644 index 00000000000..3a3832d2c6a --- /dev/null +++ b/src/renderer/src/components/codex-restart-notice-key.ts @@ -0,0 +1,6 @@ +export function buildCodexRestartNoticeKey(args: { + previousAccountLabel: string + nextAccountLabel: string +}): string { + return `${args.previousAccountLabel}\u0000${args.nextAccountLabel}` +} diff --git a/src/renderer/src/components/codex-restart-overlay-collapse.test.ts b/src/renderer/src/components/codex-restart-overlay-collapse.test.ts deleted file mode 100644 index 255925ee8dc..00000000000 --- a/src/renderer/src/components/codex-restart-overlay-collapse.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - buildCodexRestartNoticeKey, - createCodexRestartOverlayCollapseState, - getCodexRestartOverlayCollapseState -} from './codex-restart-overlay-collapse' - -describe('codex restart overlay collapse', () => { - it('builds a stable notice key from account labels', () => { - expect( - buildCodexRestartNoticeKey({ - previousAccountLabel: 'Account A', - nextAccountLabel: 'Account B' - }) - ).toBe('Account A\u0000Account B') - }) - - it('reopens loud mode when the notice key changes', () => { - const collapsed = { - noticeKey: buildCodexRestartNoticeKey({ - previousAccountLabel: 'Account A', - nextAccountLabel: 'Account B' - }), - collapsed: true - } - - expect( - getCodexRestartOverlayCollapseState( - collapsed, - buildCodexRestartNoticeKey({ - previousAccountLabel: 'Account A', - nextAccountLabel: 'Account C' - }) - ) - ).toEqual({ - noticeKey: 'Account A\u0000Account C', - collapsed: false - }) - }) - - it('preserves collapse state for the same notice key', () => { - const noticeKey = buildCodexRestartNoticeKey({ - previousAccountLabel: 'Account A', - nextAccountLabel: 'Account B' - }) - const collapsed = { noticeKey, collapsed: true } - - expect(getCodexRestartOverlayCollapseState(collapsed, noticeKey)).toBe(collapsed) - }) - - it('creates loud mode for a new notice key', () => { - const noticeKey = buildCodexRestartNoticeKey({ - previousAccountLabel: 'Account A', - nextAccountLabel: 'Account B' - }) - - expect(createCodexRestartOverlayCollapseState(noticeKey)).toEqual({ - noticeKey, - collapsed: false - }) - }) -}) diff --git a/src/renderer/src/components/codex-restart-overlay-collapse.ts b/src/renderer/src/components/codex-restart-overlay-collapse.ts deleted file mode 100644 index edc4e786eab..00000000000 --- a/src/renderer/src/components/codex-restart-overlay-collapse.ts +++ /dev/null @@ -1,32 +0,0 @@ -export type CodexRestartOverlayCollapseState = { - noticeKey: string | null - collapsed: boolean -} - -export function buildCodexRestartNoticeKey(args: { - previousAccountLabel: string - nextAccountLabel: string -}): string { - return `${args.previousAccountLabel}\u0000${args.nextAccountLabel}` -} - -export function getCodexRestartOverlayCollapseState( - state: CodexRestartOverlayCollapseState, - noticeKey: string | null -): CodexRestartOverlayCollapseState { - return state.noticeKey === noticeKey - ? state - : { - noticeKey, - collapsed: false - } -} - -export function createCodexRestartOverlayCollapseState( - noticeKey: string | null -): CodexRestartOverlayCollapseState { - return { - noticeKey, - collapsed: false - } -} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 9c34e4df489..1d7263b6a1d 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -769,8 +769,7 @@ "9263e75f49": "Codex is using the previous account", "d3e8a1f4b2": "Account switched", "9375620cc3": "Restart this session to use {{value0}}. It stays on the previous account until you do.", - "6133594b12": "Keep old account", - "b9000fe511": "Not now" + "6133594b12": "Keep old account" }, "FirstLaunchBanner": { "b9e1b966c7": "Dismiss notice", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 4311a1a9b12..a10a0ea150a 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -746,8 +746,7 @@ "a4c8e1b2f7": "Codex sigue conectado como {{value0}}", "d3e8a1f4b2": "Cuenta cambiada", "9375620cc3": "Restart this session to use {{value0}}. It stays on the previous account until you do.", - "6133594b12": "Keep old account", - "b9000fe511": "Not now" + "6133594b12": "Keep old account" }, "FirstLaunchBanner": { "b9e1b966c7": "Descartar aviso", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 891f1688b27..4531cc490a3 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -746,8 +746,7 @@ "a4c8e1b2f7": "Codex はまだ {{value0}} としてサインインしています", "d3e8a1f4b2": "アカウントを切り替えました", "9375620cc3": "Restart this session to use {{value0}}. It stays on the previous account until you do.", - "6133594b12": "Keep old account", - "b9000fe511": "Not now" + "6133594b12": "Keep old account" }, "FirstLaunchBanner": { "b9e1b966c7": "通知を閉じる", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index b38075abb12..dd155b3a4bf 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -746,8 +746,7 @@ "a4c8e1b2f7": "Codex가 아직 {{value0}} 계정으로 로그인되어 있습니다", "d3e8a1f4b2": "계정 전환됨", "9375620cc3": "Restart this session to use {{value0}}. It stays on the previous account until you do.", - "6133594b12": "Keep old account", - "b9000fe511": "Not now" + "6133594b12": "Keep old account" }, "FirstLaunchBanner": { "b9e1b966c7": "알림 닫기", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 29b59a74789..825f838ddd4 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -746,8 +746,7 @@ "a4c8e1b2f7": "Codex 仍以 {{value0}} 身份登录", "d3e8a1f4b2": "账户已切换", "9375620cc3": "Restart this session to use {{value0}}. It stays on the previous account until you do.", - "6133594b12": "Keep old account", - "b9000fe511": "Not now" + "6133594b12": "Keep old account" }, "FirstLaunchBanner": { "b9e1b966c7": "关闭通知", From 58293445e14ffe7f759ccb68ba115e0948816399 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:57:48 -0700 Subject: [PATCH 03/58] perf(status-bar): guard usage-notice anchor against equal-geometry re-render churn (#9615) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The UsagePercentageDisplayChangeNotice callout re-measures its fixed anchor on every ResizeObserver/resize delivery and calls setAnchorPosition with a fresh object each time. Equal-geometry deliveries (the common case) still churned a re-render apiece because the object identity changed. Bail when bottom/left are unchanged so React short-circuits before commit. Measured: 20 identical-geometry deliveries → 20 re-renders before, 1 after. This is defensive robustness/perf hardening for the status-bar subtree that surfaced in crash f75ed81b (React #185 in overlay.status-bar). It is NOT a proven fix for that crash — the reproduced #185 loop lives in radix Popper internals and is not unit-reproducible; this removes one churn source feeding that subtree. --- ...sagePercentageDisplayChangeNotice.test.tsx | 65 ++++++++++++++++++- .../UsagePercentageDisplayChangeNotice.tsx | 8 ++- 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/components/status-bar/UsagePercentageDisplayChangeNotice.test.tsx b/src/renderer/src/components/status-bar/UsagePercentageDisplayChangeNotice.test.tsx index cb8fea8a750..9e3973c8f78 100644 --- a/src/renderer/src/components/status-bar/UsagePercentageDisplayChangeNotice.test.tsx +++ b/src/renderer/src/components/status-bar/UsagePercentageDisplayChangeNotice.test.tsx @@ -1,11 +1,24 @@ // @vitest-environment happy-dom -import { act } from 'react' +import { act, Profiler } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { UsagePercentageDisplayChangeNotice } from './UsagePercentageDisplayChangeNotice' import { USAGE_PERCENTAGE_DISPLAY_SETTING_ID } from '../settings/appearance-usage-percentage-search' +// Controllable ResizeObserver so tests can drive reflow deliveries by hand. +type ResizeObserverStub = { cb: ResizeObserverCallback; targets: Element[] } +const resizeObservers: ResizeObserverStub[] = [] + +function fireResizeObservers(): void { + for (const observer of resizeObservers) { + if (observer.targets.length === 0) { + continue + } + observer.cb([] as unknown as ResizeObserverEntry[], observer as unknown as ResizeObserver) + } +} + const storeState = { persistedUIReady: true, usagePercentageDisplayChangeNoticeDismissed: false, @@ -31,6 +44,22 @@ describe('UsagePercentageDisplayChangeNotice', () => { beforeEach(() => { vi.useFakeTimers() + resizeObservers.length = 0 + ;(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = class { + cb: ResizeObserverCallback + targets: Element[] = [] + constructor(cb: ResizeObserverCallback) { + this.cb = cb + resizeObservers.push({ cb, targets: this.targets }) + } + observe(el: Element): void { + this.targets.push(el) + } + unobserve(): void {} + disconnect(): void { + this.targets.length = 0 + } + } storeState.persistedUIReady = true storeState.usagePercentageDisplayChangeNoticeDismissed = false storeState.statusBarVisible = true @@ -209,6 +238,40 @@ describe('UsagePercentageDisplayChangeNotice', () => { expect(storeState.dismissUsagePercentageDisplayChangeNotice).toHaveBeenCalledTimes(1) }) + it('does not re-render on ResizeObserver deliveries with unchanged geometry', () => { + let commits = 0 + act(() => { + root.render( + { + commits++ + }} + > + + usage-meters + + + ) + }) + act(() => { + vi.advanceTimersByTime(1_800) + }) + expect(document.querySelector('.status-bar-change-notice-card')).not.toBeNull() + const commitsAfterOpen = commits + + for (let i = 0; i < 20; i++) { + act(() => { + fireResizeObservers() + }) + } + // Why: without the equality guard every identical-geometry delivery churns a + // fresh AnchorPosition object → one re-render each (measured 20). The guard + // returns the same reference so React bails on all but the first settling + // commit → ≤1 (measured 1). This is the churn the fix removes. + expect(commits - commitsAfterOpen).toBeLessThanOrEqual(1) + }) + function openNotice(): void { act(() => { root.render( diff --git a/src/renderer/src/components/status-bar/UsagePercentageDisplayChangeNotice.tsx b/src/renderer/src/components/status-bar/UsagePercentageDisplayChangeNotice.tsx index 5c7263130cc..437afc314be 100644 --- a/src/renderer/src/components/status-bar/UsagePercentageDisplayChangeNotice.tsx +++ b/src/renderer/src/components/status-bar/UsagePercentageDisplayChangeNotice.tsx @@ -100,7 +100,13 @@ export function UsagePercentageDisplayChangeNotice({ } const update = (): void => { - setAnchorPosition(measureAnchorPosition(anchor)) + const next = measureAnchorPosition(anchor) + // Why: ResizeObserver/resize fire on every reflow, but measureAnchorPosition + // returns a fresh object each time; bail on unchanged geometry so equal + // deliveries don't churn re-renders (avoids feeding a layout-effect loop). + setAnchorPosition((prev) => + prev && prev.bottom === next.bottom && prev.left === next.left ? prev : next + ) } update() From f655ae39290cdeb98c1eedd85aa827b72440ee62 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:16:34 -0700 Subject: [PATCH 04/58] fix(pi): resolve OMP tab identity from the outer wrapper, not the wrapped pi (#6364) (#9600) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(pi): resolve OMP tab identity from the outer wrapper, not the wrapped pi (#6364) OMP runs as a `shell → omp → pi` process tree and Orca recognizes both `omp` and `pi` as distinct agents. The foreground-process readers scored the deepest `+` foreground descendant, so they returned the wrapped `pi` engine instead of the `omp` the user actually launched — and because the sampled `+` frame moves between omp and pi across command boundaries, the read oscillated and the tab icon/label flickered OMP↔Pi. It surfaced on Remote Host / SSH (only in a worktree, only while working) where a mirrored/restored pane has no client-side launchAgent to anchor identity, so the (host-read) foreground was the only pi/omp signal — and it was wrong. Fix the detection at its source so foregroundAgent is trustworthy everywhere (local tab icon, exit detection, and the remote mirror): - Add `resolveOuterWrapperForegroundProcess` (src/shared/foreground-wrapper-agent.ts): when the recognized foreground winner shares a `titleIdentityGroup` with a shallower recognized ancestor, return the shallowest — the outer wrapper. Stable regardless of which of omp/pi holds the foreground at the sample; bare Pi and cross-group reads (Codex, etc.) are unchanged. - Wire it into both readers: local `agent-foreground-process.ts` and the SSH relay `pty-shell-utils.ts`. Also harden the relay's fallback short-circuit so a pi-compatible process reported by node-pty rescans for the omp wrapper instead of trusting `pi` raw (never downgrading below the fallback). Also unify the host-side publish path onto the shared owner resolver: the mobile session snapshot builders now resolve pane ownership via `resolvePaneAgentOwner` ({launchAgent, hookAgent}) instead of a bespoke `launchAgent ?? foregroundAgent`, so the host stops being a divergent fourth identity path and the hook's host-stamped identity survives when launchAgent is dropped on a mirrored pane. Tests: unit coverage for the wrapper resolver, `shell→omp→pi` ps trees for both readers (outer omp, oscillating-frame, bare pi, node-pty pi fallback), and a host-snapshot regression that keeps an OMP hook labeled OMP when the wrapped pi child owns the foreground. * fix(pi): harden wrapper foreground ownership * fix(pi): preserve OMP ownership in daemon foreground reads * fix(pi): retain OMP owner when process scans fail --- ...subprocess-foreground-scan-cadence.test.ts | 61 +++++++++++- src/main/daemon/pty-subprocess.ts | 23 ++++- .../agent-foreground-process.test.ts | 97 ++++++++++++++++++- .../providers/agent-foreground-process.ts | 8 +- .../windows-agent-foreground-process.ts | 51 ++++++---- src/main/runtime/orca-runtime.test.ts | 51 ++++++++++ src/main/runtime/orca-runtime.ts | 38 +++++++- src/relay/pty-shell-utils.test.ts | 90 +++++++++++++++++ src/relay/pty-shell-utils.ts | 22 ++++- src/shared/foreground-wrapper-agent.test.ts | 55 +++++++++++ src/shared/foreground-wrapper-agent.ts | 56 +++++++++++ 11 files changed, 519 insertions(+), 33 deletions(-) create mode 100644 src/shared/foreground-wrapper-agent.test.ts create mode 100644 src/shared/foreground-wrapper-agent.ts diff --git a/src/main/daemon/pty-subprocess-foreground-scan-cadence.test.ts b/src/main/daemon/pty-subprocess-foreground-scan-cadence.test.ts index 937dca27bc4..d63acd1161a 100644 --- a/src/main/daemon/pty-subprocess-foreground-scan-cadence.test.ts +++ b/src/main/daemon/pty-subprocess-foreground-scan-cadence.test.ts @@ -40,10 +40,12 @@ vi.mock('../providers/windows-powershell-executable', () => ({ })) vi.mock('../providers/agent-foreground-process', () => ({ - resolveAgentForegroundProcessWithAvailability: async (...args: unknown[]) => ({ - available: true, - processName: await resolveAgentForegroundProcessMock(...args) - }) + resolveAgentForegroundProcessWithAvailability: async (...args: unknown[]) => { + const value = await resolveAgentForegroundProcessMock(...args) + return value && typeof value === 'object' && 'available' in value + ? value + : { available: true, processName: value } + } })) import { createPtySubprocess } from './pty-subprocess' @@ -185,4 +187,55 @@ describe('daemon pty foreground scan cadence', () => { // Scans at t=0s, 6s, 12s, 18s, 24s, 30s — the cheap `ps` path is unchanged. expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(6) }) + + it.each(['darwin', 'win32'] as const)( + 'keeps wrapped pi reads on the cached omp owner between bounded %s scans', + async (targetPlatform) => { + resolveAgentForegroundProcessMock.mockResolvedValue('omp') + const { handle } = spawnShellSubprocess('pi', targetPlatform) + + const reads: (string | null)[] = [] + for (let atMs = 0; atMs <= 3_000; atMs += 250) { + reads.push(await readForegroundAt(handle, atMs)) + } + + // The first synchronous read precedes enrichment; every later read stays on + // OMP even when its 1s cache entry expires while the next scan is in flight. + expect(reads).toEqual(['pi', ...Array.from({ length: 12 }, () => 'omp')]) + expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(4) + + await expect(handle.confirmForegroundProcess!()).resolves.toBe('omp') + expect(resolveAgentForegroundProcessMock).toHaveBeenLastCalledWith( + 12345, + 'pi', + expect.objectContaining({ fresh: true }) + ) + } + ) + + it.each(['darwin', 'win32'] as const)( + 'keeps authoritative %s omp reads on the zero-scan path', + async (targetPlatform) => { + const { handle } = spawnShellSubprocess('omp', targetPlatform) + + for (let atMs = 0; atMs <= 3_000; atMs += 250) { + expect(await readForegroundAt(handle, atMs)).toBe('omp') + } + expect(resolveAgentForegroundProcessMock).not.toHaveBeenCalled() + } + ) + + it.each(['darwin', 'win32'] as const)( + 'keeps the cached omp owner when a %s wrapper scan is unavailable', + async (targetPlatform) => { + resolveAgentForegroundProcessMock + .mockResolvedValueOnce('omp') + .mockResolvedValue({ available: false, processName: 'pi' }) + const { handle } = spawnShellSubprocess('pi', targetPlatform) + + expect(await readForegroundAt(handle, 0)).toBe('pi') + expect(await readForegroundAt(handle, 1_000)).toBe('omp') + expect(await readForegroundAt(handle, 2_500)).toBe('omp') + } + ) }) diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index 16ec128ab68..bf0662d32b7 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -53,6 +53,7 @@ import { recognizeAgentProcess, recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition' +import { shouldInspectOuterWrapperForegroundProcess } from '../../shared/foreground-wrapper-agent' import { shouldUseShellReadyStartupDelivery, type StartupCommandDelivery @@ -82,6 +83,11 @@ const PTY_SPAWN_HEALTH_TIMEOUT_MS = 4_000 const PTY_SPAWN_HEALTH_RETRY_ATTEMPTS = 2 const PENDING_PRE_LISTENER_DATA_MAX_CHARS = 512 * 1024 +function shouldInspectOuterWrapperFallback(processName: string | null): boolean { + const recognized = recognizeAgentProcess(processName) + return recognized !== null && shouldInspectOuterWrapperForegroundProcess(recognized) +} + function composeGuardedDaemonGitConfigEnv( env: Record, explicitEnv: Record | undefined, @@ -883,6 +889,7 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl fallbackProcess !== null && (isShellProcess(fallbackProcess) || isAgentForegroundWrapperProcess(fallbackProcess) || + shouldInspectOuterWrapperFallback(fallbackProcess) || // Why: agent-spawned helpers can become the PTY foreground child, but the Unix process tree still identifies the parent agent. process.platform !== 'win32') const scheduleAgentForegroundRefresh = (fallbackProcess: string | null): void => { @@ -890,9 +897,11 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl return } const fallbackIsShell = fallbackProcess !== null && isShellProcess(fallbackProcess) + const fallbackRecognition = recognizeAgentProcess(fallbackProcess) if ( !fallbackProcess || - recognizeAgentProcess(fallbackProcess) || + (fallbackRecognition !== null && + !shouldInspectOuterWrapperForegroundProcess(fallbackRecognition)) || !shouldInspectFallbackForegroundProcess(fallbackProcess) ) { return @@ -990,7 +999,11 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl } try { const fallbackProcess = getFallbackForegroundProcess() - if (fallbackProcess && recognizeAgentProcess(fallbackProcess)) { + const fallbackRecognition = recognizeAgentProcess(fallbackProcess) + const inspectOuterWrapper = + fallbackRecognition !== null && + shouldInspectOuterWrapperForegroundProcess(fallbackRecognition) + if (fallbackProcess && fallbackRecognition && !inspectOuterWrapper) { cachedAgentForeground = { processName: fallbackProcess, refreshedAt: Date.now() } startupAgentForeground = null return fallbackProcess @@ -1009,6 +1022,7 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl cachedAgentForeground && fallbackProcess !== null && (isAgentForegroundWrapperProcess(fallbackProcess) || + inspectOuterWrapper || (process.platform === 'win32' && isShellProcess(fallbackProcess))) ) { return cachedAgentForeground.processName @@ -1028,9 +1042,12 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl } try { const fallbackProcess = getFallbackForegroundProcess() + const fallbackRecognition = recognizeAgentProcess(fallbackProcess) if ( !fallbackProcess || - (recognizeAgentProcess(fallbackProcess) && process.platform !== 'win32') || + (fallbackRecognition !== null && + process.platform !== 'win32' && + !shouldInspectOuterWrapperForegroundProcess(fallbackRecognition)) || (process.platform !== 'win32' && !shouldInspectFallbackForegroundProcess(fallbackProcess)) ) { return fallbackProcess diff --git a/src/main/providers/agent-foreground-process.test.ts b/src/main/providers/agent-foreground-process.test.ts index a93735a0287..aff948c6138 100644 --- a/src/main/providers/agent-foreground-process.test.ts +++ b/src/main/providers/agent-foreground-process.test.ts @@ -109,6 +109,97 @@ describe('resolveAgentForegroundProcess', () => { await expect(resolveAgentForegroundProcess(100, 'node')).resolves.toBe('codex') }) + // Why: OMP embeds Pi, but the outer process is the user-visible identity (#6364). + it('reports the outer omp wrapper, not the wrapped pi child', async () => { + mockPs(['101 100 S+ omp', '102 101 S+ pi'].join('\n')) + + await expect(resolveAgentForegroundProcess(100, 'omp')).resolves.toBe('omp') + }) + + it('reports omp even when the wrapped pi child holds the foreground alone', async () => { + // Why: across command boundaries only the deeper `pi` carries `+`; the + // wrapper identity must stay omp regardless of which frame we sampled. + mockPs(['101 100 S omp', '102 101 S+ pi'].join('\n')) + + await expect(resolveAgentForegroundProcess(100, 'omp')).resolves.toBe('omp') + }) + + it('reports bare pi when no omp wrapper is present', async () => { + mockPs(['101 100 S+ pi'].join('\n')) + + await expect(resolveAgentForegroundProcess(100, 'pi')).resolves.toBe('pi') + }) + + it('reports the outer omp wrapper on Windows', async () => { + Object.defineProperty(process, 'platform', { value: 'win32' }) + execFileMock.mockImplementation( + (_cmd: string, _args: string[], _opts: unknown, cb: unknown) => { + const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void + callback(null, { + stdout: windowsProcessJsonRows([ + { + CommandLine: 'powershell.exe', + Name: 'powershell.exe', + ParentProcessId: 99, + ProcessId: 100 + }, + { + CommandLine: 'omp.exe', + Name: 'omp.exe', + ParentProcessId: 100, + ProcessId: 101 + }, + { + CommandLine: 'pi.exe', + Name: 'pi.exe', + ParentProcessId: 101, + ProcessId: 102 + } + ]), + stderr: '' + }) + } + ) + + await expect(resolveAgentForegroundProcess(100, 'pi.exe')).resolves.toBe('omp') + }) + + it('keeps the Windows omp ancestor when context selects one of multiple pi descendants', async () => { + Object.defineProperty(process, 'platform', { value: 'win32' }) + mockPs( + windowsProcessJsonRows([ + { + CommandLine: 'powershell.exe', + Name: 'powershell.exe', + ParentProcessId: 99, + ProcessId: 100 + }, + { + CommandLine: 'omp.exe', + Name: 'omp.exe', + ParentProcessId: 100, + ProcessId: 101 + }, + { + CommandLine: 'pi.exe --cwd C:\\repo\\orca', + Name: 'pi.exe', + ParentProcessId: 101, + ProcessId: 102 + }, + { + CommandLine: 'pi.exe --cwd C:\\repo\\other', + Name: 'pi.exe', + ParentProcessId: 100, + ProcessId: 103 + } + ]) + ) + + await expect( + resolveAgentForegroundProcess(100, 'pi.exe', { contextPaths: ['C:\\repo\\orca'] }) + ).resolves.toBe('omp') + }) + it('treats a fresh POSIX snapshot missing the PTY root as unavailable', async () => { mockPs('101 999 S+ node /Users/dev/.nvm/versions/node/bin/codex') @@ -117,7 +208,7 @@ describe('resolveAgentForegroundProcess', () => { ).resolves.toEqual({ available: false, processName: 'zsh' }) }) - it('treats a failed fresh POSIX scan as unavailable', async () => { + it('treats failed POSIX scans as unavailable', async () => { execFileMock.mockImplementation( (_cmd: string, _args: string[], _opts: unknown, cb: unknown) => { const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void @@ -128,6 +219,10 @@ describe('resolveAgentForegroundProcess', () => { await expect( resolveAgentForegroundProcessWithAvailability(100, 'zsh', { fresh: true }) ).resolves.toEqual({ available: false, processName: 'zsh' }) + await expect(resolveAgentForegroundProcessWithAvailability(100, 'zsh')).resolves.toEqual({ + available: false, + processName: 'zsh' + }) await expect(resolveAgentForegroundProcess(100, 'zsh')).resolves.toBe('zsh') }) diff --git a/src/main/providers/agent-foreground-process.ts b/src/main/providers/agent-foreground-process.ts index 6224b804953..e428c945c32 100644 --- a/src/main/providers/agent-foreground-process.ts +++ b/src/main/providers/agent-foreground-process.ts @@ -1,4 +1,5 @@ import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition' +import { resolveOuterWrapperForegroundProcess } from '../../shared/foreground-wrapper-agent' import { getFreshProcessTableSnapshot, getProcessTableSnapshot, @@ -101,7 +102,8 @@ export async function resolveAgentForegroundProcessWithAvailability( processName: resolveAgentForegroundProcessFromPs(rows, shellPid) ?? fallbackProcess } } catch { - return { available: !options.fresh, processName: fallbackProcess } + // Why: a failed scan cannot prove fallback ownership; callers retain the last recognized agent. + return { available: false, processName: fallbackProcess } } } @@ -125,7 +127,9 @@ function resolveAgentForegroundProcessFromPs( } const recognized = recognizeAgentProcessFromCommandLine(candidate.command) if (recognized) { - return recognized.processName + // Why: return the outer wrapper (omp) rather than the deeper wrapped child + // (pi) of a shell→omp→pi tree — see resolveOuterWrapperForegroundProcess. + return resolveOuterWrapperForegroundProcess(recognized, candidate, candidates) } } return null diff --git a/src/main/providers/windows-agent-foreground-process.ts b/src/main/providers/windows-agent-foreground-process.ts index 556d6763e62..8da37b5f611 100644 --- a/src/main/providers/windows-agent-foreground-process.ts +++ b/src/main/providers/windows-agent-foreground-process.ts @@ -1,8 +1,14 @@ import { isAgentForegroundWrapperProcess, isExpectedAgentProcess, - recognizeAgentProcessFromCommandLine + recognizeAgentProcess, + recognizeAgentProcessFromCommandLine, + type RecognizedAgentProcess } from '../../shared/agent-process-recognition' +import { + resolveOuterWrapperForegroundProcess, + shouldInspectOuterWrapperForegroundProcess +} from '../../shared/foreground-wrapper-agent' import { isShellProcess } from '../../shared/shell-process-detection' import { queryWindowsProcessDescendants, @@ -26,7 +32,12 @@ export type WindowsAgentForegroundResolution = { } export function shouldInspectWindowsAgentForeground(fallbackProcess: string): boolean { - return isAgentForegroundWrapperProcess(fallbackProcess) || isShellProcess(fallbackProcess) + const recognized = recognizeAgentProcess(fallbackProcess) + return ( + isAgentForegroundWrapperProcess(fallbackProcess) || + isShellProcess(fallbackProcess) || + (recognized !== null && shouldInspectOuterWrapperForegroundProcess(recognized)) + ) } export async function resolveWindowsAgentForegroundProcess( @@ -106,14 +117,18 @@ function resolveWindowsProcessName( windowsCandidateMatchesFallbackWrapper(candidate, fallbackProcess) ) if (wrapperCandidates.length !== 1) { - return resolveWrapperForegroundProcessFromWindowsCandidates(wrapperCandidates, contextPaths) + return resolveWrapperForegroundProcessFromWindowsCandidates( + wrapperCandidates, + candidates, + contextPaths + ) } const [candidate] = wrapperCandidates const recognized = recognizeAgentProcessFromCommandLine(candidate.command) ?? recognizeAgentProcessFromCommandLine(candidate.name) if (recognized) { - return recognized.processName + return resolveOuterWrapperForegroundProcess(recognized, candidate, candidates) } return null } @@ -132,6 +147,7 @@ function resolveShellForegroundProcessFromWindowsCandidates( function resolveWrapperForegroundProcessFromWindowsCandidates( candidates: readonly WindowsProcessCandidate[], + allCandidates: readonly WindowsProcessCandidate[], contextPaths: readonly string[] | undefined ): string | null { const contextCandidates = createRecognizedWindowsProcessCandidates( @@ -139,7 +155,7 @@ function resolveWrapperForegroundProcessFromWindowsCandidates( contextPaths ).filter((candidate) => candidate.contextMatch) return contextCandidates.length > 0 - ? resolveRecognizedWindowsProcessCandidates(contextCandidates, candidates) + ? resolveRecognizedWindowsProcessCandidates(contextCandidates, allCandidates) : null } @@ -147,6 +163,7 @@ type RecognizedWindowsProcessCandidate = WindowsProcessRow & { contextMatch: boolean depth: number processName: string + recognized: RecognizedAgentProcess } function createRecognizedWindowsProcessCandidates( @@ -163,7 +180,8 @@ function createRecognizedWindowsProcessCandidates( { ...candidate, contextMatch: candidateMatchesContextPath(candidate, normalizedContextPaths), - processName: recognized + processName: recognized.processName, + recognized } ] }) @@ -176,13 +194,6 @@ function resolveRecognizedWindowsProcessCandidates( if (recognizedCandidates.length === 0) { return null } - const recognizedProcessNames = new Set( - recognizedCandidates.map((candidate) => candidate.processName) - ) - if (recognizedProcessNames.size === 1) { - return [...recognizedProcessNames][0] - } - const candidatesByPid = new Map(allCandidates.map((candidate) => [candidate.pid, candidate])) const leafCandidates = recognizedCandidates.filter( (candidate) => @@ -192,7 +203,11 @@ function resolveRecognizedWindowsProcessCandidates( windowsCandidateIsAncestor(candidate, other, candidatesByPid) ) ) - const leafProcessNames = new Set(leafCandidates.map((candidate) => candidate.processName)) + const leafProcessNames = new Set( + leafCandidates.map((candidate) => + resolveOuterWrapperForegroundProcess(candidate.recognized, candidate, allCandidates) + ) + ) // Why: Windows lacks a cheap PTY foreground marker like POSIX '+'. A single // recognized lineage leaf is strong enough; sibling agent leaves are not. return leafProcessNames.size === 1 ? [...leafProcessNames][0] : null @@ -267,11 +282,13 @@ function commandLineContainsPath(haystack: string, contextPath: string): boolean return false } -function recognizeWindowsProcessCandidate(candidate: WindowsProcessRow): string | null { - const recognized = +function recognizeWindowsProcessCandidate( + candidate: WindowsProcessRow +): RecognizedAgentProcess | null { + return ( recognizeAgentProcessFromCommandLine(candidate.command) ?? recognizeAgentProcessFromCommandLine(candidate.name) - return recognized?.processName ?? null + ) } function windowsCandidateMatchesFallbackWrapper( diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index de64a3fff8c..6599359557e 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -14615,6 +14615,57 @@ describe('OrcaRuntimeService', () => { unsubscribe() }) + // Why: restored OMP panes can retain the hook while the wrapped Pi owns foreground (#6364). + it('keeps an OMP hook labeled OMP when the wrapped pi child owns the foreground', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'omp-flicker-pty' }) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + // Why: the remote relay reads the deeper `pi` child of the omp process tree. + getForegroundProcess: async () => 'pi' + }) + const events: RuntimeMobileSessionTabsResult[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot)) + + await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'omp-tab', + leafId: HEADLESS_LEAF_ID + }) + // Restored/mirrored pane: no launchAgent, only the pi foreground read remains. + const pty = ( + runtime as unknown as { + ptysById: Map + } + ).ptysById.get('omp-flicker-pty')! + pty.launchAgent = null + pty.foregroundAgent = 'pi' + events.length = 0 + + runtime.onPtyData( + 'omp-flicker-pty', + '\x1b]0;⠋ Pi\x07' + + '\x1b]9999;{"state":"working","prompt":"fix the bug","agentType":"omp"}\x07', + 100 + ) + + await waitForMobileSessionTabsEvents(events, 1) + expect(events[0]?.tabs[0]).toEqual( + expect.objectContaining({ + type: 'terminal', + title: '⠋ OMP', + agentStatus: expect.objectContaining({ + state: 'working', + agentType: 'omp', + terminalTitle: '⠋ OMP' + }) + }) + ) + + unsubscribe() + }) + it('does not republish mobile session tabs for repeated identical OSC 9999 payloads', async () => { const spawn = vi.fn().mockResolvedValue({ id: 'hook-ping-pty' }) const runtime = new OrcaRuntimeService(store) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 10e728c0d61..947f82ee8e0 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -39,6 +39,7 @@ import { normalizeCompatibleAgentStatusEntryForOwner, normalizeCompatibleAgentTitleForOwner } from '../../shared/agent-title-owner' +import { resolvePaneAgentOwner } from '../../shared/pane-agent-owner' import { createAgentStatusOscProcessor, type ProcessedAgentStatusChunk @@ -22471,6 +22472,12 @@ export class OrcaRuntimeService { const paneKey = isTerminalLeafId(tab.leafId) ? makePaneKey(tab.parentTabId, tab.leafId) : `${tab.parentTabId}:${legacyPaneId ?? tab.leafId}` + const mobileStatusPty = livePty ?? pty + // Why: headless hooks live only in main's retained rows; reuse this lookup + // for both title ownership and status publication so the two cannot diverge. + const retainedAgentStatus = tab.agentStatus + ? null + : this.getFreshRetainedAgentStatusForMobileTab(paneKey, liveLeafPty ?? mobileStatusPty, tab) const leafTitle = leaf ? getLatestAgentCandidateTitle( { title: leaf.paneTitle, updatedAt: leaf.paneTitleUpdatedAt }, @@ -22484,7 +22491,15 @@ export class OrcaRuntimeService { ) : null const launchAgent = tab.launchAgent ?? liveLeafPty?.launchAgent ?? pty?.launchAgent ?? null - const ownerAgent = launchAgent ?? liveLeafPty?.foregroundAgent ?? pty?.foregroundAgent ?? null + // Why: a retained OMP hook stays stable while wrapper foreground reads can report Pi. + const ownerAgent = + resolvePaneAgentOwner({ + launchAgent, + hookAgent: tab.agentStatus?.agentType ?? retainedAgentStatus?.payload.agentType ?? null + }) ?? + liveLeafPty?.foregroundAgent ?? + pty?.foregroundAgent ?? + null const title = normalizeCompatibleAgentTitleForOwner( leafTitle ?? ptyTitle ?? syncedTab?.title ?? tab.title, ownerAgent @@ -22543,7 +22558,13 @@ export class OrcaRuntimeService { ...(tab.ptyId ? { ptyId: tab.ptyId } : {}), ...(tab.terminalTheme ? { terminalTheme: tab.terminalTheme } : {}), ...(launchAgent ? { launchAgent } : {}), - ...(agentStatus ?? this.buildPtyMobileAgentStatus(livePty ?? pty, tab, terminalHandle)), + ...(agentStatus ?? + this.buildPtyMobileAgentStatus( + mobileStatusPty, + tab, + terminalHandle, + retainedAgentStatus + )), ...(tab.parentLayout ? { parentLayout: tab.parentLayout } : {}), ...(tab.startupCwd ? { startupCwd: tab.startupCwd } : {}), ...(tab.color != null ? { color: tab.color } : {}), @@ -22598,10 +22619,10 @@ export class OrcaRuntimeService { private buildPtyMobileAgentStatus( pty: RuntimePtyWorktreeRecord | null, tab: RuntimeMobileSessionTerminalTab, - terminalHandle: string | null + terminalHandle: string | null, + retained: RuntimeAgentRowSnapshot | null ): { agentStatus: AgentStatusEntry } | Record { const paneKey = this.getMobileTerminalPaneKey(tab) - const retained = this.getFreshRetainedAgentStatusForMobileTab(paneKey, pty, tab) if (!pty?.lastAgentStatus && !retained) { return {} } @@ -22626,7 +22647,14 @@ export class OrcaRuntimeService { return {} } } - const ownerAgent = tab.launchAgent ?? pty?.launchAgent ?? pty?.foregroundAgent ?? null + // Why: a retained OMP hook stays stable while wrapper foreground reads can report Pi. + const ownerAgent = + resolvePaneAgentOwner({ + launchAgent: tab.launchAgent ?? pty?.launchAgent ?? null, + hookAgent: retained?.payload.agentType ?? null + }) ?? + pty?.foregroundAgent ?? + null const terminalTitle = normalizeCompatibleAgentTitleForOwner( (pty ? getLatestPtyTitle(pty) : null) ?? tab.title, ownerAgent diff --git a/src/relay/pty-shell-utils.test.ts b/src/relay/pty-shell-utils.test.ts index 544e1509c12..b880c5a64e7 100644 --- a/src/relay/pty-shell-utils.test.ts +++ b/src/relay/pty-shell-utils.test.ts @@ -8,6 +8,7 @@ vi.mock('child_process', () => ({ execFile: execFileMock })) +import { resetWindowsProcessRowsSnapshotForTests } from '../main/providers/windows-foreground-process-rows' import { resetProcessTableSnapshotForTests } from '../shared/process-table-snapshot' import { getForegroundProcessName, @@ -50,6 +51,7 @@ async function withProcessPlatform( beforeEach(() => { execFileMock.mockReset() resetProcessTableSnapshotForTests() + resetWindowsProcessRowsSnapshotForTests() }) describe('isProcessAlive', () => { @@ -269,6 +271,94 @@ describe('getForegroundProcessName', () => { }) }) + // Why: OMP embeds Pi, but the outer process is the user-visible identity (#6364). + it('reports the outer omp wrapper over the wrapped pi child from a shell fallback', async () => { + await withProcessPlatform('linux', async () => { + mockExecFile((_command, args) => { + if (args[0] === '-axo') { + return { + stdout: ['100 99 Ss bash -l', '101 100 S+ omp', '102 101 S+ pi'].join('\n') + } + } + return new Error('unexpected command') + }) + + await expect(getForegroundProcessName(100, 'bash')).resolves.toBe('omp') + }) + }) + + it('rescans for the omp wrapper when node-pty reports the wrapped pi as foreground', async () => { + await withProcessPlatform('linux', async () => { + mockExecFile((_command, args) => { + if (args[0] === '-axo') { + return { + stdout: ['100 99 Ss bash -l', '101 100 S+ omp', '102 101 S+ pi'].join('\n') + } + } + return new Error('unexpected command') + }) + + await expect(getForegroundProcessName(100, 'pi')).resolves.toBe('omp') + }) + }) + + it('returns an outer omp fallback without a process-table scan', async () => { + mockExecFile(() => new Error('unexpected process-table scan')) + + await expect(getForegroundProcessName(100, 'omp')).resolves.toBe('omp') + expect(execFileMock).not.toHaveBeenCalled() + }) + + it('rescans a Windows pi fallback for its outer omp wrapper', async () => { + await withProcessPlatform('win32', async () => { + mockExecFile((command) => { + if (command === 'powershell.exe') { + return { + stdout: JSON.stringify([ + { + CommandLine: 'powershell.exe', + ExecutablePath: 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe', + Name: 'powershell.exe', + ParentProcessId: 99, + ProcessId: 100 + }, + { + CommandLine: 'omp.exe', + ExecutablePath: 'C:\\Tools\\omp.exe', + Name: 'omp.exe', + ParentProcessId: 100, + ProcessId: 101 + }, + { + CommandLine: 'pi.exe', + ExecutablePath: 'C:\\Tools\\pi.exe', + Name: 'pi.exe', + ParentProcessId: 101, + ProcessId: 102 + } + ]) + } + } + return new Error('unexpected command') + }) + + await expect(getForegroundProcessName(100, 'pi')).resolves.toBe('omp') + }) + }) + + it('keeps a pi fallback as pi when no omp wrapper is in the tree', async () => { + await withProcessPlatform('linux', async () => { + mockExecFile((_command, args) => { + if (args[0] === '-axo') { + return { stdout: ['100 99 Ss bash -l', '101 100 S+ pi'].join('\n') } + } + return new Error('unexpected command') + }) + + await expect(getForegroundProcessName(100, 'pi')).resolves.toBe('pi') + }) + }) + it('falls back to the root process command when descendant inspection fails', async () => { mockExecFile((_command, args) => { if (args[0] === '-axo') { diff --git a/src/relay/pty-shell-utils.ts b/src/relay/pty-shell-utils.ts index 87035c9510d..6d589b8c6a0 100644 --- a/src/relay/pty-shell-utils.ts +++ b/src/relay/pty-shell-utils.ts @@ -11,6 +11,10 @@ import { } from '../shared/agent-process-recognition' import { getFirstCommandToken } from '../shared/command-token-scanner' import { getProcessTableSnapshot, type ProcessTableRow } from '../shared/process-table-snapshot' +import { + resolveOuterWrapperForegroundProcess, + shouldInspectOuterWrapperForegroundProcess +} from '../shared/foreground-wrapper-agent' import { isShellProcess } from '../shared/shell-process-detection' import { resolveWindowsAgentForegroundProcess, @@ -232,7 +236,9 @@ async function getRecognizedForegroundDescendant( for (const candidate of inspectionCandidates) { const recognized = recognizeAgentProcessFromCommandLine(candidate.command) if (recognized) { - return recognized.processName + // Why: return the outer wrapper (omp) rather than the deeper wrapped child + // (pi) of a shell→omp→pi tree — see resolveOuterWrapperForegroundProcess. + return resolveOuterWrapperForegroundProcess(recognized, candidate, candidates) } } } catch { @@ -251,6 +257,20 @@ export async function getForegroundProcessName( if (fallbackProcess) { const fallbackRecognition = recognizeAgentProcess(fallbackProcess) if (fallbackRecognition) { + // Why: node-pty can report OMP's wrapped Pi; enrich only that ambiguous + // fallback so authoritative OMP reads keep the zero-subprocess fast path. + if (shouldInspectOuterWrapperForegroundProcess(fallbackRecognition)) { + if (process.platform === 'win32') { + return ( + (await resolveWindowsAgentForegroundProcess(pid, fallbackProcess, {})) ?? + fallbackRecognition.processName + ) + } + return ( + (await getRecognizedForegroundDescendant(pid, fallbackProcess)) ?? + fallbackRecognition.processName + ) + } return fallbackRecognition.processName } if (process.platform === 'win32') { diff --git a/src/shared/foreground-wrapper-agent.test.ts b/src/shared/foreground-wrapper-agent.test.ts new file mode 100644 index 00000000000..ab6e1147a6a --- /dev/null +++ b/src/shared/foreground-wrapper-agent.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest' +import { resolveOuterWrapperForegroundProcess } from './foreground-wrapper-agent' + +describe('resolveOuterWrapperForegroundProcess', () => { + const omp = { agent: 'omp' as const, processName: 'omp' } + const pi = { agent: 'pi' as const, processName: 'pi' } + + it('collapses a wrapped pi read onto the shallower omp wrapper', () => { + // Winner is the deeper pi (depth 2); omp is its wrapper at depth 1. + expect( + resolveOuterWrapperForegroundProcess(pi, { pid: 102, ppid: 101, command: 'pi' }, [ + { pid: 102, ppid: 101, command: 'pi' }, + { pid: 101, ppid: 100, command: 'omp' } + ]) + ).toBe('omp') + }) + + it('keeps bare pi when no same-group wrapper is present', () => { + const barePi = { pid: 101, ppid: 100, command: 'pi' } + expect(resolveOuterWrapperForegroundProcess(pi, barePi, [barePi])).toBe('pi') + }) + + it('leaves a cross-group agent (codex) untouched even under a deeper same-name child', () => { + const codex = { agent: 'codex' as const, processName: 'codex' } + expect( + resolveOuterWrapperForegroundProcess( + codex, + { pid: 102, ppid: 101, command: 'node /usr/bin/codex' }, + [ + { pid: 102, ppid: 101, command: 'node /usr/bin/codex' }, + { pid: 101, ppid: 100, command: 'bash -l' } + ] + ) + ).toBe('codex') + }) + + it('does not promote a deeper pi over an already-outer omp winner', () => { + expect( + resolveOuterWrapperForegroundProcess(omp, { pid: 101, ppid: 100, command: 'omp' }, [ + { pid: 101, ppid: 100, command: 'omp' }, + { pid: 102, ppid: 101, command: 'pi' } + ]) + ).toBe('omp') + }) + + it('does not treat an unrelated shallower omp sibling as the pi wrapper', () => { + expect( + resolveOuterWrapperForegroundProcess(pi, { pid: 103, ppid: 102, command: 'pi' }, [ + { pid: 101, ppid: 100, command: 'omp' }, + { pid: 102, ppid: 100, command: 'node server.js' }, + { pid: 103, ppid: 102, command: 'pi' } + ]) + ).toBe('pi') + }) +}) diff --git a/src/shared/foreground-wrapper-agent.ts b/src/shared/foreground-wrapper-agent.ts new file mode 100644 index 00000000000..9e3421d5526 --- /dev/null +++ b/src/shared/foreground-wrapper-agent.ts @@ -0,0 +1,56 @@ +import { + recognizeAgentProcessFromCommandLine, + type RecognizedAgentProcess +} from './agent-process-recognition' +import { getSyntheticAgentTitleProfile } from './synthetic-agent-title' + +export type ForegroundAgentCandidate = { + pid: number + ppid: number + command: string + name?: string +} + +export function shouldInspectOuterWrapperForegroundProcess( + process: RecognizedAgentProcess +): boolean { + // Why: only Pi is currently embedded by a same-group wrapper; scanning OMP would add a subprocess to every relay poll. + return process.agent === 'pi' +} + +/** + * Collapse a foreground read onto its outermost same-title-group ancestor. + * Why: OMP embeds Pi, while depth alone cannot distinguish wrappers from sibling jobs. + */ +export function resolveOuterWrapperForegroundProcess( + winner: RecognizedAgentProcess, + winnerCandidate: ForegroundAgentCandidate, + descendants: readonly ForegroundAgentCandidate[] +): string { + const winnerGroup = getSyntheticAgentTitleProfile(winner.agent)?.titleIdentityGroup + if (!winnerGroup) { + return winner.processName + } + const candidatesByPid = new Map(descendants.map((candidate) => [candidate.pid, candidate])) + const seen = new Set([winnerCandidate.pid]) + let outerProcessName = winner.processName + let parentPid = winnerCandidate.ppid + while (!seen.has(parentPid)) { + seen.add(parentPid) + const candidate = candidatesByPid.get(parentPid) + if (!candidate) { + break + } + const recognized = + recognizeAgentProcessFromCommandLine(candidate.command) ?? + recognizeAgentProcessFromCommandLine(candidate.name) + if ( + recognized && + getSyntheticAgentTitleProfile(recognized.agent)?.titleIdentityGroup === winnerGroup + ) { + outerProcessName = recognized.processName + } + parentPid = candidate.ppid + } + return outerProcessName +} From c45dc62eaff9c6163bf3e173ca433bb09d21f3e6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:18:43 -0700 Subject: [PATCH 05/58] fix(codex): flag signed-out system default (#9619) * fix(codex): flag signed-out system default * fix(codex): distinguish missing sign-in warnings * test(codex): preserve system OAuth auth warning --- .../src/components/settings/AccountsPane.tsx | 75 +++++++++++-------- .../codex-account-auth-warning.test.ts | 44 ++++++++++- .../settings/codex-account-auth-warning.ts | 13 +++- src/renderer/src/i18n/locales/en.json | 3 +- src/renderer/src/i18n/locales/es.json | 3 +- src/renderer/src/i18n/locales/ja.json | 3 +- src/renderer/src/i18n/locales/ko.json | 3 +- src/renderer/src/i18n/locales/zh.json | 3 +- 8 files changed, 105 insertions(+), 42 deletions(-) diff --git a/src/renderer/src/components/settings/AccountsPane.tsx b/src/renderer/src/components/settings/AccountsPane.tsx index a0f80f5922a..651b03140b3 100644 --- a/src/renderer/src/components/settings/AccountsPane.tsx +++ b/src/renderer/src/components/settings/AccountsPane.tsx @@ -414,21 +414,20 @@ export function AccountsPane({ // WSL falls back to the generic label. const systemCodexIdentity = accountRuntime.runtime === 'host' ? codexAccounts.systemDefault : undefined - // Why: the auth warning is derived from the desktop's own rate-limit poll; - // with a remote owner it would misattribute local auth state to the server. - const activeCodexAuthWarning = - codexAccountsLoaded && !isRemoteAccountScope - ? getCodexAccountAuthWarning({ - limits: codexRateLimits, - target: codexRateLimitTarget, - runtime: accountRuntime, - activeAccountId: activeCodexAccountId, - accountId: activeCodexAccountId, - authKind: activeCodexAccountId === null ? systemCodexIdentity?.authKind : undefined - }) - : null - const systemCodexNeedsReauthentication = - activeCodexAccountId === null && Boolean(activeCodexAuthWarning) + // Why: remote snapshots own their system-default identity, but the desktop's + // rate-limit poll must not be misattributed to a remote account owner. + const activeCodexAuthWarning = codexAccountsLoaded + ? getCodexAccountAuthWarning({ + limits: isRemoteAccountScope ? null : codexRateLimits, + target: codexRateLimitTarget, + runtime: accountRuntime, + activeAccountId: activeCodexAccountId, + accountId: activeCodexAccountId, + authKind: activeCodexAccountId === null ? systemCodexIdentity?.authKind : undefined + }) + : null + const systemCodexMissingSignIn = activeCodexAuthWarning === 'missing-sign-in' + const systemCodexNeedsSignIn = activeCodexAccountId === null && Boolean(activeCodexAuthWarning) const accountRuntimeUnavailable = accountRuntime.runtime === 'wsl' && !wslAvailable && !wslCapabilitiesLoading @@ -1080,16 +1079,22 @@ export function AccountsPane({
- {activeCodexAccountId + {systemCodexMissingSignIn ? translate( - 'auto.components.settings.AccountsPane.75ca9b718e', - 'Codex reported that the active account needs a fresh sign-in. Re-authenticate it before starting new Codex sessions.' - ) - : translate( - 'auto.components.settings.AccountsPane.e4a28e8894', - 'Codex reported that the {{value0}} login needs a fresh sign-in. Sign in again before starting new Codex sessions.', + 'auto.components.settings.AccountsPane.codexSystemDefaultNeedsSignIn', + 'No Codex sign-in was found for {{value0}}.', { value0: accountRuntimeSentenceLabel } - )} + ) + : activeCodexAccountId + ? translate( + 'auto.components.settings.AccountsPane.75ca9b718e', + 'Codex reported that the active account needs a fresh sign-in. Re-authenticate it before starting new Codex sessions.' + ) + : translate( + 'auto.components.settings.AccountsPane.e4a28e8894', + 'Codex reported that the {{value0}} login needs a fresh sign-in. Sign in again before starting new Codex sessions.', + { value0: accountRuntimeSentenceLabel } + )}
) : null} @@ -1156,7 +1161,7 @@ export function AccountsPane({ } disabled={codexAction !== 'idle' || accountRuntimeUnavailable} className={`flex w-full items-center justify-between gap-3 rounded-md border px-3 py-2.5 text-left transition-colors ${ - systemCodexNeedsReauthentication + systemCodexNeedsSignIn ? 'border-destructive/50 bg-destructive/5' : systemCodexActive ? 'border-foreground/20 bg-accent/15' @@ -1179,7 +1184,7 @@ export function AccountsPane({ {translate('auto.components.settings.AccountsPane.e74831fb6b', 'Active')} ) : null} - {systemCodexNeedsReauthentication ? ( + {systemCodexNeedsSignIn ? ( - {systemCodexNeedsReauthentication - ? translate( - 'auto.components.settings.AccountsPane.fd62f37c24', - 'Codex reported this {{value0}} login is out of date.', - { value0: accountRuntimeSentenceLabel } - ) + {systemCodexNeedsSignIn + ? systemCodexMissingSignIn + ? translate( + 'auto.components.settings.AccountsPane.codexSystemDefaultNeedsSignIn', + 'No Codex sign-in was found for {{value0}}.', + { value0: accountRuntimeSentenceLabel } + ) + : translate( + 'auto.components.settings.AccountsPane.fd62f37c24', + 'Codex reported this {{value0}} login is out of date.', + { value0: accountRuntimeSentenceLabel } + ) : getCodexSystemDefaultSubtitle( systemCodexIdentity, accountRuntimeSentenceLabel diff --git a/src/renderer/src/components/settings/codex-account-auth-warning.test.ts b/src/renderer/src/components/settings/codex-account-auth-warning.test.ts index dcb14b039b4..4d1631fbaad 100644 --- a/src/renderer/src/components/settings/codex-account-auth-warning.test.ts +++ b/src/renderer/src/components/settings/codex-account-auth-warning.test.ts @@ -49,7 +49,22 @@ describe('codex account auth warning', () => { activeAccountId: 'account-1', accountId: 'account-1' }) - ).toContain('access token could not be refreshed') + ).toBe('stale-sign-in') + }) + + it('preserves stale-sign-in warnings for an OAuth system default', () => { + expect( + getCodexAccountAuthWarning({ + limits: codexLimits( + 'Your access token could not be refreshed. Please log out and sign in again.' + ), + target: { runtime: 'host', wslDistro: null }, + runtime: { runtime: 'host' }, + activeAccountId: null, + accountId: null, + authKind: 'oauth' + }) + ).toBe('stale-sign-in') }) it('does not warn for inactive accounts or a different runtime', () => { @@ -90,6 +105,33 @@ describe('codex account auth warning', () => { ).toBeNull() }) + it('warns only when the active system default has no usable login', () => { + const getWarning = (authKind: 'none' | 'api-key' | 'oauth') => + getCodexAccountAuthWarning({ + limits: null, + target: { runtime: 'host', wslDistro: null }, + runtime: { runtime: 'host' }, + activeAccountId: null, + accountId: null, + authKind + }) + + expect(getWarning('none')).toBe('missing-sign-in') + expect(getWarning('api-key')).toBeNull() + expect(getWarning('oauth')).toBeNull() + + expect( + getCodexAccountAuthWarning({ + limits: null, + target: { runtime: 'host', wslDistro: null }, + runtime: { runtime: 'host' }, + activeAccountId: 'account-1', + accountId: null, + authKind: 'none' + }) + ).toBeNull() + }) + it('allows a WSL default account location to receive the active WSL target warning', () => { expect( codexRateLimitTargetMatchesAccountRuntime( diff --git a/src/renderer/src/components/settings/codex-account-auth-warning.ts b/src/renderer/src/components/settings/codex-account-auth-warning.ts index cefccac3d5c..9db1e3ae8e5 100644 --- a/src/renderer/src/components/settings/codex-account-auth-warning.ts +++ b/src/renderer/src/components/settings/codex-account-auth-warning.ts @@ -10,6 +10,8 @@ type AccountRuntime = { wslDistro?: string | null } +type CodexAccountAuthWarning = 'missing-sign-in' | 'stale-sign-in' + export function codexRateLimitTargetMatchesAccountRuntime( target: RateLimitRuntimeTarget, runtime: AccountRuntime @@ -30,14 +32,17 @@ export function getCodexAccountAuthWarning(args: { activeAccountId: string | null accountId: string | null authKind?: CodexSystemDefaultIdentity['authKind'] -}): string | null { +}): CodexAccountAuthWarning | null { + if (args.accountId !== args.activeAccountId) { + return null + } // Why: app-server reports API-key homes as a ChatGPT-auth error because // usage is unsupported; that is not a stale sign-in the user can re-auth. if (args.accountId === null && args.authKind === 'api-key') { return null } - if (args.accountId !== args.activeAccountId) { - return null + if (args.accountId === null && args.authKind === 'none') { + return 'missing-sign-in' } if (!codexRateLimitTargetMatchesAccountRuntime(args.target, args.runtime)) { return null @@ -45,5 +50,5 @@ export function getCodexAccountAuthWarning(args: { if (args.limits?.status !== 'error' || !isCodexAuthError(args.limits.error)) { return null } - return args.limits.error?.trim() || 'Codex reported that this sign-in needs re-authentication.' + return 'stale-sign-in' } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 1d7263b6a1d..1e0b811e0e5 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -4989,7 +4989,8 @@ "remoteScopeAuthContext": "Each account keeps its own sign-in context on {{value0}}.", "remoteEmptyClaudeAccounts": "No managed Claude accounts on {{value0}}. It uses its system default Claude login; add accounts on that server.", "remoteEmptyCodexAccounts": "No managed Codex accounts on {{value0}}. It uses its system default Codex login; add accounts on that server.", - "codexSystemDefaultCustomProvider": "Custom provider — no usage tracked." + "codexSystemDefaultCustomProvider": "Custom provider — no usage tracked.", + "codexSystemDefaultNeedsSignIn": "No Codex sign-in was found for {{value0}}." }, "AdvancedPane": { "40b29e0bf3": "Restart", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index a10a0ea150a..7ad26a2f327 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -4966,7 +4966,8 @@ "remoteScopeAuthContext": "Cada cuenta mantiene su propio contexto de inicio de sesión en {{value0}}.", "remoteEmptyClaudeAccounts": "No hay cuentas de Claude administradas en {{value0}}. Usa su inicio de sesión de Claude predeterminado del sistema; agrega cuentas en ese servidor.", "remoteEmptyCodexAccounts": "No hay cuentas de Codex administradas en {{value0}}. Usa su inicio de sesión de Codex predeterminado del sistema; agrega cuentas en ese servidor.", - "codexSystemDefaultCustomProvider": "Proveedor personalizado — no se registra el uso." + "codexSystemDefaultCustomProvider": "Proveedor personalizado — no se registra el uso.", + "codexSystemDefaultNeedsSignIn": "No se encontró ningún inicio de sesión de Codex para {{value0}}." }, "AdvancedPane": { "40b29e0bf3": "Reiniciar", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 4531cc490a3..f7e7c05745e 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -4951,7 +4951,8 @@ "remoteScopeAuthContext": "各アカウントは {{value0}} 上に独自のサインインコンテキストを保持します。", "remoteEmptyClaudeAccounts": "{{value0}} に管理対象の Claude アカウントはありません。システム既定の Claude ログインを使用します。アカウントの追加はそのサーバー上で行ってください。", "remoteEmptyCodexAccounts": "{{value0}} に管理対象の Codex アカウントはありません。システム既定の Codex ログインを使用します。アカウントの追加はそのサーバー上で行ってください。", - "codexSystemDefaultCustomProvider": "カスタムプロバイダー — 使用量は追跡されません。" + "codexSystemDefaultCustomProvider": "カスタムプロバイダー — 使用量は追跡されません。", + "codexSystemDefaultNeedsSignIn": "{{value0}} の Codex サインインが見つかりません。" }, "AdvancedPane": { "40b29e0bf3": "再起動", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index dd155b3a4bf..57adc540b89 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -4951,7 +4951,8 @@ "remoteScopeAuthContext": "각 계정은 {{value0}}에 자체 로그인 컨텍스트를 유지합니다.", "remoteEmptyClaudeAccounts": "{{value0}}에 관리되는 Claude 계정이 없습니다. 해당 서버의 시스템 기본 Claude 로그인을 사용하며, 계정 추가는 그 서버에서 진행하세요.", "remoteEmptyCodexAccounts": "{{value0}}에 관리되는 Codex 계정이 없습니다. 해당 서버의 시스템 기본 Codex 로그인을 사용하며, 계정 추가는 그 서버에서 진행하세요.", - "codexSystemDefaultCustomProvider": "사용자 지정 제공업체 — 사용량을 추적하지 않습니다." + "codexSystemDefaultCustomProvider": "사용자 지정 제공업체 — 사용량을 추적하지 않습니다.", + "codexSystemDefaultNeedsSignIn": "{{value0}}에 대한 Codex 로그인을 찾을 수 없습니다." }, "AdvancedPane": { "40b29e0bf3": "다시 시작", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 825f838ddd4..365a23efdf2 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -4951,7 +4951,8 @@ "remoteScopeAuthContext": "每个账户在 {{value0}} 上保留自己的登录上下文。", "remoteEmptyClaudeAccounts": "{{value0}} 上没有受管理的 Claude 账户。它使用其系统默认的 Claude 登录;请在该服务器上添加账户。", "remoteEmptyCodexAccounts": "{{value0}} 上没有受管理的 Codex 账户。它使用其系统默认的 Codex 登录;请在该服务器上添加账户。", - "codexSystemDefaultCustomProvider": "自定义提供商 — 不跟踪使用情况。" + "codexSystemDefaultCustomProvider": "自定义提供商 — 不跟踪使用情况。", + "codexSystemDefaultNeedsSignIn": "未找到 {{value0}} 的 Codex 登录信息。" }, "AdvancedPane": { "40b29e0bf3": "重新启动", From fec996a5487ac011c409b5edb300dda4b8024f13 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:25:15 -0700 Subject: [PATCH 06/58] fix(codex): preserve active account during reauth (#9620) * fix(codex): preserve active account during reauth * fix(codex): keep reauth validation authoritative * fix(codex): scope reauth selection restoration --- src/main/codex-accounts/service.test.ts | 207 +++++++++++++++++++++++- src/main/codex-accounts/service.ts | 22 ++- 2 files changed, 220 insertions(+), 9 deletions(-) diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index 3e180872eb0..54c458aaef8 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -14,7 +14,7 @@ import { import { tmpdir } from 'node:os' import { join } from 'node:path' import { PassThrough } from 'node:stream' -import type { GlobalSettings } from '../../shared/types' +import type { CodexRateLimitAccountsState, GlobalSettings } from '../../shared/types' import { buildWslCodexAvailabilityArgs, buildWslCodexLoginArgs } from './wsl-codex-command' import type { readHookTrustEntries as ReadHookTrustEntries } from '../codex/config-toml-trust' @@ -938,6 +938,179 @@ describe('CodexAccountService config sync', () => { warnSpy.mockRestore() }) + it.each([ + { + label: 'the active account', + accountId: 'account-1', + outcome: 'success', + expectedActiveAccountId: 'account-1', + expectedUpdateCount: 2 + }, + { + label: 'a different account', + accountId: 'account-2', + outcome: 'success', + expectedActiveAccountId: 'account-1', + expectedUpdateCount: 2 + }, + { + label: 'a login that fails', + accountId: 'account-1', + outcome: 'login-failure', + expectedActiveAccountId: null, + expectedUpdateCount: 1 + }, + { + label: 'credentials rejected by runtime validation', + accountId: 'account-1', + outcome: 'runtime-validation-failure', + expectedActiveAccountId: null, + expectedUpdateCount: 3 + } + ])('keeps host selection semantics when reauthenticating $label', async (testCase) => { + vi.resetModules() + + const hostAccounts = ['account-1', 'account-2'].map((id, index) => ({ + id, + email: `${id}@example.com`, + managedHomePath: createManagedHome( + testState.userDataDir, + id, + '', + createCodexAuthJson(`${id}@example.com`, `provider-${id}`, `refresh-${id}`) + ), + providerAccountId: `provider-${id}`, + workspaceLabel: null, + workspaceAccountId: `provider-${id}`, + createdAt: index + 1, + updatedAt: index + 1, + lastAuthenticatedAt: index + 1 + })) + const wslAccount = { + id: 'account-wsl', + email: 'account-wsl@example.com', + managedHomePath: createManagedHome( + testState.userDataDir, + 'account-wsl', + '', + createCodexAuthJson('account-wsl@example.com', 'provider-wsl', 'refresh-wsl') + ), + managedHomeRuntime: 'wsl' as const, + wslDistro: 'Ubuntu', + wslLinuxHomePath: '/home/test/.local/share/orca/codex-accounts/account-wsl/home', + providerAccountId: 'provider-wsl', + workspaceLabel: null, + workspaceAccountId: 'provider-wsl', + createdAt: 3, + updatedAt: 3, + lastAuthenticatedAt: 3 + } + const settings = createSettings({ + codexManagedAccounts: [...hostAccounts, wslAccount], + activeCodexManagedAccountId: 'account-1', + activeCodexManagedAccountIdsByRuntime: { + host: 'account-1', + wsl: { Ubuntu: 'account-wsl' } + } + }) + const store = createStore(settings) + const runtimeHome = createRuntimeHome() + const spawnMock = vi.fn( + (_command: string, _args: string[], options: { env: NodeJS.ProcessEnv }) => { + const child = new EventEmitter() as EventEmitter & { + stdout: PassThrough + stderr: PassThrough + kill: () => void + } + child.stdout = new PassThrough() + child.stderr = new PassThrough() + child.kill = vi.fn() + const current = store.getSettings() + store.updateSettings({ + activeCodexManagedAccountId: null, + activeCodexManagedAccountIdsByRuntime: { + ...current.activeCodexManagedAccountIdsByRuntime!, + host: null, + wsl: { Ubuntu: null } + } + }) + if (testCase.outcome === 'login-failure') { + queueMicrotask(() => child.emit('close', 1)) + return child + } + writeFileSync( + join(options.env.CODEX_HOME!, 'auth.json'), + createCodexAuthJson('reauthenticated@example.com', 'provider-new', 'refresh-new'), + 'utf-8' + ) + queueMicrotask(() => child.emit('close', 0)) + return child + } + ) + vi.doMock('node:child_process', () => ({ + execFileSync: vi.fn(), + spawn: spawnMock + })) + vi.doMock('../codex-cli/command', () => ({ + resolveCodexCommand: () => 'codex' + })) + + runtimeHome.syncForCurrentSelection.mockImplementation(() => { + if (testCase.outcome !== 'runtime-validation-failure') { + return + } + const current = store.getSettings() + store.updateSettings({ + activeCodexManagedAccountId: null, + activeCodexManagedAccountIdsByRuntime: { + ...current.activeCodexManagedAccountIdsByRuntime!, + host: null + } + }) + }) + const rateLimits = createRateLimits() + const { CodexAccountService } = await import('./service') + const service = new CodexAccountService( + store as never, + rateLimits as never, + runtimeHome as never + ) + + let result: CodexRateLimitAccountsState | null = null + if (testCase.outcome === 'login-failure') { + await expect(service.reauthenticateAccount(testCase.accountId)).rejects.toThrow( + 'Codex login exited with code 1.' + ) + } else { + result = await service.reauthenticateAccount(testCase.accountId) + } + + expect(result?.activeAccountId ?? null).toBe(testCase.expectedActiveAccountId) + if (result) { + expect(result.activeAccountIdsByRuntime).toEqual({ + host: testCase.expectedActiveAccountId, + wsl: { Ubuntu: null } + }) + } + expect(store.getSettings()).toMatchObject({ + activeCodexManagedAccountId: testCase.expectedActiveAccountId, + activeCodexManagedAccountIdsByRuntime: { + host: testCase.expectedActiveAccountId, + wsl: { Ubuntu: null } + } + }) + const completedLogin = testCase.outcome !== 'login-failure' + expect(runtimeHome.syncForCurrentSelection).toHaveBeenCalledTimes(completedLogin ? 1 : 0) + if (completedLogin) { + expect(runtimeHome.syncForCurrentSelection).toHaveBeenCalledWith({ runtime: 'host' }) + expect(rateLimits.refreshForCodexAccountChange).toHaveBeenCalledWith(undefined, { + runtime: 'host' + }) + } + expect(rateLimits.refreshForCodexAccountChange).toHaveBeenCalledTimes(completedLogin ? 1 : 0) + expect(store.updateSettings).toHaveBeenCalledTimes(testCase.expectedUpdateCount) + }) + it('does not recreate a missing managed home at a different account path', async () => { vi.resetModules() const managedHomePath = join(testState.userDataDir, 'codex-accounts', 'other-account', 'home') @@ -1252,6 +1425,7 @@ describe('CodexAccountService config sync', () => { } return '' }) + let clearSelectionDuringLogin = (): void => {} const spawnMock = vi.fn((command: string, args: string[]) => { expect(command).toBe('wsl.exe') expect(args).toEqual(buildWslCodexLoginArgs('Ubuntu', wslLinuxHomePath)) @@ -1263,6 +1437,7 @@ describe('CodexAccountService config sync', () => { child.stdout = new PassThrough() child.stderr = new PassThrough() child.kill = vi.fn() + clearSelectionDuringLogin() writeFileSync( join(wslManagedHomePath, 'auth.json'), JSON.stringify({ @@ -1307,9 +1482,22 @@ describe('CodexAccountService config sync', () => { lastAuthenticatedAt: 1 } ], - activeCodexManagedAccountId: 'account-1' + activeCodexManagedAccountId: null, + activeCodexManagedAccountIdsByRuntime: { + host: null, + wsl: { Ubuntu: 'account-1' } + } }) const store = createStore(settings) + clearSelectionDuringLogin = () => { + const current = store.getSettings() + store.updateSettings({ + activeCodexManagedAccountIdsByRuntime: { + ...current.activeCodexManagedAccountIdsByRuntime!, + wsl: { Ubuntu: null } + } + }) + } const rateLimits = createRateLimits() const runtimeHome = createRuntimeHome() @@ -1328,7 +1516,20 @@ describe('CodexAccountService config sync', () => { managedHomeRuntime: 'wsl', wslDistro: 'Ubuntu' }) - expect(runtimeHome.syncForCurrentSelection).toHaveBeenCalled() + expect(result.activeAccountId).toBe(null) + expect(result.activeAccountIdsByRuntime).toEqual({ + host: null, + wsl: { Ubuntu: 'account-1' } + }) + expect(runtimeHome.syncForCurrentSelection).toHaveBeenCalledWith({ + runtime: 'wsl', + wslDistro: 'Ubuntu' + }) + expect(rateLimits.refreshForCodexAccountChange).toHaveBeenCalledWith(undefined, { + runtime: 'wsl', + wslDistro: 'Ubuntu' + }) + expect(store.updateSettings).toHaveBeenCalledTimes(2) } finally { Object.defineProperty(process, 'platform', { configurable: true, diff --git a/src/main/codex-accounts/service.ts b/src/main/codex-accounts/service.ts index 75dd28c0eb2..5aacd356bf1 100644 --- a/src/main/codex-accounts/service.ts +++ b/src/main/codex-accounts/service.ts @@ -258,6 +258,11 @@ export class CodexAccountService { private async doReauthenticateAccount(accountId: string): Promise { const account = this.requireAccount(accountId) const managedHomePath = this.ensureManagedHomeForReauthentication(account) + const accountTarget = getCodexSelectionTargetForAccount(account) + const selectedAccountId = getSelectedCodexAccountIdForTarget( + this.store.getSettings(), + accountTarget + ) this.safeSyncCanonicalConfigIntoManagedHome(managedHomePath, undefined, account.id) await this.runCodexLogin(managedHomePath) @@ -281,19 +286,24 @@ export class CodexAccountService { } : entry ) + const activeSelection = setSelectedCodexAccountIdForTarget( + normalizeCodexRuntimeSelection(settings), + selectedAccountId, + accountTarget + ) + // Why: login can transiently clear this runtime's selection; unrelated runtime validation must remain authoritative. this.store.updateSettings({ - codexManagedAccounts: updatedAccounts + codexManagedAccounts: updatedAccounts, + activeCodexManagedAccountId: activeSelection.host, + activeCodexManagedAccountIdsByRuntime: activeSelection }) this.safeSyncCanonicalConfigToManagedHomes() this.runtimeHome.clearLastWrittenAuthJson(accountId) - this.runtimeHome.syncForCurrentSelection(getCodexSelectionTargetForAccount(account)) + this.runtimeHome.syncForCurrentSelection(accountTarget) // 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) - ) + await this.rateLimits.refreshForCodexAccountChange(undefined, accountTarget) return this.getSnapshot() } From bf7fbdd57bf07070fd47559d90c0e6d1772848b0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:37:49 -0700 Subject: [PATCH 07/58] fix(codex): auto-trust linked worktrees before launch (#9621) * fix(codex): trust linked worktrees before launch * fix(codex): validate linked worktree trust roots --- src/main/agent-trust-presets.test.ts | 65 +++++++++++++++ src/main/agent-trust-presets.ts | 40 +++++++++- src/main/index.ts | 18 ++++- src/main/ipc/pty.test.ts | 80 +++++++++++++++++-- src/main/ipc/pty.ts | 20 +++-- src/main/providers/local-pty-provider.ts | 2 + .../window/attach-main-window-services.ts | 8 +- 7 files changed, 212 insertions(+), 21 deletions(-) diff --git a/src/main/agent-trust-presets.test.ts b/src/main/agent-trust-presets.test.ts index 9c7f9122389..2a979c60e27 100644 --- a/src/main/agent-trust-presets.test.ts +++ b/src/main/agent-trust-presets.test.ts @@ -137,6 +137,71 @@ describe('markCopilotFolderTrusted', () => { }) describe('markCodexProjectTrusted', () => { + it('trusts the main repository root for a linked worktree without reading commondir', () => { + const fixtureRoot = mkdtempSync(join(tmpdir(), 'orca-codex-linked-ws-')) + const repository = join(fixtureRoot, 'repo') + const workspace = join(fixtureRoot, 'worktrees', 'feature') + const worktreeGitDir = join(repository, '.git', 'worktrees', 'feature') + try { + mkdirSync(worktreeGitDir, { recursive: true }) + mkdirSync(workspace, { recursive: true }) + writeFileSync(join(workspace, '.git'), `gitdir: ${worktreeGitDir}\n`, 'utf-8') + writeFileSync(join(worktreeGitDir, 'gitdir'), join(workspace, '.git'), 'utf-8') + + markCodexProjectTrusted(workspace) + + const repositoryRoot = realpathSync.native(repository) + const workspaceRoot = realpathSync.native(workspace) + const configPath = join(testState.fakeHomeDir, '.codex', 'config.toml') + const runtimeConfigPath = join( + testState.userDataDir, + 'codex-runtime-home', + 'home', + 'config.toml' + ) + for (const written of [ + readFileSync(configPath, 'utf-8'), + readFileSync(runtimeConfigPath, 'utf-8') + ]) { + expect(written).toContain(`[projects."${escapeTomlBasicString(repositoryRoot)}"]`) + expect(written).not.toContain(`[projects."${escapeTomlBasicString(workspaceRoot)}"]`) + } + } finally { + rmSync(fixtureRoot, { recursive: true, force: true }) + } + }) + + it('does not broaden trust through arbitrary or adversarial Git metadata', () => { + const fixtureRoot = mkdtempSync(join(tmpdir(), 'orca-codex-untrusted-gitdir-')) + const workspace = join(fixtureRoot, 'workspace') + const arbitraryGitDir = join(fixtureRoot, 'metadata', 'feature') + const unrelatedRoot = join(fixtureRoot, 'unrelated') + try { + mkdirSync(arbitraryGitDir, { recursive: true }) + mkdirSync(workspace, { recursive: true }) + mkdirSync(unrelatedRoot, { recursive: true }) + writeFileSync(join(workspace, '.git'), `gitdir: ${arbitraryGitDir}\n`, 'utf-8') + writeFileSync(join(arbitraryGitDir, 'commondir'), join(unrelatedRoot, '.git'), 'utf-8') + + markCodexProjectTrusted(workspace) + const structuredGitDir = join(unrelatedRoot, '.git', 'worktrees', 'feature') + mkdirSync(structuredGitDir, { recursive: true }) + writeFileSync(join(workspace, '.git'), `gitdir: ${structuredGitDir}\n`, 'utf-8') + writeFileSync(join(structuredGitDir, 'gitdir'), join(unrelatedRoot, '.git'), 'utf-8') + markCodexProjectTrusted(workspace) + + const written = readFileSync(join(testState.fakeHomeDir, '.codex', 'config.toml'), 'utf-8') + expect(written).toContain( + `[projects."${escapeTomlBasicString(realpathSync.native(workspace))}"]` + ) + expect(written).not.toContain( + `[projects."${escapeTomlBasicString(realpathSync.native(unrelatedRoot))}"]` + ) + } finally { + rmSync(fixtureRoot, { recursive: true, force: true }) + } + }) + it('writes ~/.codex/config.toml with the project marked trusted', () => { const workspace = mkdtempSync(join(tmpdir(), 'orca-codex-ws-')) try { diff --git a/src/main/agent-trust-presets.ts b/src/main/agent-trust-presets.ts index 9ba45a35b33..3b441d6d1fe 100644 --- a/src/main/agent-trust-presets.ts +++ b/src/main/agent-trust-presets.ts @@ -1,6 +1,6 @@ import { existsSync, mkdirSync, readFileSync, realpathSync } from 'node:fs' import { homedir } from 'node:os' -import { join } from 'node:path' +import { basename, dirname, join, resolve } from 'node:path' import { writeFileAtomically } from './codex-accounts/fs-utils' import { getOrcaManagedCodexHomePath } from './codex/codex-home-paths' import { upsertProjectTrustLevel } from './codex/config-toml-trust' @@ -109,7 +109,7 @@ export function markCopilotFolderTrusted(workspacePath: string): void { * codex-rs/core/src/config/config_tests.rs in the Codex CLI source. */ export function markCodexProjectTrusted(workspacePath: string): void { - const absPath = canonicalize(workspacePath) + const absPath = resolveCodexProjectTrustRoot(workspacePath) const configPath = join(homedir(), '.codex', 'config.toml') upsertProjectTrustLevel(configPath, absPath, 'trusted') // Why: Orca-launched Codex runs with an Orca-owned CODEX_HOME, so the trust @@ -117,6 +117,42 @@ export function markCodexProjectTrusted(workspacePath: string): void { upsertProjectTrustLevel(join(getOrcaManagedCodexHomePath(), 'config.toml'), absPath, 'trusted') } +function resolveCodexProjectTrustRoot(workspacePath: string): string { + const absPath = canonicalize(workspacePath) + try { + const gitDirReference = readFileSync(join(absPath, '.git'), 'utf-8').trim() + if (!gitDirReference.startsWith('gitdir:')) { + return absPath + } + const gitDirPath = gitDirReference.slice('gitdir:'.length).trim() + if (!gitDirPath) { + return absPath + } + const gitDir = resolve(absPath, gitDirPath) + const worktreesDir = dirname(gitDir) + if (basename(worktreesDir) !== 'worktrees') { + return absPath + } + // Why: workspace-controlled .git metadata must not broaden trust without Git's reciprocal link. + const gitDirBacklink = readFileSync(join(gitDir, 'gitdir'), 'utf-8').trim() + if (!gitDirBacklink) { + return absPath + } + const resolvedBacklink = resolve(gitDir, gitDirBacklink) + const workspaceGitFile = join(absPath, '.git') + if ( + resolvedBacklink !== workspaceGitFile && + canonicalize(resolvedBacklink) !== canonicalize(workspaceGitFile) + ) { + return absPath + } + // Why: mirror Codex's validated .git/worktrees/ traversal instead of trusting arbitrary commondir contents. + return canonicalize(dirname(dirname(worktreesDir))) + } catch { + return absPath + } +} + function canonicalize(p: string): string { // Why: macOS reports `/tmp/x` and `/private/tmp/x` as the same inode, but // both Cursor and Copilot's trust comparators run realpath() before the diff --git a/src/main/index.ts b/src/main/index.ts index b991afe1690..ef6e1e5ec9c 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -53,7 +53,7 @@ import { rebuildAppMenu } from './menu/register-app-menu' import { checkForUpdatesFromMenu, isQuittingForUpdate } from './updater' -import type { UpdateCheckOptions } from '../shared/types' +import type { TuiAgent, UpdateCheckOptions } from '../shared/types' import { recordUpdaterLifecycle } from './updater-lifecycle-diagnostics' import { configureElectronNetworkCompatibility, @@ -129,6 +129,7 @@ import { focusExistingMainWindow } from './window/focus-existing-window' import { notifyMainWindowBecameVisible } from './window/main-window-visibility' import { CodexAccountService } from './codex-accounts/service' import { CodexRuntimeHomeService } from './codex-accounts/runtime-home-service' +import { markCodexProjectTrusted } from './agent-trust-presets' import { normalizeCodexRuntimeSelection, type CodexAccountSelectionTarget @@ -695,8 +696,21 @@ function startTerminalRuntimeStartupServices(): Promise { function prepareCodexRuntimeHomeForLaunch( target?: CodexAccountSelectionTarget, - launchEnv?: NodeJS.ProcessEnv + launchEnv?: NodeJS.ProcessEnv, + launchContext?: { workspacePath?: string; launchAgent?: TuiAgent } ): string | null { + if ( + target?.runtime !== 'wsl' && + launchContext?.launchAgent === 'codex' && + launchContext.workspacePath + ) { + try { + // Why: renderer quick-launch cannot await trust IPC before its PTY mounts; launch prep runs synchronously before every recognized Codex spawn. + markCodexProjectTrusted(launchContext.workspacePath) + } catch (error) { + console.warn('[codex-project-trust] failed to pre-mark launch workspace:', error) + } + } const ensureRealHomeHooksIfSelected = (): boolean => { if ( target?.runtime === 'wsl' || diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 78d5f70eed2..85a84fe098b 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -743,7 +743,8 @@ describe('registerPtyHandlers', () => { processEnvOverrides?: Record, getSelectedCodexHomePath?: ( target?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null }, - launchEnv?: NodeJS.ProcessEnv + launchEnv?: NodeJS.ProcessEnv, + launchContext?: { workspacePath?: string; launchAgent?: TuiAgent } ) => string | null, getSettings?: () => { enableGitHubAttribution?: boolean @@ -753,7 +754,9 @@ describe('registerPtyHandlers', () => { }, // Why: PR #2662 finding 2 — accept an optional `command` so callers can exercise OMP target resolution (was untested). command?: string, - launchAgent?: TuiAgent + launchAgent?: TuiAgent, + cwd?: string, + worktreeId?: string ): Promise> { const savedEnv: Record = {} if (processEnvOverrides) { @@ -781,7 +784,9 @@ describe('registerPtyHandlers', () => { rows: 24, ...(argsEnv ? { env: argsEnv } : {}), ...(command ? { command } : {}), - ...(launchAgent ? { launchAgent } : {}) + ...(launchAgent ? { launchAgent } : {}), + ...(cwd ? { cwd } : {}), + ...(worktreeId ? { worktreeId } : {}) }) const spawnCall = spawnMock.mock.calls.at(-1)! return spawnCall[2].env as Record @@ -926,6 +931,34 @@ describe('registerPtyHandlers', () => { expect(env.ORCA_CODEX_HOME).toBe(TEST_CODEX_HOME) }) + it('prepares Codex launch state for the workspace before spawning an interactive tab', async () => { + const workspacePath = '/repo/worktrees/new-feature' + const resolveHome = vi.fn( + ( + _target?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null }, + _launchEnv?: NodeJS.ProcessEnv, + _launchContext?: { workspacePath?: string; launchAgent?: TuiAgent } + ) => null + ) + + await spawnAndGetEnv( + undefined, + undefined, + resolveHome, + undefined, + 'codex', + 'codex', + workspacePath, + `repo-id::${workspacePath}` + ) + + expect(resolveHome.mock.calls[0]?.[0]).toEqual({ runtime: 'host' }) + expect(resolveHome.mock.calls[0]?.[2]).toEqual({ workspacePath, launchAgent: 'codex' }) + expect(resolveHome.mock.invocationCallOrder[0]).toBeLessThan( + spawnMock.mock.invocationCallOrder[0]! + ) + }) + it('injects the OpenCode hook env into Orca terminal PTYs', async () => { // Why: clear any ambient OPENCODE_CONFIG_DIR so the mock's value is used const env = await spawnAndGetEnv(undefined, { OPENCODE_CONFIG_DIR: undefined }) @@ -1504,7 +1537,11 @@ describe('registerPtyHandlers', () => { async function daemonSpawnAndGetOptions( argsEnv?: Record, - getSelectedCodexHomePath?: () => string | null, + getSelectedCodexHomePath?: ( + target?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null }, + launchEnv?: NodeJS.ProcessEnv, + launchContext?: { workspacePath?: string; launchAgent?: TuiAgent } + ) => string | null, getSettings?: () => { enableGitHubAttribution?: boolean httpProxyUrl?: string @@ -1517,6 +1554,7 @@ describe('registerPtyHandlers', () => { worktreeId?: string shellOverride?: string command?: string + launchAgent?: TuiAgent envToDelete?: string[] }, supportsGitCredentialGuardHost = true @@ -1561,14 +1599,23 @@ describe('registerPtyHandlers', () => { async function daemonSpawnAndGetEnv( argsEnv?: Record, - getSelectedCodexHomePath?: () => string | null, + getSelectedCodexHomePath?: ( + target?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null }, + launchEnv?: NodeJS.ProcessEnv, + launchContext?: { workspacePath?: string; launchAgent?: TuiAgent } + ) => string | null, getSettings?: () => { enableGitHubAttribution?: boolean httpProxyUrl?: string httpProxyBypassRules?: string }, processEnvOverrides?: Record, - spawnArgs?: { cwd?: string; shellOverride?: string; command?: string }, + spawnArgs?: { + cwd?: string + shellOverride?: string + command?: string + launchAgent?: TuiAgent + }, supportsGitCredentialGuardHost = true ): Promise> { return ( @@ -1682,6 +1729,27 @@ describe('registerPtyHandlers', () => { expect(env.ORCA_CODEX_HOME).toBe(TEST_CODEX_HOME) }) + it('prepares Codex project trust before a daemon-backed interactive launch', async () => { + const workspacePath = '/repo/worktrees/new-feature' + const resolveHome = vi.fn( + ( + _target?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null }, + _launchEnv?: NodeJS.ProcessEnv, + _launchContext?: { workspacePath?: string; launchAgent?: TuiAgent } + ) => null + ) + + await daemonSpawnAndGetOptions({}, resolveHome, undefined, undefined, { + cwd: workspacePath, + worktreeId: `repo-id::${workspacePath}`, + command: 'codex', + launchAgent: 'codex' + }) + + expect(resolveHome.mock.calls[0]?.[0]).toEqual({ runtime: 'host' }) + expect(resolveHome.mock.calls[0]?.[2]).toEqual({ workspacePath, launchAgent: 'codex' }) + }) + it('injects explicit proxy settings on the daemon path', async () => { const env = await daemonSpawnAndGetEnv({}, undefined, () => ({ httpProxyUrl: 'http://proxy.example:8080', diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 047cd88749c..0d0b79b6b68 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -619,9 +619,10 @@ function getLocalOrcaCodexHomeEnvKeysToDelete(env: Record): stri return keysToDelete } -type GetSelectedCodexHomePath = ( +export type GetSelectedCodexHomePath = ( target?: CodexAccountSelectionTarget, - launchEnv?: NodeJS.ProcessEnv + launchEnv?: NodeJS.ProcessEnv, + launchContext?: { workspacePath?: string; launchAgent?: TuiAgent } ) => string | null type PrepareClaudeAuth = ( target?: ClaudeAccountSelectionTarget @@ -1452,7 +1453,10 @@ export function registerPtyHandlers( : { runtime: 'host' } const selectedCodexHomePath = getCompatibleSelectedCodexHomePath( codexSelectionTarget, - getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv) ?? null + getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv, { + workspacePath: ctx?.cwd, + launchAgent: ctx?.launchAgent + }) ?? null ) const skipCodexHomeEnv = ctx?.isWsl === true && !selectedCodexHomePath const env = buildPtyHostEnv(id, baseEnv, { @@ -2794,7 +2798,10 @@ export function registerPtyHandlers( const selectedCodexHomePath = isDaemonHostSpawn ? getCompatibleSelectedCodexHomePath( codexSelectionTarget, - getSelectedCodexHomePath?.(codexSelectionTarget, env) ?? null + getSelectedCodexHomePath?.(codexSelectionTarget, env, { + workspacePath: cwd, + launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined + }) ?? null ) : null const skipCodexHomeEnv = @@ -3682,7 +3689,10 @@ export function registerPtyHandlers( const selectedCodexHomePath = isDaemonHostSpawn ? getCompatibleSelectedCodexHomePath( codexSelectionTarget, - getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv) ?? null + getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv, { + workspacePath: cwd, + launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined + }) ?? null ) : null const skipCodexHomeEnv = diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index e83b5103d7a..9eff8feeacf 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -471,6 +471,7 @@ export type LocalPtyProviderOptions = { ctx?: { command?: string launchAgent?: PtySpawnOptions['launchAgent'] + cwd?: string shellPath?: string isWsl?: boolean wslDistro?: string | null @@ -672,6 +673,7 @@ export class LocalPtyProvider implements IPtyProvider { ? this.opts.buildSpawnEnv(id, spawnEnv, { command: args.command, launchAgent: args.launchAgent, + cwd, shellPath, isWsl: isWslShell, wslDistro: launchWslDistro diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index 82c93f809cc..55f1265ea48 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -12,7 +12,7 @@ import type { import { registerRepoHandlers } from '../ipc/repos' import { registerWorktreeHandlers } from '../ipc/worktrees' import { registerWorkspaceCleanupHandlers } from '../ipc/workspace-cleanup' -import { getLocalPtyProvider, registerPtyHandlers } from '../ipc/pty' +import { getLocalPtyProvider, registerPtyHandlers, type GetSelectedCodexHomePath } from '../ipc/pty' import { registerDaemonManagementHandlers } from '../ipc/pty-management' import { registerSshHandlers } from '../ipc/ssh' import { registerRemoteWorkspaceHandlers } from '../ipc/remote-workspace' @@ -39,7 +39,6 @@ import type { RuntimeMobileSessionTabMove } from '../../shared/runtime-types' import { isNativeFileDropPayload, type NativeFileDropPayload } from '../../shared/native-file-drop' import { requestMobileMarkdownFromRenderer } from './mobile-markdown-request-relay' import { requestTerminalTabCloseFromRenderer } from './terminal-tab-close-request-relay' -import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' import type { ClaudeAccountSelectionTarget } from '../claude-accounts/runtime-selection' import { runWorktreeChangeInvalidators } from '../ipc/worktree-change-invalidators' import { @@ -66,10 +65,7 @@ export function attachMainWindowServices( mainWindow: BrowserWindow, store: Store, runtime: OrcaRuntimeService, - getSelectedCodexHomePath?: ( - target?: CodexAccountSelectionTarget, - launchEnv?: NodeJS.ProcessEnv - ) => string | null, + getSelectedCodexHomePath?: GetSelectedCodexHomePath, prepareClaudeAuth?: ( target?: ClaudeAccountSelectionTarget ) => Promise, From 0ed1d04b3b062de652845b328275325794c7d746 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:10:07 -0700 Subject: [PATCH 08/58] fix(codex): migrate legacy shared-home sessions before resume (#9624) * fix(codex): migrate legacy shared-home resumes * fix(mobile): allow legacy Codex resume preparation --- .../MobileAgentSessionHistoryPanel.tsx | 4 +- .../session/ai-vault-resume-launch.test.ts | 54 ++++++ mobile/src/session/ai-vault-resume-launch.ts | 30 +++ .../ai-vault/runtime-session-scanner.test.ts | 43 ++++- src/main/ai-vault/runtime-session-scanner.ts | 29 +++ src/main/codex-accounts/service.test.ts | 5 +- src/main/codex-accounts/service.ts | 19 +- .../codex/codex-legacy-session-resume.test.ts | 145 +++++++++++++++ src/main/codex/codex-legacy-session-resume.ts | 175 ++++++++++++++++++ .../codex-session-migration-scheduler.test.ts | 93 ++++++++++ .../codex-session-migration-scheduler.ts | 87 +++++++++ src/main/index.ts | 56 +++--- src/main/ipc/ai-vault-resume.ts | 39 ++++ src/main/ipc/ai-vault.test.ts | 78 +++++++- src/main/ipc/ai-vault.ts | 19 +- src/main/ipc/register-core-handlers.test.ts | 23 ++- src/main/ipc/register-core-handlers.ts | 13 +- src/main/runtime/orca-runtime.ts | 19 ++ src/main/runtime/rpc/methods/ai-vault.test.ts | 41 +++- src/main/runtime/rpc/methods/ai-vault.ts | 23 ++- src/main/runtime/runtime-rpc.ts | 1 + src/preload/api-types.ts | 7 + src/preload/index.ts | 3 + .../right-sidebar/AiVaultSessionRow.tsx | 8 +- .../AiVaultSessionVirtualList.tsx | 4 + .../ai-vault-session-launch-actions.ts | 70 ++++--- .../tab-group/AiVaultSessionDropLayer.tsx | 72 ++++--- .../src/lib/ai-vault-resume-command.test.ts | 116 +++++++++++- .../src/lib/ai-vault-resume-command.ts | 33 +++- .../src/lib/ai-vault-session-drag.test.ts | 5 + src/renderer/src/lib/ai-vault-session-drag.ts | 36 +++- ...i-vault-session-resume-preparation.test.ts | 77 ++++++++ .../ai-vault-session-resume-preparation.ts | 17 ++ src/renderer/src/web/web-preload-api.ts | 6 + src/shared/ai-vault-resume-preparation.ts | 22 +++ 35 files changed, 1361 insertions(+), 111 deletions(-) create mode 100644 src/main/codex/codex-legacy-session-resume.test.ts create mode 100644 src/main/codex/codex-legacy-session-resume.ts create mode 100644 src/main/codex/codex-session-migration-scheduler.test.ts create mode 100644 src/main/codex/codex-session-migration-scheduler.ts create mode 100644 src/main/ipc/ai-vault-resume.ts create mode 100644 src/renderer/src/lib/ai-vault-session-resume-preparation.test.ts create mode 100644 src/renderer/src/lib/ai-vault-session-resume-preparation.ts create mode 100644 src/shared/ai-vault-resume-preparation.ts diff --git a/mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx b/mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx index 8879b783687..add1892aee2 100644 --- a/mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx +++ b/mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx @@ -10,6 +10,7 @@ import type { RpcClient } from '../transport/rpc-client' import { getWorktreeLabel } from '../session/worktree-label' import { buildMobileAiVaultResumeLaunch, + prepareMobileAiVaultSessionResume, createMobileAiVaultResumeMutationRegistry, readMobileRuntimeHostPlatform, readMobileRuntimeTerminalWindowsShell, @@ -197,8 +198,9 @@ export function MobileAgentSessionHistoryPanel({ return } + const preparedSession = await prepareMobileAiVaultSessionResume(client, session) const launch = buildMobileAiVaultResumeLaunch({ - session, + session: preparedSession, hostPlatform: platform, hostTerminalWindowsShell, settings diff --git a/mobile/src/session/ai-vault-resume-launch.test.ts b/mobile/src/session/ai-vault-resume-launch.test.ts index fa096be4a03..9868fd72cfc 100644 --- a/mobile/src/session/ai-vault-resume-launch.test.ts +++ b/mobile/src/session/ai-vault-resume-launch.test.ts @@ -4,6 +4,7 @@ import { buildMobileAiVaultResumeLaunch, buildMobileAiVaultResumeCommand, createMobileAiVaultResumeMutationRegistry, + prepareMobileAiVaultSessionResume, readMobileRuntimeHostPlatform, readMobileRuntimeTerminalWindowsShell, resolveMobileAiVaultResumePlatform, @@ -177,6 +178,59 @@ describe('buildMobileAiVaultResumeLaunch', () => { }) }) +describe('prepareMobileAiVaultSessionResume', () => { + it('materializes legacy shared-home sessions before building a real-home launch', async () => { + const legacy = session({ + agent: 'codex', + filePath: + '/Users/ada/Library/Application Support/orca/codex-runtime-home/home/sessions/2026/07/20/rollout-a.jsonl', + codexHome: '/Users/ada/Library/Application Support/orca/codex-runtime-home/home' + }) + const sendRequest = vi.fn().mockResolvedValue({ + ok: true, + result: { useRealCodexHome: true } + }) + + const prepared = await prepareMobileAiVaultSessionResume({ sendRequest }, legacy) + const launch = buildMobileAiVaultResumeLaunch({ session: prepared, hostPlatform: 'darwin' }) + + expect(sendRequest).toHaveBeenCalledWith( + 'aiVault.prepareSessionResume', + expect.objectContaining({ filePath: legacy.filePath, codexHome: legacy.codexHome }), + { timeoutMs: RESUME_RPC_TIMEOUT_MS } + ) + expect(launch.command).not.toContain('CODEX_HOME=') + expect(launch.envToDelete).toEqual(['CODEX_HOME', 'ORCA_CODEX_HOME']) + }) + + it('fails before terminal creation when targeted materialization fails', async () => { + const legacy = session({ + agent: 'codex', + codexHome: '/Users/ada/Library/Application Support/orca/codex-runtime-home/home' + }) + const sendRequest = vi.fn().mockResolvedValue({ + ok: false, + error: { message: 'Retry resume after checking session folder permissions.' } + }) + + await expect(prepareMobileAiVaultSessionResume({ sendRequest }, legacy)).rejects.toThrow( + /Retry resume/ + ) + }) + + it.each([ + '/Users/ada/.config/codex', + '/Users/ada/Library/Application Support/orca/codex-accounts/a/home', + '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.codex' + ])('preserves a non-legacy Codex home without an RPC: %s', async (codexHome) => { + const current = session({ agent: 'codex', codexHome }) + const sendRequest = vi.fn() + + await expect(prepareMobileAiVaultSessionResume({ sendRequest }, current)).resolves.toBe(current) + expect(sendRequest).not.toHaveBeenCalled() + }) +}) + describe('resumeAiVaultSessionInTerminal', () => { it('creates a fresh terminal and sends the command with Enter', async () => { const sendRequest = vi diff --git a/mobile/src/session/ai-vault-resume-launch.ts b/mobile/src/session/ai-vault-resume-launch.ts index 2ae7463da5a..1a7b6a3641d 100644 --- a/mobile/src/session/ai-vault-resume-launch.ts +++ b/mobile/src/session/ai-vault-resume-launch.ts @@ -4,6 +4,7 @@ import { buildAiVaultResumeShellCommand, realHomeCodexResumeEnvDeletion } from '../../../src/shared/ai-vault-types' +import { isLegacySharedCodexHome } from '../../../src/shared/ai-vault-resume-preparation' import { isResumableTuiAgent } from '../../../src/shared/agent-session-resume' import type { SleepingAgentLaunchConfig } from '../../../src/shared/agent-session-resume' import { buildAgentResumeStartupPlan } from '../../../src/shared/tui-agent-startup' @@ -151,6 +152,35 @@ function normalizeMobileAiVaultResumeCommandOverrides( // on the reconnect waiter for the full reconnect budget, pinning the spinner. export const RESUME_RPC_TIMEOUT_MS = 30_000 +export async function prepareMobileAiVaultSessionResume( + client: Pick, + session: AiVaultSession +): Promise { + if (session.agent !== 'codex' || !isLegacySharedCodexHome(session.codexHome)) { + return session + } + const response = await client.sendRequest( + 'aiVault.prepareSessionResume', + { + agent: session.agent, + filePath: session.filePath, + codexHome: session.codexHome, + executionHostId: session.executionHostId + }, + { timeoutMs: RESUME_RPC_TIMEOUT_MS } + ) + if (!response.ok) { + throw new Error( + response.error?.message || 'Could not prepare this legacy Codex session. Retry resume.' + ) + } + const result = response.result as { useRealCodexHome?: unknown } | null + if (result?.useRealCodexHome !== true) { + return session + } + return { ...session, codexHome: null } +} + export async function resumeAiVaultSessionInTerminal( client: Pick, worktreeId: string, diff --git a/src/main/ai-vault/runtime-session-scanner.test.ts b/src/main/ai-vault/runtime-session-scanner.test.ts index 07ed14298ed..d690ea10d8a 100644 --- a/src/main/ai-vault/runtime-session-scanner.test.ts +++ b/src/main/ai-vault/runtime-session-scanner.test.ts @@ -14,8 +14,11 @@ vi.mock('../ipc/runtime-environment-transport-routing', () => ({ callRuntimeEnvironment: mocks.callRuntimeEnvironment })) -const { getSavedRuntimeAiVaultHostInfos, scanRuntimeAiVaultSessions } = - await import('./runtime-session-scanner') +const { + getSavedRuntimeAiVaultHostInfos, + prepareRuntimeAiVaultSessionResume, + scanRuntimeAiVaultSessions +} = await import('./runtime-session-scanner') beforeEach(() => { vi.clearAllMocks() @@ -108,6 +111,42 @@ describe('runtime AI Vault session scanner', () => { }) ]) }) + + it('prepares a resume on the transcript-owning runtime', async () => { + mocks.callRuntimeEnvironment.mockResolvedValueOnce({ + ok: true, + result: { useRealCodexHome: true } + }) + const args = { + agent: 'codex' as const, + filePath: '/managed/sessions/2026/07/20/rollout-a.jsonl', + codexHome: '/managed', + executionHostId: 'runtime:env-1' as const + } + + await expect(prepareRuntimeAiVaultSessionResume('/user-data', 'env-1', args)).resolves.toEqual({ + useRealCodexHome: true + }) + expect(mocks.callRuntimeEnvironment).toHaveBeenCalledWith( + '/user-data', + 'env-1', + 'aiVault.prepareSessionResume', + args + ) + }) + + it('fails retryably when runtime preparation returns an invalid result', async () => { + mocks.callRuntimeEnvironment.mockResolvedValueOnce({ ok: true, result: {} }) + + await expect( + prepareRuntimeAiVaultSessionResume('/user-data', 'env-1', { + agent: 'codex', + filePath: '/managed/sessions/2026/07/20/rollout-a.jsonl', + codexHome: '/managed', + executionHostId: 'runtime:env-1' + }) + ).rejects.toThrow('Invalid aiVault.prepareSessionResume response') + }) }) function result( diff --git a/src/main/ai-vault/runtime-session-scanner.ts b/src/main/ai-vault/runtime-session-scanner.ts index b2f318d8e40..659cacc8810 100644 --- a/src/main/ai-vault/runtime-session-scanner.ts +++ b/src/main/ai-vault/runtime-session-scanner.ts @@ -9,6 +9,10 @@ import { import { normalizeExecutionHostId, toRuntimeExecutionHostId } from '../../shared/execution-host' import { listEnvironments } from '../../shared/runtime-environment-store' import { callRuntimeEnvironment } from '../ipc/runtime-environment-transport-routing' +import type { + AiVaultPrepareSessionResumeArgs, + AiVaultPrepareSessionResumeResult +} from '../../shared/ai-vault-resume-preparation' export type RuntimeAiVaultHostInfo = { environmentId: string @@ -99,6 +103,8 @@ const aiVaultListResultSchema = z.object({ scannedAt: z.string() }) +const aiVaultPrepareSessionResumeResultSchema = z.object({ useRealCodexHome: z.boolean() }) + export function getSavedRuntimeAiVaultHostInfos( userDataPath: string ): readonly RuntimeAiVaultHostInfo[] { @@ -151,6 +157,29 @@ export async function scanRuntimeAiVaultSessions( }) } +export async function prepareRuntimeAiVaultSessionResume( + userDataPath: string, + environmentId: string, + args: AiVaultPrepareSessionResumeArgs +): Promise { + const response = await callRuntimeEnvironment( + userDataPath, + environmentId, + 'aiVault.prepareSessionResume', + args + ) + if (response.ok !== true) { + throw new Error(response.error.message) + } + const parsed = aiVaultPrepareSessionResumeResultSchema.safeParse(response.result) + if (!parsed.success) { + throw new Error( + `Invalid aiVault.prepareSessionResume response: ${parsed.error.issues[0]?.message ?? 'unexpected result shape'}` + ) + } + return parsed.data +} + function withRuntimeExecutionHost( result: AiVaultListResult, executionHostId: `runtime:${string}` diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index 54c458aaef8..c0617eaea50 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -1756,12 +1756,14 @@ describe('CodexAccountService config sync', () => { const store = createStore(settings) const rateLimits = createRateLimits() const runtimeHome = createRuntimeHome() + const onHostSystemDefaultSelected = vi.fn() const { CodexAccountService } = await import('./service') const service = new CodexAccountService( store as never, rateLimits as never, - runtimeHome as never + runtimeHome as never, + { onHostSystemDefaultSelected } ) const result = await service.selectAccount(null) @@ -1769,6 +1771,7 @@ describe('CodexAccountService config sync', () => { expect(result.activeAccountId).toBe(null) expect(runtimeHome.syncForCurrentSelection).toHaveBeenCalled() expect(rateLimits.refreshForCodexAccountChange).toHaveBeenCalled() + expect(onHostSystemDefaultSelected).toHaveBeenCalledOnce() }) it('selectAccount immediately rewrites the shared runtime auth for existing terminals', async () => { diff --git a/src/main/codex-accounts/service.ts b/src/main/codex-accounts/service.ts index 5aacd356bf1..54b676aa9c1 100644 --- a/src/main/codex-accounts/service.ts +++ b/src/main/codex-accounts/service.ts @@ -79,6 +79,10 @@ export type CodexAccountAddTarget = { wslDistro?: string | null } +export type CodexAccountServiceLifecycle = { + onHostSystemDefaultSelected?: () => void +} + type ManagedHomeLocation = { managedHomePath: string managedHomeRuntime: 'host' | 'wsl' @@ -158,7 +162,8 @@ export class CodexAccountService { constructor( private readonly store: Store, private readonly rateLimits: RateLimitService, - private readonly runtimeHome: CodexRuntimeHomeService + private readonly runtimeHome: CodexRuntimeHomeService, + private readonly lifecycle: CodexAccountServiceLifecycle = {} ) { this.safeSyncCanonicalConfigToManagedHomes() } @@ -324,6 +329,9 @@ export class CodexAccountService { activeCodexManagedAccountIdsByRuntime: nextSelection }) this.runtimeHome.syncForCurrentSelection() + if (account.managedHomeRuntime === 'host' && nextSelection.host === null) { + this.lifecycle.onHostSystemDefaultSelected?.() + } this.safeRemoveManagedHome(account.managedHomePath, account.id) // Why: a removed account can no longer appear in the switcher dropdown, @@ -371,6 +379,12 @@ export class CodexAccountService { }) this.safeSyncCanonicalConfigToManagedHomes() this.runtimeHome.syncForCurrentSelection(effectiveTarget) + if ( + accountId === null && + normalizeCodexAccountSelectionTarget(effectiveTarget).runtime === 'host' + ) { + this.lifecycle.onHostSystemDefaultSelected?.() + } await this.rateLimits.refreshForCodexAccountChange(outgoingAccountId, effectiveTarget) return this.getSnapshot() @@ -520,6 +534,9 @@ export class CodexAccountService { activeCodexManagedAccountId: nextSelection.host, activeCodexManagedAccountIdsByRuntime: nextSelection }) + if (selection.host !== null && nextSelection.host === null) { + this.lifecycle.onHostSystemDefaultSelected?.() + } } } diff --git a/src/main/codex/codex-legacy-session-resume.test.ts b/src/main/codex/codex-legacy-session-resume.test.ts new file mode 100644 index 00000000000..c69d4e4ed39 --- /dev/null +++ b/src/main/codex/codex-legacy-session-resume.test.ts @@ -0,0 +1,145 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + existsSync, + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + statSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { prepareLegacySharedCodexSessionResume } from './codex-legacy-session-resume' + +describe('prepareLegacySharedCodexSessionResume', () => { + let root: string + let legacyHome: string + let systemHome: string + let rolloutPath: string + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-legacy-codex-resume-')) + legacyHome = join(root, 'codex-runtime-home', 'home') + systemHome = join(root, 'real-codex-home') + rolloutPath = join( + legacyHome, + 'sessions', + '2026', + '07', + '20', + 'rollout-2026-07-20T12-00-00-session.jsonl' + ) + mkdirSync(join(rolloutPath, '..'), { recursive: true }) + writeFileSync(rolloutPath, '{"type":"session_meta"}\n', 'utf-8') + }) + + afterEach(() => { + rmSync(root, { recursive: true, force: true }) + }) + + it('materializes an immediate legacy resume into the real home', async () => { + const result = await prepare() + const targetPath = targetRolloutPath() + + expect(result).toEqual({ useRealCodexHome: true }) + expect(readFileSync(targetPath, 'utf-8')).toBe('{"type":"session_meta"}\n') + expect(statSync(targetPath).ino).toBe(statSync(rolloutPath).ino) + }) + + it('coalesces concurrent resumes of the same legacy rollout', async () => { + const [first, second] = await Promise.all([prepare(), prepare()]) + + expect(first.useRealCodexHome).toBe(true) + expect(second.useRealCodexHome).toBe(true) + expect(readFileSync(targetRolloutPath(), 'utf-8')).toContain('session_meta') + }) + + it('fails clearly and retryably instead of approving a missing rollout', async () => { + rmSync(rolloutPath) + + await expect(prepare()).rejects.toThrow(/Retry resume/) + expect(existsSync(targetRolloutPath())).toBe(false) + }) + + it('fails closed when a different rollout already owns the target path', async () => { + mkdirSync(join(targetRolloutPath(), '..'), { recursive: true }) + writeFileSync(targetRolloutPath(), '{"different":true}\n', 'utf-8') + + await expect(prepare()).rejects.toThrow(/Retry resume/) + expect(readFileSync(targetRolloutPath(), 'utf-8')).toBe('{"different":true}\n') + }) + + it.each([ + ['per-account home', join(rootPlaceholder(), 'codex-accounts', 'account-1', 'home'), 'local'], + ['custom home', join(rootPlaceholder(), 'custom-codex-home'), 'local'], + ['WSL home', '\\\\wsl.localhost\\Ubuntu\\home\\me\\.codex', 'local'], + ['SSH session', rootPlaceholder(), 'ssh:server-1'] + ])( + 'preserves %s without materializing it', + async (_label, codexHomeTemplate, executionHostId) => { + const codexHome = codexHomeTemplate.replace(rootPlaceholder(), root) + const result = await prepareLegacySharedCodexSessionResume( + { + agent: 'codex', + filePath: rolloutPath, + codexHome, + executionHostId: executionHostId as 'local' + }, + options() + ) + + expect(result).toEqual({ useRealCodexHome: false }) + expect(existsSync(targetRolloutPath())).toBe(false) + } + ) + + it.each(['managed account', 'custom CODEX_HOME'])( + 'preserves the legacy home while the %s lane is selected', + async () => { + const result = await prepareLegacySharedCodexSessionResume(legacyArgs(), { + ...options(), + isHostSystemDefaultRealHome: () => false + }) + + expect(result).toEqual({ useRealCodexHome: false }) + expect(existsSync(targetRolloutPath())).toBe(false) + } + ) + + function prepare() { + return prepareLegacySharedCodexSessionResume(legacyArgs(), options()) + } + + function legacyArgs() { + return { + agent: 'codex' as const, + filePath: rolloutPath, + codexHome: legacyHome, + executionHostId: 'local' as const + } + } + + function options() { + return { + isHostSystemDefaultRealHome: () => true, + legacyCodexHomePath: legacyHome, + systemCodexHomePath: systemHome + } + } + + function targetRolloutPath(): string { + return join( + systemHome, + 'sessions', + '2026', + '07', + '20', + 'rollout-2026-07-20T12-00-00-session.jsonl' + ) + } +}) + +function rootPlaceholder(): string { + return '__ROOT__' +} diff --git a/src/main/codex/codex-legacy-session-resume.ts b/src/main/codex/codex-legacy-session-resume.ts new file mode 100644 index 00000000000..c2febd6d772 --- /dev/null +++ b/src/main/codex/codex-legacy-session-resume.ts @@ -0,0 +1,175 @@ +import { createHash } from 'node:crypto' +import { createReadStream } from 'node:fs' +import { link, lstat, mkdir } from 'node:fs/promises' +import { dirname, join, relative, resolve, sep } from 'node:path' +import type { + AiVaultPrepareSessionResumeArgs, + AiVaultPrepareSessionResumeResult +} from '../../shared/ai-vault-resume-preparation' +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' +import { + appendCodexSessionHealAuditRecord, + createCodexSessionBackfillAuditWriter +} from './codex-session-backfill-audit' +import { + copySessionFileWithoutOverwrite, + isAtomicNoReplaceUnsupportedError +} from './codex-session-backfill-copy' +import { resolveCodexSessionBackfillPaths } from './codex-session-backfill' + +const RETRYABLE_RESUME_ERROR = + 'Orca could not safely move this legacy Codex session into your system Codex home. Retry resume; if it still fails, check that both Codex session folders are readable and writable.' + +const materializations = new Map>() + +export async function prepareLegacySharedCodexSessionResume( + args: AiVaultPrepareSessionResumeArgs, + options: { + isHostSystemDefaultRealHome: () => boolean + legacyCodexHomePath?: string + systemCodexHomePath?: string + } +): Promise { + const paths = resolveCodexSessionBackfillPaths(options.systemCodexHomePath) + const legacyCodexHomePath = options.legacyCodexHomePath ?? dirname(paths.managedSessionsRoot) + const managedSessionsRoot = join(legacyCodexHomePath, 'sessions') + if ( + args.agent !== 'codex' || + args.executionHostId !== LOCAL_EXECUTION_HOST_ID || + !args.codexHome || + !sameRuntimePath(args.codexHome, legacyCodexHomePath) || + !options.isHostSystemDefaultRealHome() + ) { + return { useRealCodexHome: false } + } + + const sourcePath = resolve(args.filePath) + const relativePath = relative(resolve(managedSessionsRoot), sourcePath) + if (!isLegacyRolloutRelativePath(relativePath)) { + throw new Error(RETRYABLE_RESUME_ERROR) + } + const targetPath = join(paths.systemSessionsRoot, relativePath) + const key = `${normalizeRuntimePathForComparison(sourcePath)}\0${normalizeRuntimePathForComparison(targetPath)}` + let task = materializations.get(key) + if (!task) { + task = materializeLegacyRollout(sourcePath, targetPath, paths.auditLogPath) + materializations.set(key, task) + void task.then( + () => { + if (materializations.get(key) === task) { + materializations.delete(key) + } + }, + () => { + if (materializations.get(key) === task) { + materializations.delete(key) + } + } + ) + } + + try { + await task + } catch (error) { + console.warn('[codex-legacy-session-resume] Targeted session migration failed:', error) + throw new Error(RETRYABLE_RESUME_ERROR, { cause: error }) + } + return { useRealCodexHome: true } +} + +async function materializeLegacyRollout( + sourcePath: string, + targetPath: string, + auditLogPath: string +): Promise { + const sourceStat = await lstat(sourcePath) + if (!sourceStat.isFile() || sourceStat.isSymbolicLink()) { + throw new Error('Legacy rollout source is not a regular file.') + } + await mkdir(dirname(targetPath), { recursive: true }) + try { + await link(sourcePath, targetPath) + } catch (linkError) { + if (isExistsError(linkError)) { + await assertMatchingExistingTarget(sourcePath, targetPath) + } else { + try { + await copySessionFileWithoutOverwrite(sourcePath, targetPath) + } catch (copyError) { + if (isExistsError(copyError)) { + await assertMatchingExistingTarget(sourcePath, targetPath) + } else { + if (isAtomicNoReplaceUnsupportedError(copyError)) { + throw new Error('The target filesystem cannot safely install this rollout.', { + cause: copyError + }) + } + throw copyError + } + } + } + } + + const summary = { + stopped: false, + scannedFiles: 1, + linkedFiles: 0, + copiedFiles: 0, + skippedExistingFiles: 0, + skippedUnexpectedFiles: 0, + skippedSymlinkFiles: 0, + skippedUnsupportedFilesystemFiles: 0, + failedDirectories: 0, + failedFiles: 0, + failedHealAuditRecords: 0 + } + await appendCodexSessionHealAuditRecord( + createCodexSessionBackfillAuditWriter(auditLogPath), + summary, + { action: 'targeted-resume', source: sourcePath, target: targetPath } + ) +} + +async function assertMatchingExistingTarget(sourcePath: string, targetPath: string): Promise { + const [sourceStat, targetStat] = await Promise.all([lstat(sourcePath), lstat(targetPath)]) + if ( + !targetStat.isFile() || + targetStat.isSymbolicLink() || + sourceStat.size !== targetStat.size || + ((sourceStat.dev !== targetStat.dev || sourceStat.ino !== targetStat.ino) && + (await fileDigest(sourcePath)) !== (await fileDigest(targetPath))) + ) { + throw new Error('A different rollout already occupies the real-home target path.') + } +} + +async function fileDigest(filePath: string): Promise { + const hash = createHash('sha256') + for await (const chunk of createReadStream(filePath)) { + hash.update(chunk as Buffer) + } + return hash.digest('hex') +} + +function isLegacyRolloutRelativePath(relativePath: string): boolean { + if (!relativePath || relativePath.startsWith('..') || resolve(relativePath) === relativePath) { + return false + } + const parts = relativePath.split(sep) + return ( + parts.length === 4 && + /^\d{4}$/.test(parts[0] ?? '') && + /^\d{2}$/.test(parts[1] ?? '') && + /^\d{2}$/.test(parts[2] ?? '') && + /^rollout-.+\.jsonl$/.test(parts[3] ?? '') + ) +} + +function sameRuntimePath(left: string, right: string): boolean { + return normalizeRuntimePathForComparison(left) === normalizeRuntimePathForComparison(right) +} + +function isExistsError(error: unknown): boolean { + return (error as NodeJS.ErrnoException | null)?.code === 'EEXIST' +} diff --git a/src/main/codex/codex-session-migration-scheduler.test.ts b/src/main/codex/codex-session-migration-scheduler.test.ts new file mode 100644 index 00000000000..54094e022f1 --- /dev/null +++ b/src/main/codex/codex-session-migration-scheduler.test.ts @@ -0,0 +1,93 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createCodexSessionMigrationScheduler } from './codex-session-migration-scheduler' + +describe('createCodexSessionMigrationScheduler', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + it('runs after a managed-account startup switches to host system default', async () => { + let eligible = false + const startBackfill = vi.fn().mockResolvedValue(null) + const startIndexHeal = vi.fn().mockResolvedValue(null) + const scheduler = createCodexSessionMigrationScheduler({ + isEligible: () => eligible, + isQuitting: () => false, + resolveSystemCodexHomePathOverride: () => undefined, + startBackfill, + startIndexHeal + }) + + scheduler.scheduleInitialRun() + await vi.advanceTimersByTimeAsync(15_000) + expect(startBackfill).not.toHaveBeenCalled() + + eligible = true + scheduler.requestRun() + await vi.waitFor(() => expect(startIndexHeal).toHaveBeenCalledOnce()) + expect(startBackfill).toHaveBeenCalledOnce() + }) + + it('coalesces concurrent run requests and stops before index heal after opt-out', async () => { + let eligible = true + let releaseBackfill: (() => void) | undefined + const startBackfill = vi.fn( + () => + new Promise((resolve) => { + releaseBackfill = resolve + }) + ) + const startIndexHeal = vi.fn().mockResolvedValue(null) + const scheduler = createCodexSessionMigrationScheduler({ + isEligible: () => eligible, + isQuitting: () => false, + resolveSystemCodexHomePathOverride: () => '/custom/history', + startBackfill, + startIndexHeal + }) + + scheduler.requestRun() + scheduler.requestRun() + expect(startBackfill).toHaveBeenCalledOnce() + expect(startBackfill).toHaveBeenCalledWith(expect.any(Object), '/custom/history') + + eligible = false + releaseBackfill?.() + await Promise.resolve() + await Promise.resolve() + expect(startIndexHeal).not.toHaveBeenCalled() + }) + + it('reruns after a stopping migration becomes eligible again', async () => { + let eligible = true + let releaseFirstBackfill: ((result: { stopped: boolean }) => void) | undefined + const startBackfill = vi + .fn() + .mockImplementationOnce( + (_options) => + new Promise<{ stopped: boolean }>((resolve) => { + releaseFirstBackfill = resolve + }) + ) + .mockResolvedValueOnce({ stopped: false }) + const startIndexHeal = vi.fn().mockResolvedValue(null) + const scheduler = createCodexSessionMigrationScheduler({ + isEligible: () => eligible, + isQuitting: () => false, + resolveSystemCodexHomePathOverride: () => undefined, + startBackfill, + startIndexHeal + }) + + scheduler.requestRun() + const firstRunOptions = startBackfill.mock.calls[0]?.[0] + eligible = false + expect(firstRunOptions?.shouldStop()).toBe(true) + eligible = true + scheduler.requestRun() + releaseFirstBackfill?.({ stopped: true }) + + await vi.waitFor(() => expect(startBackfill).toHaveBeenCalledTimes(2)) + await vi.waitFor(() => expect(startIndexHeal).toHaveBeenCalledOnce()) + }) +}) diff --git a/src/main/codex/codex-session-migration-scheduler.ts b/src/main/codex/codex-session-migration-scheduler.ts new file mode 100644 index 00000000000..908f77a6a83 --- /dev/null +++ b/src/main/codex/codex-session-migration-scheduler.ts @@ -0,0 +1,87 @@ +import type { CodexSessionBackfillOptions } from './codex-session-backfill-types' + +type MigrationRun = ( + options: CodexSessionBackfillOptions, + systemCodexHomePathOverride?: string +) => Promise + +export type CodexSessionMigrationScheduler = { + scheduleInitialRun(): void + requestRun(): void +} + +export function createCodexSessionMigrationScheduler(args: { + isEligible: () => boolean + isQuitting: () => boolean + resolveSystemCodexHomePathOverride: () => string | undefined + startBackfill: MigrationRun + startIndexHeal: MigrationRun + initialDelayMs?: number +}): CodexSessionMigrationScheduler { + let initialTimer: ReturnType | null = null + let migrationTask: Promise | null = null + let activeRunStopObserved = false + let rerunRequested = false + + const requestRun = (): void => { + if (args.isQuitting() || !args.isEligible()) { + return + } + if (migrationTask) { + // Why: an account transition can re-enable migration while the prior run is still stopping. + rerunRequested ||= activeRunStopObserved + return + } + activeRunStopObserved = false + rerunRequested = false + const shouldStop = (): boolean => { + const stopped = args.isQuitting() || !args.isEligible() + activeRunStopObserved ||= stopped + return stopped + } + const systemCodexHomePathOverride = args.resolveSystemCodexHomePathOverride() + let stoppedBackfill = false + const task = args + .startBackfill({ shouldStop }, systemCodexHomePathOverride) + .then((result) => { + stoppedBackfill = isStoppedMigrationResult(result) + if (stoppedBackfill || shouldStop()) { + return + } + return args.startIndexHeal({ shouldStop }, systemCodexHomePathOverride) + }) + .catch((error: unknown) => { + console.warn('[codex-session-migration] Background session migration failed:', error) + }) + .then(() => undefined) + migrationTask = task + void task.finally(() => { + if (migrationTask === task) { + migrationTask = null + const shouldRerun = rerunRequested || stoppedBackfill + rerunRequested = false + activeRunStopObserved = false + if (shouldRerun) { + requestRun() + } + } + }) + } + + return { + scheduleInitialRun(): void { + if (initialTimer) { + return + } + initialTimer = setTimeout(() => { + initialTimer = null + requestRun() + }, args.initialDelayMs ?? 15_000) + }, + requestRun + } +} + +function isStoppedMigrationResult(result: unknown): boolean { + return Boolean(result && typeof result === 'object' && 'stopped' in result && result.stopped) +} diff --git a/src/main/index.ts b/src/main/index.ts index ef6e1e5ec9c..af1163f7107 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -143,6 +143,8 @@ import { import { setCodexTrustGrantTelemetry } from './codex/codex-hook-trust-grant' import { startCodexSessionBackfillInBackground } from './codex/codex-session-backfill' import { startCodexSessionIndexHealInBackground } from './codex/codex-session-index-heal' +import { createCodexSessionMigrationScheduler } from './codex/codex-session-migration-scheduler' +import { prepareLegacySharedCodexSessionResume } from './codex/codex-legacy-session-resume' import { resolveHostCodexSessionSourceHome } from './codex/codex-session-source-home' import { getDefaultWslDistro } from './wsl' import { ClaudeAccountService } from './claude-accounts/service' @@ -1023,6 +1025,12 @@ function openMainWindow(): BrowserWindow { { getAdditionalAiVaultCodexHomePaths: () => codexRuntimeHome ? codexRuntimeHome.getHostCodexHomePathsForSessionDiscovery() : [], + prepareAiVaultSessionResume: (args) => + prepareLegacySharedCodexSessionResume(args, { + isHostSystemDefaultRealHome: () => + codexRuntimeHome?.isHostSystemDefaultRealHome() === true, + systemCodexHomePath: resolveHostCodexSessionSourceHome(store!.getSettings()) + }), onBeforeRelaunch: async () => { isQuitting = true desktopRelayService?.fenceAndCloseNow() @@ -1789,41 +1797,22 @@ app.whenReady().then(async () => { codexRuntimeHome.isHostSystemDefaultRealHome() && isAgentStatusHooksEnabled(store?.getSettings()) ) - codexAccounts = new CodexAccountService(store, rateLimits, codexRuntimeHome) + const codexSessionMigration = createCodexSessionMigrationScheduler({ + isEligible: () => codexRuntimeHome?.isHostSystemDefaultRealHome() === true, + isQuitting: () => isQuitting, + resolveSystemCodexHomePathOverride: () => + resolveHostCodexSessionSourceHome(store!.getSettings()), + startBackfill: startCodexSessionBackfillInBackground, + startIndexHeal: startCodexSessionIndexHealInBackground + }) + codexAccounts = new CodexAccountService(store, rateLimits, codexRuntimeHome, { + onHostSystemDefaultSelected: codexSessionMigration.requestRun + }) // Why: one-time per-host backfill makes historical Orca-managed Codex // sessions visible to the user's own resume picker and app history (#4444, // #8612). Deferred so startup and first PTY spawns never compete with the // sessions tree walk. - setTimeout(() => { - // Why: reverse-backfilling into the user's Codex home belongs exclusively - // to the real-home lane; flag-off, managed-account, and custom-CODEX_HOME - // launch lanes must remain byte-identical and leave that history untouched. - if (!codexRuntimeHome?.isHostSystemDefaultRealHome()) { - return - } - const systemCodexHomePathOverride = resolveHostCodexSessionSourceHome(store!.getSettings()) - const shouldStopSessionMigration = (): boolean => - isQuitting || codexRuntimeHome?.isHostSystemDefaultRealHome() !== true - // Why: the heal pass chains after the backfill settles so thread/read only - // runs once the audit ledger covers this startup's newly linked rollouts; - // it also drains sessions left pending by an interrupted earlier pass. - void startCodexSessionBackfillInBackground( - { shouldStop: shouldStopSessionMigration }, - systemCodexHomePathOverride - ).then(() => { - // Why: flag-OFF, managed-account, and custom-home lanes must never spawn - // an app-server against the user's real sqlite index. - if (!codexRuntimeHome?.isHostSystemDefaultRealHome()) { - return - } - return startCodexSessionIndexHealInBackground( - { - shouldStop: shouldStopSessionMigration - }, - systemCodexHomePathOverride - ) - }) - }, 15_000) + codexSessionMigration.scheduleInitialRun() claudeRuntimeAuth = new ClaudeRuntimeAuthService(store) claudeAccounts = new ClaudeAccountService(store, rateLimits, claudeRuntimeAuth) rateLimits.setCodexHomePathResolver((target) => @@ -1914,6 +1903,11 @@ app.whenReady().then(async () => { // Why: source codex-home here (runs in window AND serve) so aiVault.listSessions includes managed-Codex sessions; registerCoreHandlers is window-only. getAdditionalAiVaultCodexHomePaths: () => codexRuntimeHome ? codexRuntimeHome.getHostCodexHomePathsForSessionDiscovery() : [], + prepareAiVaultSessionResume: (args) => + prepareLegacySharedCodexSessionResume(args, { + isHostSystemDefaultRealHome: () => codexRuntimeHome?.isHostSystemDefaultRealHome() === true, + systemCodexHomePath: resolveHostCodexSessionSourceHome(store!.getSettings()) + }), buildAgentHookPtyEnv: () => isAgentStatusHooksEnabled(store?.getSettings()) ? agentHookServer.buildPtyEnv() : {} }) diff --git a/src/main/ipc/ai-vault-resume.ts b/src/main/ipc/ai-vault-resume.ts new file mode 100644 index 00000000000..a5618c0041d --- /dev/null +++ b/src/main/ipc/ai-vault-resume.ts @@ -0,0 +1,39 @@ +import { ipcMain } from 'electron' +import type { + AiVaultPrepareSessionResumeArgs, + AiVaultPrepareSessionResumeResult, + AiVaultSessionResumePreparation +} from '../../shared/ai-vault-resume-preparation' +import { parseExecutionHostId } from '../../shared/execution-host' + +export type AiVaultResumeHandlerOptions = { + prepareSessionResume?: AiVaultSessionResumePreparation + prepareRuntimeSessionResume?: ( + environmentId: string, + args: AiVaultPrepareSessionResumeArgs + ) => Promise +} + +export function registerAiVaultResumeHandler(options: AiVaultResumeHandlerOptions): void { + ipcMain.handle('aiVault:prepareSessionResume', (_event, args: AiVaultPrepareSessionResumeArgs) => + prepareAiVaultSessionResume(args, options) + ) +} + +export async function prepareAiVaultSessionResume( + args: AiVaultPrepareSessionResumeArgs, + options: AiVaultResumeHandlerOptions +): Promise { + const executionHost = parseExecutionHostId(args.executionHostId) + if (executionHost?.kind === 'runtime') { + if (!options.prepareRuntimeSessionResume) { + throw new Error('The session host is unavailable. Reconnect it and retry resume.') + } + return options.prepareRuntimeSessionResume(executionHost.environmentId, args) + } + // Why: the desktop process must never materialize transcript paths owned by an SSH host. + if (executionHost?.kind === 'ssh') { + return { useRealCodexHome: false } + } + return options.prepareSessionResume?.(args) ?? { useRealCodexHome: false } +} diff --git a/src/main/ipc/ai-vault.test.ts b/src/main/ipc/ai-vault.test.ts index 8d4ba30dbd5..5f4153106fd 100644 --- a/src/main/ipc/ai-vault.test.ts +++ b/src/main/ipc/ai-vault.test.ts @@ -13,12 +13,13 @@ const mocks = vi.hoisted(() => ({ getAiVaultWslHomeDirs: vi.fn(), getSshFilesystemProvider: vi.fn(), getActiveSshAiVaultHostInfo: vi.fn(), - getActiveSshAiVaultHostInfos: vi.fn() + getActiveSshAiVaultHostInfos: vi.fn(), + ipcHandle: vi.fn() })) vi.mock('electron', () => ({ app: { on: vi.fn() }, - ipcMain: { handle: vi.fn() } + ipcMain: { handle: mocks.ipcHandle } })) vi.mock('../ai-vault/session-scanner', () => ({ @@ -206,6 +207,79 @@ describe('listAiVaultSessions host routing', () => { }) }) +describe('prepareSessionResume IPC', () => { + it('awaits the host-local targeted resume preparation', async () => { + const prepareSessionResume = vi.fn().mockResolvedValue({ useRealCodexHome: true }) + registerAiVaultHandlers({ prepareSessionResume }) + const registration = mocks.ipcHandle.mock.calls.find( + ([channel]) => channel === 'aiVault:prepareSessionResume' + ) + const handler = registration?.[1] as + | ((_event: unknown, args: unknown) => Promise) + | undefined + const args = { + agent: 'codex', + filePath: '/managed/sessions/2026/07/20/rollout-a.jsonl', + codexHome: '/managed', + executionHostId: 'local' + } + + await expect(handler?.({}, args)).resolves.toEqual({ useRealCodexHome: true }) + expect(prepareSessionResume).toHaveBeenCalledWith(args) + }) + + it('prepares saved-runtime sessions on the transcript-owning runtime', async () => { + const prepareSessionResume = vi.fn() + const prepareRuntimeSessionResume = vi.fn().mockResolvedValue({ useRealCodexHome: true }) + registerAiVaultHandlers({ prepareSessionResume, prepareRuntimeSessionResume }) + const args = { + agent: 'codex' as const, + filePath: '/managed/sessions/2026/07/20/rollout-a.jsonl', + codexHome: '/managed', + executionHostId: 'runtime:env-123' as const + } + + await expect(getPrepareSessionResumeHandler()({}, args)).resolves.toEqual({ + useRealCodexHome: true + }) + expect(prepareRuntimeSessionResume).toHaveBeenCalledWith('env-123', args) + expect(prepareSessionResume).not.toHaveBeenCalled() + }) + + it('preserves SSH session homes without reading their paths locally', async () => { + const prepareSessionResume = vi.fn() + const prepareRuntimeSessionResume = vi.fn() + registerAiVaultHandlers({ prepareSessionResume, prepareRuntimeSessionResume }) + + await expect( + getPrepareSessionResumeHandler()( + {}, + { + agent: 'codex', + filePath: '/managed/sessions/2026/07/20/rollout-a.jsonl', + codexHome: '/managed', + executionHostId: 'ssh:dev-box' + } + ) + ).resolves.toEqual({ useRealCodexHome: false }) + expect(prepareSessionResume).not.toHaveBeenCalled() + expect(prepareRuntimeSessionResume).not.toHaveBeenCalled() + }) +}) + +function getPrepareSessionResumeHandler(): ( + event: unknown, + args: unknown +) => Promise<{ useRealCodexHome: boolean }> { + const registration = mocks.ipcHandle.mock.calls.find( + ([channel]) => channel === 'aiVault:prepareSessionResume' + ) + if (!registration) { + throw new Error('aiVault:prepareSessionResume was not registered') + } + return registration[1] +} + describe('listAiVaultSubagentSessions gating', () => { const claudeRoot = join(homedir(), '.claude', 'projects') diff --git a/src/main/ipc/ai-vault.ts b/src/main/ipc/ai-vault.ts index 5a5dbe0c6ea..ebfd52e63a7 100644 --- a/src/main/ipc/ai-vault.ts +++ b/src/main/ipc/ai-vault.ts @@ -18,6 +18,7 @@ import type { AiVaultSubagentListArgs, AiVaultSubagentListResult } from '../../shared/ai-vault-types' +import { registerAiVaultResumeHandler, type AiVaultResumeHandlerOptions } from './ai-vault-resume' import { LOCAL_EXECUTION_HOST_ID, normalizeExecutionHostScope, @@ -35,14 +36,15 @@ import { getActiveSshAiVaultHostInfo, getActiveSshAiVaultHostInfos } from './ssh const AI_VAULT_CACHE_TTL_MS = 15_000 const AI_VAULT_ALL_HOST_RUNTIME_TIMEOUT_MS = 3_000 -type AiVaultHandlerOptions = AiVaultSessionSources & { - getActiveRuntimeAiVaultHostInfos?: () => readonly RuntimeAiVaultHostInfo[] - scanRuntimeAiVaultSessions?: ( - environmentId: string, - args: AiVaultListArgs, - options?: RuntimeAiVaultScanOptions - ) => Promise -} +type AiVaultHandlerOptions = AiVaultSessionSources & + AiVaultResumeHandlerOptions & { + getActiveRuntimeAiVaultHostInfos?: () => readonly RuntimeAiVaultHostInfo[] + scanRuntimeAiVaultSessions?: ( + environmentId: string, + args: AiVaultListArgs, + options?: RuntimeAiVaultScanOptions + ) => Promise + } type RuntimeAiVaultScanOptions = { timeoutMs?: number @@ -281,6 +283,7 @@ export function registerAiVaultHandlers(options: AiVaultHandlerOptions = {}): vo ipcMain.handle('aiVault:listSessions', (_event, args?: AiVaultListArgs) => listAiVaultSessions(args) ) + registerAiVaultResumeHandler(options) ipcMain.handle( 'aiVault:listSubagentSessions', (_event, args?: AiVaultSubagentListArgs): Promise => diff --git a/src/main/ipc/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers.test.ts index c7ff8080a28..bcfedc36311 100644 --- a/src/main/ipc/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers.test.ts @@ -512,7 +512,8 @@ describe('registerCoreHandlers', () => { expect.objectContaining({ getAdditionalCodexHomePaths: getAdditionalAiVaultCodexHomePaths, getActiveRuntimeAiVaultHostInfos: expect.any(Function), - scanRuntimeAiVaultSessions: expect.any(Function) + scanRuntimeAiVaultSessions: expect.any(Function), + prepareRuntimeSessionResume: expect.any(Function) }) ) expect(aiVaultOptions.getActiveRuntimeAiVaultHostInfos()).toEqual([]) @@ -562,6 +563,26 @@ describe('registerCoreHandlers', () => { }, 3000 ) + + callRuntimeEnvironmentMock.mockResolvedValueOnce({ + ok: true, + result: { useRealCodexHome: true } + }) + const prepareArgs = { + agent: 'codex', + filePath: '/managed/sessions/2026/07/20/rollout-a.jsonl', + codexHome: '/managed', + executionHostId: 'runtime:env-123' + } + await expect( + aiVaultOptions.prepareRuntimeSessionResume('env-123', prepareArgs) + ).resolves.toEqual({ useRealCodexHome: true }) + expect(callRuntimeEnvironmentMock).toHaveBeenLastCalledWith( + '/test/user-data', + 'env-123', + 'aiVault.prepareSessionResume', + prepareArgs + ) }) it('only registers IPC handlers once but always updates web contents id', () => { diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index 1542c49c440..9b951a62671 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -74,8 +74,13 @@ import type { AutomationService } from '../automations/service' import type { AgentAwakeService } from '../agent-awake-service' import type { CrashReportStore } from '../crash-reporting/crash-report-store' import type { KeybindingService } from '../keybindings/keybinding-service' +import type { + AiVaultPrepareSessionResumeArgs, + AiVaultPrepareSessionResumeResult +} from '../../shared/ai-vault-resume-preparation' import { getSavedRuntimeAiVaultHostInfos, + prepareRuntimeAiVaultSessionResume, scanRuntimeAiVaultSessions } from '../ai-vault/runtime-session-scanner' @@ -86,6 +91,9 @@ type CoreHandlerLifecycleOptions = { onOrcaProfileAuthMutation?: () => void onBeforeOrcaProfileSignOut?: () => void getAdditionalAiVaultCodexHomePaths?: () => readonly string[] + prepareAiVaultSessionResume?: ( + args: AiVaultPrepareSessionResumeArgs + ) => Promise } export function registerCoreHandlers( @@ -190,10 +198,13 @@ export function registerCoreHandlers( registerEphemeralVmHandlers(store) registerAiVaultHandlers({ getAdditionalCodexHomePaths: lifecycleOptions.getAdditionalAiVaultCodexHomePaths, + prepareSessionResume: lifecycleOptions.prepareAiVaultSessionResume, getActiveRuntimeAiVaultHostInfos: () => getSavedRuntimeAiVaultHostInfos(app.getPath('userData')), scanRuntimeAiVaultSessions: async (environmentId, args, options) => - scanRuntimeAiVaultSessions(app.getPath('userData'), environmentId, args, options) + scanRuntimeAiVaultSessions(app.getPath('userData'), environmentId, args, options), + prepareRuntimeSessionResume: async (environmentId, args) => + prepareRuntimeAiVaultSessionResume(app.getPath('userData'), environmentId, args) }) registerNativeChatHandlers() registerClipboardHandlers(store) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 947f82ee8e0..6d6e66a8937 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -287,6 +287,10 @@ import { listAiVaultSessions } from '../ai-vault/cached-session-list' import type { AiVaultListArgs, AiVaultListResult } from '../../shared/ai-vault-types' +import type { + AiVaultPrepareSessionResumeArgs, + AiVaultPrepareSessionResumeResult +} from '../../shared/ai-vault-resume-preparation' import type { WorkspacePortKillRequest, WorkspacePortKillResult, @@ -2661,6 +2665,9 @@ export class OrcaRuntimeService { private readonly getAgentStatusSnapshotFn: (() => AgentStatusIpcPayload[]) | null private readonly buildAgentHookPtyEnv: (() => Record) | null private readonly getDesktopWindowStatusFn: () => RuntimeDesktopWindowStatus + private readonly prepareAiVaultSessionResumeFn: + | ((args: AiVaultPrepareSessionResumeArgs) => Promise) + | null private accountServices: RuntimeAccountServices | null = null private commitMessageAgentEnv: CommitMessageAgentEnvironmentResolvers | null = null private automationService: AutomationService | null = null @@ -2694,6 +2701,9 @@ export class OrcaRuntimeService { // runs under `orca serve`, so remote/SSH hosts would silently drop // managed-Codex sessions. The runtime ctor runs in BOTH window and serve. getAdditionalAiVaultCodexHomePaths?: () => readonly string[] + prepareAiVaultSessionResume?: ( + args: AiVaultPrepareSessionResumeArgs + ) => Promise buildAgentHookPtyEnv?: () => Record getDesktopWindowStatus?: () => RuntimeDesktopWindowStatus } @@ -2724,6 +2734,7 @@ export class OrcaRuntimeService { this.onTerminalAgentStatus = deps?.onTerminalAgentStatus ?? null this.buildAgentHookPtyEnv = deps?.buildAgentHookPtyEnv ?? null this.getDesktopWindowStatusFn = deps?.getDesktopWindowStatus ?? (() => 'openable') + this.prepareAiVaultSessionResumeFn = deps?.prepareAiVaultSessionResume ?? null this.onTerminalSideEffects = deps?.onTerminalSideEffects ?? null // Why: the ConPTY spawn mark can land after daemon stream data already // created this PTY's emulator; the mark retrofits the DA1 override here @@ -3206,6 +3217,14 @@ export class OrcaRuntimeService { return listAiVaultSessions(args) } + prepareAiVaultSessionResume( + args: AiVaultPrepareSessionResumeArgs + ): Promise { + return ( + this.prepareAiVaultSessionResumeFn?.(args) ?? Promise.resolve({ useRealCodexHome: false }) + ) + } + setPtyController(controller: RuntimePtyController | null): void { // Why: CLI terminal writes must go through the main-owned PTY registry // instead of tunneling back through renderer IPC, or live handles could diff --git a/src/main/runtime/rpc/methods/ai-vault.test.ts b/src/main/runtime/rpc/methods/ai-vault.test.ts index d676f35e579..a622ea4f835 100644 --- a/src/main/runtime/rpc/methods/ai-vault.test.ts +++ b/src/main/runtime/rpc/methods/ai-vault.test.ts @@ -13,7 +13,11 @@ vi.mock('../../../ai-vault/session-scanner', () => ({ scanAiVaultSessions })) -import { AI_VAULT_METHODS, AiVaultListSessionsParams } from './ai-vault' +import { + AI_VAULT_METHODS, + AiVaultListSessionsParams, + AiVaultPrepareSessionResumeParams +} from './ai-vault' import { configureAiVaultSessionSources, listAiVaultSessions, @@ -106,6 +110,41 @@ describe('aiVault.listSessions params schema', () => { }) }) +describe('aiVault.prepareSessionResume', () => { + it('validates bounded paths and executes against the receiving host identity', async () => { + expect( + AiVaultPrepareSessionResumeParams.safeParse({ + agent: 'codex', + filePath: '/managed/sessions/rollout-a.jsonl', + codexHome: '/managed' + }).success + ).toBe(true) + const prepareAiVaultSessionResume = vi.fn().mockResolvedValue({ useRealCodexHome: true }) + const runtime = { + getRuntimeId: () => 'test-runtime', + prepareAiVaultSessionResume + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: AI_VAULT_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('aiVault.prepareSessionResume', { + agent: 'codex', + filePath: '/managed/sessions/rollout-a.jsonl', + codexHome: '/managed', + executionHostId: 'ssh:spoofed' + }) + ) + + expect(response).toMatchObject({ ok: true, result: { useRealCodexHome: true } }) + expect(prepareAiVaultSessionResume).toHaveBeenCalledWith({ + agent: 'codex', + filePath: '/managed/sessions/rollout-a.jsonl', + codexHome: '/managed', + executionHostId: 'local' + }) + }) +}) + describe('aiVault.listSessions handler + shared cache', () => { beforeEach(() => { resetAiVaultSessionListCacheForTests() diff --git a/src/main/runtime/rpc/methods/ai-vault.ts b/src/main/runtime/rpc/methods/ai-vault.ts index 50ace9d222a..cd6c9f00cca 100644 --- a/src/main/runtime/rpc/methods/ai-vault.ts +++ b/src/main/runtime/rpc/methods/ai-vault.ts @@ -2,7 +2,8 @@ import { z } from 'zod' import { defineMethod, type RpcMethod } from '../core' import { OptionalBoolean } from '../schemas' import { restampAiVaultListResult } from '../../../ai-vault/session-list-results' -import { AI_VAULT_SCOPE_PATHS_MAX_COUNT } from '../../../../shared/ai-vault-types' +import { AI_VAULT_AGENTS, AI_VAULT_SCOPE_PATHS_MAX_COUNT } from '../../../../shared/ai-vault-types' +import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' import { parseExecutionHostId } from '../../../../shared/execution-host' // Why: bound limit + scopePaths so a client cannot force an unbounded scan. @@ -45,6 +46,13 @@ export const AiVaultListSessionsParams = z.object({ executionHostId: executionHostIdSchema.optional() }) +export const AiVaultPrepareSessionResumeParams = z.object({ + agent: z.enum(AI_VAULT_AGENTS), + filePath: z.string().min(1).max(AI_VAULT_SCOPE_PATH_MAX_LENGTH), + codexHome: z.string().min(1).max(AI_VAULT_SCOPE_PATH_MAX_LENGTH).nullable(), + executionHostId: z.string().optional() +}) + export const AI_VAULT_METHODS: RpcMethod[] = [ defineMethod({ name: 'aiVault.listSessions', @@ -61,5 +69,18 @@ export const AI_VAULT_METHODS: RpcMethod[] = [ ? restampAiVaultListResult(result, params.executionHostId) : result } + }), + defineMethod({ + name: 'aiVault.prepareSessionResume', + params: AiVaultPrepareSessionResumeParams, + handler: (params, { runtime }) => + runtime.prepareAiVaultSessionResume({ + agent: params.agent, + filePath: params.filePath, + codexHome: params.codexHome, + // Why: the RPC executes on the transcript-owning host; never let a + // client-provided runtime/SSH stamp escape that host boundary. + executionHostId: LOCAL_EXECUTION_HOST_ID + }) }) ] diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index 21dde39f93d..0623366c316 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -146,6 +146,7 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'accounts.subscribe', 'accounts.unsubscribe', 'aiVault.listSessions', + 'aiVault.prepareSessionResume', 'browser.back', 'browser.dialogAccept', 'browser.dialogDismiss', diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 5fb45b992ed..ee882503be2 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -433,6 +433,10 @@ import type { AiVaultSubagentListArgs, AiVaultSubagentListResult } from '../shared/ai-vault-types' +import type { + AiVaultPrepareSessionResumeArgs, + AiVaultPrepareSessionResumeResult +} from '../shared/ai-vault-resume-preparation' import type { AgentType, NativeChatMessage, @@ -822,6 +826,9 @@ export type OpenCodeUsageApi = { export type AiVaultApi = { listSessions: (args?: AiVaultListArgs) => Promise + prepareSessionResume: ( + args: AiVaultPrepareSessionResumeArgs + ) => Promise /** Lists the Task subagent transcripts of one session, on demand. */ listSubagentSessions: (args: AiVaultSubagentListArgs) => Promise /** Fires when any app window regains OS focus; returns an unsubscribe. */ diff --git a/src/preload/index.ts b/src/preload/index.ts index be5db015851..af9439a661d 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -176,6 +176,7 @@ import type { } from '../shared/automations-types' import type { KeybindingActionId, KeybindingFileSnapshot } from '../shared/keybindings' import type { AiVaultListArgs, AiVaultSubagentListArgs } from '../shared/ai-vault-types' +import type { AiVaultPrepareSessionResumeArgs } from '../shared/ai-vault-resume-preparation' import type { AgentType } from '../shared/native-chat-types' import type { NativeChatAppendedPayload, @@ -3824,6 +3825,8 @@ const api = { aiVault: { listSessions: (args?: AiVaultListArgs): Promise => ipcRenderer.invoke('aiVault:listSessions', args), + prepareSessionResume: (args: AiVaultPrepareSessionResumeArgs): Promise => + ipcRenderer.invoke('aiVault:prepareSessionResume', args), listSubagentSessions: (args: AiVaultSubagentListArgs): Promise => ipcRenderer.invoke('aiVault:listSubagentSessions', args), onWindowFocused: (callback: () => void): (() => void) => { diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx index b5303199ce0..3347a7a786b 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx @@ -31,6 +31,7 @@ export function VaultSessionRow({ session, liveState, resumeStartup, + realHomeResumeStartup, worktreeInfo, vaultScope, detailsExpanded, @@ -54,6 +55,7 @@ export function VaultSessionRow({ session: AiVaultSession liveState: AgentStatusState | null resumeStartup: AiVaultResumeStartup + realHomeResumeStartup: AiVaultResumeStartup worktreeInfo: AiVaultSessionWorktreeInfo | null vaultScope: AiVaultScope detailsExpanded: boolean @@ -99,13 +101,15 @@ export function VaultSessionRow({ command: resumeStartup.command, sessionFilePath: session.filePath, sessionExecutionHostId: session.executionHostId, + codexHome: session.codexHome, ...(resumeStartup.env ? { env: resumeStartup.env } : {}), ...(resumeStartup.envToDelete ? { envToDelete: resumeStartup.envToDelete } : {}), - ...(resumeStartup.launchConfig ? { launchConfig: resumeStartup.launchConfig } : {}) + ...(resumeStartup.launchConfig ? { launchConfig: resumeStartup.launchConfig } : {}), + realHomeStartup: realHomeResumeStartup }) window.dispatchEvent(new Event(AI_VAULT_SESSION_DRAG_START_EVENT)) }, - [resumeDisabled, session, resumeStartup] + [realHomeResumeStartup, resumeDisabled, session, resumeStartup] ) return ( diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx index c9fcadece94..5a0f92bc046 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx @@ -327,6 +327,10 @@ function AiVaultVirtualRow({ session={row.session} liveState={getSessionLiveState(row.session)} resumeStartup={buildResumeStartup(row.session, resumeState?.worktreeId)} + realHomeResumeStartup={buildResumeStartup( + { ...row.session, codexHome: null }, + resumeState?.worktreeId + )} worktreeInfo={worktreeInfo} vaultScope={vaultScope} detailsExpanded={expandedSessionIds.has(row.session.id)} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts index 487c973bc29..ef0bba73b3f 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts @@ -17,6 +17,7 @@ import { getAiVaultResumeWorkspaceTargetStatus } from '@/lib/ai-vault-resume-target' import type { AiVaultAgent, AiVaultSession } from '../../../../shared/ai-vault-types' +import { prepareAiVaultSessionForResume } from '@/lib/ai-vault-session-resume-preparation' import type { Worktree } from '../../../../shared/types' import { translate } from '@/i18n/i18n' import { agentLabel } from './ai-vault-session-filters' @@ -65,13 +66,20 @@ export function useAiVaultSessionLaunchActions({ const copyResumeCommand = useCallback( async (session: AiVaultSession, worktreeId?: string | null): Promise => { - await window.api.ui.writeClipboardText(buildResumeCommand(session, worktreeId)) - toast.success( - translate( - 'auto.components.right.sidebar.AiVaultPanel.resumeCommandCopied', - 'Resume command copied' + try { + const preparedSession = await prepareAiVaultSessionForResume(session) + await window.api.ui.writeClipboardText(buildResumeCommand(preparedSession, worktreeId)) + toast.success( + translate( + 'auto.components.right.sidebar.AiVaultPanel.resumeCommandCopied', + 'Resume command copied' + ) ) - ) + } catch (error) { + toast.error( + error instanceof Error ? error.message : 'Could not prepare this session for resume.' + ) + } }, [buildResumeCommand] ) @@ -100,11 +108,6 @@ export function useAiVaultSessionLaunchActions({ return } - const launchResult = launchAiVaultSessionInNewTab({ - agent: session.agent, - worktreeId: targetId.worktreeId, - ...buildResumeStartup(session, targetId.worktreeId) - }) const showQueuedToast = (): void => { toast.success( translate( @@ -114,26 +117,39 @@ export function useAiVaultSessionLaunchActions({ ) ) } - if (launchResult.tabId === null) { - void launchResult.runtimeLaunch.then((created) => { - if (!created) { - toast.error( - translate( - 'auto.lib.launch.agent.in.new.tab.11cce5cc77', - 'Could not launch {{value0}} in a new terminal.', - { value0: agentLabel(session.agent) } - ) - ) + void prepareAiVaultSessionForResume(session) + .then((preparedSession) => { + const launchResult = launchAiVaultSessionInNewTab({ + agent: session.agent, + worktreeId: targetId.worktreeId, + ...buildResumeStartup(preparedSession, targetId.worktreeId) + }) + if (launchResult.tabId === null) { + void launchResult.runtimeLaunch.then((created) => { + if (!created) { + toast.error( + translate( + 'auto.lib.launch.agent.in.new.tab.11cce5cc77', + 'Could not launch {{value0}} in a new terminal.', + { value0: agentLabel(session.agent) } + ) + ) + return + } + showQueuedToast() + }) return } + if (useAppStore.getState().activeWorktreeId !== targetId.worktreeId) { + activateAiVaultResumeWorkspace(targetId.worktreeId) + } showQueuedToast() }) - return - } - if (useAppStore.getState().activeWorktreeId !== targetId.worktreeId) { - activateAiVaultResumeWorkspace(targetId.worktreeId) - } - showQueuedToast() + .catch((error: unknown) => { + toast.error( + error instanceof Error ? error.message : 'Could not prepare this session for resume.' + ) + }) }, [activeWorktree?.id, activeWorktreeId, buildResumeStartup, targetState] ) diff --git a/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx b/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx index 6d20c74c159..4728383f2f7 100644 --- a/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx +++ b/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx @@ -17,6 +17,7 @@ import { useAppStore } from '@/store' import { resolveDropZone } from './tab-drop-zone' import type { TabDropZone } from './useTabDragSplit' import { translate } from '@/i18n/i18n' +import { isLegacySharedCodexHome } from '../../../../shared/ai-vault-resume-preparation' type PaneDropTarget = { groupId: string @@ -199,16 +200,6 @@ export default function AiVaultSessionDropLayer({ return true } - const launchResult = launchAiVaultSessionInNewTab({ - agent: payload.agent, - worktreeId, - command: payload.command, - ...(payload.env ? { env: payload.env } : {}), - ...(payload.envToDelete ? { envToDelete: payload.envToDelete } : {}), - ...(payload.launchConfig ? { launchConfig: payload.launchConfig } : {}), - targetGroupId: dropTarget.groupId, - splitDirection: dropTarget.zone === 'center' ? undefined : dropTarget.zone - }) const showQueuedToast = (): void => { toast.success( translate( @@ -217,23 +208,58 @@ export default function AiVaultSessionDropLayer({ ) ) } - if (launchResult.tabId === null) { - void launchResult.runtimeLaunch.then((created) => { - if (!created) { - toast.error( - translate( - 'auto.lib.launch.agent.in.new.tab.11cce5cc77', - 'Could not launch {{value0}} in a new terminal.', - { value0: payload.agent } - ) - ) + const preparation = + payload.agent === 'codex' && + isLegacySharedCodexHome(payload.codexHome ?? null) && + payload.sessionFilePath && + payload.sessionExecutionHostId && + payload.codexHome !== undefined + ? window.api.aiVault.prepareSessionResume({ + agent: payload.agent, + filePath: payload.sessionFilePath, + executionHostId: payload.sessionExecutionHostId, + codexHome: payload.codexHome + }) + : Promise.resolve({ useRealCodexHome: false }) + void preparation + .then((result) => { + const startup = result.useRealCodexHome ? payload.realHomeStartup : payload + if (!startup) { + throw new Error('Orca could not prepare this legacy Codex session. Retry resume.') + } + const launchResult = launchAiVaultSessionInNewTab({ + agent: payload.agent, + worktreeId, + command: startup.command, + ...(startup.env ? { env: startup.env } : {}), + ...(startup.envToDelete ? { envToDelete: startup.envToDelete } : {}), + ...(startup.launchConfig ? { launchConfig: startup.launchConfig } : {}), + targetGroupId: dropTarget.groupId, + splitDirection: dropTarget.zone === 'center' ? undefined : dropTarget.zone + }) + if (launchResult.tabId === null) { + void launchResult.runtimeLaunch.then((created) => { + if (!created) { + toast.error( + translate( + 'auto.lib.launch.agent.in.new.tab.11cce5cc77', + 'Could not launch {{value0}} in a new terminal.', + { value0: payload.agent } + ) + ) + return + } + showQueuedToast() + }) return } showQueuedToast() }) - return true - } - showQueuedToast() + .catch((error: unknown) => { + toast.error( + error instanceof Error ? error.message : 'Could not prepare this session for resume.' + ) + }) return true }, [clearDragState, target, updateTarget, worktreeId] diff --git a/src/renderer/src/lib/ai-vault-resume-command.test.ts b/src/renderer/src/lib/ai-vault-resume-command.test.ts index 1fb9c2a7217..543ea0aeb65 100644 --- a/src/renderer/src/lib/ai-vault-resume-command.test.ts +++ b/src/renderer/src/lib/ai-vault-resume-command.test.ts @@ -232,6 +232,88 @@ describe('ai vault resume command runtime', () => { ).toBe("Set-Location -LiteralPath 'C:\\Users\\alice\\repo'; claude '--resume' 'session one'") }) + it('copies a real-home Codex command that clears inherited homes in PowerShell', () => { + const state = makeState({ worktreePath: 'C:\\Users\\alice\\repo' }) + + expect( + buildAiVaultResumeCopyCommandForWorktree({ + state, + session: { + agent: 'codex', + sessionId: 'session one', + cwd: 'C:\\Users\\alice\\repo', + codexHome: null + } + }) + ).toBe( + "Remove-Item Env:CODEX_HOME -ErrorAction SilentlyContinue; Remove-Item Env:ORCA_CODEX_HOME -ErrorAction SilentlyContinue; Set-Location -LiteralPath 'C:\\Users\\alice\\repo'; codex 'resume' 'session one'" + ) + }) + + it('copies a real-home Codex command that clears inherited homes in cmd', () => { + const state = makeState({ + worktreePath: 'C:\\Users\\alice\\repo', + terminalWindowsShell: 'cmd.exe' + }) + + expect( + buildAiVaultResumeCopyCommandForWorktree({ + state, + session: { + agent: 'codex', + sessionId: 'session one', + cwd: 'C:\\Users\\alice\\repo', + codexHome: null + } + }) + ).toBe( + 'set "CODEX_HOME=" & set "ORCA_CODEX_HOME=" & cd /d "C:\\Users\\alice\\repo" && codex "resume" "session one"' + ) + }) + + it('copies a real-home Codex command that clears inherited homes in POSIX shells', () => { + const state = makeState({ + worktreePath: '/home/alice/repo', + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } + }) + + expect( + buildAiVaultResumeCopyCommandForWorktree({ + state, + session: { + agent: 'codex', + sessionId: 'session one', + cwd: '/home/alice/repo', + codexHome: null + } + }) + ).toBe( + "unset CODEX_HOME; unset ORCA_CODEX_HOME; cd '/home/alice/repo' && codex 'resume' 'session one'" + ) + }) + + it('keeps copied custom-home Codex commands pinned to that home', () => { + const state = makeState({ + worktreePath: '/home/alice/repo', + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } + }) + + const command = buildAiVaultResumeCopyCommandForWorktree({ + state, + session: { + agent: 'codex', + sessionId: 'session one', + cwd: '/home/alice/repo', + codexHome: '/home/alice/custom-codex' + } + }) + + expect(command).toBe( + "cd '/home/alice/repo' && CODEX_HOME='/home/alice/custom-codex' codex 'resume' 'session one'" + ) + expect(command).not.toContain('unset CODEX_HOME') + }) + it('uses configured agent defaults for resumable session history entries', () => { const state = makeState({ worktreePath: 'C:\\Users\\alice\\repo', @@ -410,7 +492,7 @@ describe('ai vault resume command runtime', () => { }) }) - it('returns the remote resume command verbatim for non-local host sessions', () => { + it('rebuilds remote real-home Codex commands without a stored home assignment', () => { const state = makeState({ worktreePath: '/home/alice/repo' }) state.repos = [{ id: 'repo-1', path: '/home/alice/repo', connectionId: 'ssh-1' }] as never @@ -427,13 +509,13 @@ describe('ai vault resume command runtime', () => { resumeCommand: "CODEX_HOME='/root/.codex' codex resume 'session one'" } }) - ).toEqual({ - command: "CODEX_HOME='/root/.codex' codex resume 'session one'", + ).toMatchObject({ + command: "cd '/home/alice/repo' && codex 'resume' 'session one'", envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME'] }) }) - it('bypasses the resume pipeline even when the command override is blank', () => { + it('rebuilds remote real-home Codex commands when the override is blank', () => { const state = makeState({ worktreePath: '/home/alice/repo' }) state.repos = [{ id: 'repo-1', path: '/home/alice/repo', connectionId: 'ssh-1' }] as never @@ -451,7 +533,31 @@ describe('ai vault resume command runtime', () => { resumeCommand: "CODEX_HOME='/root/.codex' codex resume 'session one'" } }) - ).toBe("CODEX_HOME='/root/.codex' codex resume 'session one'") + ).toBe("cd '/home/alice/repo' && codex 'resume' 'session one'") + }) + + it('copies remote real-home Codex commands with explicit environment cleanup', () => { + const state = makeState({ worktreePath: '/home/alice/repo' }) + state.repos = [{ id: 'repo-1', path: '/home/alice/repo', connectionId: 'ssh-1' }] as never + + const command = buildAiVaultResumeCopyCommandForWorktree({ + state, + worktreeId: 'repo-1::worktree-1', + session: { + agent: 'codex', + sessionId: 'session one', + cwd: '/home/alice/repo', + codexHome: null, + executionHostId: 'runtime:env-1', + executionHostPlatform: 'linux', + resumeCommand: "CODEX_HOME='/retired/shared-home' codex resume 'session one'" + } + }) + + expect(command).toBe( + "unset CODEX_HOME; unset ORCA_CODEX_HOME; cd '/home/alice/repo' && codex 'resume' 'session one'" + ) + expect(command).not.toContain('/retired/shared-home') }) it('rebuilds the command when a non-blank override is supplied for a remote session', () => { diff --git a/src/renderer/src/lib/ai-vault-resume-command.ts b/src/renderer/src/lib/ai-vault-resume-command.ts index 1e8eb04f252..b59751f08ce 100644 --- a/src/renderer/src/lib/ai-vault-resume-command.ts +++ b/src/renderer/src/lib/ai-vault-resume-command.ts @@ -15,6 +15,11 @@ import { import { parseWslUncPath } from '../../../shared/wsl-paths' import { resolveWindowsShellStartupFamily } from '../../../shared/windows-terminal-shell' import type { AgentStartupShell } from '../../../shared/tui-agent-startup-shell' +import { + clearEnvCommand, + commandSeparator, + resolveStartupShell +} from '../../../shared/tui-agent-startup-shell' import type { AppState } from '@/store/types' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' import { CLIENT_PLATFORM } from '@/lib/new-workspace' @@ -56,7 +61,16 @@ type AiVaultResumeWorktreeArgs = { } export function buildAiVaultResumeCopyCommandForWorktree(args: AiVaultResumeWorktreeArgs): string { - return buildAiVaultResumeForWorktree(args).command + const command = buildAiVaultResumeForWorktree(args).command + if (args.session.agent !== 'codex' || args.session.codexHome !== null) { + return command + } + const shell = resolveAiVaultResumeShell(args) + const separator = commandSeparator(shell) + const clearHomes = ['CODEX_HOME', 'ORCA_CODEX_HOME'] + .map((name) => clearEnvCommand(name, shell)) + .join(separator) + return `${clearHomes}${separator}${command}` } export function buildAiVaultResumeStartupForWorktree( @@ -70,6 +84,7 @@ function buildAiVaultResumeForWorktree(args: AiVaultResumeWorktreeArgs): AiVault args.session.executionHostId && args.session.executionHostId !== LOCAL_EXECUTION_HOST_ID && args.session.resumeCommand && + !(args.session.agent === 'codex' && args.session.codexHome === null) && !args.commandOverride?.trim() ) { return { @@ -146,6 +161,22 @@ function buildAiVaultResumeForWorktree(args: AiVaultResumeWorktreeArgs): AiVault } } +function resolveAiVaultResumeShell(args: AiVaultResumeWorktreeArgs): AgentStartupShell { + const platform = + args.session.executionHostId && + args.session.executionHostId !== LOCAL_EXECUTION_HOST_ID && + args.session.executionHostPlatform + ? args.session.executionHostPlatform + : getAiVaultResumePlatform(args.state, args.worktreeId) + const isLocalSession = + !args.session.executionHostId || args.session.executionHostId === LOCAL_EXECUTION_HOST_ID + const shell = + platform === 'win32' && isLocalSession + ? resolveWindowsShellStartupFamily(args.state.settings?.terminalWindowsShell) + : undefined + return resolveStartupShell(platform, shell) +} + function getAiVaultResumeCodexHome( codexHome: string | null, platform: NodeJS.Platform diff --git a/src/renderer/src/lib/ai-vault-session-drag.test.ts b/src/renderer/src/lib/ai-vault-session-drag.test.ts index 87da752157a..161ad6eb5a0 100644 --- a/src/renderer/src/lib/ai-vault-session-drag.test.ts +++ b/src/renderer/src/lib/ai-vault-session-drag.test.ts @@ -49,12 +49,17 @@ describe('Session History session drag data', () => { title: 'Fix terminal split', command: "cd '/repo' && claude --resume session-1", sessionFilePath: '/Users/ada/.claude/projects/-repo/session-1.jsonl', + codexHome: '/Users/ada/Library/Application Support/orca/codex-runtime-home/home', env: { ANTHROPIC_BASE_URL: 'https://claude.example.test' }, envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME'], launchConfig: { agentCommand: 'claude --dangerously-skip-permissions', agentArgs: '--dangerously-skip-permissions', agentEnv: { ANTHROPIC_BASE_URL: 'https://claude.example.test' } + }, + realHomeStartup: { + command: "cd '/repo' && claude --resume session-1", + envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME'] } } diff --git a/src/renderer/src/lib/ai-vault-session-drag.ts b/src/renderer/src/lib/ai-vault-session-drag.ts index b4bc124ae06..2fe733d1fe2 100644 --- a/src/renderer/src/lib/ai-vault-session-drag.ts +++ b/src/renderer/src/lib/ai-vault-session-drag.ts @@ -17,10 +17,17 @@ export type AiVaultSessionDragPayload = { // WSL) to reject SSH panes that cannot reach it. sessionFilePath?: string sessionExecutionHostId?: ExecutionHostId + codexHome?: string | null // Why: drag/drop resume must preserve planned env mutations/default args, not just the command. env?: Record envToDelete?: string[] launchConfig?: SleepingAgentLaunchConfig + realHomeStartup?: { + command: string + env?: Record + envToDelete?: string[] + launchConfig?: SleepingAgentLaunchConfig + } } let activeAiVaultSessionDragPayload: AiVaultSessionDragPayload | null = null @@ -80,9 +87,28 @@ function isSerializedPayload(value: unknown): value is SerializedAiVaultSessionD (payload.sessionFilePath === undefined || isNonEmptyString(payload.sessionFilePath)) && (payload.sessionExecutionHostId === undefined || Boolean(normalizeExecutionHostId(payload.sessionExecutionHostId))) && + (payload.codexHome === undefined || + payload.codexHome === null || + isNonEmptyString(payload.codexHome)) && (payload.env === undefined || isStringRecord(payload.env)) && (payload.envToDelete === undefined || isEnvDeletionList(payload.envToDelete)) && - (payload.launchConfig === undefined || isLaunchConfig(payload.launchConfig)) + (payload.launchConfig === undefined || isLaunchConfig(payload.launchConfig)) && + (payload.realHomeStartup === undefined || isResumeStartup(payload.realHomeStartup)) + ) +} + +function isResumeStartup( + value: unknown +): value is NonNullable { + if (!value || typeof value !== 'object') { + return false + } + const startup = value as NonNullable + return ( + isNonEmptyString(startup.command) && + (startup.env === undefined || isStringRecord(startup.env)) && + (startup.envToDelete === undefined || isEnvDeletionList(startup.envToDelete)) && + (startup.launchConfig === undefined || isLaunchConfig(startup.launchConfig)) ) } @@ -135,9 +161,11 @@ export function readAiVaultSessionDragData( command, sessionFilePath, sessionExecutionHostId, + codexHome, env, envToDelete, - launchConfig + launchConfig, + realHomeStartup } = parsed return { agent, @@ -146,9 +174,11 @@ export function readAiVaultSessionDragData( command, ...(sessionFilePath ? { sessionFilePath } : {}), ...(sessionExecutionHostId ? { sessionExecutionHostId } : {}), + ...(codexHome !== undefined ? { codexHome } : {}), ...(env ? { env } : {}), ...(envToDelete ? { envToDelete } : {}), - ...(launchConfig ? { launchConfig } : {}) + ...(launchConfig ? { launchConfig } : {}), + ...(realHomeStartup ? { realHomeStartup } : {}) } } catch { return null diff --git a/src/renderer/src/lib/ai-vault-session-resume-preparation.test.ts b/src/renderer/src/lib/ai-vault-session-resume-preparation.test.ts new file mode 100644 index 00000000000..92b0199a1d1 --- /dev/null +++ b/src/renderer/src/lib/ai-vault-session-resume-preparation.test.ts @@ -0,0 +1,77 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AiVaultSession } from '../../../shared/ai-vault-types' +import { prepareAiVaultSessionForResume } from './ai-vault-session-resume-preparation' + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('prepareAiVaultSessionForResume', () => { + it('returns a real-home launch identity only after targeted materialization succeeds', async () => { + const prepareSessionResume = vi.fn().mockResolvedValue({ useRealCodexHome: true }) + stubPreparation(prepareSessionResume) + const legacy = session({ + codexHome: '/Users/ada/Library/Application Support/orca/codex-runtime-home/home' + }) + + const prepared = await prepareAiVaultSessionForResume(legacy) + + expect(prepared.codexHome).toBeNull() + expect(prepareSessionResume).toHaveBeenCalledWith({ + agent: 'codex', + filePath: legacy.filePath, + codexHome: legacy.codexHome, + executionHostId: 'local' + }) + }) + + it('rejects without changing the launch identity when materialization fails', async () => { + stubPreparation(vi.fn().mockRejectedValue(new Error('Retry resume.'))) + + await expect( + prepareAiVaultSessionForResume(session({ codexHome: '/tmp/orca/codex-runtime-home/home' })) + ).rejects.toThrow('Retry resume.') + }) + + it.each(['/custom/codex', '/tmp/orca/codex-accounts/account-1/home'])( + 'preserves a non-legacy home without materialization: %s', + async (codexHome) => { + const prepareSessionResume = vi.fn() + stubPreparation(prepareSessionResume) + const current = session({ codexHome }) + + await expect(prepareAiVaultSessionForResume(current)).resolves.toBe(current) + expect(prepareSessionResume).not.toHaveBeenCalled() + } + ) +}) + +function stubPreparation(prepareSessionResume: ReturnType): void { + vi.stubGlobal('window', { api: { aiVault: { prepareSessionResume } } }) +} + +function session(overrides: Partial = {}): AiVaultSession { + return { + id: 'local:codex:session-1:/tmp/rollout.jsonl', + executionHostId: 'local', + agent: 'codex', + sessionId: 'session-1', + title: 'Legacy session', + cwd: '/repo', + branch: null, + model: null, + filePath: '/tmp/rollout.jsonl', + codexHome: null, + createdAt: null, + updatedAt: null, + modifiedAt: '2026-07-20T00:00:00.000Z', + messageCount: 1, + totalTokens: 0, + previewMessages: [], + queuedMessageCount: 0, + subagentTranscriptCount: 0, + resumeCommand: "codex resume 'session-1'", + subagent: null, + ...overrides + } +} diff --git a/src/renderer/src/lib/ai-vault-session-resume-preparation.ts b/src/renderer/src/lib/ai-vault-session-resume-preparation.ts new file mode 100644 index 00000000000..d65da5a737e --- /dev/null +++ b/src/renderer/src/lib/ai-vault-session-resume-preparation.ts @@ -0,0 +1,17 @@ +import type { AiVaultSession } from '../../../shared/ai-vault-types' +import { isLegacySharedCodexHome } from '../../../shared/ai-vault-resume-preparation' + +export async function prepareAiVaultSessionForResume( + session: AiVaultSession +): Promise { + if (session.agent !== 'codex' || !isLegacySharedCodexHome(session.codexHome)) { + return session + } + const result = await window.api.aiVault.prepareSessionResume({ + agent: session.agent, + filePath: session.filePath, + codexHome: session.codexHome, + executionHostId: session.executionHostId + }) + return result.useRealCodexHome ? { ...session, codexHome: null } : session +} diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 153839e27bf..f65fe06075e 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -8,6 +8,10 @@ import type { } from '../../../preload/api-types' import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' import type { AiVaultListArgs, AiVaultListResult } from '../../../shared/ai-vault-types' +import type { + AiVaultPrepareSessionResumeArgs, + AiVaultPrepareSessionResumeResult +} from '../../../shared/ai-vault-resume-preparation' import { buildNativeChatUnsubscribe } from '../../../shared/native-chat-stream-unsubscribe' import type { ComputerUsePermissionSetupResult, @@ -1316,6 +1320,8 @@ function createAiVaultApi(): NonNullable['aiVault']> { executionHostId }) }, + prepareSessionResume: (args: AiVaultPrepareSessionResumeArgs) => + callRuntimeResult('aiVault.prepareSessionResume', args), // Why: no server-side RPC for subagent transcript listing yet, so report an empty (not erroring) result. listSubagentSessions: () => Promise.resolve({ sessions: [], issues: [] }), onWindowFocused: () => noopUnsubscribe diff --git a/src/shared/ai-vault-resume-preparation.ts b/src/shared/ai-vault-resume-preparation.ts new file mode 100644 index 00000000000..7100f5ce29c --- /dev/null +++ b/src/shared/ai-vault-resume-preparation.ts @@ -0,0 +1,22 @@ +import type { AiVaultSession } from './ai-vault-types' + +export type AiVaultPrepareSessionResumeArgs = Pick< + AiVaultSession, + 'agent' | 'filePath' | 'codexHome' | 'executionHostId' +> + +export type AiVaultPrepareSessionResumeResult = { + useRealCodexHome: boolean +} + +export type AiVaultSessionResumePreparation = ( + args: AiVaultPrepareSessionResumeArgs +) => Promise + +export function isLegacySharedCodexHome(codexHome: string | null): boolean { + if (!codexHome) { + return false + } + const segments = codexHome.split(/[\\/]/).filter(Boolean) + return segments.at(-2) === 'codex-runtime-home' && segments.at(-1) === 'home' +} From 3468b434d609a1bda5510ec4bc3ba07cc0e9d405 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:11:23 -0700 Subject: [PATCH 09/58] feat(dashboard): add agent dashboard popout (#9604) * feat(dashboard): add agent dashboard popout * fix(dashboard): gate and harden agent popout * test(ipc): isolate dashboard handler registration * fix(dashboard): drop diff status from bucket counts * fix(dashboard): address review feedback * perf(dashboard): ignore unrelated store churn --- agent-dashboard-mocks/RESEARCH.md | 33 + agent-dashboard-mocks/index.html | 788 ++++++++++++++++++ electron.vite.config.ts | 13 + src/main/ipc/crash-reporting.ts | 3 +- .../ipc/dashboard-payload-validation.test.ts | 65 ++ src/main/ipc/dashboard-payload-validation.ts | 84 ++ src/main/ipc/dashboard-popout.test.ts | 183 ++++ src/main/ipc/dashboard-popout.ts | 125 +++ src/main/ipc/pty.ts | 2 +- src/main/ipc/register-core-handlers.test.ts | 16 + src/main/ipc/register-core-handlers.ts | 4 + .../ipc/terminal-preview-output-stream.ts | 238 ++++++ src/main/ipc/terminal-preview.test.ts | 293 +++++++ src/main/ipc/terminal-preview.ts | 155 ++++ src/main/ipc/ui.test.ts | 15 + src/main/ipc/ui.ts | 21 +- src/main/runtime/mobile-presence-lock.test.ts | 23 +- src/main/runtime/orca-runtime.ts | 57 ++ .../runtime/terminal-query-responder.test.ts | 15 + src/main/window/createMainWindow.ts | 72 +- .../window/dashboard-popout-window.test.ts | 270 ++++++ src/main/window/dashboard-popout-window.ts | 189 +++++ src/main/window/focus-existing-window.ts | 2 +- .../window/privileged-window-navigation.ts | 33 + src/main/window/window-bounds-validation.ts | 35 + src/preload/api-types.ts | 28 + src/preload/index.ts | 72 ++ src/renderer/popout.html | 13 + src/renderer/src/App.tsx | 2 + .../AgentKanbanBoard.test.tsx | 172 ++++ .../dashboard-popout/AgentKanbanBoard.tsx | 172 ++++ .../dashboard-popout/AgentKanbanCard.test.tsx | 93 +++ .../dashboard-popout/AgentKanbanCard.tsx | 143 ++++ .../dashboard-popout/AgentTerminalDialog.tsx | 100 +++ .../AgentTerminalPreview.test.tsx | 220 +++++ .../dashboard-popout/AgentTerminalPreview.tsx | 288 +++++++ .../dashboard-popout/DashboardPopoutRoot.tsx | 17 + .../agent-board-transitions.css | 54 ++ .../dashboard-popout/useDashboardSnapshot.ts | 55 ++ .../build-dashboard-snapshot.test.ts | 186 +++++ .../dashboard/build-dashboard-snapshot.ts | 172 ++++ .../dashboard/useAgentBucketCounts.ts | 87 ++ .../useDashboardPopoutBridge.test.tsx | 151 ++++ .../dashboard/useDashboardPopoutBridge.ts | 157 ++++ .../settings/ExperimentalPane.test.tsx | 32 + .../components/settings/ExperimentalPane.tsx | 49 ++ .../settings/experimental-search.ts | 42 + .../components/sidebar/SidebarNav.test.tsx | 25 + .../src/components/sidebar/SidebarNav.tsx | 82 +- src/renderer/src/i18n/locales/en.json | 47 ++ src/renderer/src/i18n/locales/es.json | 47 ++ src/renderer/src/i18n/locales/ja.json | 47 ++ src/renderer/src/i18n/locales/ko.json | 47 ++ src/renderer/src/i18n/locales/zh.json | 47 ++ src/renderer/src/popout.tsx | 122 +++ src/shared/constants.test.ts | 4 + src/shared/constants.ts | 1 + src/shared/crash-reporting.ts | 1 + src/shared/dashboard-snapshot.ts | 74 ++ src/shared/telemetry-events.ts | 1 + src/shared/terminal-preview.ts | 20 + src/shared/types.ts | 5 + 62 files changed, 5539 insertions(+), 70 deletions(-) create mode 100644 agent-dashboard-mocks/RESEARCH.md create mode 100644 agent-dashboard-mocks/index.html create mode 100644 src/main/ipc/dashboard-payload-validation.test.ts create mode 100644 src/main/ipc/dashboard-payload-validation.ts create mode 100644 src/main/ipc/dashboard-popout.test.ts create mode 100644 src/main/ipc/dashboard-popout.ts create mode 100644 src/main/ipc/terminal-preview-output-stream.ts create mode 100644 src/main/ipc/terminal-preview.test.ts create mode 100644 src/main/ipc/terminal-preview.ts create mode 100644 src/main/window/dashboard-popout-window.test.ts create mode 100644 src/main/window/dashboard-popout-window.ts create mode 100644 src/main/window/privileged-window-navigation.ts create mode 100644 src/main/window/window-bounds-validation.ts create mode 100644 src/renderer/popout.html create mode 100644 src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx create mode 100644 src/renderer/src/components/dashboard-popout/AgentKanbanBoard.tsx create mode 100644 src/renderer/src/components/dashboard-popout/AgentKanbanCard.test.tsx create mode 100644 src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx create mode 100644 src/renderer/src/components/dashboard-popout/AgentTerminalDialog.tsx create mode 100644 src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx create mode 100644 src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx create mode 100644 src/renderer/src/components/dashboard-popout/DashboardPopoutRoot.tsx create mode 100644 src/renderer/src/components/dashboard-popout/agent-board-transitions.css create mode 100644 src/renderer/src/components/dashboard-popout/useDashboardSnapshot.ts create mode 100644 src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts create mode 100644 src/renderer/src/components/dashboard/build-dashboard-snapshot.ts create mode 100644 src/renderer/src/components/dashboard/useAgentBucketCounts.ts create mode 100644 src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx create mode 100644 src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts create mode 100644 src/renderer/src/popout.tsx create mode 100644 src/shared/dashboard-snapshot.ts create mode 100644 src/shared/terminal-preview.ts diff --git a/agent-dashboard-mocks/RESEARCH.md b/agent-dashboard-mocks/RESEARCH.md new file mode 100644 index 00000000000..da546889c1e --- /dev/null +++ b/agent-dashboard-mocks/RESEARCH.md @@ -0,0 +1,33 @@ +# How people like to see all their agents at once — research + how it shaped the mocks + +Grounded in shipping tools (Warp Agent Panel, herdr, Claude Code Agent View, Claude Squad, uzi, Conductor/Crystal, Vibe Kanban, k9s/Lens, Datadog Host Map, Buildkite, pm2/btop) and notification/triage UX literature. This drives `index.html`. + +## The core finding +**"Waiting for input" is the whole reason this view exists.** Everything else is ambient. Every serious tool makes the needs-a-human state the loudest thing on screen and lets you resolve it without a full context switch (Warp "jump to pane", Claude Agent View "reply without attaching"). Practical parallelism tops out around **4–8 agents** (occasionally ~30), so the default should optimize glanceability for a handful, with a density fallback for many. + +## Design principles applied to the mocks +1. **Waiting-for-input is loudest.** Amber + gentle pulse, top-of-sort priority, an always-visible aggregate ("2 need you"), a one-line *reason label*, and inline **Answer**/**Open** actions. → the **Needs you** inbox is the purest form; every other view surfaces it too. +2. **State is the primary channel, encoded redundantly.** Color + icon + text label (never color alone — accessibility). Colors map to Orca's `main.css` tokens (success green, destructive red) with a mock-local amber/blue for waiting/working. +3. **Motion = "needs you or is changing," never decoration.** Only *working* (subtle) and *waiting* (pulse) animate; done/idle/error are static. Respects `prefers-reduced-motion`. +4. **Density adapts to count.** Cards (≤8) → table (8–20) → tiles/grid (20+). The switcher lets you feel each. +5. **Group by project with roll-up, but let urgency flatten it.** Gallery/tree group by repo; the inbox is the flat, urgency-sorted triage view. +6. **Per-agent info priority:** state → reason (when blocked) → what it's working on → last activity → elapsed → provider badge → branch. Shown progressively (1–4 always, 5–7 on hover/detail). +7. **Live preview on hover, dots at rest.** Don't mount 30 live xterms (perf) — rest = status; **hover → terminal tail popover** with a jump-in affordance. This is the shared popover in every variant. +8. **Notify on transitions, tiered.** waiting/error = interruptive (toast + unread badge, and Orca's mobile push); done = ambient; working/idle = silent. + +## The seven variants ↔ research concepts +| Variant | Maps to | Best for | +|---|---|---| +| **Repo › worktree › agent** (nested, default) | herdr state roll-up; k9s XRay tree; your own idea | Mirrors Orca's real hierarchy; roll-up dot on each repo/worktree tells you if anything inside needs you; agents sorted by urgency | +| **Needs you** (inbox) | Inbox-zero triage; Warp attention state | Any count — the "just tell me what needs me" view | +| **Card gallery** (default) | Conductor/Lens cards + progressive disclosure | Typical 5–12 — best glanceability/info balance | +| **Kanban board** | Vibe Kanban (columns = state) | 8–15; managing rather than co-coding | +| **Mission control** (table) | uzi `ls -w`, k9s | 15–30; terminal-native power users | +| **Terminal wall** (mosaic) | tmux wall, Claude Squad panes | 2–6; comparison runs / ambient monitor | +| **Tree + focus** (master-detail) | k9s XRay tree, Conductor, herdr roll-up | Supervise + dive-in; strong general default | +| **Radial map** | Datadog Host Map / your original concentric dashboard | Stretch/overview concept; homage to the movable-UI branch | + +**Recommendation from the research:** ship **Card gallery** as default + **Tree + focus** as the working surface, with **Needs you** as a one-key filter and **Mission control** as the "I have 25 agents" density toggle. Wall/radial are focus/overview modes. + +## Sources +Warp [managing-agents](https://docs.warp.dev/agents/using-agents/managing-agents) · herdr [github](https://github.com/ogulcancelik/herdr), [docs](https://herdr.dev/docs/agents/) · Claude Code [agent-teams](https://code.claude.com/docs/en/agent-teams), [Agent View cockpit](https://www.contextstudios.ai/blog/claude-code-agent-view-multi-agent-cockpit) · Claude Squad [github](https://github.com/smtg-ai/claude-squad) · uzi [github](https://github.com/devflowinc/uzi) · Conductor [docs](https://www.conductor.build/docs/guides/parallel-agents/run-multiple-claude-code-sessions) · Vibe Kanban [github](https://github.com/BloopAI/vibe-kanban) · k9s [site](https://k9scli.io/), [palark](https://palark.com/blog/k9s-the-powerful-terminal-ui-for-kubernetes/) · Datadog [host map](https://docs.datadoghq.com/dashboards/widgets/hostmap/), [widget colors](https://docs.datadoghq.com/dashboards/guide/widget_colors/) · Buildkite [dashboard](https://buildkite.com/docs/pipelines/dashboard-walkthrough) · pm2/btop [monitoring](https://pm2.keymetrics.io/docs/usage/monitoring/) · Triage UX [Smart Interface Design Patterns](https://smart-interface-design-patterns.com/articles/notifications/), [Missive inbox zero](https://missiveapp.com/blog/inbox-zero) diff --git a/agent-dashboard-mocks/index.html b/agent-dashboard-mocks/index.html new file mode 100644 index 00000000000..b0d95b6d2b0 --- /dev/null +++ b/agent-dashboard-mocks/index.html @@ -0,0 +1,788 @@ + + + + + +Orca · Agent Dashboard — UI explorations + + + +
+
OrcaAgents
+
+
+ +
+ +
+
+ Eight ways to see every agent across all your worktrees at once. Hover any agent to peek its live terminal + in a popover (move into the popover to interact). Layout switcher is top-right; ◐ toggles light/dark. +
+ +
+ + + + + + + diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 7733aa0e520..956349c70cb 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -239,6 +239,19 @@ export default defineConfig({ plugins: [react(), tailwindcss()], worker: { format: 'es' + }, + build: { + // Why: the pop-out dashboard is a second top-level window with its own + // React root. It gets its own HTML entry so it can boot independently of + // the main window while reusing the same preload/window.api. `index` must + // stay listed — overriding input otherwise drops electron-vite's default + // renderer entry. + rollupOptions: { + input: { + index: resolve('src/renderer/index.html'), + popout: resolve('src/renderer/popout.html') + } + } } } }) diff --git a/src/main/ipc/crash-reporting.ts b/src/main/ipc/crash-reporting.ts index 89b88537eb9..7ced0354ee4 100644 --- a/src/main/ipc/crash-reporting.ts +++ b/src/main/ipc/crash-reporting.ts @@ -53,7 +53,8 @@ const REACT_ERROR_BOUNDARY_SURFACES = new Set { + it('accepts a complete dashboard snapshot', () => { + expect(isDashboardSnapshot(SNAPSHOT)).toBe(true) + }) + + it('rejects malformed or unbounded snapshot fields', () => { + expect(isDashboardSnapshot({ ...SNAPSHOT, generatedAt: Number.NaN })).toBe(false) + expect( + isDashboardSnapshot({ + ...SNAPSHOT, + cards: [{ ...SNAPSHOT.cards[0], bucket: 'unexpected' }] + }) + ).toBe(false) + expect( + isDashboardSnapshot({ + ...SNAPSHOT, + cards: [{ ...SNAPSHOT.cards[0], lastAgentMessage: 'x'.repeat(8_001) }] + }) + ).toBe(false) + }) + + it('requires complete bounded reveal routing', () => { + expect( + isDashboardRevealAgentArgs({ + repoId: 'repo-1', + worktreeId: 'worktree-1', + tabId: 'tab-1', + leafId: null + }) + ).toBe(true) + expect( + isDashboardRevealAgentArgs({ repoId: 'repo-1', worktreeId: 'worktree-1', tabId: '' }) + ).toBe(false) + }) +}) diff --git a/src/main/ipc/dashboard-payload-validation.ts b/src/main/ipc/dashboard-payload-validation.ts new file mode 100644 index 00000000000..a729db97f4d --- /dev/null +++ b/src/main/ipc/dashboard-payload-validation.ts @@ -0,0 +1,84 @@ +import type { DashboardRevealAgentArgs, DashboardSnapshot } from '../../shared/dashboard-snapshot' +import { + AGENT_STATUS_ASSISTANT_MESSAGE_MAX_LENGTH, + AGENT_STATUS_INTERACTIVE_PROMPT_MAX_LENGTH, + AGENT_STATUS_MAX_FIELD_LENGTH, + AGENT_TYPE_MAX_LENGTH +} from '../../shared/agent-status-types' + +const MAX_DASHBOARD_CARDS = 1_000 +const MAX_ID_LENGTH = 4_096 +const MAX_LABEL_LENGTH = 1_024 +const DASHBOARD_BUCKETS = new Set(['attention', 'working', 'idle']) +const DASHBOARD_DOT_STATES = new Set(['working', 'blocked', 'waiting', 'done', 'idle']) + +function isBoundedString(value: unknown, maxLength: number, allowEmpty = false): value is string { + return typeof value === 'string' && value.length <= maxLength && (allowEmpty || value.length > 0) +} + +function isOptionalBoundedString(value: unknown, maxLength: number): boolean { + return value === undefined || isBoundedString(value, maxLength, true) +} + +function isFiniteNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) +} + +export function isDashboardRevealAgentArgs(value: unknown): value is DashboardRevealAgentArgs { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false + } + const args = value as Record + return ( + isBoundedString(args.repoId, MAX_ID_LENGTH) && + isBoundedString(args.worktreeId, MAX_ID_LENGTH) && + isBoundedString(args.tabId, MAX_ID_LENGTH) && + (args.leafId === null || isBoundedString(args.leafId, MAX_ID_LENGTH)) + ) +} + +export function isDashboardPaneKey(value: unknown): value is string { + return isBoundedString(value, MAX_ID_LENGTH) +} + +export function isDashboardSnapshot(value: unknown): value is DashboardSnapshot { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false + } + const snapshot = value as Record + return ( + isFiniteNumber(snapshot.generatedAt) && + Array.isArray(snapshot.cards) && + snapshot.cards.length <= MAX_DASHBOARD_CARDS && + snapshot.cards.every(isDashboardCard) + ) +} + +function isDashboardCard(value: unknown): boolean { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false + } + const card = value as Record + return ( + isBoundedString(card.paneKey, MAX_ID_LENGTH) && + (card.ptyId === null || isBoundedString(card.ptyId, MAX_ID_LENGTH)) && + isBoundedString(card.agentType, AGENT_TYPE_MAX_LENGTH) && + typeof card.bucket === 'string' && + DASHBOARD_BUCKETS.has(card.bucket) && + typeof card.dotState === 'string' && + DASHBOARD_DOT_STATES.has(card.dotState) && + isBoundedString(card.task, AGENT_STATUS_MAX_FIELD_LENGTH, true) && + isOptionalBoundedString(card.lastUserMessage, AGENT_STATUS_MAX_FIELD_LENGTH) && + isOptionalBoundedString(card.lastAgentMessage, AGENT_STATUS_ASSISTANT_MESSAGE_MAX_LENGTH) && + isBoundedString(card.repoId, MAX_ID_LENGTH) && + isBoundedString(card.worktreeId, MAX_ID_LENGTH) && + isBoundedString(card.tabId, MAX_ID_LENGTH) && + (card.leafId === null || isBoundedString(card.leafId, MAX_ID_LENGTH)) && + isBoundedString(card.repoName, MAX_LABEL_LENGTH, true) && + isBoundedString(card.worktreeName, MAX_LABEL_LENGTH, true) && + isFiniteNumber(card.startedAt) && + isFiniteNumber(card.stateChangedAt) && + typeof card.unseen === 'boolean' && + isOptionalBoundedString(card.askSummary, AGENT_STATUS_INTERACTIVE_PROMPT_MAX_LENGTH) + ) +} diff --git a/src/main/ipc/dashboard-popout.test.ts b/src/main/ipc/dashboard-popout.test.ts new file mode 100644 index 00000000000..c14b8b3c1fb --- /dev/null +++ b/src/main/ipc/dashboard-popout.test.ts @@ -0,0 +1,183 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { + handlers, + ipcMainMock, + appMock, + createPopoutMock, + closePopoutMock, + getPopoutMock, + isPopoutRendererMock, + isTrustedUIRendererMock, + getTrustedWindowMock, + sendToTrustedMock, + safelyRevealMock +} = vi.hoisted(() => { + const map = new Map unknown>() + return { + handlers: map, + ipcMainMock: { + removeHandler: vi.fn(), + handle: (channel: string, fn: (...args: unknown[]) => unknown) => map.set(channel, fn) + }, + appMock: { focus: vi.fn() }, + createPopoutMock: vi.fn(), + closePopoutMock: vi.fn(), + getPopoutMock: vi.fn((): unknown => null), + isPopoutRendererMock: vi.fn((_sender: unknown) => false), + isTrustedUIRendererMock: vi.fn((_sender: unknown) => false), + getTrustedWindowMock: vi.fn((): unknown => null), + sendToTrustedMock: vi.fn(), + safelyRevealMock: vi.fn() + } +}) + +vi.mock('electron', () => ({ app: appMock, ipcMain: ipcMainMock })) +vi.mock('../window/dashboard-popout-window', () => ({ + createOrFocusDashboardPopout: createPopoutMock, + closeDashboardPopout: closePopoutMock, + getDashboardPopoutWindow: getPopoutMock, + isDashboardPopoutRenderer: isPopoutRendererMock, + onDashboardPopoutOpenChanged: vi.fn() +})) +vi.mock('../window/focus-existing-window', () => ({ safelyRevealWindow: safelyRevealMock })) +vi.mock('./ui', () => ({ + getTrustedUIRendererWindow: getTrustedWindowMock, + isTrustedUIRenderer: isTrustedUIRendererMock, + sendToTrustedUIRenderer: sendToTrustedMock +})) + +import { registerDashboardPopoutHandlers } from './dashboard-popout' + +const mainSender = { id: 1, send: vi.fn() } +const popoutSender = { id: 2, send: vi.fn() } +const untrustedSender = { id: 3, send: vi.fn() } +const SNAPSHOT = { generatedAt: 1, cards: [] } + +function makeWindow(sender: typeof mainSender) { + return { + isDestroyed: () => false, + webContents: sender + } +} + +function makeStore(enabled = true) { + let settingsListener: + | ((updates: Record, settings: Record) => void) + | null = null + return { + getSettings: vi.fn(() => ({ experimentalAgentDashboardPopout: enabled })), + onSettingsChanged: vi.fn((listener) => { + settingsListener = listener + return vi.fn() + }), + fireSettingsChanged: (nextEnabled: boolean) => + settingsListener?.( + { experimentalAgentDashboardPopout: nextEnabled }, + { experimentalAgentDashboardPopout: nextEnabled } + ) + } +} + +describe('registerDashboardPopoutHandlers', () => { + let store: ReturnType + + beforeEach(() => { + handlers.clear() + store = makeStore() + isTrustedUIRendererMock.mockImplementation((sender) => sender === mainSender) + isPopoutRendererMock.mockImplementation((sender) => sender === popoutSender) + registerDashboardPopoutHandlers(store as never) + }) + + afterEach(() => { + vi.clearAllMocks() + getPopoutMock.mockReturnValue(null) + getTrustedWindowMock.mockReturnValue(null) + }) + + it('opens only for the trusted main renderer while the feature is enabled', () => { + handlers.get('dashboardPopout:open')!({ sender: untrustedSender } as never) + expect(createPopoutMock).not.toHaveBeenCalled() + + store.getSettings.mockReturnValue({ experimentalAgentDashboardPopout: false }) + handlers.get('dashboardPopout:open')!({ sender: mainSender } as never) + expect(createPopoutMock).not.toHaveBeenCalled() + + store.getSettings.mockReturnValue({ experimentalAgentDashboardPopout: true }) + handlers.get('dashboardPopout:open')!({ sender: mainSender } as never) + expect(createPopoutMock).toHaveBeenCalledWith(store) + }) + + it('auto-closes the popout when the feature is disabled', () => { + store.fireSettingsChanged(false) + expect(closePopoutMock).toHaveBeenCalledOnce() + }) + + it('caches and forwards only valid trusted snapshots', () => { + const popout = makeWindow(popoutSender) + getPopoutMock.mockReturnValue(popout) + + handlers.get('dashboard:publishSnapshot')!({ sender: untrustedSender } as never, SNAPSHOT) + handlers.get('dashboard:publishSnapshot')!({ sender: mainSender } as never, { + generatedAt: Number.NaN, + cards: [] + }) + expect(popoutSender.send).not.toHaveBeenCalled() + + handlers.get('dashboard:publishSnapshot')!({ sender: mainSender } as never, SNAPSHOT) + expect(popoutSender.send).toHaveBeenCalledWith('dashboard:snapshot', SNAPSHOT) + }) + + it('replays the cached snapshot only to the popout and nudges only the trusted main renderer', () => { + const popout = makeWindow(popoutSender) + getPopoutMock.mockReturnValue(popout) + handlers.get('dashboard:publishSnapshot')!({ sender: mainSender } as never, SNAPSHOT) + + handlers.get('dashboard:requestSnapshot')!({ sender: untrustedSender } as never) + expect(untrustedSender.send).not.toHaveBeenCalled() + + handlers.get('dashboard:requestSnapshot')!({ sender: popoutSender } as never) + expect(popoutSender.send).toHaveBeenCalledWith('dashboard:snapshot', SNAPSHOT) + expect(sendToTrustedMock).toHaveBeenCalledWith('dashboard:snapshotRequested', null) + }) + + it('reports open state only to the trusted main renderer', () => { + getPopoutMock.mockReturnValue(makeWindow(popoutSender)) + expect(handlers.get('dashboard:getPopoutOpen')!({ sender: untrustedSender } as never)).toBe( + false + ) + expect(handlers.get('dashboard:getPopoutOpen')!({ sender: mainSender } as never)).toBe(true) + }) + + it('relays valid seen acknowledgements from only the popout', () => { + handlers.get('dashboardPopout:ackAgent')!({ sender: untrustedSender } as never, { + paneKey: 'tab1:leaf1' + }) + handlers.get('dashboardPopout:ackAgent')!({ sender: popoutSender } as never, { paneKey: '' }) + expect(sendToTrustedMock).not.toHaveBeenCalled() + + handlers.get('dashboardPopout:ackAgent')!({ sender: popoutSender } as never, { + paneKey: 'tab1:leaf1' + }) + expect(sendToTrustedMock).toHaveBeenCalledWith('ui:ackDashboardAgent', 'tab1:leaf1') + }) + + it('reveals an agent in only the trusted main window', () => { + const main = makeWindow(mainSender) + getTrustedWindowMock.mockReturnValue(main) + const args = { repoId: 'r1', worktreeId: 'w1', tabId: 't1', leafId: 'l1' } + + handlers.get('dashboardPopout:revealAgent')!({ sender: untrustedSender } as never, args) + handlers.get('dashboardPopout:revealAgent')!({ sender: popoutSender } as never, { + ...args, + tabId: '' + }) + expect(safelyRevealMock).not.toHaveBeenCalled() + + handlers.get('dashboardPopout:revealAgent')!({ sender: popoutSender } as never, args) + expect(safelyRevealMock).toHaveBeenCalledWith(main) + expect(mainSender.send).toHaveBeenCalledWith('ui:revealDashboardAgent', args) + expect(appMock.focus).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/ipc/dashboard-popout.ts b/src/main/ipc/dashboard-popout.ts new file mode 100644 index 00000000000..95e602ad1ca --- /dev/null +++ b/src/main/ipc/dashboard-popout.ts @@ -0,0 +1,125 @@ +import { app, ipcMain } from 'electron' +import type { Store } from '../persistence' +import type { DashboardSnapshot } from '../../shared/dashboard-snapshot' +import { + createOrFocusDashboardPopout, + closeDashboardPopout, + getDashboardPopoutWindow, + isDashboardPopoutRenderer, + onDashboardPopoutOpenChanged +} from '../window/dashboard-popout-window' +import { safelyRevealWindow } from '../window/focus-existing-window' +import { getTrustedUIRendererWindow, isTrustedUIRenderer, sendToTrustedUIRenderer } from './ui' +import { + isDashboardPaneKey, + isDashboardRevealAgentArgs, + isDashboardSnapshot +} from './dashboard-payload-validation' + +// The most recent snapshot the main renderer published, replayed to the popout +// the instant it mounts so the board paints without waiting for the next tick. +// Cleared on close so a reopened popout never flashes a previous session. +let lastSnapshot: DashboardSnapshot | null = null + +function isDashboardEnabled(store: Store): boolean { + return store.getSettings().experimentalAgentDashboardPopout === true +} + +export function registerDashboardPopoutHandlers(store: Store): void { + ipcMain.removeHandler('dashboardPopout:open') + ipcMain.removeHandler('dashboard:publishSnapshot') + ipcMain.removeHandler('dashboard:requestSnapshot') + ipcMain.removeHandler('dashboard:getPopoutOpen') + ipcMain.removeHandler('dashboardPopout:revealAgent') + ipcMain.removeHandler('dashboardPopout:ackAgent') + + onDashboardPopoutOpenChanged((open) => { + if (!open) { + lastSnapshot = null + } + }) + store.onSettingsChanged((updates, settings) => { + if ( + 'experimentalAgentDashboardPopout' in updates && + settings.experimentalAgentDashboardPopout !== true + ) { + lastSnapshot = null + closeDashboardPopout() + } + }) + + ipcMain.handle('dashboardPopout:open', (event): void => { + if (!isTrustedUIRenderer(event.sender) || !isDashboardEnabled(store)) { + return + } + createOrFocusDashboardPopout(store) + }) + + // Relay: the main renderer publishes derived snapshots; forward to the popout. + ipcMain.handle('dashboard:publishSnapshot', (event, snapshot: unknown): void => { + if ( + !isTrustedUIRenderer(event.sender) || + !isDashboardEnabled(store) || + !isDashboardSnapshot(snapshot) + ) { + return + } + lastSnapshot = snapshot + getDashboardPopoutWindow()?.webContents.send('dashboard:snapshot', snapshot) + }) + + // The popout asks for a snapshot on mount: replay the cache immediately, then + // nudge the main renderer to publish a fresh one. + ipcMain.handle('dashboard:requestSnapshot', (event): void => { + if (!isDashboardPopoutRenderer(event.sender) || !isDashboardEnabled(store)) { + return + } + if (lastSnapshot) { + event.sender.send('dashboard:snapshot', lastSnapshot) + } + sendToTrustedUIRenderer('dashboard:snapshotRequested', null) + }) + + ipcMain.handle('dashboard:getPopoutOpen', (event): boolean => + isTrustedUIRenderer(event.sender) && isDashboardEnabled(store) + ? getDashboardPopoutWindow() !== null + : false + ) + + // Seen-sync: opening a card's terminal dialog acknowledges the agent in the + // main renderer's store — the same ack that mutes its sidebar row. + ipcMain.handle('dashboardPopout:ackAgent', (event, args: unknown): void => { + if ( + !isDashboardPopoutRenderer(event.sender) || + !isDashboardEnabled(store) || + !args || + typeof args !== 'object' || + !isDashboardPaneKey((args as { paneKey?: unknown }).paneKey) + ) { + return + } + sendToTrustedUIRenderer('ui:ackDashboardAgent', (args as { paneKey: string }).paneKey) + }) + + // Click-to-focus: raise the main window and route it to the agent's pane. + ipcMain.handle('dashboardPopout:revealAgent', (event, args: unknown): void => { + if ( + !isDashboardPopoutRenderer(event.sender) || + !isDashboardEnabled(store) || + !isDashboardRevealAgentArgs(args) + ) { + return + } + const mainWindow = getTrustedUIRendererWindow() + if (!mainWindow) { + return + } + safelyRevealWindow(mainWindow) + mainWindow.webContents.send('ui:revealDashboardAgent', args) + try { + app.focus({ steal: true }) + } catch { + // Best-effort; the per-window focus above may still bring it forward. + } + }) +} diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 0d0b79b6b68..573506367ea 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -1605,7 +1605,7 @@ export function registerPtyHandlers( const backgroundedDeliverySyncByPty = new Map() function syncPtyBackgroundedDelivery(id: string, caller: string): void { const background = - rendererPtyIsKnownHidden(id) && !(runtime?.hasRemoteTerminalViewSubscriber(id) ?? false) + rendererPtyIsKnownHidden(id) && !(runtime?.hasRawTerminalViewSubscriber?.(id) ?? false) if ((backgroundedDeliverySyncByPty.get(id) ?? false) === background) { return } diff --git a/src/main/ipc/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers.test.ts index bcfedc36311..787053b4995 100644 --- a/src/main/ipc/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers.test.ts @@ -54,6 +54,8 @@ const { registerHostedReviewHandlersMock, registerExportHandlersMock, registerOnboardingHandlersMock, + registerDashboardPopoutHandlersMock, + registerTerminalPreviewHandlersMock, registerSpeechHandlersMock, registerSkillsHandlersMock, registerWorkspaceSpaceHandlersMock, @@ -116,6 +118,8 @@ const { registerHostedReviewHandlersMock: vi.fn(), registerExportHandlersMock: vi.fn(), registerOnboardingHandlersMock: vi.fn(), + registerDashboardPopoutHandlersMock: vi.fn(), + registerTerminalPreviewHandlersMock: vi.fn(), registerSpeechHandlersMock: vi.fn(), registerSkillsHandlersMock: vi.fn(), registerWorkspaceSpaceHandlersMock: vi.fn(), @@ -144,6 +148,14 @@ vi.mock('./onboarding', () => ({ registerOnboardingHandlers: registerOnboardingHandlersMock })) +vi.mock('./dashboard-popout', () => ({ + registerDashboardPopoutHandlers: registerDashboardPopoutHandlersMock +})) + +vi.mock('./terminal-preview', () => ({ + registerTerminalPreviewHandlers: registerTerminalPreviewHandlersMock +})) + vi.mock('./speech', () => ({ registerSpeechHandlers: registerSpeechHandlersMock })) @@ -417,6 +429,8 @@ describe('registerCoreHandlers', () => { registerGitLabHandlersMock.mockReset() registerHostedReviewHandlersMock.mockReset() registerExportHandlersMock.mockReset() + registerDashboardPopoutHandlersMock.mockReset() + registerTerminalPreviewHandlersMock.mockReset() registerSpeechHandlersMock.mockReset() registerSkillsHandlersMock.mockReset() registerWorkspaceSpaceHandlersMock.mockReset() @@ -493,6 +507,8 @@ describe('registerCoreHandlers', () => { expect(registerNotificationHandlersMock).toHaveBeenCalledWith(store, runtime) expect(registerDeveloperPermissionHandlersMock).toHaveBeenCalled() expect(registerComputerUsePermissionHandlersMock).toHaveBeenCalled() + expect(registerDashboardPopoutHandlersMock).toHaveBeenCalledWith(store) + expect(registerTerminalPreviewHandlersMock).toHaveBeenCalledWith(runtime) expect(registerSettingsHandlersMock).toHaveBeenCalledWith(store, agentAwakeService) expect(registerSkillsHandlersMock).toHaveBeenCalledWith(store) expect(registerWorkspaceSpaceHandlersMock).toHaveBeenCalledWith(store) diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index 9b951a62671..ec25e2779ff 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -30,6 +30,8 @@ import { registerNativeChatHandlers } from './native-chat' import { registerNotificationHandlers } from './notifications' import { registerNotebookHandlers } from './notebook' import { registerOnboardingHandlers } from './onboarding' +import { registerDashboardPopoutHandlers } from './dashboard-popout' +import { registerTerminalPreviewHandlers } from './terminal-preview' import { registerDeveloperPermissionHandlers } from './developer-permissions' import { registerComputerUsePermissionHandlers } from './computer-use-permissions' import { setTrustedBrowserRendererWebContentsId, setAgentBrowserBridgeRef } from './browser' @@ -155,6 +157,8 @@ export function registerCoreHandlers( registerNotificationHandlers(store, runtime) registerNotebookHandlers(store) registerOnboardingHandlers(store) + registerDashboardPopoutHandlers(store) + registerTerminalPreviewHandlers(runtime) registerDeveloperPermissionHandlers() // Why: diagnostics handlers are wired alongside telemetry but the two // lanes never share a code path — `ipc/diagnostics.ts` imports only from diff --git a/src/main/ipc/terminal-preview-output-stream.ts b/src/main/ipc/terminal-preview-output-stream.ts new file mode 100644 index 00000000000..1eb9699ecb3 --- /dev/null +++ b/src/main/ipc/terminal-preview-output-stream.ts @@ -0,0 +1,238 @@ +import type { WebContents } from 'electron' +import { iterateTerminalInputChunks } from '../../shared/terminal-input' +import type { TerminalPreviewDataPayload } from '../../shared/terminal-preview' + +const OUTPUT_BATCH_MS = 5 +export const TERMINAL_PREVIEW_OUTPUT_BATCH_MAX_BYTES = 64 * 1024 +const OUTPUT_IN_FLIGHT_MAX_BYTES = 512 * 1024 +const OUTPUT_PENDING_MAX_BYTES = 256 * 1024 +const INITIAL_PENDING_MAX_BYTES = 256 * 1024 + +export type TerminalPreviewOutputMeta = { + seq?: number + rawLength?: number + transformed?: boolean +} + +type PendingOutput = { data: string; bytes: number; meta?: TerminalPreviewOutputMeta } + +function outputAfterSnapshotSeq(output: PendingOutput, snapshotSeq?: number): string | null { + if ( + typeof snapshotSeq !== 'number' || + typeof output.meta?.seq !== 'number' || + typeof output.meta.rawLength !== 'number' + ) { + return output.data + } + if (output.meta.seq <= snapshotSeq) { + return null + } + const startSeq = output.meta.seq - output.meta.rawLength + if (startSeq >= snapshotSeq || output.meta.transformed === true) { + return output.data + } + return output.data.slice(snapshotSeq - startSeq) +} + +/** Owns one preview's bounded snapshot/live output queue and acknowledgements. */ +export class TerminalPreviewOutputStream { + private bufferingSnapshot = true + private initialPending: PendingOutput[] = [] + private initialPendingBytes = 0 + private initialPendingOverflowed = false + private batchChunks: string[] = [] + private batchBytes = 0 + private batchTimer: ReturnType | null = null + private inFlightBytes = 0 + private pendingBatches: { data: string; bytes: number }[] = [] + private pendingBatchBytes = 0 + private resyncPending = false + private awaitingReconnect = false + private unsubscribeData: () => void = () => {} + private isDisposed = false + + constructor( + readonly contents: WebContents, + readonly ptyId: string, + private readonly releaseRawView: () => void, + private readonly onDispose: (stream: TerminalPreviewOutputStream) => void + ) {} + + get disposed(): boolean { + return this.isDisposed + } + + setDataSubscription(unsubscribe: () => void): void { + this.unsubscribeData = unsubscribe + } + + dispose(): void { + if (this.isDisposed) { + return + } + this.isDisposed = true + if (this.batchTimer) { + clearTimeout(this.batchTimer) + this.batchTimer = null + } + this.unsubscribeData() + this.releaseRawView() + this.onDispose(this) + } + + append(data: string, meta?: TerminalPreviewOutputMeta): void { + if (this.bufferingSnapshot) { + this.appendInitial(data, meta) + } else { + this.appendLive(data) + } + } + + consumeInitialOverflow(): boolean { + if (!this.initialPendingOverflowed) { + return false + } + this.initialPending = [] + this.initialPendingBytes = 0 + this.initialPendingOverflowed = false + return true + } + + completeSnapshot(snapshotSeq?: number): string[] { + const replay = this.initialPending.flatMap((output) => { + const uncovered = outputAfterSnapshotSeq(output, snapshotSeq) + return uncovered ? [uncovered] : [] + }) + this.initialPending = [] + this.initialPendingBytes = 0 + this.bufferingSnapshot = false + return replay + } + + pauseForReconnect(): void { + if (this.batchTimer) { + clearTimeout(this.batchTimer) + this.batchTimer = null + } + this.batchChunks = [] + this.batchBytes = 0 + this.pendingBatches = [] + this.pendingBatchBytes = 0 + this.awaitingReconnect = true + } + + acknowledge(bytes: number): void { + this.inFlightBytes = Math.max( + 0, + this.inFlightBytes - Math.min(this.inFlightBytes, Math.floor(bytes)) + ) + this.maybeDrain() + } + + private send(payload: TerminalPreviewDataPayload): boolean { + if (this.isDisposed || this.contents.isDestroyed()) { + this.dispose() + return false + } + try { + this.contents.send('terminalPreview:data', payload) + return true + } catch { + this.dispose() + return false + } + } + + private maybeDrain(): void { + if (this.isDisposed || this.awaitingReconnect) { + return + } + if (this.resyncPending) { + if (this.inFlightBytes === 0) { + this.resyncPending = false + this.awaitingReconnect = true + this.send({ type: 'resync', ptyId: this.ptyId }) + } + return + } + while (this.pendingBatches.length > 0) { + const next = this.pendingBatches[0]! + if (this.inFlightBytes > 0 && this.inFlightBytes + next.bytes > OUTPUT_IN_FLIGHT_MAX_BYTES) { + break + } + this.pendingBatches.shift() + this.pendingBatchBytes -= next.bytes + if (this.send({ type: 'data', ptyId: this.ptyId, data: next.data, bytes: next.bytes })) { + this.inFlightBytes += next.bytes + } + } + } + + private queueBatch(data: string, bytes: number): void { + if (this.isDisposed || this.awaitingReconnect || this.resyncPending) { + return + } + if ( + this.pendingBatches.length === 0 && + (this.inFlightBytes === 0 || this.inFlightBytes + bytes <= OUTPUT_IN_FLIGHT_MAX_BYTES) + ) { + if (this.send({ type: 'data', ptyId: this.ptyId, data, bytes })) { + this.inFlightBytes += bytes + } + return + } + this.pendingBatches.push({ data, bytes }) + this.pendingBatchBytes += bytes + if (this.pendingBatchBytes > OUTPUT_PENDING_MAX_BYTES) { + // Why: a stuck renderer heals from a fresh authoritative snapshot instead of retaining output without bound. + this.pendingBatches = [] + this.pendingBatchBytes = 0 + this.resyncPending = true + } + } + + private flushBatch(): void { + if (this.batchTimer) { + clearTimeout(this.batchTimer) + this.batchTimer = null + } + if (this.batchChunks.length === 0) { + return + } + const data = this.batchChunks.length === 1 ? this.batchChunks[0]! : this.batchChunks.join('') + const bytes = this.batchBytes + this.batchChunks = [] + this.batchBytes = 0 + this.queueBatch(data, bytes) + } + + private appendLive(data: string): void { + for (const chunk of iterateTerminalInputChunks(data, TERMINAL_PREVIEW_OUTPUT_BATCH_MAX_BYTES)) { + const bytes = Buffer.byteLength(chunk, 'utf8') + if ( + this.batchBytes > 0 && + this.batchBytes + bytes > TERMINAL_PREVIEW_OUTPUT_BATCH_MAX_BYTES + ) { + this.flushBatch() + } + this.batchChunks.push(chunk) + this.batchBytes += bytes + if (this.batchBytes >= TERMINAL_PREVIEW_OUTPUT_BATCH_MAX_BYTES) { + this.flushBatch() + } else if (!this.batchTimer) { + this.batchTimer = setTimeout(() => this.flushBatch(), OUTPUT_BATCH_MS) + this.batchTimer.unref?.() + } + } + } + + private appendInitial(data: string, meta?: TerminalPreviewOutputMeta): void { + const bytes = Buffer.byteLength(data, 'utf8') + this.initialPending.push({ data, bytes, meta }) + this.initialPendingBytes += bytes + while (this.initialPendingBytes > INITIAL_PENDING_MAX_BYTES && this.initialPending.length > 0) { + this.initialPendingBytes -= this.initialPending.shift()!.bytes + this.initialPendingOverflowed = true + } + } +} diff --git a/src/main/ipc/terminal-preview.test.ts b/src/main/ipc/terminal-preview.test.ts new file mode 100644 index 00000000000..cb5cf50d626 --- /dev/null +++ b/src/main/ipc/terminal-preview.test.ts @@ -0,0 +1,293 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { handlers, ipcMainMock, isDashboardPopoutRendererMock } = vi.hoisted(() => { + const map = new Map unknown>() + return { + handlers: map, + ipcMainMock: { + removeHandler: vi.fn(), + handle: (channel: string, fn: (...args: unknown[]) => unknown) => map.set(channel, fn) + }, + isDashboardPopoutRendererMock: vi.fn(() => true) + } +}) + +vi.mock('electron', () => ({ ipcMain: ipcMainMock })) +vi.mock('../window/dashboard-popout-window', () => ({ + isDashboardPopoutRenderer: isDashboardPopoutRendererMock +})) + +import { registerTerminalPreviewHandlers } from './terminal-preview' + +type OutputMeta = { seq?: number; rawLength?: number; transformed?: boolean } +type Listener = (data: string, meta?: OutputMeta) => void + +function makeRuntime() { + const listeners: Listener[] = [] + const unsubscribe = vi.fn() + const releaseRawView = vi.fn() + return { + listeners, + unsubscribe, + releaseRawView, + serializeTerminalBuffer: vi.fn( + async (): Promise<{ data: string; cols: number; rows: number; seq: number } | null> => ({ + data: 'screen', + cols: 80, + rows: 20, + seq: 5 + }) + ), + subscribeToTerminalData: vi.fn((_ptyId: string, listener: Listener) => { + listeners.push(listener) + return unsubscribe + }), + registerRawTerminalViewSubscriber: vi.fn(() => releaseRawView), + writeTerminalPreviewInput: vi.fn(async () => true) + } +} + +function makeSender(id = 1) { + const destroyedListeners: (() => void)[] = [] + return { + id, + isDestroyed: () => false, + send: vi.fn(), + once: (event: string, cb: () => void) => { + if (event === 'destroyed') { + destroyedListeners.push(cb) + } + }, + fireDestroyed: () => destroyedListeners.forEach((cb) => cb()) + } +} + +function eventFor(sender: ReturnType) { + return { sender } as never +} + +describe('registerTerminalPreviewHandlers', () => { + beforeEach(() => { + handlers.clear() + isDashboardPopoutRendererMock.mockReturnValue(true) + }) + afterEach(() => { + vi.clearAllMocks() + vi.useRealTimers() + }) + + it('subscribes before snapshot acquisition and replays only bytes after its sequence', async () => { + const runtime = makeRuntime() + let resolveSnapshot!: (snapshot: { + data: string + cols: number + rows: number + seq: number + }) => void + runtime.serializeTerminalBuffer.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSnapshot = resolve + }) + ) + registerTerminalPreviewHandlers(runtime as never) + const sender = makeSender() + + const resultPromise = handlers.get('terminalPreview:connect')!(eventFor(sender), { + ptyId: 'p1', + opts: { scrollbackRows: 24 } + }) as Promise + + expect(runtime.subscribeToTerminalData).toHaveBeenCalledBefore(runtime.serializeTerminalBuffer) + runtime.listeners[0]!('abc', { seq: 7, rawLength: 3 }) + resolveSnapshot({ data: 'screen', cols: 80, rows: 20, seq: 6 }) + + await expect(resultPromise).resolves.toEqual({ + snapshot: { data: 'screen', cols: 80, rows: 20, seq: 6 }, + replay: ['c'] + }) + expect(runtime.registerRawTerminalViewSubscriber).toHaveBeenCalledWith('p1') + }) + + it('coalesces live chunks before crossing IPC and releases them with acknowledgements', async () => { + vi.useFakeTimers() + const runtime = makeRuntime() + registerTerminalPreviewHandlers(runtime as never) + const sender = makeSender() + await handlers.get('terminalPreview:connect')!(eventFor(sender), { ptyId: 'p1' }) + + runtime.listeners[0]!('a') + runtime.listeners[0]!('b') + expect(sender.send).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(5) + expect(sender.send).toHaveBeenCalledTimes(1) + expect(sender.send).toHaveBeenCalledWith('terminalPreview:data', { + type: 'data', + ptyId: 'p1', + data: 'ab', + bytes: 2 + }) + + handlers.get('terminalPreview:ack')!(eventFor(sender), { ptyId: 'p1', bytes: 2 }) + expect(sender.send).toHaveBeenCalledTimes(1) + }) + + it('bounds an unresponsive renderer and requests a snapshot resync after prior writes drain', async () => { + const runtime = makeRuntime() + registerTerminalPreviewHandlers(runtime as never) + const sender = makeSender() + await handlers.get('terminalPreview:connect')!(eventFor(sender), { ptyId: 'p1' }) + + const chunk = 'x'.repeat(64 * 1024) + for (let index = 0; index < 13; index++) { + runtime.listeners[0]!(chunk) + } + const dataSends = sender.send.mock.calls.filter(([, payload]) => payload.type === 'data') + expect(dataSends).toHaveLength(8) + + for (let index = 0; index < 8; index++) { + handlers.get('terminalPreview:ack')!(eventFor(sender), { + ptyId: 'p1', + bytes: 64 * 1024 + }) + } + expect(sender.send).toHaveBeenLastCalledWith('terminalPreview:data', { + type: 'resync', + ptyId: 'p1' + }) + }) + + it('never overshoots the in-flight byte budget with partial batches', async () => { + vi.useFakeTimers() + const runtime = makeRuntime() + registerTerminalPreviewHandlers(runtime as never) + const sender = makeSender() + await handlers.get('terminalPreview:connect')!(eventFor(sender), { ptyId: 'p1' }) + + const fullChunk = 'x'.repeat(64 * 1024) + for (let index = 0; index < 7; index++) { + runtime.listeners[0]!(fullChunk) + } + runtime.listeners[0]!('a'.repeat(40 * 1024)) + await vi.advanceTimersByTimeAsync(5) + runtime.listeners[0]!('b'.repeat(40 * 1024)) + await vi.advanceTimersByTimeAsync(5) + expect(sender.send).toHaveBeenCalledTimes(8) + + handlers.get('terminalPreview:ack')!(eventFor(sender), { + ptyId: 'p1', + bytes: 64 * 1024 + }) + expect(sender.send).toHaveBeenCalledTimes(9) + }) + + it('fails safe to another resync when output overflows both snapshot captures', async () => { + const runtime = makeRuntime() + const snapshotResolvers: ((snapshot: { + data: string + cols: number + rows: number + seq: number + }) => void)[] = [] + runtime.serializeTerminalBuffer.mockImplementation( + () => + new Promise((resolve) => { + snapshotResolvers.push(resolve) + }) + ) + registerTerminalPreviewHandlers(runtime as never) + const sender = makeSender() + + const resultPromise = handlers.get('terminalPreview:connect')!(eventFor(sender), { + ptyId: 'p1' + }) as Promise + const chunk = 'x'.repeat(64 * 1024) + for (let index = 0; index < 5; index++) { + runtime.listeners[0]!(chunk) + } + snapshotResolvers[0]!({ data: 'first', cols: 80, rows: 20, seq: 1 }) + await vi.waitFor(() => expect(snapshotResolvers).toHaveLength(2)) + for (let index = 0; index < 5; index++) { + runtime.listeners[0]!(chunk) + } + snapshotResolvers[1]!({ data: 'second', cols: 80, rows: 20, seq: 2 }) + + await expect(resultPromise).resolves.toEqual({ + snapshot: { data: 'second', cols: 80, rows: 20, seq: 2 }, + replay: [], + resyncRequired: true + }) + runtime.listeners[0]!('held until reconnect') + expect(sender.send).not.toHaveBeenCalled() + }) + + it('releases output and raw-view presence on unsubscribe and sender destruction', async () => { + const runtime = makeRuntime() + registerTerminalPreviewHandlers(runtime as never) + const sender = makeSender() + await handlers.get('terminalPreview:connect')!(eventFor(sender), { ptyId: 'p1' }) + handlers.get('terminalPreview:unsubscribe')!(eventFor(sender), { ptyId: 'p1' }) + expect(runtime.unsubscribe).toHaveBeenCalledTimes(1) + expect(runtime.releaseRawView).toHaveBeenCalledTimes(1) + + await handlers.get('terminalPreview:connect')!(eventFor(sender), { ptyId: 'p2' }) + sender.fireDestroyed() + expect(runtime.unsubscribe).toHaveBeenCalledTimes(2) + expect(runtime.releaseRawView).toHaveBeenCalledTimes(2) + }) + + it('releases output and raw-view presence when no snapshot exists', async () => { + const runtime = makeRuntime() + runtime.serializeTerminalBuffer.mockResolvedValueOnce(null) + registerTerminalPreviewHandlers(runtime as never) + const sender = makeSender() + + await expect( + handlers.get('terminalPreview:connect')!(eventFor(sender), { ptyId: 'missing' }) + ).resolves.toEqual({ snapshot: null, replay: [] }) + expect(runtime.unsubscribe).toHaveBeenCalledTimes(1) + expect(runtime.releaseRawView).toHaveBeenCalledTimes(1) + }) + + it('rejects non-dashboard senders on every preview channel', async () => { + const runtime = makeRuntime() + registerTerminalPreviewHandlers(runtime as never) + const sender = makeSender() + isDashboardPopoutRendererMock.mockReturnValue(false) + + await expect( + handlers.get('terminalPreview:connect')!(eventFor(sender), { ptyId: 'p1' }) + ).resolves.toEqual({ snapshot: null, replay: [] }) + await expect( + handlers.get('terminalPreview:input')!(eventFor(sender), { ptyId: 'p1', data: 'x' }) + ).resolves.toBe(false) + handlers.get('terminalPreview:ack')!(eventFor(sender), { ptyId: 'p1', bytes: 1 }) + handlers.get('terminalPreview:unsubscribe')!(eventFor(sender), { ptyId: 'p1' }) + + expect(runtime.serializeTerminalBuffer).not.toHaveBeenCalled() + expect(runtime.subscribeToTerminalData).not.toHaveBeenCalled() + expect(runtime.writeTerminalPreviewInput).not.toHaveBeenCalled() + }) + + it('validates input before routing it to the runtime', async () => { + const runtime = makeRuntime() + registerTerminalPreviewHandlers(runtime as never) + const sender = makeSender() + + await expect( + handlers.get('terminalPreview:input')!(eventFor(sender), { ptyId: 'p1', data: 'ls\r' }) + ).resolves.toBe(true) + expect(runtime.writeTerminalPreviewInput).toHaveBeenCalledWith('p1', 'ls\r') + + await expect( + handlers.get('terminalPreview:input')!(eventFor(sender), { ptyId: '', data: 'x' }) + ).resolves.toBe(false) + await expect( + handlers.get('terminalPreview:input')!(eventFor(sender), { + ptyId: 'x'.repeat(4097), + data: 'x' + }) + ).resolves.toBe(false) + expect(runtime.writeTerminalPreviewInput).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/ipc/terminal-preview.ts b/src/main/ipc/terminal-preview.ts new file mode 100644 index 00000000000..3df3c472695 --- /dev/null +++ b/src/main/ipc/terminal-preview.ts @@ -0,0 +1,155 @@ +import { ipcMain, type WebContents } from 'electron' +import type { + TerminalPreviewConnectResult, + TerminalPreviewSnapshot +} from '../../shared/terminal-preview' +import type { OrcaRuntimeService } from '../runtime/orca-runtime' +import { isDashboardPopoutRenderer } from '../window/dashboard-popout-window' +import { + TERMINAL_PREVIEW_OUTPUT_BATCH_MAX_BYTES, + TerminalPreviewOutputStream +} from './terminal-preview-output-stream' + +const PREVIEW_ID_MAX_LENGTH = 4096 + +function isValidPtyId(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 && value.length <= PREVIEW_ID_MAX_LENGTH +} + +/** Pop-out terminal transport with an atomic snapshot/live boundary. */ +export function registerTerminalPreviewHandlers(runtime: OrcaRuntimeService): void { + ipcMain.removeHandler('terminalPreview:connect') + ipcMain.removeHandler('terminalPreview:unsubscribe') + ipcMain.removeHandler('terminalPreview:input') + ipcMain.removeHandler('terminalPreview:ack') + + const subscriptionsByContents = new Map>() + + const removeSubscription = (subscription: TerminalPreviewOutputStream): void => { + const perPty = subscriptionsByContents.get(subscription.contents.id) + if (perPty?.get(subscription.ptyId) === subscription) { + perPty.delete(subscription.ptyId) + } + } + + const disposeContents = (contentsId: number): void => { + const perPty = subscriptionsByContents.get(contentsId) + if (!perPty) { + return + } + for (const subscription of perPty.values()) { + subscription.dispose() + } + subscriptionsByContents.delete(contentsId) + } + + const subscriptionsFor = (contents: WebContents): Map => { + let perPty = subscriptionsByContents.get(contents.id) + if (!perPty) { + perPty = new Map() + subscriptionsByContents.set(contents.id, perPty) + contents.once('destroyed', () => disposeContents(contents.id)) + } + return perPty + } + + ipcMain.handle( + 'terminalPreview:connect', + async ( + event, + args: { ptyId?: unknown; opts?: { scrollbackRows?: unknown } } + ): Promise => { + if (!isDashboardPopoutRenderer(event.sender) || !isValidPtyId(args?.ptyId)) { + return { snapshot: null, replay: [] } + } + const ptyId = args.ptyId + const perPty = subscriptionsFor(event.sender) + perPty.get(ptyId)?.dispose() + + const subscription = new TerminalPreviewOutputStream( + event.sender, + ptyId, + runtime.registerRawTerminalViewSubscriber(ptyId), + removeSubscription + ) + subscription.setDataSubscription( + runtime.subscribeToTerminalData(ptyId, (data, meta) => subscription.append(data, meta)) + ) + perPty.set(ptyId, subscription) + + const requestedRows = args.opts?.scrollbackRows + const scrollbackRows = + typeof requestedRows === 'number' && Number.isFinite(requestedRows) + ? Math.max(0, Math.min(1000, Math.floor(requestedRows))) + : undefined + let snapshot: TerminalPreviewSnapshot | null + let resyncRequired = false + try { + snapshot = await runtime.serializeTerminalBuffer(ptyId, { scrollbackRows }) + if (subscription.consumeInitialOverflow() && !subscription.disposed) { + snapshot = await runtime.serializeTerminalBuffer(ptyId, { scrollbackRows }) + if (subscription.consumeInitialOverflow()) { + // Why: never replay a tail with a silently missing middle; the renderer keeps its old frame while reconnecting. + resyncRequired = true + } + } + } catch { + subscription.dispose() + return { snapshot: null, replay: [] } + } + if (subscription.disposed) { + return { snapshot: null, replay: [] } + } + if (!snapshot) { + // Why: a failed lookup has no future live boundary; release raw presence even if the renderer never invokes unsubscribe. + subscription.dispose() + return { snapshot: null, replay: [] } + } + + const replay = subscription.completeSnapshot(snapshot.seq) + if (resyncRequired) { + // Why: no live writes may outlive this stream and acknowledge bytes against its replacement. + subscription.pauseForReconnect() + } + return { snapshot, replay, ...(resyncRequired ? { resyncRequired: true } : {}) } + } + ) + + ipcMain.handle( + 'terminalPreview:input', + (event, args: { ptyId?: unknown; data?: unknown }): Promise => { + if ( + !isDashboardPopoutRenderer(event.sender) || + !isValidPtyId(args?.ptyId) || + typeof args.data !== 'string' + ) { + return Promise.resolve(false) + } + return runtime.writeTerminalPreviewInput(args.ptyId, args.data) + } + ) + + ipcMain.handle( + 'terminalPreview:ack', + (event, args: { ptyId?: unknown; bytes?: unknown }): void => { + if ( + !isDashboardPopoutRenderer(event.sender) || + !isValidPtyId(args?.ptyId) || + typeof args.bytes !== 'number' || + !Number.isFinite(args.bytes) || + args.bytes <= 0 || + args.bytes > TERMINAL_PREVIEW_OUTPUT_BATCH_MAX_BYTES + ) { + return + } + subscriptionsByContents.get(event.sender.id)?.get(args.ptyId)?.acknowledge(args.bytes) + } + ) + + ipcMain.handle('terminalPreview:unsubscribe', (event, args: { ptyId?: unknown }): void => { + if (!isDashboardPopoutRenderer(event.sender) || !isValidPtyId(args?.ptyId)) { + return + } + subscriptionsByContents.get(event.sender.id)?.get(args.ptyId)?.dispose() + }) +} diff --git a/src/main/ipc/ui.test.ts b/src/main/ipc/ui.test.ts index 806d0fa5ab3..398efa8065a 100644 --- a/src/main/ipc/ui.test.ts +++ b/src/main/ipc/ui.test.ts @@ -36,6 +36,7 @@ vi.mock('electron', () => ({ import { clearTrustedUIRendererWebContentsId, + getTrustedUIRendererWindow, registerUIHandlers, sendToTrustedUIRenderer, setTrustedUIRendererWebContentsId @@ -112,6 +113,20 @@ describe('UI IPC', () => { expect(guestSends.reduce((total, send) => total + send.mock.calls.length, 0)).toBe(0) }) + it('resolves only the BrowserWindow that owns the trusted renderer', () => { + const renderer = { id: 17, isDestroyed: () => false } + const mainWindow = { id: 'main' } + fromIdMock.mockReturnValue(renderer) + fromWebContentsMock.mockReturnValue(mainWindow) + setTrustedUIRendererWebContentsId(17) + + expect(getTrustedUIRendererWindow()).toBe(mainWindow) + expect(fromWebContentsMock).toHaveBeenCalledWith(renderer) + + fromIdMock.mockReturnValue({ id: 17, isDestroyed: () => true }) + expect(getTrustedUIRendererWindow()).toBeNull() + }) + it('skips missing, destroyed, and originating renderers', () => { const rendererSend = vi.fn() setTrustedUIRendererWebContentsId(17) diff --git a/src/main/ipc/ui.ts b/src/main/ipc/ui.ts index de6f26136d5..83744b0404b 100644 --- a/src/main/ipc/ui.ts +++ b/src/main/ipc/ui.ts @@ -20,17 +20,28 @@ export function sendToTrustedUIRenderer( payload: unknown, excludedWebContentsId?: number ): void { + const renderer = getTrustedUIRendererWebContents(excludedWebContentsId) + renderer?.send(channel, payload) +} + +export function getTrustedUIRendererWebContents( + excludedWebContentsId?: number +): WebContents | null { + // Why: exact targeting avoids waking retained browser/utility windows that cannot consume app UI events. const rendererId = trustedUIRendererWebContentsId if (rendererId == null || rendererId === excludedWebContentsId) { - return + return null } const renderer = webContents.fromId(rendererId) if (!renderer || renderer.isDestroyed()) { - return + return null } - // Why: app events belong to the registered UI renderer; enumerating every - // WebContents wakes retained browser guests that cannot consume the channel. - renderer.send(channel, payload) + return renderer +} + +export function getTrustedUIRendererWindow(): BrowserWindow | null { + const renderer = getTrustedUIRendererWebContents() + return renderer ? BrowserWindow.fromWebContents(renderer) : null } export function registerUIHandlers(store: Store): void { diff --git a/src/main/runtime/mobile-presence-lock.test.ts b/src/main/runtime/mobile-presence-lock.test.ts index 87568e3e9d7..517c038bd70 100644 --- a/src/main/runtime/mobile-presence-lock.test.ts +++ b/src/main/runtime/mobile-presence-lock.test.ts @@ -71,12 +71,16 @@ function createRuntime(mobileAutoRestoreFitMs: number | null = 5_000) { ['pty-1', { cols: 150, rows: 40 }] ]) const resizes: { ptyId: string; cols: number; rows: number }[] = [] + const writes: string[] = [] const driverEvents: { ptyId: string; driver: { kind: string; clientId?: string } }[] = [] const fitOverrideEvents: { ptyId: string; mode: string; cols: number; rows: number }[] = [] let resizeSucceeds = true runtime.setPtyController({ - write: () => true, + write: (_ptyId, data) => { + writes.push(data) + return true + }, kill: () => true, getForegroundProcess: async () => null, resize: (ptyId, cols, rows) => { @@ -111,6 +115,7 @@ function createRuntime(mobileAutoRestoreFitMs: number | null = 5_000) { runtime, ptySizes, resizes, + writes, driverEvents, fitOverrideEvents, setResizeSucceeds: (next: boolean) => { @@ -128,6 +133,22 @@ describe('mobile presence lock — driver state machine', () => { expect(runtime.getDriver('pty-1')).toEqual({ kind: 'idle' }) }) + it('stops a preview paste when mobile claims the floor between chunks', async () => { + const { runtime, writes } = createRuntime() + let driverChecks = 0 + vi.spyOn(runtime, 'getDriver').mockImplementation(() => { + driverChecks++ + return driverChecks >= 3 ? { kind: 'mobile', clientId: 'phone-A' } : { kind: 'idle' } + }) + + const result = runtime.writeTerminalPreviewInput('pty-1', 'x'.repeat(32 * 1024)) + await vi.advanceTimersByTimeAsync(0) + + await expect(result).resolves.toBe(false) + expect(writes).toHaveLength(1) + expect(Buffer.byteLength(writes[0]!, 'utf8')).toBe(16 * 1024) + }) + it('handleMobileSubscribe in auto mode transitions idle → mobile{clientId}', async () => { const { runtime, driverEvents } = createRuntime() await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 6d6e66a8937..492d7abbb17 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -2488,6 +2488,9 @@ export class OrcaRuntimeService { // streams can attach concurrently; mobileSubscribers is consulted too so // grace-window mobile records keep suppressing. private remoteTerminalViewSubscriberCounts = new Map() + // Preview windows consume the raw stream but deliberately leave terminal + // query replies to main's headless emulator. + private rawTerminalViewSubscriberCounts = new Map() // Why: per-PTY driver state. The "driver" is whoever currently owns the // input/resize floor. While `kind === 'mobile'` the desktop renderer drops @@ -7263,6 +7266,37 @@ export class OrcaRuntimeService { } } + /** Mark a raw-output viewer without transferring terminal query authority. */ + registerRawTerminalViewSubscriber(ptyId: string): () => void { + this.rawTerminalViewSubscriberCounts.set( + ptyId, + (this.rawTerminalViewSubscriberCounts.get(ptyId) ?? 0) + 1 + ) + this.notifyRemoteTerminalViewPresenceChanged(ptyId) + let released = false + return () => { + if (released) { + return + } + released = true + const next = (this.rawTerminalViewSubscriberCounts.get(ptyId) ?? 1) - 1 + if (next <= 0) { + this.rawTerminalViewSubscriberCounts.delete(ptyId) + } else { + this.rawTerminalViewSubscriberCounts.set(ptyId, next) + } + this.notifyRemoteTerminalViewPresenceChanged(ptyId) + } + } + + /** Raw stream presence prevents provider thinning without changing reply ownership. */ + hasRawTerminalViewSubscriber(ptyId: string): boolean { + return ( + (this.rawTerminalViewSubscriberCounts.get(ptyId) ?? 0) > 0 || + this.hasRemoteTerminalViewSubscriber(ptyId) + ) + } + hasRemoteTerminalViewSubscriber(ptyId: string): boolean { if ((this.remoteTerminalViewSubscriberCounts.get(ptyId) ?? 0) > 0) { return true @@ -7344,6 +7378,28 @@ export class OrcaRuntimeService { return this.serializeTerminalBufferFromAvailableState(ptyId, opts) } + /** Raw keystroke pass-through for the pop-out dashboard's terminal preview. + * Honors the mobile-presence lock like the main window's pty:write path. */ + async writeTerminalPreviewInput(ptyId: string, data: string): Promise { + if (data.length === 0 || this.getDriver(ptyId).kind === 'mobile') { + return false + } + try { + await assertTerminalInputWithinLimitWithYield(data) + await this.writeTerminalInputChunks(ptyId, data, { + // Why: a phone can claim the floor while a paste yields between chunks. + beforeWrite: () => { + if (this.getDriver(ptyId).kind === 'mobile') { + throw new Error('terminal_mobile_driver_active') + } + } + }) + return true + } catch { + return false + } + } + hasHeadlessTerminalState(ptyId: string): boolean { return this.headlessTerminals.has(ptyId) } @@ -9284,6 +9340,7 @@ export class OrcaRuntimeService { // Clean up new mobile state for this PTY this.mobileSubscribers.delete(ptyId) this.remoteTerminalViewSubscriberCounts.delete(ptyId) + this.rawTerminalViewSubscriberCounts.delete(ptyId) this.mobileDisplayModes.delete(ptyId) this.resizeListeners.delete(ptyId) this.lastRendererSizes.delete(ptyId) diff --git a/src/main/runtime/terminal-query-responder.test.ts b/src/main/runtime/terminal-query-responder.test.ts index 896fa59b060..cdcd10dfbc3 100644 --- a/src/main/runtime/terminal-query-responder.test.ts +++ b/src/main/runtime/terminal-query-responder.test.ts @@ -247,6 +247,21 @@ describe('reply ownership matrix', () => { expect(runtime.hasRemoteTerminalViewSubscriber('pty-m')).toBe(false) }) + it('keeps raw preview presence separate from terminal query authority', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-preview') + const release = runtime.registerRawTerminalViewSubscriber('pty-preview') + + expect(runtime.hasRawTerminalViewSubscriber('pty-preview')).toBe(true) + expect(runtime.hasRemoteTerminalViewSubscriber('pty-preview')).toBe(false) + runtime.onPtyData('pty-preview', DA1, Date.now()) + await settle(runtime, 'pty-preview') + expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?1;2c']) + + release() + expect(runtime.hasRawTerminalViewSubscriber('pty-preview')).toBe(false) + }) + it('treats mobile subscriber records as remote view subscribers', async () => { const { runtime } = createResponderRuntime() await runtime.handleMobileSubscribe('pty-mob', 'client-1', { cols: 40, rows: 20 }) diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index 3be600cb56b..e35cd24d71a 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -7,8 +7,7 @@ import { nativeTheme, Notification, powerMonitor, - screen, - shell + screen } from 'electron' import { join } from 'node:path' import { is } from '@electron-toolkit/utils' @@ -17,10 +16,7 @@ import { getAppIconPath } from '../app-icon' import { browserManager } from '../browser/browser-manager' import { browserSessionRegistry } from '../browser/browser-session-registry' import { translateMain } from '../i18n/main-i18n' -import { - normalizeBrowserNavigationUrl, - normalizeExternalBrowserUrl -} from '../../shared/browser-url' +import { normalizeBrowserNavigationUrl } from '../../shared/browser-url' import { ORCA_BROWSER_GUEST_WEB_PREFERENCES } from '../../shared/browser-guest-web-preferences' import { isCrashReportReason } from '../../shared/crash-reporting' import { @@ -49,6 +45,9 @@ import { getMainE2EConfig } from '../e2e-config' import { buildEditableContextMenuTemplate } from './editable-context-menu' import { clearTrustedUIRendererWebContentsId, setTrustedUIRendererWebContentsId } from '../ipc/ui' import { resolveWindowCloseAction } from './window-close-decision' +import { rectHasVisibleAreaOnAnyDisplay } from './window-bounds-validation' +import { closeDashboardPopout } from './dashboard-popout-window' +import { installPrivilegedWindowNavigationPolicy } from './privileged-window-navigation' // Why: show/restore/resume can overlap before the size nudge resets; never capture the temporary width as the next baseline. const activeRepaintJiggles = new WeakSet() @@ -173,34 +172,12 @@ export function createMainWindow( opts?: CreateMainWindowOptions ): BrowserWindow { const rawSavedBounds = store?.getUI().windowBounds - // Why: discard persisted bounds that are shrink-to-min (corrupt, see freezeBoundsOnQuit) or off-screen, falling back to defaultBounds. - // Require ~half MIN_WIDTH/HEIGHT of workArea overlap so a 1px sliver or dock-hidden rect isn't treated as visible. - const rectHasVisibleAreaOnAnyDisplay = (b: { - x: number - y: number - width: number - height: number - }): boolean => { - try { - return screen.getAllDisplays().some((d) => { - const wa = d.workArea - const overlapX = Math.max(0, Math.min(b.x + b.width, wa.x + wa.width) - Math.max(b.x, wa.x)) - const overlapY = Math.max( - 0, - Math.min(b.y + b.height, wa.y + wa.height) - Math.max(b.y, wa.y) - ) - return overlapX >= MIN_WIDTH / 2 && overlapY >= MIN_HEIGHT / 2 - }) - } catch (err) { - console.warn('[window] screen.getAllDisplays() threw; treating bounds as off-screen', err) - return false - } - } + // Why: reject min-size or substantially off-screen bounds so the titlebar stays reachable after display changes. const savedBounds = rawSavedBounds && rawSavedBounds.width > MIN_WIDTH && rawSavedBounds.height > MIN_HEIGHT && - rectHasVisibleAreaOnAnyDisplay(rawSavedBounds) + rectHasVisibleAreaOnAnyDisplay(rawSavedBounds, MIN_WIDTH / 2, MIN_HEIGHT / 2) ? rawSavedBounds : undefined if (rawSavedBounds && !savedBounds) { @@ -417,13 +394,7 @@ export function createMainWindow( mainWindow.webContents.send('window:fullscreen-changed', false) }) - mainWindow.webContents.setWindowOpenHandler((details) => { - const externalUrl = normalizeExternalBrowserUrl(details.url) - if (externalUrl) { - shell.openExternal(externalUrl) - } - return { action: 'deny' } - }) + installPrivilegedWindowNavigationPolicy(mainWindow.webContents) mainWindow.webContents.on('will-attach-webview', (event, webPreferences, params) => { const src = typeof params.src === 'string' ? params.src : '' @@ -458,29 +429,6 @@ export function createMainWindow( browserManager.attachGuestPolicies(guest) }) - // Why: block in-window navigations so remote pages can't inherit the privileged preload bridge (dev server allowed for HMR). - mainWindow.webContents.on('will-navigate', (event, url) => { - const externalUrl = normalizeExternalBrowserUrl(url) - - if (externalUrl) { - const target = new URL(externalUrl) - if (is.dev && process.env.ELECTRON_RENDERER_URL) { - try { - const allowed = new URL(process.env.ELECTRON_RENDERER_URL) - if (target.origin === allowed.origin) { - return // allow dev server navigations (HMR, etc.) - } - } catch { - // fall through to prevent - } - } - - shell.openExternal(externalUrl) - } - - event.preventDefault() - }) - // Why: mirror markdown-editor focus so before-input-event skips Cmd/Ctrl+B while TipTap owns focus (docs/markdown-cmd-b-bold-design.md). let markdownEditorFocused = false let terminalInputFocused = false @@ -1033,6 +981,10 @@ export function createMainWindow( ipcMain.on(confirmCloseChannel, onConfirmClose) mainWindow.on('closed', () => { + // Why: the dashboard pop-out is a companion of the main window — close it + // alongside so it never orphans as a lone window after the app window is + // gone (e.g. on macOS where the app stays alive after the window closes). + closeDashboardPopout() clearInitialRevealFallbackTimer() // Why: default-deny the Cmd+B carve-out after the window is gone so a stale-true flag can't leak into later state. markdownEditorFocused = false diff --git a/src/main/window/dashboard-popout-window.test.ts b/src/main/window/dashboard-popout-window.test.ts new file mode 100644 index 00000000000..79396e1c728 --- /dev/null +++ b/src/main/window/dashboard-popout-window.test.ts @@ -0,0 +1,270 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +type Display = { workArea: { x: number; y: number; width: number; height: number } } + +const { + instances, + BrowserWindowMock, + nativeThemeMock, + appOnMock, + appRemoveListenerMock, + getAllDisplaysMock, + installNavigationPolicyMock, + sendToTrustedUIRendererMock, + isMock +} = vi.hoisted(() => { + const created: FakeWindow[] = [] + + class FakeWindow { + options: Electron.BrowserWindowConstructorOptions + private handlers: Record void)[]> = {} + private onceHandlers: Record void)[]> = {} + destroyed = false + minimized = false + fullscreen = false + webContents = { id: created.length + 1, send: vi.fn(), isDestroyed: () => this.destroyed } + bounds = { x: 100, y: 100, width: 960, height: 720 } + focus = vi.fn() + show = vi.fn() + restore = vi.fn(() => { + this.minimized = false + }) + loadURL = vi.fn() + loadFile = vi.fn() + close = vi.fn(() => { + this.destroyed = true + this.emit('close') + this.emit('closed') + }) + + constructor(options: Electron.BrowserWindowConstructorOptions) { + this.options = options + created.push(this) + } + + on(event: string, cb: (...args: unknown[]) => void): this { + ;(this.handlers[event] ||= []).push(cb) + return this + } + + once(event: string, cb: (...args: unknown[]) => void): this { + ;(this.onceHandlers[event] ||= []).push(cb) + return this + } + + emit(event: string, ...args: unknown[]): void { + for (const cb of this.handlers[event] ?? []) { + cb(...args) + } + for (const cb of this.onceHandlers[event] ?? []) { + cb(...args) + } + } + + isDestroyed(): boolean { + return this.destroyed + } + isMinimized(): boolean { + return this.minimized + } + isFullScreen(): boolean { + return this.fullscreen + } + getBounds(): { x: number; y: number; width: number; height: number } { + return this.bounds + } + } + + return { + instances: created, + BrowserWindowMock: FakeWindow, + nativeThemeMock: { shouldUseDarkColors: true }, + appOnMock: vi.fn(), + appRemoveListenerMock: vi.fn(), + getAllDisplaysMock: vi.fn((): Display[] => [ + { workArea: { x: 0, y: 0, width: 1920, height: 1080 } } + ]), + installNavigationPolicyMock: vi.fn(), + sendToTrustedUIRendererMock: vi.fn(), + isMock: { dev: false } as { dev: boolean } + } +}) + +// Static helpers live on the constructor; broadcastPopoutOpenChanged uses +// BrowserWindow.getAllWindows(). Return the instances created in this test. +;(BrowserWindowMock as unknown as { getAllWindows: () => unknown[] }).getAllWindows = () => + instances + +vi.mock('electron', () => ({ + app: { on: appOnMock, removeListener: appRemoveListenerMock }, + BrowserWindow: BrowserWindowMock, + nativeTheme: nativeThemeMock, + screen: { getAllDisplays: getAllDisplaysMock } +})) + +vi.mock('@electron-toolkit/utils', () => ({ is: isMock })) +vi.mock('../ipc/ui', () => ({ sendToTrustedUIRenderer: sendToTrustedUIRendererMock })) +vi.mock('./privileged-window-navigation', () => ({ + installPrivilegedWindowNavigationPolicy: installNavigationPolicyMock +})) + +import { + createOrFocusDashboardPopout, + closeDashboardPopout, + isDashboardPopoutRenderer +} from './dashboard-popout-window' + +type FakeWindow = InstanceType + +function makeStore(ui: Record = {}): { + getUI: () => Record + updateUI: ReturnType +} { + return { getUI: () => ui, updateUI: vi.fn() } +} + +const RENDERER_URL = 'http://localhost:5173' + +describe('createOrFocusDashboardPopout', () => { + beforeEach(() => { + instances.length = 0 + isMock.dev = false + vi.stubEnv('ELECTRON_RENDERER_URL', '') + getAllDisplaysMock.mockReturnValue([{ workArea: { x: 0, y: 0, width: 1920, height: 1080 } }]) + }) + + afterEach(() => { + // Reset the module-level singleton between tests. + closeDashboardPopout() + vi.unstubAllEnvs() + vi.clearAllMocks() + }) + + it('creates a native-frame window with the shared preload and no webview surface', () => { + createOrFocusDashboardPopout(makeStore() as never) + + expect(instances).toHaveLength(1) + const opts = instances[0].options + expect(opts.title).toBe('Orca Agent Dashboard') + expect(opts.minWidth).toBe(480) + expect(opts.minHeight).toBe(360) + // Native frame: neither a custom titleBarStyle nor frame:false is set. + expect(opts.titleBarStyle).toBeUndefined() + expect(opts.frame).toBeUndefined() + expect(opts.backgroundColor).toBe('#0a0a0a') // dark theme mock + expect(opts.webPreferences?.sandbox).toBe(true) + expect(opts.webPreferences?.webviewTag).toBe(false) + expect(opts.webPreferences?.preload).toMatch(/preload[\\/]index\.js$/) + expect(installNavigationPolicyMock).toHaveBeenCalledWith(instances[0].webContents) + expect(sendToTrustedUIRendererMock).toHaveBeenCalledWith('dashboard:popoutOpenChanged', true) + }) + + it('shows the window on ready-to-show', () => { + createOrFocusDashboardPopout(makeStore() as never) + const win = instances[0] + expect(win.show).not.toHaveBeenCalled() + win.emit('ready-to-show') + expect(win.show).toHaveBeenCalledTimes(1) + }) + + it('loads the prod file entry with the requested view', () => { + createOrFocusDashboardPopout(makeStore() as never, 'kanban') + const win = instances[0] + expect(win.loadURL).not.toHaveBeenCalled() + expect(win.loadFile).toHaveBeenCalledTimes(1) + const [file, options] = win.loadFile.mock.calls[0] + expect(String(file)).toMatch(/renderer[\\/]popout\.html$/) + expect(options).toEqual({ search: 'view=kanban' }) + }) + + it('loads the dev server URL with the requested view when in dev', () => { + isMock.dev = true + vi.stubEnv('ELECTRON_RENDERER_URL', RENDERER_URL) + createOrFocusDashboardPopout(makeStore() as never, 'kanban') + const win = instances[0] + expect(win.loadFile).not.toHaveBeenCalled() + expect(win.loadURL).toHaveBeenCalledWith(`${RENDERER_URL}/popout.html?view=kanban`) + }) + + it('focuses the existing window instead of creating a second one', () => { + const store = makeStore() + const first = createOrFocusDashboardPopout(store as never) + const second = createOrFocusDashboardPopout(store as never) + expect(instances).toHaveLength(1) + expect(second).toBe(first) + expect(instances[0].focus).toHaveBeenCalledTimes(1) + }) + + it('trusts only the live popout webContents', () => { + const win = createOrFocusDashboardPopout(makeStore() as never) as unknown as FakeWindow + expect(isDashboardPopoutRenderer(win.webContents as never)).toBe(true) + expect(isDashboardPopoutRenderer({ id: win.webContents.id } as never)).toBe(false) + win.destroyed = true + expect(isDashboardPopoutRenderer(win.webContents as never)).toBe(false) + }) + + it('restores a minimized window when re-requested', () => { + const store = makeStore() + const win = createOrFocusDashboardPopout(store as never) as unknown as FakeWindow + win.minimized = true + createOrFocusDashboardPopout(store as never) + expect(win.restore).toHaveBeenCalledTimes(1) + expect(win.focus).toHaveBeenCalledTimes(1) + }) + + it('restores valid persisted bounds', () => { + const store = makeStore({ + dashboardPopoutBounds: { x: 200, y: 150, width: 1000, height: 800 } + }) + createOrFocusDashboardPopout(store as never) + const opts = instances[0].options + expect(opts.x).toBe(200) + expect(opts.y).toBe(150) + expect(opts.width).toBe(1000) + expect(opts.height).toBe(800) + }) + + it('discards off-screen persisted bounds and falls back to defaults', () => { + // Display does not overlap the saved rect at all. + getAllDisplaysMock.mockReturnValue([{ workArea: { x: 0, y: 0, width: 800, height: 600 } }]) + const store = makeStore({ + dashboardPopoutBounds: { x: 5000, y: 5000, width: 1000, height: 800 } + }) + createOrFocusDashboardPopout(store as never) + const opts = instances[0].options + expect(opts.x).toBeUndefined() + expect(opts.y).toBeUndefined() + expect(opts.width).toBe(960) + expect(opts.height).toBe(720) + }) + + it('persists bounds on resize after the debounce, guarding near-minimum sizes', () => { + vi.useFakeTimers() + try { + const store = makeStore() + const win = createOrFocusDashboardPopout(store as never) as unknown as FakeWindow + + win.bounds = { x: 10, y: 10, width: 1200, height: 900 } + win.emit('resize') + vi.advanceTimersByTime(500) + expect(store.updateUI).toHaveBeenCalledWith({ + dashboardPopoutBounds: { x: 10, y: 10, width: 1200, height: 900 } + }) + + store.updateUI.mockClear() + win.bounds = { x: 10, y: 10, width: 100, height: 100 } // below minimum + win.emit('resize') + vi.advanceTimersByTime(500) + expect(store.updateUI).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it('closeDashboardPopout closes an open window', () => { + createOrFocusDashboardPopout(makeStore() as never) + const win = instances[0] + closeDashboardPopout() + expect(win.close).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/window/dashboard-popout-window.ts b/src/main/window/dashboard-popout-window.ts new file mode 100644 index 00000000000..bcfd5985721 --- /dev/null +++ b/src/main/window/dashboard-popout-window.ts @@ -0,0 +1,189 @@ +import { app, BrowserWindow, nativeTheme, type WebContents } from 'electron' +import { join } from 'node:path' +import { is } from '@electron-toolkit/utils' +import type { Store } from '../persistence' +import { rectHasVisibleAreaOnAnyDisplay } from './window-bounds-validation' +import { sendToTrustedUIRenderer } from '../ipc/ui' +import { installPrivilegedWindowNavigationPolicy } from './privileged-window-navigation' + +const MIN_WIDTH = 480 +const MIN_HEIGHT = 360 +const DEFAULT_WIDTH = 960 +const DEFAULT_HEIGHT = 720 +const DEFAULT_VIEW = 'kanban' + +// Why: singleton — the dashboard is a companion surface, so a second "Pop Out" +// request focuses the existing window rather than spawning duplicates. +let dashboardPopoutWindow: BrowserWindow | null = null + +/** The live pop-out window, or null when closed. Used by the dashboard relay to + * forward snapshots to the popout's webContents. */ +export function getDashboardPopoutWindow(): BrowserWindow | null { + return dashboardPopoutWindow && + !dashboardPopoutWindow.isDestroyed() && + !dashboardPopoutWindow.webContents.isDestroyed() + ? dashboardPopoutWindow + : null +} + +export function isDashboardPopoutRenderer(sender: WebContents): boolean { + return getDashboardPopoutWindow()?.webContents === sender +} + +// In-process listeners (the dashboard relay clears its cached snapshot on close). +const popoutOpenListeners = new Set<(open: boolean) => void>() + +/** Subscribe to pop-out open/close transitions in the main process. */ +export function onDashboardPopoutOpenChanged(listener: (open: boolean) => void): () => void { + popoutOpenListeners.add(listener) + return () => popoutOpenListeners.delete(listener) +} + +// Why: the main renderer's snapshot publisher only runs while the pop-out is +// open. Tell that exact window when the state flips, then notify listeners. +function broadcastPopoutOpenChanged(open: boolean): void { + sendToTrustedUIRenderer('dashboard:popoutOpenChanged', open) + for (const listener of popoutOpenListeners) { + listener(open) + } +} + +function loadDashboardPopout(window: BrowserWindow, view: string): void { + const search = `view=${encodeURIComponent(view)}` + // Why: mirror loadMainWindow's dev/prod branch — the dev server serves the + // second HTML entry, prod loads the emitted file. + if (is.dev && process.env.ELECTRON_RENDERER_URL) { + void window.loadURL(`${process.env.ELECTRON_RENDERER_URL}/popout.html?${search}`) + } else { + void window.loadFile(join(__dirname, '../renderer/popout.html'), { search }) + } +} + +function resolveRestoredBounds(store: Store | null): { + x: number + y: number + width: number + height: number +} | null { + const raw = store?.getUI().dashboardPopoutBounds ?? null + if ( + raw && + raw.width >= MIN_WIDTH && + raw.height >= MIN_HEIGHT && + rectHasVisibleAreaOnAnyDisplay(raw, MIN_WIDTH / 2, MIN_HEIGHT / 2) + ) { + return raw + } + if (raw) { + console.warn('[dashboard-popout] Discarding off-screen/near-min popout bounds:', raw) + } + return null +} + +/** + * Open the pop-out dashboard window, or focus it if already open. The window is + * a standalone top-level BrowserWindow with a native frame that reuses the same + * preload/window.api as the main window but renders its own React root + * (popout.html?view=…). + */ +export function createOrFocusDashboardPopout( + store: Store | null, + view: string = DEFAULT_VIEW +): BrowserWindow { + if (dashboardPopoutWindow && !dashboardPopoutWindow.isDestroyed()) { + if (dashboardPopoutWindow.isMinimized()) { + dashboardPopoutWindow.restore() + } + dashboardPopoutWindow.focus() + return dashboardPopoutWindow + } + + const savedBounds = resolveRestoredBounds(store) + + const window = new BrowserWindow({ + width: savedBounds?.width ?? DEFAULT_WIDTH, + height: savedBounds?.height ?? DEFAULT_HEIGHT, + ...(savedBounds ? { x: savedBounds.x, y: savedBounds.y } : {}), + minWidth: MIN_WIDTH, + minHeight: MIN_HEIGHT, + title: 'Orca Agent Dashboard', + show: false, + autoHideMenuBar: true, + backgroundColor: nativeTheme.shouldUseDarkColors ? '#0a0a0a' : '#ffffff', + // Why: the pop-out uses a standard native frame so it is movable, closable, + // and minimizable on every platform without reimplementing the main + // window's custom titlebar/drag-region/window-control chrome. The main + // window is frameless because it draws its own titlebar; the dashboard has + // no such chrome yet, so a native frame is the correct default here. + webPreferences: { + preload: join(__dirname, '../preload/index.js'), + sandbox: true, + // Why: the dashboard is plain DOM — no guests — so keep the + // guest-embedding surface off for this window. + webviewTag: false + } + }) + installPrivilegedWindowNavigationPolicy(window.webContents) + dashboardPopoutWindow = window + broadcastPopoutOpenChanged(true) + + window.once('ready-to-show', () => { + if (!window.isDestroyed()) { + window.show() + } + }) + + // Bounds persistence — mirrors the main window's debounced/frozen approach so + // teardown-time resize/move events can't clobber the remembered size with + // near-minimum bounds. + let boundsTimer: ReturnType | null = null + let windowClosing = false + const saveBounds = (): void => { + if (boundsTimer) { + clearTimeout(boundsTimer) + } + boundsTimer = setTimeout(() => { + boundsTimer = null + if (windowClosing || window.isDestroyed() || window.isMinimized() || window.isFullScreen()) { + return + } + const bounds = window.getBounds() + if (bounds.width < MIN_WIDTH || bounds.height < MIN_HEIGHT) { + return + } + store?.updateUI({ dashboardPopoutBounds: bounds }) + }, 500) + } + window.on('resize', saveBounds) + window.on('move', saveBounds) + + const freezeBounds = (): void => { + windowClosing = true + if (boundsTimer) { + clearTimeout(boundsTimer) + boundsTimer = null + } + } + window.on('close', freezeBounds) + app.on('before-quit', freezeBounds) + + window.on('closed', () => { + app.removeListener('before-quit', freezeBounds) + if (dashboardPopoutWindow === window) { + dashboardPopoutWindow = null + } + broadcastPopoutOpenChanged(false) + }) + + loadDashboardPopout(window, view) + return window +} + +/** Close the pop-out dashboard if it is open. Called when the main window + * closes so the dashboard never orphans without its owning app window. */ +export function closeDashboardPopout(): void { + if (dashboardPopoutWindow && !dashboardPopoutWindow.isDestroyed()) { + dashboardPopoutWindow.close() + } + dashboardPopoutWindow = null +} diff --git a/src/main/window/focus-existing-window.ts b/src/main/window/focus-existing-window.ts index 1ff9d86478f..7c73b58b1c9 100644 --- a/src/main/window/focus-existing-window.ts +++ b/src/main/window/focus-existing-window.ts @@ -25,7 +25,7 @@ function safelyFocusApp(app: Pick): void { } } -function safelyRevealWindow(window: BrowserWindow): void { +export function safelyRevealWindow(window: BrowserWindow): void { if (window.isDestroyed()) { return } diff --git a/src/main/window/privileged-window-navigation.ts b/src/main/window/privileged-window-navigation.ts new file mode 100644 index 00000000000..a5afbbe0a98 --- /dev/null +++ b/src/main/window/privileged-window-navigation.ts @@ -0,0 +1,33 @@ +import { shell, type WebContents } from 'electron' +import { is } from '@electron-toolkit/utils' +import { normalizeExternalBrowserUrl } from '../../shared/browser-url' + +/** Keep remote documents from inheriting an Orca window's privileged preload. */ +export function installPrivilegedWindowNavigationPolicy(contents: WebContents): void { + contents.setWindowOpenHandler(({ url }) => { + const externalUrl = normalizeExternalBrowserUrl(url) + if (externalUrl) { + void shell.openExternal(externalUrl) + } + return { action: 'deny' } + }) + + contents.on('will-navigate', (event, url) => { + const externalUrl = normalizeExternalBrowserUrl(url) + if (externalUrl) { + if (is.dev && process.env.ELECTRON_RENDERER_URL) { + try { + const target = new URL(externalUrl) + const allowed = new URL(process.env.ELECTRON_RENDERER_URL) + if (target.origin === allowed.origin) { + return + } + } catch { + // Fall through and block malformed navigation targets. + } + } + void shell.openExternal(externalUrl) + } + event.preventDefault() + }) +} diff --git a/src/main/window/window-bounds-validation.ts b/src/main/window/window-bounds-validation.ts new file mode 100644 index 00000000000..f358ef391c6 --- /dev/null +++ b/src/main/window/window-bounds-validation.ts @@ -0,0 +1,35 @@ +import { screen } from 'electron' + +export type WindowRect = { x: number; y: number; width: number; height: number } + +/** + * True when `rect` overlaps some currently-attached display's workArea by at + * least the given visible width/height. Used to reject persisted bounds that + * would restore a window off-screen — e.g. saved while an external monitor was + * connected. workArea excludes the menu bar / dock, so a rect hidden entirely + * under the dock is also correctly rejected. Requiring a *meaningful* overlap + * (not just >0) avoids a one-pixel sliver leaving the titlebar unreachable. + */ +export function rectHasVisibleAreaOnAnyDisplay( + rect: WindowRect, + minVisibleWidth: number, + minVisibleHeight: number +): boolean { + try { + return screen.getAllDisplays().some((display) => { + const wa = display.workArea + const overlapX = Math.max( + 0, + Math.min(rect.x + rect.width, wa.x + wa.width) - Math.max(rect.x, wa.x) + ) + const overlapY = Math.max( + 0, + Math.min(rect.y + rect.height, wa.y + wa.height) - Math.max(rect.y, wa.y) + ) + return overlapX >= minVisibleWidth && overlapY >= minVisibleHeight + }) + } catch (err) { + console.warn('[window] screen.getAllDisplays() threw; treating bounds as off-screen', err) + return false + } +} diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index ee882503be2..f10ba519bd9 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -9,6 +9,11 @@ import type { HostedReviewProvider } from '../shared/hosted-review' import type { NativeFileDropPayload } from '../shared/native-file-drop' +import type { DashboardSnapshot, DashboardRevealAgentArgs } from '../shared/dashboard-snapshot' +import type { + TerminalPreviewConnectResult, + TerminalPreviewDataPayload +} from '../shared/terminal-preview' import type { TerminalTabCloseRequest, TerminalTabCloseResponse @@ -2212,6 +2217,29 @@ export type PreloadApi = { } ) => Promise } + dashboard: { + openPopout: () => Promise + publishSnapshot: (snapshot: DashboardSnapshot) => Promise + getPopoutOpen: () => Promise + onPopoutOpenChanged: (callback: (open: boolean) => void) => () => void + onSnapshotRequested: (callback: () => void) => () => void + onRevealAgent: (callback: (args: DashboardRevealAgentArgs) => void) => () => void + onAckAgent: (callback: (paneKey: string) => void) => () => void + requestSnapshot: () => Promise + onSnapshot: (callback: (snapshot: DashboardSnapshot) => void) => () => void + revealAgent: (args: DashboardRevealAgentArgs) => Promise + ackAgent: (paneKey: string) => Promise + } + terminalPreview: { + connect: ( + ptyId: string, + opts?: { scrollbackRows?: number } + ) => Promise + input: (ptyId: string, data: string) => Promise + ack: (ptyId: string, bytes: number) => Promise + unsubscribe: (ptyId: string) => Promise + onData: (callback: (payload: TerminalPreviewDataPayload) => void) => () => void + } developerPermissions: { getStatus: () => Promise request: (args: { id: DeveloperPermissionId }) => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index af9439a661d..0021f55dfa7 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -4,6 +4,11 @@ import { electronAPI } from '@electron-toolkit/preload' import { preloadE2EConfig } from './e2e-config' import { glApi } from './gitlab' import type { AppIdentity } from '../shared/app-identity' +import type { DashboardSnapshot, DashboardRevealAgentArgs } from '../shared/dashboard-snapshot' +import type { + TerminalPreviewConnectResult, + TerminalPreviewDataPayload +} from '../shared/terminal-preview' import type { CliInstallStatus } from '../shared/cli-install-types' import type { AgentHookInstallStatus } from '../shared/agent-hook-types' import type { TerminalPaneSplitSource } from '../shared/feature-education-telemetry' @@ -2069,6 +2074,73 @@ const api = { ): Promise => ipcRenderer.invoke('onboarding:update', updates) }, + dashboard: { + // Open the pop-out dashboard window, or focus it if already open. + openPopout: (): Promise => ipcRenderer.invoke('dashboardPopout:open'), + + // ── Producer side (main window) ────────────────────────────────────── + publishSnapshot: (snapshot: DashboardSnapshot): Promise => + ipcRenderer.invoke('dashboard:publishSnapshot', snapshot), + getPopoutOpen: (): Promise => ipcRenderer.invoke('dashboard:getPopoutOpen'), + onPopoutOpenChanged: (callback: (open: boolean) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, open: boolean): void => callback(open) + ipcRenderer.on('dashboard:popoutOpenChanged', listener) + return () => ipcRenderer.removeListener('dashboard:popoutOpenChanged', listener) + }, + onSnapshotRequested: (callback: () => void): (() => void) => { + const listener = (): void => callback() + ipcRenderer.on('dashboard:snapshotRequested', listener) + return () => ipcRenderer.removeListener('dashboard:snapshotRequested', listener) + }, + onRevealAgent: (callback: (args: DashboardRevealAgentArgs) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, args: DashboardRevealAgentArgs): void => + callback(args) + ipcRenderer.on('ui:revealDashboardAgent', listener) + return () => ipcRenderer.removeListener('ui:revealDashboardAgent', listener) + }, + onAckAgent: (callback: (paneKey: string) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, paneKey: string): void => + callback(paneKey) + ipcRenderer.on('ui:ackDashboardAgent', listener) + return () => ipcRenderer.removeListener('ui:ackDashboardAgent', listener) + }, + + // ── Consumer side (pop-out window) ─────────────────────────────────── + requestSnapshot: (): Promise => ipcRenderer.invoke('dashboard:requestSnapshot'), + onSnapshot: (callback: (snapshot: DashboardSnapshot) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, snapshot: DashboardSnapshot): void => + callback(snapshot) + ipcRenderer.on('dashboard:snapshot', listener) + return () => ipcRenderer.removeListener('dashboard:snapshot', listener) + }, + revealAgent: (args: DashboardRevealAgentArgs): Promise => + ipcRenderer.invoke('dashboardPopout:revealAgent', args), + ackAgent: (paneKey: string): Promise => + ipcRenderer.invoke('dashboardPopout:ackAgent', { paneKey }) + }, + + terminalPreview: { + connect: ( + ptyId: string, + opts?: { scrollbackRows?: number } + ): Promise => + ipcRenderer.invoke('terminalPreview:connect', { ptyId, opts }), + input: (ptyId: string, data: string): Promise => + ipcRenderer.invoke('terminalPreview:input', { ptyId, data }), + ack: (ptyId: string, bytes: number): Promise => + ipcRenderer.invoke('terminalPreview:ack', { ptyId, bytes }), + unsubscribe: (ptyId: string): Promise => + ipcRenderer.invoke('terminalPreview:unsubscribe', { ptyId }), + onData: (callback: (payload: TerminalPreviewDataPayload) => void): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + payload: TerminalPreviewDataPayload + ): void => callback(payload) + ipcRenderer.on('terminalPreview:data', listener) + return () => ipcRenderer.removeListener('terminalPreview:data', listener) + } + }, + developerPermissions: { getStatus: (): Promise => ipcRenderer.invoke('developerPermissions:getStatus'), request: (args: { id: string }): Promise => diff --git a/src/renderer/popout.html b/src/renderer/popout.html new file mode 100644 index 00000000000..944bcd23b30 --- /dev/null +++ b/src/renderer/popout.html @@ -0,0 +1,13 @@ + + + + + Orca Agent Dashboard + + + + +
+ + + diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index faf1b7bb9ba..b228a47158f 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -91,6 +91,7 @@ import RecentTabSwitcher from './components/tab-bar/RecentTabSwitcher' import { useGitStatusPolling } from './components/right-sidebar/useGitStatusPolling' import { useEditorExternalWatch } from './hooks/useEditorExternalWatch' import { useAutoAckViewedAgent } from './hooks/useAutoAckViewedAgent' +import { useDashboardPopoutBridge } from './components/dashboard/useDashboardPopoutBridge' import { useUnreadDockBadge } from './hooks/useUnreadDockBadge' import { resolvePrimarySelectionMiddleClickPaste, @@ -715,6 +716,7 @@ function App(): React.JSX.Element { useEditorExternalWatch() useGlobalFileDrop() useAutoAckViewedAgent() + useDashboardPopoutBridge(settings?.experimentalAgentDashboardPopout === true) useEffect(() => { return onOnboardingReopened(setOnboarding) diff --git a/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx b/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx new file mode 100644 index 00000000000..da866c17ac4 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx @@ -0,0 +1,172 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen, within } from '@testing-library/react' +import type { DashboardCard, DashboardSnapshot } from '../../../../shared/dashboard-snapshot' +import { AgentKanbanBoard } from './AgentKanbanBoard' + +// Stub the card and dialog so the board test stays free of xterm / Radix +// machinery while still exercising the board-owned dialog wiring. +vi.mock('./AgentKanbanCard', () => ({ + AgentKanbanCard: ({ + card, + onOpenTerminal + }: { + card: DashboardCard + onOpenTerminal: (card: DashboardCard) => void + }) => ( +
onOpenTerminal(card)} + > + {card.worktreeName} +
+ ) +})) +vi.mock('./AgentTerminalDialog', () => ({ + AgentTerminalDialog: ({ + card, + onOpenChange + }: { + card: DashboardCard | null + onOpenChange: (open: boolean) => void + }) => ( +
+
+ ) +})) + +function card(overrides: Partial): DashboardCard { + return { + paneKey: Math.random().toString(36), + ptyId: 'p1', + agentType: 'claude', + bucket: 'working', + dotState: 'working', + task: 't', + repoId: 'r1', + worktreeId: 'w1', + tabId: 'tab1', + leafId: 'l1', + repoName: 'Repo', + worktreeName: 'wt', + startedAt: 0, + stateChangedAt: 0, + unseen: false, + ...overrides + } +} + +function renderBoard(cards: DashboardCard[]): void { + const snapshot: DashboardSnapshot = { generatedAt: 1, cards } + render() +} + +const ackAgent = vi.fn(async () => {}) + +describe('AgentKanbanBoard', () => { + beforeEach(() => { + // The board relays seen-acks through the dashboard preload API. + ;(window as unknown as { api: unknown }).api = { dashboard: { ackAgent } } + }) + afterEach(() => { + cleanup() + vi.clearAllMocks() + }) + + it('renders the three fixed columns in order', () => { + renderBoard([]) + const headers = screen.getAllByText(/Needs You|Working|Idle/) + expect(headers.map((h) => h.textContent)).toEqual(['Needs You', 'Working', 'Idle']) + }) + + it('places cards in their bucket column and counts them', () => { + renderBoard([ + card({ bucket: 'attention', worktreeName: 'a1' }), + card({ bucket: 'attention', worktreeName: 'a2' }), + card({ bucket: 'idle', worktreeName: 'i1' }) + ]) + const cards = screen.getAllByTestId('card') + expect(cards).toHaveLength(3) + expect(cards.filter((c) => c.dataset.bucket === 'attention')).toHaveLength(2) + expect(within(document.body).getByText('i1').dataset.bucket).toBe('idle') + expect(screen.getByText('3 total')).toBeTruthy() + }) + + it('shows "None" for empty columns', () => { + renderBoard([card({ bucket: 'working' })]) + // attention and idle are empty → two "None" placeholders. + expect(screen.getAllByText('None')).toHaveLength(2) + }) + + it('orders cards in a column by most recent bucket entry first', () => { + renderBoard([ + card({ bucket: 'working', worktreeName: 'old-move', stateChangedAt: 1000 }), + card({ bucket: 'working', worktreeName: 'new-move', stateChangedAt: 3000 }), + card({ bucket: 'working', worktreeName: 'mid-move', stateChangedAt: 2000 }) + ]) + const names = screen.getAllByTestId('card').map((c) => c.textContent) + expect(names).toEqual(['new-move', 'mid-move', 'old-move']) + }) + + it('keeps the terminal dialog open across bucket moves and card removal', () => { + const agent = card({ paneKey: 'pk-1', bucket: 'idle', worktreeName: 'wt1' }) + const { rerender } = render() + expect(screen.getByTestId('terminal-dialog').dataset.open).toBe('false') + + fireEvent.click(screen.getByTestId('card')) + expect(screen.getByTestId('terminal-dialog').dataset.open).toBe('true') + + // Sending a message flips the agent idle → working; the dialog must + // follow the card to its new bucket instead of closing. + const moved = { ...agent, bucket: 'working' as const, dotState: 'working' as const } + rerender() + expect(screen.getByTestId('terminal-dialog').dataset.open).toBe('true') + expect(screen.getByTestId('terminal-dialog').dataset.bucket).toBe('working') + + // Even a vanished card (pane closed) keeps the dialog up — the user + // dismisses it explicitly, but stale live routing is cleared. + rerender() + expect(screen.getByTestId('terminal-dialog').dataset.open).toBe('true') + expect(screen.getByTestId('terminal-dialog').dataset.ptyId).toBeUndefined() + }) + + it('relays a seen-ack when a dialog opens and when the open agent changes state', () => { + const agent = card({ paneKey: 'pk-ack', bucket: 'idle', unseen: true }) + const { rerender } = render() + // unseen comes straight from the snapshot (the shared ack map). + expect(screen.getByTestId('card').dataset.unseen).toBe('true') + + fireEvent.click(screen.getByTestId('card')) + expect(ackAgent).toHaveBeenCalledWith('pk-ack') + ackAgent.mockClear() + + // The ack round-trips through the main window; the next snapshot mutes it. + rerender( + + ) + expect(screen.getByTestId('card').dataset.unseen).toBe('false') + expect(ackAgent).not.toHaveBeenCalled() + + // A state change while the dialog is open re-acks (watching counts as + // seeing), so the card never flips bold under an open dialog. + rerender( + + ) + expect(ackAgent).toHaveBeenCalledWith('pk-ack') + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.tsx b/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.tsx new file mode 100644 index 00000000000..b386b5d0c76 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.tsx @@ -0,0 +1,172 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { + DASHBOARD_BUCKET_ORDER, + type DashboardBucket, + type DashboardCard, + type DashboardSnapshot +} from '../../../../shared/dashboard-snapshot' +import { cn } from '@/lib/utils' +import { AgentKanbanCard } from './AgentKanbanCard' +import { AgentTerminalDialog } from './AgentTerminalDialog' +import './agent-board-transitions.css' +import { translate } from '@/i18n/i18n' + +function bucketLabel(bucket: DashboardBucket): string { + switch (bucket) { + case 'attention': + return translate('dashboardPopout.bucket.attention', 'Needs You') + case 'working': + return translate('dashboardPopout.bucket.working', 'Working') + case 'idle': + return translate('dashboardPopout.bucket.idle', 'Idle') + } +} + +function groupByBucket(cards: DashboardCard[]): Record { + const grouped: Record = { + attention: [], + working: [], + idle: [] + } + for (const card of cards) { + grouped[card.bucket].push(card) + } + // Most-recently-moved first: a card entering a column lands at the top, + // matching the view-transition motion the user just watched. + for (const bucket of DASHBOARD_BUCKET_ORDER) { + grouped[bucket].sort((a, b) => b.stateChangedAt - a.stateChangedAt) + } + return grouped +} + +function KanbanColumn({ + bucket, + cards, + now, + onOpenTerminal +}: { + bucket: DashboardBucket + cards: DashboardCard[] + now: number + onOpenTerminal: (card: DashboardCard) => void +}): React.JSX.Element { + const highlight = bucket === 'attention' && cards.length > 0 + return ( +
+
+ + {bucketLabel(bucket)} + + + {cards.length} + +
+
+ {cards.length === 0 ? ( +

+ {translate('dashboardPopout.bucket.empty', 'None')} +

+ ) : ( + cards.map((card) => ( + + )) + )} +
+
+ ) +} + +/** The pop-out agent board: status columns fed by the relayed snapshot. */ +export function AgentKanbanBoard({ snapshot }: { snapshot: DashboardSnapshot }): React.JSX.Element { + const grouped = useMemo(() => groupByBucket(snapshot.cards), [snapshot.cards]) + const [now, setNow] = useState(() => Date.now()) + + useEffect(() => { + const timer = setInterval(() => setNow(Date.now()), 30_000) + return () => clearInterval(timer) + }, []) + + // The open terminal dialog survives bucket moves: only the paneKey is + // remembered, and the card data is re-resolved from each fresh snapshot. + // The opened card is kept as a fallback so the dialog also survives the + // card vanishing entirely (pane closed) — the user dismisses it explicitly. + // Its live routing is cleared because daemon PTY ids can be reused. + const [openedCard, setOpenedCard] = useState(null) + const dialogCard = useMemo(() => { + if (!openedCard) { + return null + } + return ( + snapshot.cards.find((c) => c.paneKey === openedCard.paneKey) ?? { + ...openedCard, + ptyId: null, + leafId: null + } + ) + }, [snapshot.cards, openedCard]) + const handleDialogOpenChange = useCallback((open: boolean) => { + if (!open) { + setOpenedCard(null) + } + }, []) + + // Seen-state is the app-wide ack map (same signal as the sidebar's bold/mute + // rows): opening a dialog acks the agent in the main renderer via the relay, + // and the next snapshot comes back with unseen=false. + // ?. shields dialog-opening from dev-HMR preload skew (renderer updates + // hot, the preload only on app restart) — acks just no-op until restart. + const handleOpenTerminal = useCallback((card: DashboardCard) => { + void window.api.dashboard.ackAgent?.(card.paneKey) + setOpenedCard(card) + }, []) + // Watching the open dialog counts as seeing state changes as they happen — + // without this, an agent finishing while you watch would re-flag its card. + useEffect(() => { + if (dialogCard?.unseen) { + void window.api.dashboard.ackAgent?.(dialogCard.paneKey) + } + }, [dialogCard?.unseen, dialogCard?.paneKey]) + + return ( +
+
+

+ {translate('dashboardPopout.title', 'Agents')} +

+ + {translate('dashboardPopout.total', '{{count}} total', { + count: snapshot.cards.length + })} + +
+
+ {/* Why: columns share the window width up to a readable cap; mx-auto + centers the capped board so leftover space splits evenly instead of + pooling on the right. In overflow the auto margins collapse to 0, + keeping the left edge reachable while scrolling. */} +
+ {DASHBOARD_BUCKET_ORDER.map((bucket) => ( + + ))} +
+
+ +
+ ) +} diff --git a/src/renderer/src/components/dashboard-popout/AgentKanbanCard.test.tsx b/src/renderer/src/components/dashboard-popout/AgentKanbanCard.test.tsx new file mode 100644 index 00000000000..9e2712e2e6c --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentKanbanCard.test.tsx @@ -0,0 +1,93 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' +import { act, cleanup, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { DashboardCard } from '../../../../shared/dashboard-snapshot' +import { i18n } from '@/i18n/i18n' +import { AgentKanbanCard } from './AgentKanbanCard' + +const agentIconRender = vi.fn() + +vi.mock('@/lib/agent-catalog', () => ({ + AgentIcon: () => { + agentIconRender() + return + } +})) + +vi.mock('@/components/AgentStateDot', () => ({ + AgentStateDot: () => +})) + +function card(overrides: Partial = {}): DashboardCard { + return { + paneKey: 'tab:leaf', + ptyId: 'pty-1', + agentType: 'claude', + bucket: 'working', + dotState: 'working', + task: 'Review the change', + repoId: 'repo-1', + worktreeId: 'worktree-1', + tabId: 'tab', + leafId: 'leaf', + repoName: 'Orca', + worktreeName: 'dashboard-review', + startedAt: 1_000, + stateChangedAt: 1_000, + unseen: false, + ...overrides + } +} + +describe('AgentKanbanCard', () => { + beforeEach(async () => { + await i18n.changeLanguage('en') + }) + + afterEach(() => { + cleanup() + vi.clearAllMocks() + }) + + it('does not render an invented age when the start time is unknown', () => { + render( + + ) + + expect(screen.queryByText(/\d+d/)).not.toBeInTheDocument() + }) + + it('skips structured-clone rerenders until visible card data or its age changes', () => { + const onOpenTerminal = vi.fn() + const initial = card({ startedAt: 1_000 }) + const { rerender } = render( + + ) + expect(agentIconRender).toHaveBeenCalledTimes(1) + expect(screen.getByText('1m')).toBeInTheDocument() + + rerender() + expect(agentIconRender).toHaveBeenCalledTimes(1) + + rerender( + + ) + expect(agentIconRender).toHaveBeenCalledTimes(2) + expect(screen.getByText('2m')).toBeInTheDocument() + }) + + it('updates the relative age when the UI language changes', async () => { + render( + + ) + expect(screen.getByText('2m')).toBeInTheDocument() + + await act(async () => { + await i18n.changeLanguage('ja') + }) + + expect(screen.getByText('2分')).toBeInTheDocument() + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx b/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx new file mode 100644 index 00000000000..ef4d6ddec32 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx @@ -0,0 +1,143 @@ +import { memo } from 'react' +import { useTranslation } from 'react-i18next' +import { AgentIcon } from '@/lib/agent-catalog' +import { agentTypeToIconAgent, formatAgentTypeLabel } from '@/lib/agent-status' +import { AgentStateDot } from '@/components/AgentStateDot' +import { cn } from '@/lib/utils' +import type { DashboardCard } from '../../../../shared/dashboard-snapshot' +import { translate } from '@/i18n/i18n' + +/** Compact "started N ago" (the card is glanceable — coarse units are fine). */ +function formatStartedAgo(startedAt: number, now: number): string { + const seconds = Math.max(0, Math.floor((now - startedAt) / 1000)) + if (seconds < 60) { + return translate('dashboardPopout.card.time.justNow', 'just now') + } + const minutes = Math.floor(seconds / 60) + if (minutes < 60) { + return translate('dashboardPopout.card.time.minutes', '{{count}}m', { count: minutes }) + } + const hours = Math.floor(minutes / 60) + if (hours < 24) { + return translate('dashboardPopout.card.time.hours', '{{count}}h', { count: hours }) + } + return translate('dashboardPopout.card.time.days', '{{count}}d', { + count: Math.floor(hours / 24) + }) +} + +function sameCard(a: DashboardCard, b: DashboardCard): boolean { + return ( + a.paneKey === b.paneKey && + a.ptyId === b.ptyId && + a.agentType === b.agentType && + a.bucket === b.bucket && + a.dotState === b.dotState && + a.task === b.task && + a.lastUserMessage === b.lastUserMessage && + a.lastAgentMessage === b.lastAgentMessage && + a.repoId === b.repoId && + a.worktreeId === b.worktreeId && + a.tabId === b.tabId && + a.leafId === b.leafId && + a.repoName === b.repoName && + a.worktreeName === b.worktreeName && + a.startedAt === b.startedAt && + a.stateChangedAt === b.stateChangedAt && + a.unseen === b.unseen && + a.askSummary === b.askSummary + ) +} + +type AgentKanbanCardProps = { + card: DashboardCard + now: number + /** Opens the board-level terminal dialog. The dialog is NOT owned by the + * card: bucket moves remount the card, and an embedded dialog would close + * the chat mid-conversation. */ + onOpenTerminal: (card: DashboardCard) => void +} + +/** One agent on the kanban board. Clicking opens the board's live terminal dialog. */ +export const AgentKanbanCard = memo( + function AgentKanbanCard({ card, now, onOpenTerminal }: AgentKanbanCardProps): React.JSX.Element { + useTranslation() + + return ( + + ) + }, + (previous, next) => + previous.onOpenTerminal === next.onOpenTerminal && + sameCard(previous.card, next.card) && + (previous.card.startedAt <= 0 || + formatStartedAgo(previous.card.startedAt, previous.now) === + formatStartedAgo(next.card.startedAt, next.now)) +) diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.tsx new file mode 100644 index 00000000000..76177fe955b --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.tsx @@ -0,0 +1,100 @@ +import { useCallback } from 'react' +import { AgentIcon } from '@/lib/agent-catalog' +import { agentTypeToIconAgent, formatAgentTypeLabel } from '@/lib/agent-status' +import { agentStateLabel } from '@/components/AgentStateDot' +import { Dialog, DialogClose, DialogContent, DialogTitle } from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import type { DashboardCard } from '../../../../shared/dashboard-snapshot' +import { AgentTerminalPreview } from './AgentTerminalPreview' +import { translate } from '@/i18n/i18n' + +type AgentTerminalDialogProps = { + /** The agent shown in the dialog; null renders the dialog closed. */ + card: DashboardCard | null + onOpenChange: (open: boolean) => void +} + +/** + * The near-fullscreen live-terminal dialog for one agent. Hosted by the BOARD, + * not the card: sending a message flips the agent's bucket, which remounts its + * card in another column — a card-owned dialog would close mid-conversation. + * Only an explicit close (button, click-outside, Esc outside the terminal) + * dismisses it. + */ +export function AgentTerminalDialog({ + card, + onOpenChange +}: AgentTerminalDialogProps): React.JSX.Element { + const reveal = useCallback(() => { + if (!card) { + return + } + void window.api.dashboard.revealAgent({ + repoId: card.repoId, + worktreeId: card.worktreeId, + tabId: card.tabId, + leafId: card.leafId + }) + }, [card]) + + return ( + + {card ? ( + { + if (e.target instanceof HTMLElement && e.target.closest('.xterm')) { + e.preventDefault() + } + }} + // Why: the preview focuses its terminal once the snapshot paints; + // Radix's default focus target would tug focus away first. + onOpenAutoFocus={(e) => { + if (card.ptyId) { + e.preventDefault() + } + }} + > +
+ + + {card.worktreeName} + + + {formatAgentTypeLabel(card.agentType)} · {agentStateLabel(card.dotState)} + +
+ {card.ptyId ? ( + + ) : ( +
+ {translate( + 'dashboardPopout.terminal.closed', + "No live terminal — this agent's pane has closed." + )} +
+ )} +
+ + + + + + +
+
+ ) : null} +
+ ) +} diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx new file mode 100644 index 00000000000..4102e0fe59e --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx @@ -0,0 +1,220 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' +import { act, cleanup, render, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const terminalHarness = vi.hoisted(() => ({ + instances: [] as { + write: ReturnType + writeCallbacks: (() => void)[] + onDataListener: ((data: string) => void) | null + dispose: ReturnType + resize: ReturnType + reset: ReturnType + }[], + userInputListener: null as (() => void) | null, + userInputDispose: vi.fn() +})) + +vi.mock('@xterm/xterm', () => ({ + Terminal: class { + rows = 24 + buffer = { active: { cursorY: 0 } } + writeCallbacks: (() => void)[] = [] + onDataListener: ((data: string) => void) | null = null + write = vi.fn((_data: string, callback?: () => void) => { + if (callback) { + this.writeCallbacks.push(callback) + } + }) + open = vi.fn() + focus = vi.fn() + dispose = vi.fn() + resize = vi.fn() + reset = vi.fn() + onData = vi.fn((listener: (data: string) => void) => { + this.onDataListener = listener + return { dispose: vi.fn() } + }) + + constructor() { + terminalHarness.instances.push(this) + } + } +})) +vi.mock('@/lib/pane-manager/pane-terminal-options', () => ({ + buildDefaultTerminalOptions: () => ({}) +})) +vi.mock('@/components/terminal-pane/terminal-user-input-signal', () => ({ + subscribeToTerminalUserInput: (_terminal: unknown, listener: () => void) => { + terminalHarness.userInputListener = listener + return { dispose: terminalHarness.userInputDispose } + } +})) +vi.mock('@/components/terminal-pane/use-system-prefers-dark', () => ({ + useSystemPrefersDark: () => false +})) +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: { settings: null }) => unknown) => selector({ settings: null }) +})) + +import { AgentTerminalPreview } from './AgentTerminalPreview' + +describe('AgentTerminalPreview', () => { + const input = vi.fn(async () => true) + const ack = vi.fn(async () => {}) + const unsubscribe = vi.fn(async () => {}) + const connect = vi.fn() + let emitData: ((payload: unknown) => void) | null + + beforeEach(() => { + terminalHarness.instances.length = 0 + terminalHarness.userInputListener = null + emitData = null + connect.mockResolvedValue({ + snapshot: { data: '', cols: 80, rows: 24, seq: 1 }, + replay: [] + }) + Object.assign(window, { + api: { + terminalPreview: { + connect, + input, + ack, + unsubscribe, + onData: (listener: (payload: unknown) => void) => { + emitData = listener + return vi.fn() + } + } + } + }) + }) + + afterEach(() => { + cleanup() + vi.clearAllMocks() + }) + + it('routes signaled user input while a live write parses and drops parser replies', async () => { + render() + await waitFor(() => expect(terminalHarness.instances).toHaveLength(1)) + const terminal = terminalHarness.instances[0]! + await waitFor(() => expect(terminal.onDataListener).not.toBeNull()) + + act(() => { + emitData?.({ type: 'data', ptyId: 'pty-1', data: '\x1b[6n', bytes: 4 }) + }) + expect(terminal.write).toHaveBeenCalledWith('\x1b[6n', expect.any(Function)) + + act(() => { + terminalHarness.userInputListener?.() + terminal.onDataListener?.('k') + terminal.onDataListener?.('\x1b[1;1R') + }) + expect(input).toHaveBeenCalledTimes(1) + expect(input).toHaveBeenCalledWith('pty-1', 'k') + + act(() => terminal.writeCallbacks.shift()?.()) + expect(ack).toHaveBeenCalledWith('pty-1', 4) + }) + + it('keeps the existing terminal visible while a resync snapshot is captured', async () => { + let resolveRefresh!: (value: { + snapshot: { data: string; cols: number; rows: number; seq: number } + replay: string[] + }) => void + connect + .mockResolvedValueOnce({ + snapshot: { data: 'first', cols: 80, rows: 24, seq: 1 }, + replay: [] + }) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveRefresh = resolve + }) + ) + const view = render() + await waitFor(() => expect(terminalHarness.instances).toHaveLength(1)) + const terminal = terminalHarness.instances[0]! + + act(() => emitData?.({ type: 'resync', ptyId: 'pty-1' })) + await waitFor(() => expect(connect).toHaveBeenCalledTimes(2)) + expect(terminalHarness.instances).toHaveLength(1) + expect(terminal.dispose).not.toHaveBeenCalled() + expect(terminal.reset).not.toHaveBeenCalled() + expect(view.queryByText(/No live terminal/)).not.toBeInTheDocument() + + await act(async () => { + resolveRefresh({ + snapshot: { data: 'second', cols: 100, rows: 30, seq: 2 }, + replay: [] + }) + }) + await waitFor(() => expect(terminal.reset).toHaveBeenCalledTimes(1)) + expect(terminal.resize).toHaveBeenCalledWith(100, 30) + expect(terminalHarness.instances).toHaveLength(1) + expect(terminal.dispose).not.toHaveBeenCalled() + expect(view.queryByText(/No live terminal/)).not.toBeInTheDocument() + }) + + it('disposes a stale terminal when resync confirms the pty is gone', async () => { + connect + .mockResolvedValueOnce({ + snapshot: { data: 'first', cols: 80, rows: 24, seq: 1 }, + replay: [] + }) + .mockResolvedValueOnce({ snapshot: null, replay: [] }) + const view = render() + await waitFor(() => expect(terminalHarness.instances).toHaveLength(1)) + const terminal = terminalHarness.instances[0]! + + act(() => emitData?.({ type: 'resync', ptyId: 'pty-1' })) + + await waitFor(() => expect(view.getByText(/No live terminal/)).toBeInTheDocument()) + expect(terminal.dispose).toHaveBeenCalledTimes(1) + expect(terminalHarness.userInputDispose).toHaveBeenCalledTimes(1) + expect(unsubscribe).toHaveBeenCalledWith('pty-1') + }) + + it('connects a replacement pty after the previous pty was gone', async () => { + connect.mockResolvedValueOnce({ snapshot: null, replay: [] }).mockResolvedValueOnce({ + snapshot: { data: 'replacement', cols: 80, rows: 24, seq: 1 }, + replay: [] + }) + const view = render() + await waitFor(() => expect(view.getByText(/No live terminal/)).toBeInTheDocument()) + + view.rerender() + + await waitFor(() => expect(terminalHarness.instances).toHaveLength(1)) + expect(connect).toHaveBeenLastCalledWith('pty-live', { scrollbackRows: 24 }) + expect(view.queryByText(/No live terminal/)).not.toBeInTheDocument() + }) + + it('delays repeated capture after an overflow and cancels the retry on unmount', async () => { + vi.useFakeTimers() + connect.mockResolvedValue({ + snapshot: { data: 'screen', cols: 80, rows: 24, seq: 1 }, + replay: [], + resyncRequired: true + }) + const view = render() + await vi.waitFor(() => expect(terminalHarness.instances).toHaveLength(1)) + const terminal = terminalHarness.instances[0]! + expect(connect).toHaveBeenCalledTimes(1) + + act(() => terminal.writeCallbacks.splice(0).forEach((callback) => callback())) + await vi.advanceTimersByTimeAsync(149) + expect(connect).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1) + expect(connect).toHaveBeenCalledTimes(2) + + act(() => terminal.writeCallbacks.splice(0).forEach((callback) => callback())) + view.unmount() + await vi.advanceTimersByTimeAsync(150) + expect(connect).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx new file mode 100644 index 00000000000..129bcb35ce9 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx @@ -0,0 +1,288 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { Terminal } from '@xterm/xterm' +import '@xterm/xterm/css/xterm.css' +import { buildDefaultTerminalOptions } from '@/lib/pane-manager/pane-terminal-options' +import { subscribeToTerminalUserInput } from '@/components/terminal-pane/terminal-user-input-signal' +import { composeActiveTerminalTheme } from '@/components/terminal-pane/terminal-appearance' +import { useSystemPrefersDark } from '@/components/terminal-pane/use-system-prefers-dark' +import { translate } from '@/i18n/i18n' +import { getBuiltinTheme, resolveEffectiveTerminalAppearance } from '@/lib/terminal-theme' +import { cn } from '@/lib/utils' +import { useAppStore } from '@/store' +import type { TerminalPreviewDataPayload } from '../../../../shared/terminal-preview' + +const PREVIEW_SCROLLBACK_ROWS = 24 +const FALLBACK_COLS = 80 +const FALLBACK_ROWS = 24 +const RESYNC_RETRY_DELAY_MS = 150 + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)) +} + +/** + * Live peek at an agent's terminal, streaming from the main process's per-PTY + * headless emulator. The terminal is created at the pane's REAL cols/rows — + * the serialized ANSI was produced at those dimensions, and replaying it into + * a narrower terminal rewraps every full-width line into garbage. The box + * stays fixed; the oversized terminal is scaled down to fit the width and + * bottom-anchored so the tail (prompt, status line) stays visible. Keystrokes + * pass through to the PTY; DOM renderer so it never grabs a WebGL context. + */ +export function AgentTerminalPreview({ ptyId }: { ptyId: string }): React.JSX.Element { + const containerRef = useRef(null) + const settings = useAppStore((state) => state.settings) + const systemPrefersDark = useSystemPrefersDark() + const terminalTheme = useMemo(() => { + if (!settings) { + return null + } + const appearance = resolveEffectiveTerminalAppearance(settings, systemPrefersDark) + return composeActiveTerminalTheme( + appearance.theme ?? getBuiltinTheme(appearance.themeName), + settings + ) + }, [settings, systemPrefersDark]) + // A null snapshot means no serializer knows this pty (it died or was never + // spawned this session) — say so instead of painting a silent blank terminal. + const [ptyGone, setPtyGone] = useState(false) + + useEffect(() => { + setPtyGone(false) + const container = containerRef.current + if (!container) { + return + } + let disposed = false + let terminal: Terminal | null = null + let offData: (() => void) | null = null + let userInputDisposable: { dispose: () => void } | null = null + let refreshInFlight = false + let refreshAgain = false + let retryTimer: ReturnType | null = null + const pendingLivePayloads: Extract[] = [] + + const fitToBox = (): void => { + const screen = container.querySelector('.xterm-screen') + const box = container.parentElement + if (!screen || !box || !terminal) { + return + } + const scale = Math.min(1, box.clientWidth / Math.max(1, screen.offsetWidth)) + container.style.transform = scale < 1 ? `scale(${scale})` : '' + // Anchor whichever end keeps the CURSOR row in view when the terminal is + // taller than the box: a fresh shell prompts at the TOP of its screen + // (blind bottom-anchoring clipped it away), while a busy TUI keeps its + // action at the bottom. + const cellHeight = screen.offsetHeight / Math.max(1, terminal.rows) + const cursorBottom = (terminal.buffer.active.cursorY + 1) * cellHeight * scale + const anchorTop = cursorBottom <= box.clientHeight + box.style.alignItems = anchorTop ? 'flex-start' : 'flex-end' + container.style.transformOrigin = anchorTop ? 'top left' : 'bottom left' + } + // Re-fit after every parsed write (cursor may move ends); rAF coalesces. + let fitScheduled = false + const scheduleFit = (): void => { + if (fitScheduled) { + return + } + fitScheduled = true + requestAnimationFrame(() => { + fitScheduled = false + fitToBox() + }) + } + + let replayDepth = 0 + const writeReplayed = (chunk: string, onDone?: () => void): void => { + replayDepth++ + terminal?.write(chunk, () => { + replayDepth-- + scheduleFit() + onDone?.() + }) + } + + const writeLive = (payload: Extract): void => { + if (!terminal) { + pendingLivePayloads.push(payload) + return + } + writeReplayed(payload.data, () => { + if (!disposed) { + void window.api.terminalPreview.ack(ptyId, payload.bytes) + } + }) + } + + const installInputRouting = (): void => { + if (!terminal) { + return + } + let pendingUserInputSignals = 0 + userInputDisposable = subscribeToTerminalUserInput(terminal, () => { + pendingUserInputSignals = Math.min(32, pendingUserInputSignals + 1) + }) + terminal.onData((data) => { + const signaledUserInput = pendingUserInputSignals > 0 + if (signaledUserInput) { + pendingUserInputSignals-- + } + // Why: core's signal distinguishes real input from parser replies, so typing survives live replay without forwarding synthetic CPR/DA bytes. + if (userInputDisposable ? !signaledUserInput : replayDepth > 0) { + return + } + void window.api.terminalPreview.input(ptyId, data) + }) + } + + const replayConnection = ( + connection: Awaited>, + replaceExisting: boolean, + requestRefresh: () => void + ): void => { + const snap = connection.snapshot! + if (!terminal) { + terminal = new Terminal({ + ...buildDefaultTerminalOptions(), + cols: clamp(snap.cols ?? FALLBACK_COLS, 2, 500), + rows: clamp(snap.rows ?? FALLBACK_ROWS, 2, 200), + theme: terminalTheme ?? undefined, + scrollback: 1000 + }) + try { + terminal.open(container) + } catch { + terminal.dispose() + terminal = null + return + } + installInputRouting() + } else if (replaceExisting) { + // Why: keep the old frame visible during capture, then atomically replace it once the authoritative snapshot arrives. + terminal.resize( + clamp(snap.cols ?? FALLBACK_COLS, 2, 500), + clamp(snap.rows ?? FALLBACK_ROWS, 2, 200) + ) + terminal.reset() + } + if (snap.scrollbackAnsi) { + writeReplayed(snap.scrollbackAnsi) + } + if (snap.data) { + writeReplayed(snap.data) + } + if (snap.pendingEscapeTailAnsi) { + writeReplayed(snap.pendingEscapeTailAnsi) + } + for (const data of connection.replay) { + writeReplayed(data) + } + for (const payload of pendingLivePayloads.splice(0)) { + writeLive(payload) + } + if (connection.resyncRequired) { + refreshAgain = false + // Why: sustained output can overflow every capture; delay retries so recovery cannot spin two serializations per event-loop turn. + writeReplayed('', () => { + if (disposed || retryTimer) { + return + } + retryTimer = setTimeout(() => { + retryTimer = null + requestRefresh() + }, RESYNC_RETRY_DELAY_MS) + }) + } else if (refreshAgain) { + refreshAgain = false + // Queue behind every replay write so replacement never clears a half-parsed frame. + writeReplayed('', requestRefresh) + } + scheduleFit() + terminal.focus() + } + + const setup = async (replaceExisting = false): Promise => { + if (refreshInFlight) { + refreshAgain = true + return + } + refreshInFlight = true + const connection = await window.api.terminalPreview.connect(ptyId, { + scrollbackRows: PREVIEW_SCROLLBACK_ROWS + }) + if (disposed) { + return + } + const snap = connection.snapshot + if (!snap) { + refreshInFlight = false + setPtyGone(true) + offData?.() + offData = null + userInputDisposable?.dispose() + userInputDisposable = null + terminal?.dispose() + terminal = null + void window.api.terminalPreview.unsubscribe(ptyId) + return + } + refreshInFlight = false + if (!connection.resyncRequired && retryTimer) { + clearTimeout(retryTimer) + retryTimer = null + } + replayConnection(connection, replaceExisting, () => void setup(true)) + } + + offData = window.api.terminalPreview.onData((payload) => { + if (payload.ptyId !== ptyId) { + return + } + if (payload.type === 'resync') { + void setup(true) + return + } + writeLive(payload) + }) + + void setup() + + return () => { + disposed = true + if (retryTimer) { + clearTimeout(retryTimer) + } + offData?.() + userInputDisposable?.dispose() + void window.api.terminalPreview.unsubscribe(ptyId) + terminal?.dispose() + } + }, [ptyId, terminalTheme]) + + return ( + // Why: a size FIXED by the viewport (not shrink-to-fit) + overflow-hidden + // keeps the dialog stable no matter how wide/tall the pane's serialized + // buffer is. The terminal keeps the pane's true dimensions and is scaled/ + // clipped to fit; fitToBox anchors whichever end keeps the cursor in view. +
+ {ptyGone ? ( +
+ {translate( + 'dashboardPopout.terminal.closed', + "No live terminal — this agent's pane has closed." + )} +
+ ) : null} +
+
+
+
+ ) +} diff --git a/src/renderer/src/components/dashboard-popout/DashboardPopoutRoot.tsx b/src/renderer/src/components/dashboard-popout/DashboardPopoutRoot.tsx new file mode 100644 index 00000000000..a857b298b50 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/DashboardPopoutRoot.tsx @@ -0,0 +1,17 @@ +import { AgentKanbanBoard } from './AgentKanbanBoard' +import { useDashboardSnapshot } from './useDashboardSnapshot' + +type DashboardPopoutRootProps = { + /** The layout requested via popout.html?view=. Only "kanban" exists + * today; unknown views fall back to it. */ + view: string | null +} + +/** + * Root of the pop-out dashboard window. Subscribes to the live snapshot relayed + * from the main window and renders the requested layout. + */ +export function DashboardPopoutRoot(_props: DashboardPopoutRootProps): React.JSX.Element { + const snapshot = useDashboardSnapshot() + return +} diff --git a/src/renderer/src/components/dashboard-popout/agent-board-transitions.css b/src/renderer/src/components/dashboard-popout/agent-board-transitions.css new file mode 100644 index 00000000000..96c407e1132 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-board-transitions.css @@ -0,0 +1,54 @@ +/* + * View-transition tuning for the agent board. Each card carries a unique + * `view-transition-name` (agentcard-…), so when a card changes column the + * browser morphs it from its old position to the new one. Only loaded in the + * pop-out (imported by AgentKanbanBoard), so these document-global pseudo rules + * never affect the main window. + */ + +/* Card move/resize easing (applies to every transition group). */ +::view-transition-group(*) { + animation-duration: 260ms; + animation-timing-function: cubic-bezier(0.2, 0, 0, 1); +} + +/* The columns/background don't move, so skip the root cross-fade — it would + * otherwise briefly double-image the lane heights as a card leaves/joins. */ +::view-transition-old(root), +::view-transition-new(root) { + animation-duration: 0s; +} + +/* + * Enter/exit. A card that MOVES columns has both an old and a new image, so it + * is not `:only-child` and just morphs position (above). A genuinely new or + * removed card has only one image (`:only-child`) — fade + scale it. + */ +@keyframes agent-card-enter { + from { + opacity: 0; + transform: scale(0.96); + } +} +@keyframes agent-card-exit { + to { + opacity: 0; + transform: scale(0.96); + } +} +::view-transition-new(*):only-child { + animation: 240ms cubic-bezier(0.2, 0, 0, 1) both agent-card-enter; +} +::view-transition-old(*):only-child { + animation: 180ms cubic-bezier(0.4, 0, 1, 1) both agent-card-exit; +} + +/* Respect reduced-motion — the JS also skips startViewTransition, but guard the + * pseudo animations too. */ +@media (prefers-reduced-motion: reduce) { + ::view-transition-group(*), + ::view-transition-old(*), + ::view-transition-new(*) { + animation: none !important; + } +} diff --git a/src/renderer/src/components/dashboard-popout/useDashboardSnapshot.ts b/src/renderer/src/components/dashboard-popout/useDashboardSnapshot.ts new file mode 100644 index 00000000000..0a35499ea87 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/useDashboardSnapshot.ts @@ -0,0 +1,55 @@ +import { useEffect, useRef, useState } from 'react' +import { flushSync } from 'react-dom' +import { + EMPTY_DASHBOARD_SNAPSHOT, + type DashboardSnapshot +} from '../../../../shared/dashboard-snapshot' + +/** Which column each card sits in — the only thing a view transition should + * animate on. Content-only updates (such as new messages) must not. */ +function columnSignature(snapshot: DashboardSnapshot): string { + return snapshot.cards + .map((card) => `${card.paneKey}:${card.bucket}`) + .sort() + .join(',') +} + +function prefersReducedMotion(): boolean { + return window.matchMedia?.('(prefers-reduced-motion: reduce)').matches === true +} + +/** + * Pop-out side of the dashboard bridge: subscribe to snapshots relayed from the + * main window and request an initial one on mount. When a card changes column + * (or one appears/disappears), the update is wrapped in a View Transition so + * the browser morphs each card from its old position to its new one. + */ +export function useDashboardSnapshot(): DashboardSnapshot { + const [snapshot, setSnapshot] = useState(EMPTY_DASHBOARD_SNAPSHOT) + const columnSignatureRef = useRef('') + + useEffect(() => { + const apply = (next: DashboardSnapshot): void => { + const nextSignature = columnSignature(next) + const layoutChanged = nextSignature !== columnSignatureRef.current + columnSignatureRef.current = nextSignature + + const startViewTransition = document.startViewTransition?.bind(document) + if (!layoutChanged || prefersReducedMotion() || !startViewTransition) { + setSnapshot(next) + return + } + // flushSync so the DOM reflects `next` synchronously inside the transition + // callback — the browser captures the "after" state from it. + startViewTransition(() => { + flushSync(() => setSnapshot(next)) + }) + } + + const unsubscribe = window.api.dashboard.onSnapshot(apply) + void window.api.dashboard.requestSnapshot() + return unsubscribe + }, []) + + return snapshot +} diff --git a/src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts b/src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts new file mode 100644 index 00000000000..8afb5bee8d4 --- /dev/null +++ b/src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, it } from 'vitest' +import { buildDashboardSnapshot, type DashboardSnapshotState } from './build-dashboard-snapshot' +import { + AGENT_STATUS_STALE_AFTER_MS, + type AgentStatusEntry +} from '../../../../shared/agent-status-types' +import { makePaneKey } from '../../../../shared/stable-pane-id' + +const NOW = 1_000_000_000 +const TAB_ID = 'tab1' +const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const GONE_LEAF_ID = '22222222-2222-4222-8222-222222222222' +const PANE_KEY = makePaneKey(TAB_ID, LEAF_ID) + +function entry(overrides: Partial): AgentStatusEntry { + return { + paneKey: PANE_KEY, + state: 'working', + prompt: 'do the thing', + updatedAt: NOW, + stateStartedAt: NOW - 5000, + stateHistory: [], + agentType: 'claude', + tabId: TAB_ID, + worktreeId: 'w1', + ...overrides + } +} + +function tab(): unknown { + return { + id: TAB_ID, + ptyId: 'pty1', + worktreeId: 'w1', + title: 'agent', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: NOW + } +} + +function baseState(overrides: Partial): DashboardSnapshotState { + return { + repos: [{ id: 'r1', path: '/r1', displayName: 'Repo One', badgeColor: '#000' }], + worktreesByRepo: { r1: [{ id: 'w1', displayName: 'wt-one', isArchived: false }] }, + tabsByWorktree: { w1: [tab()] }, + agentStatusByPaneKey: {}, + retainedAgentsByPaneKey: {}, + migrationUnsupportedByPtyId: {}, + runtimeAgentOrchestrationByPaneKey: {}, + terminalLayoutsByTabId: { + [TAB_ID]: { + root: { type: 'leaf', leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + ptyIdsByLeafId: { [LEAF_ID]: 'pty1' } + } + }, + ptyIdsByTabId: { [TAB_ID]: ['pty1'] }, + runtimePaneTitlesByTabId: {}, + acknowledgedAgentsByPaneKey: {}, + ...overrides + } as unknown as DashboardSnapshotState +} + +describe('buildDashboardSnapshot', () => { + it('maps a live working agent to the working bucket with a resolved ptyId', () => { + const snapshot = buildDashboardSnapshot( + baseState({ + agentStatusByPaneKey: { + [PANE_KEY]: entry({ state: 'working', lastAssistantMessage: 'Working on it now' }) + } + }), + NOW + ) + expect(snapshot.cards).toHaveLength(1) + const card = snapshot.cards[0] + expect(card.bucket).toBe('working') + expect(card.dotState).toBe('working') + expect(card.ptyId).toBe('pty1') + expect(card.worktreeName).toBe('wt-one') + expect(card.repoName).toBe('Repo One') + expect(card.leafId).toBe(LEAF_ID) + expect(card.lastUserMessage).toBe('do the thing') + expect(card.lastAgentMessage).toBe('Working on it now') + // Column ordering key: when the agent entered its current state. + expect(card.stateChangedAt).toBe(NOW - 5000) + // No ack yet → unseen, mirroring the sidebar's unvisited signal. + expect(card.unseen).toBe(true) + }) + + it('nulls ptyId when the layout entry points at a dead pty', () => { + const snapshot = buildDashboardSnapshot( + baseState({ + agentStatusByPaneKey: { [PANE_KEY]: entry({}) }, + // Layout still remembers pty1 (it survives restarts), but the live + // set says that pty is gone — e.g. a parked tab after an app restart. + ptyIdsByTabId: { [TAB_ID]: [] } + }), + NOW + ) + expect(snapshot.cards[0].ptyId).toBeNull() + }) + + it('mutes unseen once the agent is acknowledged after its state change', () => { + const snapshot = buildDashboardSnapshot( + baseState({ + agentStatusByPaneKey: { [PANE_KEY]: entry({}) }, + acknowledgedAgentsByPaneKey: { [PANE_KEY]: NOW - 1000 } + }), + NOW + ) + // ack (NOW-1000) is after stateStartedAt (NOW-5000) → seen. + expect(snapshot.cards[0].unseen).toBe(false) + }) + + it('does not mark title-derived rows unseen from synthetic timestamps', () => { + const snapshot = buildDashboardSnapshot( + baseState({ + agentStatusByPaneKey: {}, + runtimePaneTitlesByTabId: { + [TAB_ID]: { 1: '⠋ Claude Code' } + } + }), + NOW + ) + + expect(snapshot.cards).toHaveLength(1) + expect(snapshot.cards[0].startedAt).toBe(0) + expect(snapshot.cards[0].unseen).toBe(false) + }) + + it.each(['blocked', 'waiting'] as const)( + 'routes %s agents to the attention bucket with an ask summary', + (state) => { + const snapshot = buildDashboardSnapshot( + baseState({ + agentStatusByPaneKey: { + [PANE_KEY]: entry({ state, interactivePrompt: 'Approve deploy?' }) + } + }), + NOW + ) + expect(snapshot.cards[0].bucket).toBe('attention') + expect(snapshot.cards[0].dotState).toBe(state) + expect(snapshot.cards[0].askSummary).toBe('Approve deploy?') + } + ) + + it('decays a stale working agent to the idle bucket', () => { + const snapshot = buildDashboardSnapshot( + baseState({ + agentStatusByPaneKey: { + [PANE_KEY]: entry({ + state: 'working', + updatedAt: NOW - AGENT_STATUS_STALE_AFTER_MS - 1000 + }) + } + }), + NOW + ) + expect(snapshot.cards[0].bucket).toBe('idle') + expect(snapshot.cards[0].dotState).toBe('idle') + }) + + it('folds retained done agents into the idle bucket, keeping a done dot', () => { + const donePaneKey = makePaneKey(TAB_ID, GONE_LEAF_ID) + const snapshot = buildDashboardSnapshot( + baseState({ + retainedAgentsByPaneKey: { + [donePaneKey]: { + entry: entry({ paneKey: donePaneKey, state: 'done' }), + worktreeId: 'w1', + tab: tab() as never, + agentType: 'claude', + startedAt: NOW - 60_000 + } as never + } + }), + NOW + ) + const done = snapshot.cards.find((c) => c.dotState === 'done') + expect(done).toBeDefined() + expect(done?.bucket).toBe('idle') + }) +}) diff --git a/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts b/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts new file mode 100644 index 00000000000..6a44e73c828 --- /dev/null +++ b/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts @@ -0,0 +1,172 @@ +import type { AppState } from '@/store/types' +import type { + DashboardBucket, + DashboardCard, + DashboardCardDotState, + DashboardSnapshot +} from '../../../../shared/dashboard-snapshot' +import { parsePaneKey } from '../../../../shared/stable-pane-id' +import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry' +import { applyAgentRowLineage } from './agent-row-lineage' +import type { DashboardAgentRow } from './useDashboardData' +import { buildWorktreeAgentRows } from '../sidebar/worktree-agent-rows' +import { + selectLiveAgentStatusEntriesForWorktree, + selectMigrationUnsupportedEntriesForWorktree, + selectRetainedAgentEntriesForWorktree, + selectRuntimeAgentOrchestrationForWorktree, + selectTerminalLayoutsForWorktree +} from '../sidebar/worktree-agent-row-selectors' +import { + selectLivePtyIdsForWorktree, + selectRuntimePaneTitlesForWorktree +} from '../sidebar/worktree-card-status-inputs' + +/** The store slices the snapshot builder reads. Kept as a Pick so unit tests + * can pass a partial store without constructing the whole AppState. */ +export type DashboardSnapshotState = Pick< + AppState, + | 'repos' + | 'worktreesByRepo' + | 'tabsByWorktree' + | 'agentStatusByPaneKey' + | 'retainedAgentsByPaneKey' + | 'migrationUnsupportedByPtyId' + | 'runtimeAgentOrchestrationByPaneKey' + | 'terminalLayoutsByTabId' + | 'ptyIdsByTabId' + | 'runtimePaneTitlesByTabId' + | 'acknowledgedAgentsByPaneKey' +> + +function bucketForState(state: DashboardAgentRow['state']): DashboardBucket { + switch (state) { + case 'working': + return 'working' + // 'done' folds into Idle — it's only reported when a completion hook fires, + // so it's not a reliable standalone column. The card keeps a done dot. + case 'done': + case 'idle': + return 'idle' + // blocked | waiting — the agent needs the user. + case 'blocked': + case 'waiting': + return 'attention' + } +} + +function rowTask(row: DashboardAgentRow): string { + return (row.entry.orchestration?.taskTitle ?? '').trim() || (row.entry.prompt ?? '').trim() +} + +function nonEmpty(value: string | undefined): string | undefined { + const trimmed = (value ?? '').trim() + return trimmed.length > 0 ? trimmed : undefined +} + +/** + * Derive the serializable dashboard snapshot from the live renderer store. + * Reuses the exact per-worktree row machinery the sidebar uses + * (buildWorktreeAgentRows + the indexed selectors), then flattens every + * worktree's rows into presentational cards. Subagent/child rows are excluded + * from the board (out of scope for v1). + */ +export function buildDashboardSnapshot( + state: DashboardSnapshotState, + now: number +): DashboardSnapshot { + const cards: DashboardCard[] = [] + + for (const repo of state.repos ?? []) { + for (const worktree of state.worktreesByRepo?.[repo.id] ?? []) { + if (worktree.isArchived) { + continue + } + const worktreeId = worktree.id + const liveEntries = selectLiveAgentStatusEntriesForWorktree(state, worktreeId) + const migrationUnsupported = selectMigrationUnsupportedEntriesForWorktree(state, worktreeId) + const entries = + migrationUnsupported.length > 0 + ? [ + ...liveEntries, + ...migrationUnsupported.flatMap((unsupported) => { + const entry = migrationUnsupportedToAgentStatusEntry(unsupported) + return entry ? [entry] : [] + }) + ] + : liveEntries + const terminalLayoutsByTabId = selectTerminalLayoutsForWorktree(state, worktreeId) + + const rows = applyAgentRowLineage( + buildWorktreeAgentRows({ + tabs: state.tabsByWorktree[worktreeId] ?? [], + entries, + retained: selectRetainedAgentEntriesForWorktree(state, worktreeId), + runtimePaneTitlesByTabId: selectRuntimePaneTitlesForWorktree(state, worktreeId), + ptyIdsByTabId: selectLivePtyIdsForWorktree(state, worktreeId), + terminalLayoutsByTabId, + runtimeAgentOrchestrationByPaneKey: selectRuntimeAgentOrchestrationForWorktree( + state, + worktreeId + ), + now + }) + ) + + for (const row of rows) { + // Child rows have no pane of their own; the board lists top-level agents. + if (row.rowSource === 'subagent') { + continue + } + // Title-derived rows (a live pane read only from its terminal title, no + // agent-hook status) carry synthetic prompt/lastAssistantMessage — the + // agent LABEL and a status word like "Idle". They're marked by + // startedAt === 0, and must NOT be shown as real conversation. + const isTitleDerived = row.startedAt === 0 + const routingPaneKey = row.activationPaneKey ?? row.paneKey + const parsed = parsePaneKey(routingPaneKey) + const tabId = parsed?.tabId ?? row.tab.id + const leafId = parsed?.leafId ?? null + const layoutPtyId = + (leafId ? terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId?.[leafId] : undefined) ?? null + // Layout entries survive app restarts, but their PTYs may not (parked + // tabs keep the pre-restart id). Only advertise a pty the terminal + // preview can actually serialize — ptyIdsByTabId is the liveness truth. + const ptyId = + layoutPtyId && (state.ptyIdsByTabId?.[tabId] ?? []).includes(layoutPtyId) + ? layoutPtyId + : null + const dotState = row.state as DashboardCardDotState + const bucket = bucketForState(row.state) + + cards.push({ + paneKey: row.paneKey, + ptyId, + agentType: row.agentType, + bucket, + dotState, + task: isTitleDerived ? '' : rowTask(row), + repoId: repo.id, + worktreeId, + tabId, + leafId, + repoName: repo.displayName, + worktreeName: worktree.displayName, + lastUserMessage: isTitleDerived ? undefined : nonEmpty(row.entry.prompt), + lastAgentMessage: isTitleDerived ? undefined : nonEmpty(row.entry.lastAssistantMessage), + startedAt: row.startedAt, + stateChangedAt: row.entry.stateStartedAt || row.startedAt, + // Same derivation as WorktreeCardAgents' unvisitedByPaneKey, so the + // board and the sidebar bold/mute the same agents at the same time. + unseen: + !isTitleDerived && + (state.acknowledgedAgentsByPaneKey?.[row.paneKey] ?? 0) < row.entry.stateStartedAt, + askSummary: + bucket === 'attention' ? (row.entry.interactivePrompt ?? undefined) : undefined + }) + } + } + } + + return { generatedAt: now, cards } +} diff --git a/src/renderer/src/components/dashboard/useAgentBucketCounts.ts b/src/renderer/src/components/dashboard/useAgentBucketCounts.ts new file mode 100644 index 00000000000..d302e77865e --- /dev/null +++ b/src/renderer/src/components/dashboard/useAgentBucketCounts.ts @@ -0,0 +1,87 @@ +import { useMemo } from 'react' +import { useAppStore } from '@/store' +import { useShallow } from 'zustand/react/shallow' +import type { DashboardBucket } from '../../../../shared/dashboard-snapshot' +import { buildDashboardSnapshot } from './build-dashboard-snapshot' + +export type AgentBucketCounts = Record + +const EMPTY_COUNTS: AgentBucketCounts = { attention: 0, working: 0, idle: 0 } + +/** + * Per-state agent counts for the sidebar dashboard entry, derived from the same + * builder that feeds the pop-out board so the numbers always agree. Recomputes + * only when an input slice changes (mirrors useDashboardData's cost profile). + */ +export function useAgentBucketCounts(): AgentBucketCounts { + const { + repos, + worktreesByRepo, + tabsByWorktree, + agentStatusByPaneKey, + retainedAgentsByPaneKey, + migrationUnsupportedByPtyId, + runtimeAgentOrchestrationByPaneKey, + terminalLayoutsByTabId, + ptyIdsByTabId, + runtimePaneTitlesByTabId, + agentStatusEpoch + } = useAppStore( + useShallow((s) => ({ + repos: s.repos, + worktreesByRepo: s.worktreesByRepo, + tabsByWorktree: s.tabsByWorktree, + agentStatusByPaneKey: s.agentStatusByPaneKey, + retainedAgentsByPaneKey: s.retainedAgentsByPaneKey, + migrationUnsupportedByPtyId: s.migrationUnsupportedByPtyId, + runtimeAgentOrchestrationByPaneKey: s.runtimeAgentOrchestrationByPaneKey, + terminalLayoutsByTabId: s.terminalLayoutsByTabId, + ptyIdsByTabId: s.ptyIdsByTabId, + runtimePaneTitlesByTabId: s.runtimePaneTitlesByTabId, + agentStatusEpoch: s.agentStatusEpoch + })) + ) + + return useMemo(() => { + const snapshot = buildDashboardSnapshot( + { + repos, + worktreesByRepo, + tabsByWorktree, + agentStatusByPaneKey, + retainedAgentsByPaneKey, + migrationUnsupportedByPtyId, + runtimeAgentOrchestrationByPaneKey, + terminalLayoutsByTabId, + ptyIdsByTabId, + runtimePaneTitlesByTabId, + // Counts do not render acknowledgement state, so avoid subscribing the sidebar to it. + acknowledgedAgentsByPaneKey: {} + }, + Date.now() + ) + if (snapshot.cards.length === 0) { + return EMPTY_COUNTS + } + const counts: AgentBucketCounts = { attention: 0, working: 0, idle: 0 } + for (const card of snapshot.cards) { + counts[card.bucket] += 1 + } + return counts + // Why: Date.now() is read inside the memo (not a dep) so idle-decay tracks + // agentStatusEpoch ticks, matching useDashboardData. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + repos, + worktreesByRepo, + tabsByWorktree, + agentStatusByPaneKey, + retainedAgentsByPaneKey, + migrationUnsupportedByPtyId, + runtimeAgentOrchestrationByPaneKey, + terminalLayoutsByTabId, + ptyIdsByTabId, + runtimePaneTitlesByTabId, + agentStatusEpoch + ]) +} diff --git a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx new file mode 100644 index 00000000000..6b388f95fbb --- /dev/null +++ b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx @@ -0,0 +1,151 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + acknowledgeAgents: vi.fn(), + setActiveWorktree: vi.fn(), + subscribeStore: vi.fn(() => vi.fn()), + onRevealAgent: vi.fn(), + onAckAgent: vi.fn(), + onPopoutOpenChanged: vi.fn(), + onSnapshotRequested: vi.fn(), + getPopoutOpen: vi.fn(async () => false), + publishSnapshot: vi.fn(async () => undefined), + offRevealAgent: vi.fn(), + offAckAgent: vi.fn(), + offPopoutOpenChanged: vi.fn(), + offSnapshotRequested: vi.fn() +})) + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => ({ + acknowledgeAgents: mocks.acknowledgeAgents, + setActiveWorktree: mocks.setActiveWorktree + }), + subscribe: mocks.subscribeStore + } +})) + +vi.mock('@/lib/activate-tab-and-focus-pane', () => ({ + activateTabAndFocusPane: vi.fn() +})) + +import { + dashboardSnapshotInputsChanged, + useDashboardPopoutBridge +} from './useDashboardPopoutBridge' +import type { DashboardSnapshotState } from './build-dashboard-snapshot' +import type { AppState } from '@/store/types' + +type DashboardSnapshotWatchState = DashboardSnapshotState & Pick + +function makeSnapshotWatchState(): DashboardSnapshotWatchState { + return { + repos: [], + worktreesByRepo: {}, + tabsByWorktree: {}, + agentStatusByPaneKey: {}, + retainedAgentsByPaneKey: {}, + migrationUnsupportedByPtyId: {}, + runtimeAgentOrchestrationByPaneKey: {}, + terminalLayoutsByTabId: {}, + ptyIdsByTabId: {}, + runtimePaneTitlesByTabId: {}, + acknowledgedAgentsByPaneKey: {}, + agentStatusEpoch: 0 + } +} + +function Harness({ enabled }: { enabled: boolean }): null { + useDashboardPopoutBridge(enabled) + return null +} + +describe('useDashboardPopoutBridge', () => { + let root: Root + let container: HTMLDivElement + + beforeEach(() => { + vi.clearAllMocks() + mocks.onRevealAgent.mockReturnValue(mocks.offRevealAgent) + mocks.onAckAgent.mockReturnValue(mocks.offAckAgent) + mocks.onPopoutOpenChanged.mockReturnValue(mocks.offPopoutOpenChanged) + mocks.onSnapshotRequested.mockReturnValue(mocks.offSnapshotRequested) + ;(window as unknown as { api: unknown }).api = { + dashboard: { + onRevealAgent: mocks.onRevealAgent, + onAckAgent: mocks.onAckAgent, + onPopoutOpenChanged: mocks.onPopoutOpenChanged, + onSnapshotRequested: mocks.onSnapshotRequested, + getPopoutOpen: mocks.getPopoutOpen, + publishSnapshot: mocks.publishSnapshot + } + } + container = document.createElement('div') + root = createRoot(container) + }) + + afterEach(async () => { + await act(async () => root.unmount()) + }) + + it('does not register dashboard or store subscriptions while disabled', async () => { + await act(async () => root.render()) + + expect(mocks.onRevealAgent).not.toHaveBeenCalled() + expect(mocks.onAckAgent).not.toHaveBeenCalled() + expect(mocks.onPopoutOpenChanged).not.toHaveBeenCalled() + expect(mocks.onSnapshotRequested).not.toHaveBeenCalled() + expect(mocks.getPopoutOpen).not.toHaveBeenCalled() + expect(mocks.subscribeStore).not.toHaveBeenCalled() + }) + + it('ignores unrelated store writes while retaining every snapshot input', () => { + const previousState = makeSnapshotWatchState() + expect(dashboardSnapshotInputsChanged({ ...previousState }, previousState)).toBe(false) + + const referenceInputs = [ + 'repos', + 'worktreesByRepo', + 'tabsByWorktree', + 'agentStatusByPaneKey', + 'retainedAgentsByPaneKey', + 'migrationUnsupportedByPtyId', + 'runtimeAgentOrchestrationByPaneKey', + 'terminalLayoutsByTabId', + 'ptyIdsByTabId', + 'runtimePaneTitlesByTabId', + 'acknowledgedAgentsByPaneKey' + ] as const + for (const key of referenceInputs) { + expect( + dashboardSnapshotInputsChanged({ ...previousState, [key]: {} }, previousState), + key + ).toBe(true) + } + expect( + dashboardSnapshotInputsChanged({ ...previousState, agentStatusEpoch: 1 }, previousState) + ).toBe(true) + }) + + it('releases every dashboard listener when the experiment is disabled', async () => { + await act(async () => root.render()) + + expect(mocks.onRevealAgent).toHaveBeenCalledTimes(1) + expect(mocks.onAckAgent).toHaveBeenCalledTimes(1) + expect(mocks.onPopoutOpenChanged).toHaveBeenCalledTimes(1) + expect(mocks.onSnapshotRequested).toHaveBeenCalledTimes(1) + expect(mocks.getPopoutOpen).toHaveBeenCalledTimes(1) + + await act(async () => root.render()) + + expect(mocks.offRevealAgent).toHaveBeenCalledTimes(1) + expect(mocks.offAckAgent).toHaveBeenCalledTimes(1) + expect(mocks.offPopoutOpenChanged).toHaveBeenCalledTimes(1) + expect(mocks.offSnapshotRequested).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts new file mode 100644 index 00000000000..3a8e8c2c04e --- /dev/null +++ b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts @@ -0,0 +1,157 @@ +import { useEffect } from 'react' +import { useAppStore, type AppState } from '@/store' +import { activateTabAndFocusPane } from '@/lib/activate-tab-and-focus-pane' +import { buildDashboardSnapshot, type DashboardSnapshotState } from './build-dashboard-snapshot' + +// Why: cap snapshot rebuilds during bursts of agent-status pings. The board is a +// glanceable surface, so ~4 updates/sec is plenty and keeps the cross-worktree +// rebuild off the hot path. +const PUBLISH_THROTTLE_MS = 250 + +type DashboardSnapshotWatchState = DashboardSnapshotState & Pick + +export function dashboardSnapshotInputsChanged( + state: DashboardSnapshotWatchState, + previousState: DashboardSnapshotWatchState +): boolean { + return ( + state.repos !== previousState.repos || + state.worktreesByRepo !== previousState.worktreesByRepo || + state.tabsByWorktree !== previousState.tabsByWorktree || + state.agentStatusByPaneKey !== previousState.agentStatusByPaneKey || + state.retainedAgentsByPaneKey !== previousState.retainedAgentsByPaneKey || + state.migrationUnsupportedByPtyId !== previousState.migrationUnsupportedByPtyId || + state.runtimeAgentOrchestrationByPaneKey !== previousState.runtimeAgentOrchestrationByPaneKey || + state.terminalLayoutsByTabId !== previousState.terminalLayoutsByTabId || + state.ptyIdsByTabId !== previousState.ptyIdsByTabId || + state.runtimePaneTitlesByTabId !== previousState.runtimePaneTitlesByTabId || + state.acknowledgedAgentsByPaneKey !== previousState.acknowledgedAgentsByPaneKey || + // Why: freshness can change a bucket without replacing any backing map. + state.agentStatusEpoch !== previousState.agentStatusEpoch + ) +} + +/** + * Runs in the MAIN window (mount once in App). Two responsibilities: + * 1. While the pop-out dashboard is open, derive the snapshot from the live + * store and publish it to the main process (which relays it to the popout). + * Does nothing while the popout is closed, so it's free in the common case. + * 2. Handle click-to-focus reveal requests forwarded from the popout: activate + * the agent's worktree and focus its pane in this (main) window. + */ +export function useDashboardPopoutBridge(enabled: boolean): void { + useEffect(() => { + if (!enabled) { + return + } + return window.api.dashboard.onRevealAgent((args) => { + useAppStore.getState().setActiveWorktree(args.worktreeId) + activateTabAndFocusPane(args.tabId, args.leafId, { flashFocusedPane: true }) + }) + }, [enabled]) + + // Opening a card's terminal dialog in the popout acks the agent here — the + // same ack the sidebar's bold/mute treatment reads, keeping both in lockstep. + // ?. shields App mount from dev-HMR preload skew (preload updates only on + // app restart). + useEffect(() => { + if (!enabled) { + return + } + return window.api.dashboard.onAckAgent?.((paneKey) => { + useAppStore.getState().acknowledgeAgents([paneKey]) + }) + }, [enabled]) + + useEffect(() => { + if (!enabled) { + return + } + let open = false + let disposed = false + let unsubscribeStore: (() => void) | null = null + let trailingTimer: ReturnType | null = null + let lastPublishAt = 0 + + const publishNow = (): void => { + lastPublishAt = Date.now() + const snapshot = buildDashboardSnapshot(useAppStore.getState(), lastPublishAt) + void window.api.dashboard.publishSnapshot(snapshot) + } + + // Leading + trailing throttle so the first change paints immediately and + // bursts collapse into one trailing publish. + const publishThrottled = (): void => { + if (!open || disposed) { + return + } + const elapsed = Date.now() - lastPublishAt + if (elapsed >= PUBLISH_THROTTLE_MS) { + if (trailingTimer) { + clearTimeout(trailingTimer) + trailingTimer = null + } + publishNow() + return + } + if (!trailingTimer) { + trailingTimer = setTimeout(() => { + trailingTimer = null + if (open && !disposed) { + publishNow() + } + }, PUBLISH_THROTTLE_MS - elapsed) + } + } + + const setOpen = (next: boolean): void => { + if (next === open || disposed) { + return + } + open = next + if (open) { + if (!unsubscribeStore) { + unsubscribeStore = useAppStore.subscribe((state, previousState) => { + // Why: unrelated high-frequency store writes must not rebuild a cross-worktree snapshot. + if (dashboardSnapshotInputsChanged(state, previousState)) { + publishThrottled() + } + }) + } + publishNow() + } else { + unsubscribeStore?.() + unsubscribeStore = null + if (trailingTimer) { + clearTimeout(trailingTimer) + trailingTimer = null + } + } + } + + const offOpenChanged = window.api.dashboard.onPopoutOpenChanged((next) => setOpen(next)) + // Popout mount asks for a fresh snapshot (its cached one may be stale). + const offRequested = window.api.dashboard.onSnapshotRequested(() => { + if (open) { + publishNow() + } + }) + // Recover the open state when the main window (re)mounts while a pop-out is + // already open — e.g. after a renderer reload. + void window.api.dashboard.getPopoutOpen().then((isOpen) => { + if (!disposed && isOpen) { + setOpen(true) + } + }) + + return () => { + disposed = true + offOpenChanged?.() + offRequested?.() + unsubscribeStore?.() + if (trailingTimer) { + clearTimeout(trailingTimer) + } + } + }, [enabled]) +} diff --git a/src/renderer/src/components/settings/ExperimentalPane.test.tsx b/src/renderer/src/components/settings/ExperimentalPane.test.tsx index 768e8a9fb7a..8d86cc6078b 100644 --- a/src/renderer/src/components/settings/ExperimentalPane.test.tsx +++ b/src/renderer/src/components/settings/ExperimentalPane.test.tsx @@ -134,6 +134,38 @@ describe('ExperimentalPane', () => { ) }) + it('renders the agent dashboard as an off-by-default searchable experiment', () => { + const settings = getDefaultSettings('/tmp') + const markup = renderToStaticMarkup( + + ) + + expect(settings.experimentalAgentDashboardPopout).toBe(false) + expect(markup).toContain('Agent Dashboard') + expect(markup).toContain('monitor attention, working, and idle agents') + expect(getExperimentalPaneSearchEntries().map((entry) => entry.title)).toContain( + 'Agent Dashboard' + ) + }) + + it('enables the agent dashboard through its experimental switch', async () => { + const updateSettings = vi.fn() + const { root, container } = await renderExperimentalPane({ updateSettings }) + const switchButton = container.querySelector( + '#experimental-agent-dashboard button[role="switch"]' + ) + if (!switchButton) { + throw new Error('Agent Dashboard switch was not rendered') + } + + await act(async () => { + switchButton.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(updateSettings).toHaveBeenCalledWith({ experimentalAgentDashboardPopout: true }) + root.unmount() + }) + it('renders per-workspace environments as an off-by-default experimental subsection', () => { const settings = getDefaultSettings('/tmp') const markup = renderToStaticMarkup( diff --git a/src/renderer/src/components/settings/ExperimentalPane.tsx b/src/renderer/src/components/settings/ExperimentalPane.tsx index 2aaa38d6bbb..1cf60d414f4 100644 --- a/src/renderer/src/components/settings/ExperimentalPane.tsx +++ b/src/renderer/src/components/settings/ExperimentalPane.tsx @@ -37,6 +37,9 @@ export function ExperimentalPane({ const showAgentsView = matchesSettingsSearch(searchQuery, [ getExperimentalSearchEntry().agentsView ]) + const showAgentDashboard = matchesSettingsSearch(searchQuery, [ + getExperimentalSearchEntry().agentDashboard + ]) const showNativeChat = matchesSettingsSearch(searchQuery, [ getExperimentalSearchEntry().nativeChat ]) @@ -148,6 +151,52 @@ export function ExperimentalPane({ ) : null} + {showAgentDashboard ? ( + +
+
+ +

+ {translate( + 'auto.components.settings.ExperimentalPane.agentDashboard.copy', + 'Adds an Agent Dashboard entry to the left sidebar. Open it to monitor attention, working, and idle agents in a separate window and jump into their live terminals.' + )} +

+
+ + updateSettings({ + experimentalAgentDashboardPopout: + settings.experimentalAgentDashboardPopout !== true + }) + } + /> +
+
+ ) : null} + {showNativeChat ? ( ) : null} diff --git a/src/renderer/src/components/settings/experimental-search.ts b/src/renderer/src/components/settings/experimental-search.ts index 71ff93ed5b9..b79fd0a413a 100644 --- a/src/renderer/src/components/settings/experimental-search.ts +++ b/src/renderer/src/components/settings/experimental-search.ts @@ -95,6 +95,42 @@ export const getExperimentalPaneSearchEntries = createLocalizedCatalog( ) ] }, + { + title: translate( + 'auto.components.settings.experimental.search.agentDashboard.title', + 'Agent Dashboard' + ), + description: translate( + 'auto.components.settings.experimental.search.agentDashboard.description', + 'Pop-out Kanban board for monitoring agents across worktrees.' + ), + keywords: [ + ...translateSearchKeyword( + 'auto.components.settings.experimental.search.0d24759f14', + 'experimental' + ), + ...translateSearchKeyword( + 'auto.components.settings.experimental.search.agentDashboard.agent', + 'agent' + ), + ...translateSearchKeyword( + 'auto.components.settings.experimental.search.agentDashboard.dashboard', + 'dashboard' + ), + ...translateSearchKeyword( + 'auto.components.settings.experimental.search.agentDashboard.kanban', + 'kanban' + ), + ...translateSearchKeyword( + 'auto.components.settings.experimental.search.agentDashboard.popout', + 'pop-out' + ), + ...translateSearchKeyword( + 'auto.components.settings.experimental.search.agentDashboard.worktrees', + 'worktrees' + ) + ] + }, getNativeChatExperimentalSearchEntry(), { title: translate( @@ -206,6 +242,12 @@ export function getExperimentalSearchEntry() { agentsView: findEntry( translate('auto.components.settings.experimental.search.ccc5548ac5', 'Agents View') ), + agentDashboard: findEntry( + translate( + 'auto.components.settings.experimental.search.agentDashboard.title', + 'Agent Dashboard' + ) + ), nativeChat: findEntry( translate('auto.components.settings.experimental.search.nativeChat.title', 'Native chat') ), diff --git a/src/renderer/src/components/sidebar/SidebarNav.test.tsx b/src/renderer/src/components/sidebar/SidebarNav.test.tsx index 0411aa615fd..a436942bdd3 100644 --- a/src/renderer/src/components/sidebar/SidebarNav.test.tsx +++ b/src/renderer/src/components/sidebar/SidebarNav.test.tsx @@ -77,6 +77,7 @@ vi.mock('@/components/ui/context-menu', () => ({ import { getSetupGuideSidebarEntryReady, + shouldShowAgentDashboardButton, shouldShowAgentsButton, shouldShowAutomationsButton, shouldShowMobileButton, @@ -227,6 +228,30 @@ describe('SidebarNav', () => { ).toBe(true) }) + it('shows the Agent Dashboard entry only when its experiment is enabled', () => { + expect(shouldShowAgentDashboardButton(null)).toBe(false) + expect(shouldShowAgentDashboardButton({ experimentalAgentDashboardPopout: false })).toBe(false) + expect(shouldShowAgentDashboardButton({ experimentalAgentDashboardPopout: true })).toBe(true) + }) + + it('keeps the Agent Dashboard row unmounted by default', async () => { + const container = await renderSidebarNav() + + expect(queryButtonByText(container, 'Agent Dashboard')).toBeNull() + }) + + it('mounts the Agent Dashboard row after opt-in', async () => { + setSidebarState({ + settings: { + ...getDefaultSettings('/tmp'), + experimentalAgentDashboardPopout: true + } + }) + const container = await renderSidebarNav() + + expect(queryButtonByText(container, 'Agent Dashboard')).not.toBeNull() + }) + it('shows the Mobile entry by default for older settings', () => { expect(shouldShowMobileButton(null)).toBe(true) expect(shouldShowMobileButton({})).toBe(true) diff --git a/src/renderer/src/components/sidebar/SidebarNav.tsx b/src/renderer/src/components/sidebar/SidebarNav.tsx index bf92490d370..0572150a56c 100644 --- a/src/renderer/src/components/sidebar/SidebarNav.tsx +++ b/src/renderer/src/components/sidebar/SidebarNav.tsx @@ -1,9 +1,11 @@ import React from 'react' -import { Bell, CalendarClock, EyeOff, Search, Smartphone } from 'lucide-react' +import { Bell, CalendarClock, EyeOff, LayoutDashboard, Search, Smartphone } from 'lucide-react' import { useTranslation } from 'react-i18next' import { useAppStore } from '@/store' import { cn } from '@/lib/utils' import type { GlobalSettings } from '../../../../shared/types' +import { DASHBOARD_BUCKET_ORDER, type DashboardBucket } from '../../../../shared/dashboard-snapshot' +import { useAgentBucketCounts } from '@/components/dashboard/useAgentBucketCounts' import { useActivityUnreadCount } from '@/components/activity/useActivityUnreadCount' import { useShortcutKeyComboDetails } from '@/hooks/useShortcutLabel' import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo' @@ -24,6 +26,12 @@ export function shouldShowAgentsButton( return settings?.experimentalActivity === true } +export function shouldShowAgentDashboardButton( + settings: Pick | null | undefined +): boolean { + return settings?.experimentalAgentDashboardPopout === true +} + export function shouldShowMobileButton( settings: Pick | null | undefined ): boolean { @@ -36,6 +44,76 @@ export function shouldShowAutomationsButton( return settings?.showAutomationsButton !== false } +// Per-state dot colors mirror AgentStateDot so the sidebar counts read the same +// as the board (amber = needs you, yellow = working, neutral = idle, emerald = done). +const DASHBOARD_BUCKET_DOT_CLASS: Record = { + attention: 'bg-amber-500', + working: 'bg-yellow-500', + idle: 'bg-neutral-500/50' +} + +function dashboardBucketLabel(bucket: DashboardBucket): string { + switch (bucket) { + case 'attention': + return translate('dashboardPopout.bucket.attention', 'Needs You') + case 'working': + return translate('dashboardPopout.bucket.working', 'Working') + case 'idle': + return translate('dashboardPopout.bucket.idle', 'Idle') + } +} + +function DashboardBucketCounts({ + counts +}: { + counts: Record +}): React.JSX.Element | null { + const active = DASHBOARD_BUCKET_ORDER.filter((bucket) => counts[bucket] > 0) + if (active.length === 0) { + return null + } + return ( + + {active.map((bucket) => ( + + + {counts[bucket]} + + ))} + + ) +} + +// Why: keep the dashboard's broad aggregate subscriptions out of SidebarNav so +// agent-status churn only updates this opt-in row, not the full navigation. +function AgentDashboardSidebarEntry(): React.JSX.Element { + const dashboardBucketCounts = useAgentBucketCounts() + + return ( + + ) +} + const SidebarNav = React.memo(function SidebarNav() { // Why: this memo boundary needs its own language subscription, while // translate() preserves Orca's pseudo-localization behavior. @@ -48,6 +126,7 @@ const SidebarNav = React.memo(function SidebarNav() { const updateSettings = useAppStore((s) => s.updateSettings) const activeView = useAppStore((s) => s.activeView) const showAgentsButton = useAppStore((s) => shouldShowAgentsButton(s.settings)) + const showAgentDashboardButton = useAppStore((s) => shouldShowAgentDashboardButton(s.settings)) const showAutomationsButton = useAppStore((s) => shouldShowAutomationsButton(s.settings)) const showMobileButton = useAppStore((s) => shouldShowMobileButton(s.settings)) const automationsActive = activeView === 'automations' @@ -98,6 +177,7 @@ const SidebarNav = React.memo(function SidebarNav() { ) : null} + {showAgentDashboardButton ? : null} {showAgentsButton ? ( { - skipCloseAutoFocusRef.current = true - }} - onCloseAutoFocus={(event) => { - if (!skipCloseAutoFocusRef.current) { - return - } - skipCloseAutoFocusRef.current = false - // Why: focus the clicked surface (esp. xterm) — Radix's default trigger restore steals that first click. - event.preventDefault() - }} + onPointerDownOutside={menuFocusHandoff.onPointerDownOutside} + onCloseAutoFocus={menuFocusHandoff.onCloseAutoFocus} > - {topContent} -
- {/* Why: provider-specific action sections may render richer reset-credit UI. */} - -
- {children ? ( - <> - - {children} - - ) : null} + {panelBody}
) @@ -1824,11 +1932,44 @@ export function ProviderDetailsMenu({ const CLOSE_ALL_CONTEXT_MENUS_EVENT = 'orca-close-all-context-menus' +function useStatusBarMenuFocusHandoff(): { + reset: () => void + onPointerDownOutside: () => void + onCloseAutoFocus: (event: Event) => void +} { + const skipCloseAutoFocusRef = useRef(false) + return { + reset: () => { + skipCloseAutoFocusRef.current = false + }, + onPointerDownOutside: () => { + skipCloseAutoFocusRef.current = true + }, + onCloseAutoFocus: (event) => { + if (!skipCloseAutoFocusRef.current) { + return + } + skipCloseAutoFocusRef.current = false + // Why: Radix trigger restoration steals the first click from surfaces such as xterm. + event.preventDefault() + } + } +} + function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Element | null { const floatingTerminalShortcut = useShortcutLabel('floatingTerminal.toggle') const rateLimits = useAppStore((s) => s.rateLimits) const settings = useAppStore((s) => s.settings) const refreshRateLimits = useAppStore((s) => s.refreshRateLimits) + const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) + const openSettingsPage = useAppStore((s) => s.openSettingsPage) + const usagePercentageDisplay = normalizeUsagePercentageDisplay( + useAppStore((s) => s.usagePercentageDisplay) + ) + const statusBarUsageMode = normalizeStatusBarUsageMode(useAppStore((s) => s.statusBarUsageMode)) + const setStatusBarUsageMode = useAppStore((s) => s.setStatusBarUsageMode) + const [usageMenuOpen, setUsageMenuOpen] = useState(false) + const usageMenuFocusHandoff = useStatusBarMenuFocusHandoff() const statusBarVisible = useAppStore((s) => s.statusBarVisible) const statusBarItems = useAppStore((s) => s.statusBarItems) const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction) @@ -2000,6 +2141,46 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele : 'Show Floating Workspace' const showFloatingWorkspaceAttentionDot = !floatingTerminalOpen && hasFloatingUnread + // Why: the roster must contain only status items the user left visible; + // otherwise an empty trigger would bypass those visibility controls. + const rosterProviders = [ + showClaude ? visibleClaude : null, + showCodex ? visibleCodex : null, + showGemini ? visibleGemini : null, + showAntigravity ? visibleAntigravity : null, + showOpencodeGo ? visibleOpencodeGo : null, + showKimi ? visibleKimi : null, + showMiniMax ? visibleMiniMax : null, + showGrok ? visibleGrok : null + ].filter((p): p is ProviderRateLimits => p !== null) + + const handleManageAccounts = (): void => { + setUsageMenuOpen(false) + openSettingsTarget({ pane: 'accounts', repoId: null }) + openSettingsPage() + } + const handleUsageDetails = (): void => { + setUsageMenuOpen(false) + openSettingsTarget({ pane: 'stats', repoId: null }) + openSettingsPage() + } + const handleOpenProviderAccounts = (provider: ProviderRateLimits['provider']): void => { + const sectionId = getUsageProviderAccountsSectionId(provider) + if (!sectionId) { + return + } + setUsageMenuOpen(false) + openSettingsTarget({ pane: 'accounts', repoId: null, sectionId }) + openSettingsPage() + } + const handleUsageMenuOpenChange = (nextOpen: boolean): void => { + if (nextOpen) { + usageMenuFocusHandoff.reset() + recordFeatureInteraction('usage-tracking') + } + setUsageMenuOpen(nextOpen) + } + return (
) : null - ) : ( - // Why: anchor the one-time %-display callout to this meter cluster, not a global toast. + ) : hasVisibleUsageMeters ? ( + // Consolidated roster pill → opens the all-agents Usage popover (mock parity). - {showClaude && ( - - )} - {showCodex && ( - - )} - {showGemini && ( - - )} - {showAntigravity && ( - - )} - {showOpencodeGo && ( - - )} - {showKimi && ( - - )} - {showMiniMax && ( - - )} - {showGrok && ( - - )} + + + + + + getUsageProviderAccountsSectionId(provider) !== null} + onManageAccounts={handleManageAccounts} + onUsageDetails={handleUsageDetails} + renderRow={(p, rowNode) => { + // Every provider drills into its detail panel (parity with the + // per-provider dropdowns on main); Claude/Codex additionally get + // the account switcher + runtime toggle + Codex reset credits. + if (p.provider === 'claude') { + return ( + + ) + } + if (p.provider === 'codex') { + return ( + + ) + } + return ( + + ) + }} + /> + + - )} + ) : null} {anyVisible && !isEmptyUsageState && ( diff --git a/src/renderer/src/components/status-bar/UsageRosterPanel.test.tsx b/src/renderer/src/components/status-bar/UsageRosterPanel.test.tsx new file mode 100644 index 00000000000..c61faf88367 --- /dev/null +++ b/src/renderer/src/components/status-bar/UsageRosterPanel.test.tsx @@ -0,0 +1,206 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { renderToStaticMarkup } from 'react-dom/server' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ProviderRateLimits } from '../../../../shared/rate-limit-types' + +const mocks = vi.hoisted(() => ({ + now: 1_000_000_000, + useResetCountdownClock: vi.fn(() => 1_000_000_000) +})) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) +vi.mock('@/lib/agent-catalog', () => ({ + AgentIcon: ({ agent }: { agent: string }) => +})) +vi.mock('@/hooks/useResetCountdownClock', () => ({ + useResetCountdownClock: mocks.useResetCountdownClock +})) +vi.mock('@/components/ui/dropdown-menu', () => ({ + DropdownMenuItem: ({ + children, + onSelect: _onSelect, + ...props + }: React.PropsWithChildren<{ onSelect?: () => void }>) =>
{children}
+})) + +import { UsageRosterPanel, UsageRow } from './UsageRosterPanel' + +const signedOutCodex: ProviderRateLimits = { + provider: 'codex', + session: null, + weekly: null, + updatedAt: 0, + error: 'ChatGPT authentication required to read rate limits', + status: 'error' +} + +describe('UsageRow', () => { + beforeEach(() => { + mocks.useResetCountdownClock.mockClear() + }) + + it('renders sign-in as row copy instead of nesting an interactive button', () => { + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('not signed in') + expect(markup).toContain('Sign in') + expect(markup).not.toContain(' { + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('75%') + expect(markup).toContain('width:75%') + expect(markup).not.toContain('width:25%') + }) + + it('uses one shared clock for live reset labels across the roster', () => { + const sessionReset = mocks.now + 2 * 60_000 + const weeklyReset = mocks.now + 7 * 24 * 60 * 60_000 + const markup = renderToStaticMarkup( + {}} + isRefreshing={false} + onRefresh={() => {}} + onOpenProvider={() => {}} + onSignIn={() => {}} + canSignIn={() => true} + onManageAccounts={() => {}} + onUsageDetails={() => {}} + /> + ) + + expect(mocks.useResetCountdownClock).toHaveBeenCalledOnce() + expect(mocks.useResetCountdownClock).toHaveBeenCalledWith([sessionReset, weeklyReset]) + expect(markup).toContain('Resets in 2m') + }) +}) + +describe('UsageRosterPanel density picker', () => { + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + mocks.useResetCountdownClock.mockClear() + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() + }) + + function renderPanel( + statusBarUsageMode: 'verbose' | 'compact', + onStatusBarUsageModeChange: (mode: 'verbose' | 'compact') => void + ): void { + act(() => { + root.render( + {}} + onOpenProvider={() => {}} + onSignIn={() => {}} + canSignIn={() => true} + onManageAccounts={() => {}} + onUsageDetails={() => {}} + /> + ) + }) + } + + function segmentButton(label: string): HTMLButtonElement { + const button = [...container.querySelectorAll('button')].find( + (node) => node.textContent === label + ) + if (!button) { + throw new Error(`missing "${label}" segment`) + } + return button as HTMLButtonElement + } + + it('offers named Detailed/Compact segments and marks the active one', () => { + renderPanel('compact', () => {}) + + expect(container.textContent).toContain('Detailed') + expect(container.textContent).toContain('Compact') + expect(segmentButton('Compact').getAttribute('aria-checked')).toBe('true') + expect(segmentButton('Detailed').getAttribute('aria-checked')).toBe('false') + }) + + it('switches mode when a segment is chosen', () => { + const onStatusBarUsageModeChange = vi.fn() + renderPanel('compact', onStatusBarUsageModeChange) + + act(() => { + segmentButton('Detailed').click() + }) + expect(onStatusBarUsageModeChange).toHaveBeenLastCalledWith('verbose') + + act(() => { + segmentButton('Compact').click() + }) + expect(onStatusBarUsageModeChange).toHaveBeenLastCalledWith('compact') + }) +}) diff --git a/src/renderer/src/components/status-bar/UsageRosterPanel.tsx b/src/renderer/src/components/status-bar/UsageRosterPanel.tsx new file mode 100644 index 00000000000..fa11825d140 --- /dev/null +++ b/src/renderer/src/components/status-bar/UsageRosterPanel.tsx @@ -0,0 +1,306 @@ +import React from 'react' +import { ChevronRight, RefreshCw } from 'lucide-react' +import { DropdownMenuItem } from '@/components/ui/dropdown-menu' +import { SettingsSegmentedControl } from '@/components/settings/SettingsFormControls' +import { useResetCountdownClock } from '@/hooks/useResetCountdownClock' +import { translate } from '@/i18n/i18n' +import { formatRateLimitWindowChipLabel, formatWindowLabel } from '@/lib/window-label-formatter' +import type { ProviderRateLimits, RateLimitWindow } from '../../../../shared/rate-limit-types' +import { + clampUsedPercent, + getDisplayedUsagePercentage, + type UsagePercentageDisplay +} from '../../../../shared/usage-percentage-display' +import { barColor, formatResetCountdown, getWindowSections, ProviderIcon } from './tooltip' +import { getProviderDisplayName } from './usage-error-copy' +import { formatPlanLabel, usageTextColorClass } from './usage-roster-formatting' +import { getUsageRosterRowState, type UsageRosterRowState } from './usage-roster-row-state' +import type { StatusBarUsageMode } from '../../../../shared/status-bar-usage-mode' + +type ProviderId = ProviderRateLimits['provider'] +export type UsageSection = { label: string; window: RateLimitWindow } + +// Windows/buckets that actually carry data — the null ones are absent limits. +function usedSections(p: ProviderRateLimits): UsageSection[] { + return getWindowSections(p).filter((s): s is UsageSection => s.window !== null) +} + +function providerMaxUsed(sections: UsageSection[]): number { + return sections.length > 0 + ? Math.max(...sections.map((s) => clampUsedPercent(s.window.usedPercent))) + : 0 +} + +// Buckets (Gemini Flash/Pro) keep their model name; windows use their duration. +function shortLabel( + p: ProviderRateLimits, + section: UsageSection, + useRemainingDuration = false +): string { + if (p.buckets?.some((b) => b.name === section.label)) { + return section.label + } + // fableWeekly shares the 7d window with weekly; label it distinctly so the two + // don't both render as "wk". + if (section.window === p.fableWeekly) { + return 'Fable' + } + return useRemainingDuration + ? formatRateLimitWindowChipLabel(section.window) + : formatWindowLabel(section.window.windowMinutes) +} + +export function getTightestUsageSection(p: ProviderRateLimits): UsageSection | null { + const sections = usedSections(p) + if (sections.length === 0) { + return null + } + // Why: the footer promises one quiet summary per provider; choose urgency by + // consumption even when the user displays the complementary “% left” value. + const tightest = sections.reduce((current, candidate) => + clampUsedPercent(candidate.window.usedPercent) > clampUsedPercent(current.window.usedPercent) + ? candidate + : current + ) + return { ...tightest, label: shortLabel(p, tightest, true) } +} + +// The soonest-resetting window summarizes the agent's next reset in one line. +function soonestResetLabel(sections: UsageSection[], now: number): string | null { + const resets = sections + .map((s) => s.window.resetsAt) + .filter((r): r is number => typeof r === 'number' && Number.isFinite(r)) + if (resets.length === 0) { + return null + } + return formatResetCountdown(Math.min(...resets) - now) +} + +// Presentational row: a compact header (icon · name · plan · reset) with +// the per-window metrics beneath, so the reset stays visible and multi-window +// agents stay short. The wrapper supplies padding + interaction (drill-in +// submenu or plain clickable row). +export function UsageRow({ + p, + display, + state, + showSignInAction, + now +}: { + p: ProviderRateLimits + display: UsagePercentageDisplay + state: UsageRosterRowState + showSignInAction: boolean + now: number +}): React.JSX.Element { + const sections = usedSections(p) + const hasUsage = sections.length > 0 + const name = getProviderDisplayName(p.provider) + const plan = formatPlanLabel(p.planType) + const reset = hasUsage ? soonestResetLabel(sections, now) : null + + return ( +
+
+ + + + + {name} + {plan ? · {plan} : null} + + {!hasUsage ? ( + <> + + {state.statusLabel} + + {showSignInAction ? ( + + {translate('auto.components.status.bar.StatusBar.c35af53b73', 'Sign in')} + + ) : null} + + ) : reset ? ( + {reset} + ) : null} +
+ {hasUsage ? ( +
+ {sections.map((s) => { + const used = clampUsedPercent(s.window.usedPercent) + const shown = getDisplayedUsagePercentage(s.window.usedPercent, display) + return ( + + {shortLabel(p, s)} + + + + + {shown}% + + + ) + })} +
+ ) : null} +
+ ) +} + +/** + * Consolidated "Usage" popover — one row per agent (icon · name · reset · + * per-window bars), opened from the status-bar roster pill. Deep per-agent + * actions route to Settings via the callbacks. + */ +export function UsageRosterPanel({ + providers, + display, + statusBarUsageMode, + onStatusBarUsageModeChange, + isRefreshing, + onRefresh, + onOpenProvider, + onSignIn, + canSignIn, + onManageAccounts, + onUsageDetails, + renderRow +}: { + providers: ProviderRateLimits[] + display: UsagePercentageDisplay + statusBarUsageMode: StatusBarUsageMode + onStatusBarUsageModeChange: (mode: StatusBarUsageMode) => void + isRefreshing: boolean + onRefresh: () => void + onOpenProvider: (provider: ProviderId) => void + onSignIn: (provider: ProviderId) => void + canSignIn: (provider: ProviderId) => boolean + onManageAccounts: () => void + onUsageDetails: () => void + // Lets the host wrap a provider's row in a richer control (e.g. the + // Claude/Codex account-switch drill-in submenu); return null to use the + // default clickable row. + renderRow?: (p: ProviderRateLimits, row: React.ReactNode) => React.ReactNode +}): React.JSX.Element { + // Why: one boundary-scheduled clock keeps every open row current without per-provider timers. + const now = useResetCountdownClock( + providers.flatMap((provider) => + usedSections(provider).map((section) => section.window.resetsAt) + ) + ) + // Worst-first so the agent nearest a limit sits on top. + const sorted = [...providers].sort( + (a, b) => providerMaxUsed(usedSections(b)) - providerMaxUsed(usedSections(a)) + ) + + return ( +
+
+ + {translate('auto.components.status.bar.UsageRosterPanel.title', 'Usage')} + +
+ + {translate('auto.components.status.bar.UsageRosterPanel.allAgents', 'all agents')} + + { + event.preventDefault() + onRefresh() + }} + aria-label={translate( + 'auto.components.status.bar.StatusBar.3325d996cb', + 'Refresh rate limits' + )} + className="size-5 justify-center p-0" + > + + +
+
+ {/* Density picker lives at the top of the popover it controls (view-switcher + pattern) so both modes are named and discoverable on first open. */} +
+ + value={statusBarUsageMode} + onChange={onStatusBarUsageModeChange} + ariaLabel={translate( + 'auto.components.status.bar.UsageRosterPanel.footerDetailAria', + 'Usage footer detail' + )} + size="sm" + equalWidth + options={[ + { + value: 'verbose', + label: translate('auto.components.status.bar.UsageRosterPanel.detailed', 'Detailed') + }, + { + value: 'compact', + label: translate('auto.components.status.bar.UsageRosterPanel.compact', 'Compact') + } + ]} + /> +
+
+ {sorted.map((p) => { + const state = getUsageRosterRowState(p, usedSections(p).length > 0) + const showSignInAction = state.kind === 'sign-in' && canSignIn(p.provider) + const rowNode = ( + + ) + if (showSignInAction) { + return ( + onSignIn(p.provider)} + className="w-full cursor-pointer rounded-none px-3.5 py-2.5" + > + {rowNode} + + ) + } + const custom = renderRow?.(p, rowNode) + if (custom) { + return {custom} + } + return ( + onOpenProvider(p.provider)} + className="w-full cursor-pointer rounded-none px-3.5 py-2.5" + > + {rowNode} + + ) + })} +
+ + {translate( + 'auto.components.status.bar.UsageRosterPanel.usageDetails', + 'Usage details & history' + )} + + + + {translate('auto.components.status.bar.StatusBar.75ded02687', 'Manage Accounts…')} + + +
+ ) +} diff --git a/src/renderer/src/components/status-bar/provider-segment-monthly-window.test.tsx b/src/renderer/src/components/status-bar/provider-segment-monthly-window.test.tsx index 09919903161..ef4ec96e0ef 100644 --- a/src/renderer/src/components/status-bar/provider-segment-monthly-window.test.tsx +++ b/src/renderer/src/components/status-bar/provider-segment-monthly-window.test.tsx @@ -48,7 +48,7 @@ describe('ProviderSegment monthly window', () => { const { ProviderSegment } = await import('./StatusBar') const markup = renderToStaticMarkup( - + ) expect(markup).toContain('25% used 30d') @@ -58,16 +58,19 @@ describe('ProviderSegment monthly window', () => { const { ProviderSegment } = await import('./StatusBar') const markup = renderToStaticMarkup( - + ) expect(markup).toContain('25% used 30d') expect(markup).not.toContain('···') }) - // Why: providers with session/weekly windows (OpenCode Go) keep monthly - // tooltip-only so the chip stays uncluttered. - it('keeps monthly out of the chip when session and weekly windows exist', async () => { + it('shows only the highest-used window when several windows exist', async () => { const { ProviderSegment } = await import('./StatusBar') const limits: ProviderRateLimits = { @@ -80,12 +83,35 @@ describe('ProviderSegment monthly window', () => { status: 'ok' } const markup = renderToStaticMarkup( - + ) - expect(markup).toContain('10% used 5h') - expect(markup).toContain('20% used wk') - expect(markup).not.toContain('30d') + expect(markup).toContain('30% used 30d') + expect(markup).not.toContain('10% used') + expect(markup).not.toContain('20% used') + }) + + it('selects a named bucket as the tightest provider window', async () => { + const { ProviderSegment } = await import('./StatusBar') + const limits: ProviderRateLimits = { + provider: 'gemini', + session: null, + weekly: null, + buckets: [ + { ...windowOf(25, 300), name: 'Flash' }, + { ...windowOf(80, 300), name: 'Pro' } + ], + updatedAt: Date.now(), + error: null, + status: 'ok' + } + + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('80% used Pro') + expect(markup).not.toContain('25% used') }) // Why: #8378 — status-bar chip showed fixed window size ("5h") while the @@ -106,15 +132,39 @@ describe('ProviderSegment monthly window', () => { status: 'ok' } const markup = renderToStaticMarkup( - + ) expect(markup).toContain('42% used 2h 33m') expect(markup).not.toContain('5h') - expect(markup).toContain('10% used 6d') + // The consolidated footer intentionally renders only the tightest window. + expect(markup).not.toContain('10% used') expect(markup).not.toContain('wk') } finally { dateNow.mockRestore() } }) + + it('restores every inline window in verbose mode', async () => { + const { ProviderSegment } = await import('./StatusBar') + const limits: ProviderRateLimits = { + provider: 'claude', + session: windowOf(10, 300), + weekly: windowOf(20, 10_080), + fableWeekly: windowOf(30, 10_080), + monthly: windowOf(40, 43_200), + updatedAt: Date.now(), + error: null, + status: 'ok' + } + + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('10% used 5h') + expect(markup).toContain('20% used wk') + expect(markup).toContain('30% used Fable') + expect(markup).not.toContain('40% used') + }) }) diff --git a/src/renderer/src/components/status-bar/tooltip.test.ts b/src/renderer/src/components/status-bar/tooltip.test.ts index ed80c7b9ab8..2dc23cf100a 100644 --- a/src/renderer/src/components/status-bar/tooltip.test.ts +++ b/src/renderer/src/components/status-bar/tooltip.test.ts @@ -563,11 +563,12 @@ describe('clampUsedPercent', () => { }) describe('barColor', () => { - // Why: thresholds are on % used (consumption). Guard against flipping back - // to remaining-based colors without noticing. - it('maps used percent to green / yellow / red bands', () => { - expect(barColor(0)).toBe('bg-green-500') - expect(barColor(59)).toBe('bg-green-500') + // Why: thresholds are on % used (consumption). The <60 band is neutral (not + // green) so the always-visible meter stays quiet until a limit nears; guard + // against flipping back to green or to remaining-based colors without noticing. + it('maps used percent to neutral / yellow / red bands', () => { + expect(barColor(0)).toBe('bg-muted-foreground/40') + expect(barColor(59)).toBe('bg-muted-foreground/40') expect(barColor(60)).toBe('bg-yellow-500') expect(barColor(79)).toBe('bg-yellow-500') expect(barColor(80)).toBe('bg-red-500') diff --git a/src/renderer/src/components/status-bar/tooltip.tsx b/src/renderer/src/components/status-bar/tooltip.tsx index 2a45500ddb8..fecb07d0d48 100644 --- a/src/renderer/src/components/status-bar/tooltip.tsx +++ b/src/renderer/src/components/status-bar/tooltip.tsx @@ -185,12 +185,11 @@ export function getWindowSections( // `text-background` for primary text and `text-background/50` for secondary // to stay readable inside the inverted tooltip container. -// Why: color always tracks % used so urgency reads correctly even when the meter -// fills with % remaining (#8560) — low remaining still turns red, not green. -// Green = comfortable (<60% used), yellow = caution (60-80%), red = critical (≥80%). +// Why: urgency color tracks % used even when fill represents % remaining; +// low usage stays neutral so persistent chrome stays quiet. export function barColor(usedPct: number): string { if (usedPct < 60) { - return 'bg-green-500' + return 'bg-muted-foreground/40' } if (usedPct < 80) { return 'bg-yellow-500' diff --git a/src/renderer/src/components/status-bar/usage-provider-settings-target.test.ts b/src/renderer/src/components/status-bar/usage-provider-settings-target.test.ts new file mode 100644 index 00000000000..cbcfcda0492 --- /dev/null +++ b/src/renderer/src/components/status-bar/usage-provider-settings-target.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' +import { getUsageProviderAccountsSectionId } from './usage-provider-settings-target' + +describe('getUsageProviderAccountsSectionId', () => { + it('routes providers only to settings sections that exist', () => { + expect(getUsageProviderAccountsSectionId('claude')).toBe('accounts-claude') + expect(getUsageProviderAccountsSectionId('codex')).toBe('accounts-codex') + expect(getUsageProviderAccountsSectionId('gemini')).toBe('accounts-gemini') + expect(getUsageProviderAccountsSectionId('antigravity')).toBe('accounts-gemini') + expect(getUsageProviderAccountsSectionId('opencode-go')).toBe('accounts-opencode-go') + expect(getUsageProviderAccountsSectionId('minimax')).toBe('accounts-minimax') + expect(getUsageProviderAccountsSectionId('grok')).toBe('accounts-grok') + }) + + it('does not invent an Accounts section for CLI-owned Kimi credentials', () => { + expect(getUsageProviderAccountsSectionId('kimi')).toBeNull() + }) +}) diff --git a/src/renderer/src/components/status-bar/usage-provider-settings-target.ts b/src/renderer/src/components/status-bar/usage-provider-settings-target.ts new file mode 100644 index 00000000000..59ea7104249 --- /dev/null +++ b/src/renderer/src/components/status-bar/usage-provider-settings-target.ts @@ -0,0 +1,25 @@ +import type { ProviderRateLimits } from '../../../../shared/rate-limit-types' + +export function getUsageProviderAccountsSectionId( + provider: ProviderRateLimits['provider'] +): string | null { + switch (provider) { + case 'claude': + return 'accounts-claude' + case 'codex': + return 'accounts-codex' + case 'gemini': + case 'antigravity': + // Why: Antigravity usage currently shares Gemini's OAuth configuration. + return 'accounts-gemini' + case 'opencode-go': + return 'accounts-opencode-go' + case 'minimax': + return 'accounts-minimax' + case 'grok': + return 'accounts-grok' + case 'kimi': + // Why: Orca must not mutate Kimi's CLI-owned credential lifecycle. + return null + } +} diff --git a/src/renderer/src/components/status-bar/usage-roster-formatting.test.ts b/src/renderer/src/components/status-bar/usage-roster-formatting.test.ts new file mode 100644 index 00000000000..a4e71244731 --- /dev/null +++ b/src/renderer/src/components/status-bar/usage-roster-formatting.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' +import { formatPlanLabel, usageTextColorClass } from './usage-roster-formatting' + +describe('formatPlanLabel', () => { + it('capitalizes a single-word plan', () => { + expect(formatPlanLabel('plus')).toBe('Plus') + expect(formatPlanLabel('pro')).toBe('Pro') + expect(formatPlanLabel('business')).toBe('Business') + }) + + it('title-cases multi-token plans across separators', () => { + expect(formatPlanLabel('chatgpt_business')).toBe('ChatGPT Business') + expect(formatPlanLabel('CHATGPT_PLUS')).toBe('ChatGPT Plus') + expect(formatPlanLabel('team-plus')).toBe('Team Plus') + expect(formatPlanLabel('pro trial')).toBe('Pro Trial') + }) + + it('returns null when there is no usable plan', () => { + expect(formatPlanLabel(null)).toBeNull() + expect(formatPlanLabel(undefined)).toBeNull() + expect(formatPlanLabel('')).toBeNull() + expect(formatPlanLabel(' ')).toBeNull() + }) +}) + +describe('usageTextColorClass', () => { + it('stays neutral below the 60% caution line', () => { + expect(usageTextColorClass(0)).toBe('text-foreground') + expect(usageTextColorClass(59)).toBe('text-foreground') + }) + + it('turns amber in the 60–79% caution band', () => { + expect(usageTextColorClass(60)).toBe('text-yellow-500') + expect(usageTextColorClass(79)).toBe('text-yellow-500') + }) + + it('turns red at the 80% critical line and above', () => { + expect(usageTextColorClass(80)).toBe('text-red-500') + expect(usageTextColorClass(100)).toBe('text-red-500') + }) +}) diff --git a/src/renderer/src/components/status-bar/usage-roster-formatting.ts b/src/renderer/src/components/status-bar/usage-roster-formatting.ts new file mode 100644 index 00000000000..614b540b5a5 --- /dev/null +++ b/src/renderer/src/components/status-bar/usage-roster-formatting.ts @@ -0,0 +1,32 @@ +// Pure formatting for the consolidated Usage roster, split out so it can be unit +// tested without pulling in React / UI dependencies. + +// "plus" -> "Plus", "chatgpt_business" -> "ChatGPT Business". Codex is the only +// provider that reports a plan today; others render just the name. +export function formatPlanLabel(planType: string | null | undefined): string | null { + const trimmed = planType?.trim() + if (!trimmed) { + return null + } + return trimmed + .split(/[\s_-]+/) + .map((word) => { + const normalized = word.toLowerCase() + return normalized === 'chatgpt' + ? 'ChatGPT' + : normalized.charAt(0).toUpperCase() + normalized.slice(1) + }) + .join(' ') +} + +// Mirrors barColor's 60/80 thresholds so the number matches its bar; neutral +// inherits the foreground color (STYLEGUIDE: color reserved for state). +export function usageTextColorClass(usedPercent: number): string { + if (usedPercent >= 80) { + return 'text-red-500' + } + if (usedPercent >= 60) { + return 'text-yellow-500' + } + return 'text-foreground' +} diff --git a/src/renderer/src/components/status-bar/usage-roster-row-state.test.ts b/src/renderer/src/components/status-bar/usage-roster-row-state.test.ts new file mode 100644 index 00000000000..b63a9a3ad5b --- /dev/null +++ b/src/renderer/src/components/status-bar/usage-roster-row-state.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it, vi } from 'vitest' +import type { ProviderRateLimits } from '../../../../shared/rate-limit-types' + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +import { getUsageRosterRowState } from './usage-roster-row-state' + +function provider(overrides: Partial = {}): ProviderRateLimits { + return { + provider: 'claude', + session: null, + weekly: null, + updatedAt: 0, + error: null, + status: 'ok', + ...overrides + } +} + +describe('getUsageRosterRowState', () => { + it('keeps fetching providers in a loading state instead of calling them signed out', () => { + expect(getUsageRosterRowState(provider({ status: 'fetching' }), false)).toEqual({ + kind: 'loading', + statusLabel: 'Loading usage…' + }) + }) + + it('preserves transient Claude failure copy instead of offering sign-in', () => { + expect( + getUsageRosterRowState( + provider({ + status: 'error', + error: 'OAuth token is stale', + usageMetadata: { failureKind: 'stale-token' } + }), + false + ) + ).toEqual({ kind: 'error', statusLabel: 'Refreshing sign-in' }) + expect( + getUsageRosterRowState( + provider({ + status: 'error', + error: 'network unavailable', + usageMetadata: { failureKind: 'network' } + }), + false + ) + ).toEqual({ kind: 'error', statusLabel: 'Network issue' }) + }) + + it('offers sign-in only for confirmed signed-out failures', () => { + expect( + getUsageRosterRowState( + provider({ status: 'error', usageMetadata: { failureKind: 'missing-credentials' } }), + false + ) + ).toEqual({ kind: 'sign-in', statusLabel: 'not signed in' }) + expect( + getUsageRosterRowState( + provider({ + provider: 'codex', + status: 'error', + error: 'ChatGPT authentication required to read rate limits' + }), + false + ) + ).toEqual({ kind: 'sign-in', statusLabel: 'not signed in' }) + }) + + it('does not turn an expired CLI-owned Kimi token into a sign-in action', () => { + expect( + getUsageRosterRowState( + provider({ + provider: 'kimi', + status: 'error', + error: 'Kimi token expired — open Kimi to refresh' + }), + false + ) + ).toEqual({ kind: 'error', statusLabel: 'Refresh failed' }) + }) + + it('distinguishes unavailable and empty successful responses', () => { + expect( + getUsageRosterRowState( + provider({ status: 'unavailable', error: 'Claude CLI not found' }), + false + ) + ).toEqual({ kind: 'unavailable', statusLabel: 'Usage unavailable' }) + expect(getUsageRosterRowState(provider(), false)).toEqual({ + kind: 'empty', + statusLabel: 'No usage data' + }) + }) + + it('lets real usage data win over a stale error status', () => { + expect(getUsageRosterRowState(provider({ status: 'error' }), true)).toEqual({ + kind: 'usage', + statusLabel: null + }) + }) +}) diff --git a/src/renderer/src/components/status-bar/usage-roster-row-state.ts b/src/renderer/src/components/status-bar/usage-roster-row-state.ts new file mode 100644 index 00000000000..68762327599 --- /dev/null +++ b/src/renderer/src/components/status-bar/usage-roster-row-state.ts @@ -0,0 +1,77 @@ +import { translate } from '@/i18n/i18n' +import type { ProviderRateLimits } from '../../../../shared/rate-limit-types' +import { getProviderUsageStatusLabel } from './usage-error-copy' + +export type UsageRosterRowState = { + kind: 'usage' | 'loading' | 'sign-in' | 'unavailable' | 'error' | 'empty' + statusLabel: string | null +} + +const CONFIRMED_SIGN_OUT_PATTERNS = [ + /\bnot signed in\b/i, + /\bnot logged in\b/i, + /\blogged out\b/i, + /\bauthentication required\b/i, + /\b(?:sign|log)[ -]?in required\b/i, + /\bplease (?:sign|log) in\b/i, + /\bplease reauthenticate\b/i +] + +function isConfirmedSignedOut(provider: ProviderRateLimits): boolean { + if (provider.usageMetadata?.failureKind === 'missing-credentials') { + return true + } + // Why: credential refresh and network failures can mention auth while live + // sessions remain valid; only explicit signed-out copy earns a sign-in CTA. + if (provider.usageMetadata?.failureKind) { + return false + } + const error = provider.error + return Boolean(error && CONFIRMED_SIGN_OUT_PATTERNS.some((pattern) => pattern.test(error))) +} + +export function getUsageRosterRowState( + provider: ProviderRateLimits, + hasUsage: boolean +): UsageRosterRowState { + if (hasUsage) { + return { kind: 'usage', statusLabel: null } + } + if (provider.status === 'idle' || provider.status === 'fetching') { + return { + kind: 'loading', + statusLabel: translate( + 'auto.components.status.bar.UsageRosterPanel.loadingUsage', + 'Loading usage…' + ) + } + } + if (isConfirmedSignedOut(provider)) { + return { + kind: 'sign-in', + statusLabel: translate( + 'auto.components.status.bar.UsageRosterPanel.notSignedIn', + 'not signed in' + ) + } + } + if (provider.status === 'error') { + return { kind: 'error', statusLabel: getProviderUsageStatusLabel(provider) } + } + if (provider.status === 'unavailable') { + return { + kind: 'unavailable', + statusLabel: translate( + 'auto.components.status.bar.UsageRosterPanel.usageUnavailable', + 'Usage unavailable' + ) + } + } + return { + kind: 'empty', + statusLabel: translate( + 'auto.components.status.bar.UsageRosterPanel.noUsageData', + 'No usage data' + ) + } +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index e5f93b0bcb0..8924345d3cc 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -3298,6 +3298,19 @@ "dismiss": "Dismiss", "openSettings": "Open Settings", "gotIt": "Got it" + }, + "UsageRosterPanel": { + "title": "Usage", + "openDetails": "Open usage details", + "notSignedIn": "not signed in", + "allAgents": "all agents", + "usageDetails": "Usage details & history", + "loadingUsage": "Loading usage…", + "usageUnavailable": "Usage unavailable", + "noUsageData": "No usage data", + "detailed": "Detailed", + "compact": "Compact", + "footerDetailAria": "Usage footer detail" } } }, diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index de5d52e3224..475da5a98b7 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -3275,6 +3275,19 @@ "dismiss": "Dismiss", "openSettings": "Open Settings", "gotIt": "Got it" + }, + "UsageRosterPanel": { + "title": "Usage", + "openDetails": "Open usage details", + "notSignedIn": "not signed in", + "allAgents": "all agents", + "usageDetails": "Usage details & history", + "loadingUsage": "Loading usage…", + "usageUnavailable": "Usage unavailable", + "noUsageData": "No usage data", + "detailed": "Detailed", + "compact": "Compact", + "footerDetailAria": "Usage footer detail" } } }, diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index b356273ee0a..c4465f1a46c 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -3275,6 +3275,19 @@ "dismiss": "Dismiss", "openSettings": "Open Settings", "gotIt": "Got it" + }, + "UsageRosterPanel": { + "title": "Usage", + "openDetails": "Open usage details", + "notSignedIn": "not signed in", + "allAgents": "all agents", + "usageDetails": "Usage details & history", + "loadingUsage": "Loading usage…", + "usageUnavailable": "Usage unavailable", + "noUsageData": "No usage data", + "detailed": "Detailed", + "compact": "Compact", + "footerDetailAria": "Usage footer detail" } } }, diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 12e2c9b75a3..a67574c5e58 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -3275,6 +3275,19 @@ "dismiss": "Dismiss", "openSettings": "Open Settings", "gotIt": "Got it" + }, + "UsageRosterPanel": { + "title": "Usage", + "openDetails": "Open usage details", + "notSignedIn": "not signed in", + "allAgents": "all agents", + "usageDetails": "Usage details & history", + "loadingUsage": "Loading usage…", + "usageUnavailable": "Usage unavailable", + "noUsageData": "No usage data", + "detailed": "Detailed", + "compact": "Compact", + "footerDetailAria": "Usage footer detail" } } }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 93edb6ba3a1..8a772231435 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -3275,6 +3275,19 @@ "dismiss": "Dismiss", "openSettings": "Open Settings", "gotIt": "Got it" + }, + "UsageRosterPanel": { + "title": "Usage", + "openDetails": "Open usage details", + "notSignedIn": "not signed in", + "allAgents": "all agents", + "usageDetails": "Usage details & history", + "loadingUsage": "Loading usage…", + "usageUnavailable": "Usage unavailable", + "noUsageData": "No usage data", + "detailed": "Detailed", + "compact": "Compact", + "footerDetailAria": "Usage footer detail" } } }, diff --git a/src/renderer/src/runtime/runtime-provider-accounts-client.test.ts b/src/renderer/src/runtime/runtime-provider-accounts-client.test.ts index c53d7198a85..2bd3f65e8d9 100644 --- a/src/renderer/src/runtime/runtime-provider-accounts-client.test.ts +++ b/src/renderer/src/runtime/runtime-provider-accounts-client.test.ts @@ -283,6 +283,84 @@ describe('watchProviderAccounts', () => { }) describe('fetchProviderAccountsSnapshot', () => { + it('deduplicates concurrent local reads but does not cache completed snapshots', async () => { + let resolveClaude!: (state: ClaudeRateLimitAccountsState) => void + let resolveCodex!: (state: CodexRateLimitAccountsState) => void + claudeListLocal.mockImplementation( + () => new Promise((resolve) => (resolveClaude = resolve)) + ) + codexListLocal.mockImplementation( + () => new Promise((resolve) => (resolveCodex = resolve)) + ) + + const first = fetchProviderAccountsSnapshot(LOCAL) + const second = fetchProviderAccountsSnapshot(LOCAL) + + expect(second).toBe(first) + expect(claudeListLocal).toHaveBeenCalledTimes(1) + expect(codexListLocal).toHaveBeenCalledTimes(1) + + resolveClaude(emptyClaudeState()) + resolveCodex(emptyCodexState()) + await Promise.all([first, second]) + + claudeListLocal.mockResolvedValue(emptyClaudeState()) + codexListLocal.mockResolvedValue(emptyCodexState()) + await fetchProviderAccountsSnapshot(LOCAL) + expect(claudeListLocal).toHaveBeenCalledTimes(2) + expect(codexListLocal).toHaveBeenCalledTimes(2) + }) + + it('isolates in-flight snapshots by remote account owner', async () => { + const first = fetchProviderAccountsSnapshot({ activeRuntimeEnvironmentId: 'env-1' }) + const second = fetchProviderAccountsSnapshot({ activeRuntimeEnvironmentId: 'env-2' }) + await flushMicrotasks() + + expect(runtimeEnvironmentSubscribe).toHaveBeenCalledTimes(2) + const firstCallbacks = runtimeEnvironmentSubscribe.mock.calls[0]?.[1] as SubscriptionCallbacks + const secondCallbacks = runtimeEnvironmentSubscribe.mock.calls[1]?.[1] as SubscriptionCallbacks + firstCallbacks.onResponse({ + ok: true, + result: { type: 'ready', snapshot: snapshotFixture('one') } + }) + secondCallbacks.onResponse({ + ok: true, + result: { type: 'ready', snapshot: snapshotFixture('two') } + }) + + await expect(first).resolves.toMatchObject({ codex: { activeAccountId: 'codex-one' } }) + await expect(second).resolves.toMatchObject({ codex: { activeAccountId: 'codex-two' } }) + }) + + it('does not share a local read with a remote environment named local', async () => { + let resolveClaude!: (state: ClaudeRateLimitAccountsState) => void + let resolveCodex!: (state: CodexRateLimitAccountsState) => void + claudeListLocal.mockImplementation( + () => new Promise((resolve) => (resolveClaude = resolve)) + ) + codexListLocal.mockImplementation( + () => new Promise((resolve) => (resolveCodex = resolve)) + ) + + const local = fetchProviderAccountsSnapshot(LOCAL) + const remote = fetchProviderAccountsSnapshot({ activeRuntimeEnvironmentId: 'local' }) + await flushMicrotasks() + + expect(remote).not.toBe(local) + expect(runtimeEnvironmentSubscribe).toHaveBeenCalledTimes(1) + subscriptionCallbacks?.onResponse({ + ok: true, + result: { type: 'ready', snapshot: snapshotFixture('remote-local') } + }) + resolveClaude(emptyClaudeState()) + resolveCodex(emptyCodexState()) + + await expect(remote).resolves.toMatchObject({ + codex: { activeAccountId: 'codex-remote-local' } + }) + await expect(local).resolves.toMatchObject({ codex: { activeAccountId: null } }) + }) + it('resolves with the first remote snapshot and closes the subscription', async () => { const pending = fetchProviderAccountsSnapshot(REMOTE) await flushMicrotasks() diff --git a/src/renderer/src/runtime/runtime-provider-accounts-client.ts b/src/renderer/src/runtime/runtime-provider-accounts-client.ts index ec579069c87..6a43ddf8a6e 100644 --- a/src/renderer/src/runtime/runtime-provider-accounts-client.ts +++ b/src/renderer/src/runtime/runtime-provider-accounts-client.ts @@ -33,6 +33,16 @@ const REMOTE_ACCOUNTS_FIRST_SNAPSHOT_TIMEOUT_MS = 15_000 // refreshes, and those refreshes can crawl behind broken auth. Give the call // room to finish instead of reporting failure for an applied switch. const REMOTE_ACCOUNT_MUTATION_TIMEOUT_MS = 30_000 +const pendingProviderAccountsSnapshots = new Map>() + +function getProviderAccountsOwnerKey( + settings: Pick | null | undefined +): string { + const target = getActiveRuntimeTarget(settings) + // Why: environment ids are user-controlled strings; prefix the target kind + // so a remote id such as “local” cannot share the desktop's pending read. + return target.kind === 'local' ? 'local' : `environment:${target.environmentId}` +} export function hasRemoteProviderAccountOwner( settings: Pick | null | undefined @@ -194,10 +204,16 @@ export function watchProviderAccounts( // One-shot convenience over watchProviderAccounts for surfaces that only need // the current snapshot (status-bar switcher menus). -export async function fetchProviderAccountsSnapshot( +export function fetchProviderAccountsSnapshot( settings: Pick | null | undefined ): Promise { - return await new Promise((resolve, reject) => { + const ownerKey = getProviderAccountsOwnerKey(settings) + const pending = pendingProviderAccountsSnapshots.get(ownerKey) + if (pending) { + return pending + } + + const request = new Promise((resolve, reject) => { const watcher = watchProviderAccounts(settings, { onSnapshot: (snapshot) => { watcher.close() @@ -209,6 +225,16 @@ export async function fetchProviderAccountsSnapshot( } }) }) + pendingProviderAccountsSnapshots.set(ownerKey, request) + const clearPending = (): void => { + if (pendingProviderAccountsSnapshots.get(ownerKey) === request) { + pendingProviderAccountsSnapshots.delete(ownerKey) + } + } + // Why: both status-bar switchers mount together; share their in-flight read + // without caching the result past completion or across account owners. + void request.then(clearPending, clearPending) + return request } export async function selectClaudeProviderAccount( diff --git a/src/renderer/src/store/slices/ui.test.ts b/src/renderer/src/store/slices/ui.test.ts index f7fa68279f1..b9d9454de08 100644 --- a/src/renderer/src/store/slices/ui.test.ts +++ b/src/renderer/src/store/slices/ui.test.ts @@ -1452,6 +1452,34 @@ describe('createUISlice hydratePersistedUI', () => { expect(store.getState().usagePercentageDisplay).toBe('used') }) + it('persists and hydrates the status bar usage mode', () => { + const setUI = vi.fn().mockResolvedValue(undefined) + vi.stubGlobal('window', { api: { ui: { set: setUI } } }) + const store = createUIStore() + + expect(store.getState().statusBarUsageMode).toBe('verbose') + + store.getState().setStatusBarUsageMode('compact') + + expect(store.getState().statusBarUsageMode).toBe('compact') + expect(setUI).toHaveBeenCalledWith({ statusBarUsageMode: 'compact' }) + + store.getState().hydratePersistedUI(makePersistedUI({ statusBarUsageMode: 'verbose' })) + expect(store.getState().statusBarUsageMode).toBe('verbose') + }) + + it('defaults invalid status bar usage modes to verbose', () => { + const store = createUIStore() + + store.getState().hydratePersistedUI( + makePersistedUI({ + statusBarUsageMode: 'expanded' as PersistedUIState['statusBarUsageMode'] + }) + ) + + expect(store.getState().statusBarUsageMode).toBe('verbose') + }) + it('clamps persisted workspace board column width', () => { const store = createUIStore() diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index fc0d5eba962..1a46226d356 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -41,6 +41,11 @@ import { DEFAULT_USAGE_PERCENTAGE_DISPLAY, normalizeUsagePercentageDisplay } from '../../../../shared/usage-percentage-display' +import { + DEFAULT_STATUS_BAR_USAGE_MODE, + normalizeStatusBarUsageMode, + type StatusBarUsageMode +} from '../../../../shared/status-bar-usage-mode' import type { GitLabWorkItem } from '../../../../shared/gitlab-types' import type { LaunchSource } from '../../../../shared/telemetry-events' import type { TaskSourceContext } from '../../../../shared/task-source-context' @@ -878,6 +883,8 @@ export type UISlice = { setStatusBarVisible: (v: boolean) => void usagePercentageDisplay: UsagePercentageDisplay setUsagePercentageDisplay: (display: UsagePercentageDisplay) => void + statusBarUsageMode: StatusBarUsageMode + setStatusBarUsageMode: (mode: StatusBarUsageMode) => void workspacePortScan: { key: string; result: WorkspacePortScanResult } | null workspacePortScansByKey: Record workspacePortScanRefreshing: boolean @@ -2109,6 +2116,12 @@ export const createUISlice: StateCreator = (set, get) usagePercentageDisplayChangeNoticeDismissed: true }) }, + statusBarUsageMode: DEFAULT_STATUS_BAR_USAGE_MODE, + setStatusBarUsageMode: (mode) => { + const normalized = normalizeStatusBarUsageMode(mode) + window.api.ui.set({ statusBarUsageMode: normalized }).catch(console.error) + set({ statusBarUsageMode: normalized }) + }, workspacePortScan: null, workspacePortScansByKey: {}, workspacePortScanRefreshing: false, @@ -2377,6 +2390,7 @@ export const createUISlice: StateCreator = (set, get) statusBarItems: statusBarItemsWithGrok, statusBarVisible: ui.statusBarVisible ?? true, usagePercentageDisplay: normalizeUsagePercentageDisplay(ui.usagePercentageDisplay), + statusBarUsageMode: normalizeStatusBarUsageMode(ui.statusBarUsageMode), // Why: default true so existing users see the pet on first enabling the flag; only an explicit Hide persists false. petVisible: ui.petVisible ?? ui.sidekickVisible ?? true, petSize: clampPetSize(ui.petSize ?? ui.sidekickSize ?? PET_SIZE_DEFAULT), diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index f65fe06075e..ba97c10f127 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -79,6 +79,7 @@ import { normalizeTerminalCursorStyleDefault } from '../../../shared/terminal-cu import { normalizeTerminalCustomThemes } from '../../../shared/terminal-custom-themes' import { normalizeUiLanguage } from '../../../shared/ui-language' import { normalizeUsagePercentageDisplay } from '../../../shared/usage-percentage-display' +import { normalizeStatusBarUsageMode } from '../../../shared/status-bar-usage-mode' import type { RateLimitState } from '../../../shared/rate-limit-types' import type { RuntimeStatus, RuntimeSyncWindowGraph } from '../../../shared/runtime-types' import { @@ -3455,6 +3456,9 @@ function mergeWebUIState( ), usagePercentageDisplay: normalizeUsagePercentageDisplay( safeUpdates.usagePercentageDisplay ?? base.usagePercentageDisplay + ), + statusBarUsageMode: normalizeStatusBarUsageMode( + safeUpdates.statusBarUsageMode ?? base.statusBarUsageMode ) } } diff --git a/src/shared/constants.ts b/src/shared/constants.ts index d33cbd7e1f9..a9dc020ac3f 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -32,6 +32,7 @@ import { DEFAULT_SOURCE_CONTROL_GROUP_ORDER } from './source-control-group-order import { DEFAULT_SETUP_AGENT_STARTUP_POLICY } from './setup-agent-startup-policy' import { DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT } from './terminal-scrollback-policy' import { DEFAULT_USAGE_PERCENTAGE_DISPLAY } from './usage-percentage-display' +import { DEFAULT_STATUS_BAR_USAGE_MODE } from './status-bar-usage-mode' export { DEFAULT_STATUS_BAR_ITEMS } from './status-bar-defaults' export { @@ -465,6 +466,7 @@ export function getDefaultUIState(): PersistedUIState { statusBarItems: [...DEFAULT_STATUS_BAR_ITEMS], statusBarVisible: true, usagePercentageDisplay: DEFAULT_USAGE_PERCENTAGE_DISPLAY, + statusBarUsageMode: DEFAULT_STATUS_BAR_USAGE_MODE, dismissedUpdateVersion: null, lastUpdateCheckAt: null, trustedOrcaHooks: {}, diff --git a/src/shared/rate-limit-types.ts b/src/shared/rate-limit-types.ts index 0e77decc76e..46e79b79ac2 100644 --- a/src/shared/rate-limit-types.ts +++ b/src/shared/rate-limit-types.ts @@ -76,6 +76,8 @@ export type ProviderRateLimits = { grantedAt: number | null }[] } | null + /** Subscription plan tier for the active account (Codex `plan_type`, e.g. "plus"). */ + planType?: string | null /** Unix ms timestamp of the last successful data update. */ updatedAt: number /** Human-readable error message, null when status is 'ok'. */ diff --git a/src/shared/status-bar-usage-mode.test.ts b/src/shared/status-bar-usage-mode.test.ts new file mode 100644 index 00000000000..f5d2e3e384c --- /dev/null +++ b/src/shared/status-bar-usage-mode.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest' +import { normalizeStatusBarUsageMode } from './status-bar-usage-mode' + +describe('normalizeStatusBarUsageMode', () => { + it('defaults missing and invalid values to verbose', () => { + expect(normalizeStatusBarUsageMode(undefined)).toBe('verbose') + expect(normalizeStatusBarUsageMode('expanded')).toBe('verbose') + }) + + it('preserves supported modes', () => { + expect(normalizeStatusBarUsageMode('verbose')).toBe('verbose') + expect(normalizeStatusBarUsageMode('compact')).toBe('compact') + }) +}) diff --git a/src/shared/status-bar-usage-mode.ts b/src/shared/status-bar-usage-mode.ts new file mode 100644 index 00000000000..aa780e92909 --- /dev/null +++ b/src/shared/status-bar-usage-mode.ts @@ -0,0 +1,7 @@ +export type StatusBarUsageMode = 'verbose' | 'compact' + +export const DEFAULT_STATUS_BAR_USAGE_MODE: StatusBarUsageMode = 'verbose' + +export function normalizeStatusBarUsageMode(value: unknown): StatusBarUsageMode { + return value === 'verbose' || value === 'compact' ? value : DEFAULT_STATUS_BAR_USAGE_MODE +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 20924539f0f..438a831648b 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -41,6 +41,7 @@ import type { LocalWindowsRuntimePreference } from './project-execution-runtime' import type { UsagePercentageDisplay } from './usage-percentage-display' +import type { StatusBarUsageMode } from './status-bar-usage-mode' import type { PersistedNativeChatSessionOptions } from './native-chat-session-options' // Re-exported for backward compat with renderer call sites that import @@ -3233,6 +3234,8 @@ export type PersistedUIState = { statusBarVisible: boolean /** Why: this is client-side presentation, not a provider/account or execution-host setting. */ usagePercentageDisplay?: UsagePercentageDisplay + /** Client-side footer presentation; verbose preserves the pre-roster all-window default. */ + statusBarUsageMode?: StatusBarUsageMode dismissedUpdateVersion: string | null lastUpdateCheckAt: number | null pendingUpdateNudgeId?: string | null From 6424a735201e3f0b053e933d984e64a9c6681c1f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 00:54:15 +0000 Subject: [PATCH 11/58] Update README downloads badge --- docs/assets/readme-downloads.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index a21f01fca97..c516026baa6 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 6.7m + + downloads: 6.8m @@ -15,7 +15,7 @@ downloads downloads - 6.7m - 6.7m + 6.8m + 6.8m From 57865139d99960f32f2c148acc7323b071e51052 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 20 Jul 2026 18:18:11 -0700 Subject: [PATCH 12/58] Fix 6 e2e test flakes: worktree teardown, window focus, and terminal cleanup (#9625) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix 6 e2e flakes: worktree teardown, window focus, and terminal cleanup Tolerate selector_not_found during destructive worktree removal, retry transient window reopens with bounded attempts, collapse dead split leaves in parked tabs, stabilize tab-close persistence and diff-scroll assertions, and forward app logs for visibility into failures. Each fix addresses a root cause—not a test workaround —and includes a verification pass confirming no regressions. Fixes identified in shard-10 CI failure (oracle #29710852934). * Remove E2E fix verification document This smoke-evidence file was used to track adversarial verification of the 6 e2e fixes during development. Now that the fixes are committed and tested, the temporary verification notes can be removed. * Unify win32 window activation for sync and retry paths Extract activateWindow to prevent drift between sync and async paths on win32 reinforcement (moveTop, pulseAlwaysOnTop, retry focus). Adds test coverage for window recovery on retry. --- src/main/runtime/worktree-teardown.test.ts | 19 +++ src/main/runtime/worktree-teardown.ts | 12 +- src/main/window/focus-existing-window.test.ts | 142 +++++++++++++++++- src/main/window/focus-existing-window.ts | 80 ++++++++-- .../terminal-parked-tab-watchers.test.ts | 36 +++++ .../terminal-parked-tab-watchers.ts | 12 +- .../e2e/combined-diff-scroll-restore.spec.ts | 10 +- .../headless-serve-desktop-activation.spec.ts | 21 ++- tests/e2e/helpers/orca-app.ts | 5 +- ...inal-tab-close-restart-persistence.spec.ts | 18 ++- 10 files changed, 323 insertions(+), 32 deletions(-) diff --git a/src/main/runtime/worktree-teardown.test.ts b/src/main/runtime/worktree-teardown.test.ts index 827ec95d92d..1025c6be020 100644 --- a/src/main/runtime/worktree-teardown.test.ts +++ b/src/main/runtime/worktree-teardown.test.ts @@ -567,6 +567,25 @@ describe('killAllProcessesForWorktree', () => { expect(result.runtimeStopped).toBe(0) }) + it('tolerates an unresolved runtime selector during destructive removal', async () => { + // Why: a just-created/removed worktree can be absent from the runtime + // graph; that means zero runtime-owned PTYs, not a failed teardown. + const stopTerminalsForWorktree = vi.fn().mockRejectedValue(new Error('selector_not_found')) + const runtime = { + stopTerminalsForWorktree + } as unknown as Parameters[1]['runtime'] + const localProvider = createProviderStub(async () => []) + listRegisteredPtysMock.mockReturnValue([]) + + const result = await killAllProcessesForWorktree('w1', { + runtime, + localProvider, + requirePhysicalStop: true + }) + + expect(result.runtimeStopped).toBe(0) + }) + it('fails destructive teardown closed when the runtime sweep rejects', async () => { const stopTerminalsForWorktree = vi.fn().mockRejectedValue(new Error('runtime sweep failed')) const runtime = { diff --git a/src/main/runtime/worktree-teardown.ts b/src/main/runtime/worktree-teardown.ts index 77ebaa8737a..8dfe63a9f7e 100644 --- a/src/main/runtime/worktree-teardown.ts +++ b/src/main/runtime/worktree-teardown.ts @@ -93,15 +93,21 @@ export async function killAllProcessesForWorktree( return current } - // Why: headless CLI has no ready renderer graph; only that known sentinel - // may fall through to the provider and registry physical-owner sweeps. + // Why: headless CLI has no ready renderer graph, and a just-created/removed + // worktree may not resolve in the graph yet; either case means zero + // runtime-owned PTYs, so both sentinels fall through instead of failing + // destructive removal closed. const runtimeSweep = deps.runtime ? settleBeforeDeadline( () => deps.runtime!.stopTerminalsForWorktree(worktreeId, { deadline, stopPty }), { stopped: 0 }, deadline, deps.requirePhysicalStop ? deadlineError : undefined, - (error) => !(error instanceof Error && error.message === 'runtime_unavailable') + (error) => + !( + error instanceof Error && + (error.message === 'runtime_unavailable' || error.message === 'selector_not_found') + ) ) : Promise.resolve({ stopped: 0 }) const providerSweep = settleBeforeDeadline( diff --git a/src/main/window/focus-existing-window.test.ts b/src/main/window/focus-existing-window.test.ts index e452489a6eb..e12e71ae109 100644 --- a/src/main/window/focus-existing-window.test.ts +++ b/src/main/window/focus-existing-window.test.ts @@ -63,7 +63,14 @@ function makeTimer(): { return callbacks.length }, run: (ms) => { - for (const entry of callbacks.filter((entry) => entry.ms === ms)) { + // Why: splice out matching entries before invoking — real timers fire + // once, and a retry callback here can schedule a fresh same-ms entry + // that a later run() call must not confuse with one already fired. + const due = callbacks.filter((entry) => entry.ms === ms) + for (const entry of due) { + callbacks.splice(callbacks.indexOf(entry), 1) + } + for (const entry of due) { entry.callback() } }, @@ -154,4 +161,137 @@ describe('focusExistingMainWindow', () => { expect(openedWindow.calls.show).toHaveBeenCalledTimes(1) expect(openedWindow.calls.focus).toHaveBeenCalledTimes(1) }) + + it('retries a transient reopen failure instead of stranding the app windowless', () => { + const app = makeFakeApp() + const timer = makeTimer() + const openedWindow = makeFakeWindow() + const warn = vi.fn() + let attempts = 0 + const openWindow = vi.fn(() => { + attempts += 1 + if (attempts < 2) { + throw new Error('transient failure') + } + return openedWindow + }) + + const result = focusExistingMainWindow({ + app, + getWindow: () => null, + openWindow, + warn, + setTimeout: timer.setTimeout + }) + + // Why: the first attempt's own return value is synchronous, so a retry + // scheduled after the first throw still reports 'pending' immediately. + expect(result).toBe('pending') + expect(warn).toHaveBeenCalledTimes(1) + expect(openWindow).toHaveBeenCalledTimes(1) + + timer.run(300) + expect(openWindow).toHaveBeenCalledTimes(2) + expect(openedWindow.calls.show).toHaveBeenCalledTimes(1) + expect(openedWindow.calls.focus).toHaveBeenCalledTimes(1) + }) + + it('adopts a window that appeared during the retry gap instead of opening a duplicate', () => { + // Why: openMainWindow is not idempotent — a duplicate open would orphan the + // window already on screen. If getWindow() resolves before the retry fires, + // the retry must reveal that window and never call openWindow again. + const app = makeFakeApp() + const timer = makeTimer() + const raceWindow = makeFakeWindow() + const warn = vi.fn() + let liveWindow: BrowserWindow | null = null + const openWindow = vi.fn(() => { + // First attempt throws, but a concurrent path lands a window before retry. + liveWindow = raceWindow + throw new Error('transient failure') + }) + + const result = focusExistingMainWindow({ + app, + getWindow: () => liveWindow, + openWindow, + warn, + setTimeout: timer.setTimeout + }) + + expect(result).toBe('pending') + expect(openWindow).toHaveBeenCalledTimes(1) + + timer.run(300) + // Retry adopts the raced-in window; it must not construct a second one. + expect(openWindow).toHaveBeenCalledTimes(1) + expect(raceWindow.calls.show).toHaveBeenCalledTimes(1) + expect(raceWindow.calls.focus).toHaveBeenCalledTimes(1) + }) + + it('reinforces win32 activation when a window is recovered on the retry path', () => { + const app = makeFakeApp() + const timer = makeTimer() + const openedWindow = makeFakeWindow() + const warn = vi.fn() + let attempts = 0 + const openWindow = vi.fn(() => { + attempts += 1 + if (attempts < 2) { + throw new Error('transient failure') + } + return openedWindow + }) + + focusExistingMainWindow({ + app, + getWindow: () => null, + openWindow, + warn, + platform: 'win32', + setTimeout: timer.setTimeout + }) + + timer.run(300) + // Why: the retry callback must run the same win32 reinforcement + focus retry + // as the sync success path, not just show/focus. + expect(openedWindow.calls.moveTop).toHaveBeenCalledTimes(1) + expect(openedWindow.calls.setAlwaysOnTop).toHaveBeenCalledWith(true) + + timer.run(100) + expect(app.focus).toHaveBeenCalledTimes(2) + expect(openedWindow.calls.focus).toHaveBeenCalledTimes(2) + + timer.run(250) + expect(openedWindow.calls.setAlwaysOnTop).toHaveBeenLastCalledWith(false) + }) + + it('gives up after exhausting reopen retries', () => { + const timer = makeTimer() + const warn = vi.fn() + const openWindow = vi.fn(() => { + throw new Error('persistent failure') + }) + + const result = focusExistingMainWindow({ + app: makeFakeApp(), + getWindow: () => null, + openWindow, + warn, + setTimeout: timer.setTimeout + }) + + expect(result).toBe('pending') + expect(openWindow).toHaveBeenCalledTimes(1) + + timer.run(300) + timer.run(300) + // Why: REOPEN_MAX_ATTEMPTS caps total tries at 3 (1 initial + 2 retries); + // a further run() call must not schedule or fire a 4th attempt. + expect(openWindow).toHaveBeenCalledTimes(3) + expect(warn).toHaveBeenCalledTimes(3) + timer.run(300) + expect(openWindow).toHaveBeenCalledTimes(3) + expect(warn).toHaveBeenCalledTimes(3) + }) }) diff --git a/src/main/window/focus-existing-window.ts b/src/main/window/focus-existing-window.ts index 7c73b58b1c9..3ba740cd2df 100644 --- a/src/main/window/focus-existing-window.ts +++ b/src/main/window/focus-existing-window.ts @@ -64,6 +64,66 @@ function retryFocus(window: BrowserWindow, app: Pick, setTimer: Fo }, 100) } +// Why: shared so the sync success path and the async retry/adopt callback can't +// drift on win32 reinforcement (moveTop/pulseAlwaysOnTop) or the 100ms focus retry. +function activateWindow( + window: BrowserWindow, + app: Pick, + platform: NodeJS.Platform, + setTimer: FocusTimer +): void { + safelyFocusApp(app) + safelyRevealWindow(window) + if (platform === 'win32') { + try { + window.moveTop() + } catch { + // Older Electron versions or destroyed windows may reject this; focus retry remains. + } + pulseAlwaysOnTop(window, setTimer) + } + retryFocus(window, app, setTimer) +} + +// Why: a second-instance/activate reopen can race transient startup pressure +// (e.g. GPU/process churn right after another launch attempt exits); one +// swallowed throw would otherwise strand the app with no window until some +// later external trigger happens to retry. +const REOPEN_MAX_ATTEMPTS = 3 +const REOPEN_RETRY_DELAY_MS = 300 + +function openWindowWithRetry( + opts: Pick, + platform: NodeJS.Platform, + setTimer: FocusTimer, + attempt: number +): BrowserWindow | null { + try { + return opts.openWindow() + } catch (error) { + opts.warn?.('[window] Failed to reopen main window for second-instance launch', error) + if (attempt >= REOPEN_MAX_ATTEMPTS) { + return null + } + setTimer(() => { + // Why: openWindow() (openMainWindow) is not idempotent — it constructs and + // registers a fresh BrowserWindow on every call. Between attempts another + // path (or a first attempt that threw after creating its window) may have + // produced a live window, so adopt it instead of opening a duplicate that + // would orphan the one already on screen. + const existing = opts.getWindow() + const window = + existing && !existing.isDestroyed() + ? existing + : openWindowWithRetry(opts, platform, setTimer, attempt + 1) + if (window) { + activateWindow(window, opts.app, platform, setTimer) + } + }, REOPEN_RETRY_DELAY_MS) + return null + } +} + export function focusExistingMainWindow( opts: FocusExistingMainWindowOptions ): FocusExistingMainWindowResult { @@ -76,25 +136,13 @@ export function focusExistingMainWindow( if (!opts.app.isReady()) { return 'pending' } - try { - window = opts.openWindow() - openedWindow = true - } catch (error) { - opts.warn?.('[window] Failed to reopen main window for second-instance launch', error) + window = openWindowWithRetry(opts, platform, setTimer, 1) + if (!window) { return 'pending' } + openedWindow = true } - safelyFocusApp(opts.app) - safelyRevealWindow(window) - if (platform === 'win32') { - try { - window.moveTop() - } catch { - // Older Electron versions or destroyed windows may reject this; focus retry remains. - } - pulseAlwaysOnTop(window, setTimer) - } - retryFocus(window, opts.app, setTimer) + activateWindow(window, opts.app, platform, setTimer) return openedWindow ? 'opened' : 'focused' } diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts index 00c73ae9be1..d2a56f66bf7 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts @@ -259,6 +259,42 @@ describe('terminal-parked-tab-watchers', () => { expect(getParkedTerminalWatcherTabIds()).toEqual([TAB_ID]) }) + it('collapses a dead split leaf even when a stale primary handler also observed the exit', () => { + // Why (regression, #ghost-blank-pane): a genuinely parked tab's PaneManager + // is already destroyed, so the retained primary exit handler's own + // split-collapse path is a no-op against the persisted layout — hadPrimary + // must not skip this sidecar's collapse for a surviving sibling leaf. + capturePanes([ + { ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }, + { ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false } + ]) + syncParked() + mockStoreState.terminalLayoutsByTabId[TAB_ID] = { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: LEAF_ID }, + second: { type: 'leaf', leafId: SECOND_LEAF_ID } + }, + activeLeafId: SECOND_LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: PTY_ID, [SECOND_LEAF_ID]: SECOND_PTY_ID } + } + + const exited = exitSubscriptions.find((entry) => entry.ptyId === SECOND_PTY_ID) + exited?.callback(0, { hadPrimary: true }) + + expect(mockStoreState.setTabLayout).toHaveBeenCalledWith(TAB_ID, { + root: { type: 'leaf', leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: PTY_ID } + }) + expect(startedWatchers[1].dispose).toHaveBeenCalledTimes(1) + expect(startedWatchers[0].dispose).not.toHaveBeenCalled() + expect(getParkedTerminalWatcherTabIds()).toEqual([TAB_ID]) + }) + it('seeds each watcher with the pane slot last known runtime title', () => { mockStoreState.runtimePaneTitlesByTabId = { [TAB_ID]: { 1: '⠋ Build feature' } } capturePanes([ diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts index 8261e1a66c2..581378cf40c 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts @@ -146,16 +146,16 @@ function startParkedTabWatchers( const unsubscribeExit = subscribeToPtyExit(ptyId, (_code, { hadPrimary }) => { // Why: while parked this sidecar is the only exit observer, so teardown must run here or dead leaves resurrect on reveal. useAppStore.getState().clearRuntimePaneTitle(tab.id, pane.paneId) - if (hadPrimary) { - // Why: primary exit owner already closes the tab/leaf; just retire parked observation so one exit can't double-confirm. + if (disposersByPtyId.size > 1) { + // Why: a parked PaneManager is gone, so its retained primary cannot remove a dead split leaf from persisted layout. + discardPreHandlerPtyState(ptyId) + collapseParkedExitedLeaf(tab.id, ptyId) disposersByPtyId.get(ptyId)?.() disposersByPtyId.delete(ptyId) return } - if (disposersByPtyId.size > 1) { - // Why: dead split leaf never remounts; consume its buffered frame and tombstone duplicate exits. - discardPreHandlerPtyState(ptyId) - collapseParkedExitedLeaf(tab.id, ptyId) + if (hadPrimary) { + // Why: the sole pane's primary owner closes its tab; retire the sidecar to avoid duplicate confirmation. disposersByPtyId.get(ptyId)?.() disposersByPtyId.delete(ptyId) return diff --git a/tests/e2e/combined-diff-scroll-restore.spec.ts b/tests/e2e/combined-diff-scroll-restore.spec.ts index 784e67c3eb0..496bead3db6 100644 --- a/tests/e2e/combined-diff-scroll-restore.spec.ts +++ b/tests/e2e/combined-diff-scroll-restore.spec.ts @@ -453,8 +453,16 @@ test.describe('Combined diff scroll restore', () => { // Assert the viewport barely moved rather than an exact anchor key: sections // are ~viewport-sized, so a sub-pixel focus scroll from the click can flip the // topmost-visible key by one without meaningfully moving the scroll position. - expect(Math.abs(afterLineClick.scrollTop - afterSwitch.scrollTop)).toBeLessThan(40) expect(Math.abs(afterLineClick.top - afterSwitch.top)).toBeLessThan(80) + // Why: when a section above the viewport swaps its estimated height for + // Monaco's measured one, scrollHeight changes and Chromium's default + // scroll anchoring (no `overflow-anchor: none` here) legitimately moves + // raw scrollTop to keep the row above asserted `top` pinned — that is + // not an anchoring regression, so only cap scrollTop drift when content + // height held steady (same rule as getLargestBackwardScrollJump above). + if (afterLineClick.scrollHeight === afterSwitch.scrollHeight) { + expect(Math.abs(afterLineClick.scrollTop - afterSwitch.scrollTop)).toBeLessThan(40) + } } finally { rmSync(fixture.repoPath, { recursive: true, force: true }) } diff --git a/tests/e2e/headless-serve-desktop-activation.spec.ts b/tests/e2e/headless-serve-desktop-activation.spec.ts index 0baa26d0761..77b85929d70 100644 --- a/tests/e2e/headless-serve-desktop-activation.spec.ts +++ b/tests/e2e/headless-serve-desktop-activation.spec.ts @@ -3,7 +3,7 @@ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'no import os from 'node:os' import path from 'node:path' import { _electron as electron, type ElectronApplication } from '@stablyai/playwright-test' -import { test, expect } from './helpers/orca-app' +import { test, expect, forwardElectronProcessLogs } from './helpers/orca-app' import { TEST_REPO_PATH_FILE } from './global-setup' import { getE2ECompletedOnboardingProfile } from './helpers/e2e-completed-onboarding-profile' import { getOrcaElectronLaunchArgs } from './helpers/electron-launch-args' @@ -87,7 +87,7 @@ async function waitForProcessExit(child: ChildProcess, timeoutMs: number): Promi test.describe.configure({ mode: 'serial' }) test('promotes the headless owner without replacing its daemon terminal', async (// oxlint-disable-next-line no-empty-pattern -- This lifecycle test owns both launches and intentionally opts out of the default app fixture. -{}) => { +{}, testInfo) => { const repoPath = readFileSync(TEST_REPO_PATH_FILE, 'utf8').trim() if (!repoPath || !existsSync(repoPath)) { test.skip(true, 'Global setup did not produce a seeded test repo') @@ -113,6 +113,8 @@ test('promotes the headless owner without replacing its daemon terminal', async }) const resolvedHome = await serveApp.evaluate(({ app }) => app.getPath('home')) assertElectronResolvedIsolatedHome(resolvedHome, homeIsolation) + // Why: this spec bypasses the app fixture, so opt into its gated Electron log capture for CI failures. + forwardElectronProcessLogs(serveApp, testInfo) const ownerPid = serveApp.process().pid const client = new RuntimeClient(userDataDir, 5_000) @@ -154,13 +156,26 @@ test('promotes the headless owner without replacing its daemon terminal', async ) .toContain(beforeMarker) + const forwardAppLogs = process.env.ORCA_E2E_FORWARD_APP_LOGS === '1' activatingProcess = spawn(electronPath, getOrcaElectronLaunchArgs(mainPath, false), { env, - stdio: 'ignore' + stdio: forwardAppLogs ? 'pipe' : 'ignore' }) activatingProcess.on('error', (error) => { console.error('[e2e] activating process failed to spawn:', error) }) + if (forwardAppLogs) { + const prefix = '[e2e] activating process' + activatingProcess.stdout?.on('data', (chunk: Buffer) => { + console.log(`${prefix} stdout: ${chunk.toString().trimEnd()}`) + }) + activatingProcess.stderr?.on('data', (chunk: Buffer) => { + console.error(`${prefix} stderr: ${chunk.toString().trimEnd()}`) + }) + activatingProcess.on('exit', (code, signal) => { + console.log(`${prefix} exit: code=${code ?? 'null'} signal=${signal ?? 'null'}`) + }) + } const page = await serveApp.firstWindow({ timeout: 60_000 }) await page.waitForLoadState('domcontentloaded') diff --git a/tests/e2e/helpers/orca-app.ts b/tests/e2e/helpers/orca-app.ts index 0e8f4aaf386..7fc62563bf9 100644 --- a/tests/e2e/helpers/orca-app.ts +++ b/tests/e2e/helpers/orca-app.ts @@ -111,7 +111,10 @@ function shouldLaunchHeadful(testInfo: TestInfo): boolean { return testInfo.project.metadata.orcaHeadful === true } -function forwardElectronProcessLogs(app: ElectronApplication, testInfo: TestInfo): void { +// Why: exported so specs that launch their own ElectronApplication outside +// this fixture (e.g. multi-instance lifecycle tests) can still opt into the +// same ORCA_E2E_FORWARD_APP_LOGS-gated stdout/stderr capture. +export function forwardElectronProcessLogs(app: ElectronApplication, testInfo: TestInfo): void { if (process.env.ORCA_E2E_FORWARD_APP_LOGS !== '1') { return } diff --git a/tests/e2e/terminal-tab-close-restart-persistence.spec.ts b/tests/e2e/terminal-tab-close-restart-persistence.spec.ts index 10efd6491c7..12cf89319d2 100644 --- a/tests/e2e/terminal-tab-close-restart-persistence.spec.ts +++ b/tests/e2e/terminal-tab-close-restart-persistence.spec.ts @@ -99,7 +99,23 @@ test('durable whole-tab close removes a split tab across restart', async (// oxl // Why: wait past initial worktree effects so this checks resurrection, not // only the first hydrated frame before default-tab logic has run. await secondLaunch.page.waitForTimeout(1_000) - expect(await getWorktreeTabs(secondLaunch.page, worktreeId)).toEqual([]) + // Why: reattaching to an emptied worktree intentionally spawns a fresh + // "Terminal 1" tab (Terminal.tsx's shouldAutoCreateInitialTerminal + // fallback fires whenever the active worktree has zero renderable tabs — + // true for a durably-closed worktree just like a brand-new one). That + // fallback is unrelated to this test and reproduces even mid-session with + // no restart at all, so asserting an eternally-empty tab list here is + // wrong. What "durable" actually promises is that the specific closed + // split tab never comes back — assert on its identity, not on tab count. + const restoredTabs = await getWorktreeTabs(secondLaunch.page, worktreeId) + expect(restoredTabs.some((tab) => tab.id === closedTabId)).toBe(false) + + const afterRestart = await client.call('terminal.list', { + worktree: `id:${worktreeId}` + }) + expect( + afterRestart.result.terminals.filter((terminal) => terminal.tabId === closedTabId) + ).toEqual([]) } finally { if (firstApp) { await session.close(firstApp) From 0e7b0ac22079f4ab6dba708bb595daaa627e3285 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 20 Jul 2026 18:23:35 -0700 Subject: [PATCH 13/58] Revert "Confirm before merging a PR/MR from the sidebar strategy dropdown (#8911)" (#9636) This reverts commit 37cd830192309267e224bbbaf413a22d73de8f37. --- .../use-hosted-review-actions.test.tsx | 87 ++----------------- .../use-hosted-review-actions.ts | 28 +----- src/renderer/src/i18n/locales/en.json | 7 -- src/renderer/src/i18n/locales/es.json | 7 -- src/renderer/src/i18n/locales/ja.json | 7 -- src/renderer/src/i18n/locales/ko.json | 7 -- src/renderer/src/i18n/locales/zh.json | 7 -- 7 files changed, 8 insertions(+), 142 deletions(-) diff --git a/src/renderer/src/components/right-sidebar/use-hosted-review-actions.test.tsx b/src/renderer/src/components/right-sidebar/use-hosted-review-actions.test.tsx index 761abf269f4..82527dc8ed5 100644 --- a/src/renderer/src/components/right-sidebar/use-hosted-review-actions.test.tsx +++ b/src/renderer/src/components/right-sidebar/use-hosted-review-actions.test.tsx @@ -46,25 +46,14 @@ function makeRepo(overrides: Partial = {}): Repo { } as Repo } -type ProviderOverrides = { - review?: HostedReviewActionInfo - isGitLab?: boolean - shortLabel?: string - reviewLabel?: string -} - -function HookProbe(props: { - repo: Repo - onRefreshReview: () => Promise - overrides?: ProviderOverrides -}): null { +function HookProbe(props: { repo: Repo; onRefreshReview: () => Promise }): null { latest = useHostedReviewActions({ - review: props.overrides?.review ?? review, + review, githubPR, repo: props.repo, - isGitLab: props.overrides?.isGitLab ?? false, - shortLabel: props.overrides?.shortLabel ?? 'PR', - reviewLabel: props.overrides?.reviewLabel ?? 'pull request', + isGitLab: false, + shortLabel: 'PR', + reviewLabel: 'pull request', defaultMergeMethod: 'squash', autoMergeAction: null, onRefreshReview: props.onRefreshReview @@ -72,16 +61,12 @@ function HookProbe(props: { return null } -async function renderHook( - repo: Repo, - onRefreshReview = vi.fn().mockResolvedValue(undefined), - overrides?: ProviderOverrides -) { +async function renderHook(repo: Repo, onRefreshReview = vi.fn().mockResolvedValue(undefined)) { const container = document.createElement('div') document.body.appendChild(container) root = createRoot(container) await act(async () => { - root?.render(createElement(HookProbe, { repo, onRefreshReview, overrides })) + root?.render(createElement(HookProbe, { repo, onRefreshReview })) }) return { onRefreshReview } } @@ -155,62 +140,4 @@ describe('useHostedReviewActions', () => { expect(runtimeRpcMocks.callRuntimeRpc).not.toHaveBeenCalled() expect(onRefreshReview).toHaveBeenCalledTimes(1) }) - - it('confirms before merging with the selected strategy label (#7943)', async () => { - await renderHook(makeRepo()) - - await act(async () => { - await latest?.handleMerge('merge') - }) - - expect(confirmationMocks.confirm).toHaveBeenCalledWith( - expect.objectContaining({ - title: 'Create merge commit PR #1015?', - confirmLabel: 'Create merge commit' - }) - ) - }) - - it('does not merge when the confirmation is cancelled (#7943)', async () => { - confirmationMocks.confirm.mockResolvedValue(false) - const { onRefreshReview } = await renderHook(makeRepo()) - - await act(async () => { - await latest?.handleMerge('squash') - }) - - expect(confirmationMocks.confirm).toHaveBeenCalledTimes(1) - expect(window.api.gh.mergePR).not.toHaveBeenCalled() - expect(runtimeRpcMocks.callRuntimeRpc).not.toHaveBeenCalled() - expect(onRefreshReview).not.toHaveBeenCalled() - }) - - it('confirms GitLab MR merges with provider-aware copy (#7943)', async () => { - await renderHook(makeRepo(), undefined, { - review: { - provider: 'gitlab', - number: 42, - state: 'open', - status: 'success', - mergeable: 'MERGEABLE' - }, - isGitLab: true, - shortLabel: 'MR', - reviewLabel: 'merge request' - }) - - await act(async () => { - await latest?.handleMerge('squash') - }) - - expect(confirmationMocks.confirm).toHaveBeenCalledWith( - expect.objectContaining({ title: 'Squash and merge MR !42?' }) - ) - expect(window.api.gl.mergeMR).toHaveBeenCalledWith({ - repoPath: '/repo', - repoId: 'repo-1', - iid: 42, - method: 'squash' - }) - }) }) diff --git a/src/renderer/src/components/right-sidebar/use-hosted-review-actions.ts b/src/renderer/src/components/right-sidebar/use-hosted-review-actions.ts index 7f2274bfdcc..ed3a1b23bcc 100644 --- a/src/renderer/src/components/right-sidebar/use-hosted-review-actions.ts +++ b/src/renderer/src/components/right-sidebar/use-hosted-review-actions.ts @@ -5,7 +5,6 @@ import type { GitHubPRAutoMergeAction } from '@/components/github-pr-merge-state import type { HostedReviewInfo } from '../../../../shared/hosted-review' import type { PRInfo, Repo } from '../../../../shared/types' import type { GitHubPRMergeMethod } from '../../../../shared/types' -import { GITHUB_PR_MERGE_METHOD_LABELS } from '../../../../shared/github-pr-merge-methods' import { mergeGitHubHostedReview, setGitHubHostedReviewAutoMerge, @@ -64,21 +63,6 @@ export function useHostedReviewActions({ const handleMerge = useCallback( async (method: GitHubPRMergeMethod = defaultMergeMethod) => { - // Why: choosing a strategy from the merge dropdown must not merge on its - // own — confirm first, matching every other PR/MR merge surface (#7943). - const label = GITHUB_PR_MERGE_METHOD_LABELS[method] - const confirmed = await confirm({ - title: `${label} ${shortLabel} ${isGitLab ? '!' : '#'}${review.number}?`, - description: translate( - 'auto.components.right.sidebar.use.hosted.review.actions.e475c29b17', - 'This will merge the {{value0}}.', - { value0: reviewLabel } - ), - confirmLabel: label - }) - if (!confirmed) { - return - } setMerging(true) setActionError(null) try { @@ -106,17 +90,7 @@ export function useHostedReviewActions({ setMerging(false) } }, - [ - confirm, - githubPR?.prRepo, - isGitLab, - shortLabel, - reviewLabel, - defaultMergeMethod, - onRefreshReview, - repo, - review.number - ] + [githubPR?.prRepo, isGitLab, defaultMergeMethod, onRefreshReview, repo, review.number] ) const handleAutoMerge = useCallback(async () => { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 8924345d3cc..9d830a1aa56 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -10271,13 +10271,6 @@ "cfafa92509": "No unresolved conflicts to send." } } - }, - "hosted": { - "review": { - "actions": { - "e475c29b17": "This will merge the {{value0}}." - } - } } }, "fileExplorerOperationOwner": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 475da5a98b7..2bd07533c66 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -10248,13 +10248,6 @@ "cfafa92509": "No hay conflictos sin resolver para enviar." } } - }, - "hosted": { - "review": { - "actions": { - "e475c29b17": "This will merge the {{value0}}." - } - } } }, "fileExplorerOperationOwner": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index c4465f1a46c..c76b43af82d 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -10248,13 +10248,6 @@ "cfafa92509": "送信する未解決の競合はありません。" } } - }, - "hosted": { - "review": { - "actions": { - "e475c29b17": "This will merge the {{value0}}." - } - } } }, "fileExplorerOperationOwner": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index a67574c5e58..755bc978803 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -10248,13 +10248,6 @@ "cfafa92509": "보낼 해결되지 않은 충돌이 없습니다." } } - }, - "hosted": { - "review": { - "actions": { - "e475c29b17": "This will merge the {{value0}}." - } - } } }, "fileExplorerOperationOwner": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 8a772231435..caba5e748ae 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -10248,13 +10248,6 @@ "cfafa92509": "没有未解决的冲突需要发送。" } } - }, - "hosted": { - "review": { - "actions": { - "e475c29b17": "This will merge the {{value0}}." - } - } } }, "fileExplorerOperationOwner": { From 6444be3a0164218176d08d44d62ad74f4b8a08a7 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Mon, 20 Jul 2026 21:35:14 -0400 Subject: [PATCH 14/58] fix(accounts): follow the runtime for WSL provider account detection (#9537) (#9611) * fix(accounts): follow the runtime for WSL provider account detection (#9537) On Windows + WSL, provider-account detection (usage recognition and the status-bar account switcher) was pinned to the Windows host even when the project runs in WSL, so WSL accounts were never recognized and the WSL switcher group never appeared. Root cause: `localAccountRuntime` hard-defaulted to 'host', which short-circuited `getInitialClaude/CodexRateLimitTarget` before the existing "follow the global Windows runtime default" branch could run. That branch was therefore dead for every real user. Fix: add an 'auto' value for `localAccountRuntime`, make it the default, and migrate the untouched legacy 'host' default to 'auto' once (guarded by `localAccountRuntimeDefaultedToAutoForAllUsers`; explicit 'wsl' is preserved). 'auto' resolves via a shared `resolveLocalAccountRuntimeTarget` helper: on a windows-host default it stays host (no behavior change); on a WSL default it follows WSL, so WSL accounts are recognized and the WSL group appears. Wired the shared resolver into the managed-account default target, the status-bar WSL-group gate, and the Accounts settings location toggle. Note: detection follows the global Windows runtime default, not the live active project's runtime (the fetch target is a single global value); the latter is a larger follow-up. * fix(accounts): align auto runtime consumers * fix(accounts): keep runtime polling aligned with settings --------- Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> --- .../runtime-auth-service.test.ts | 66 +++++++++++ .../claude-accounts/runtime-auth-service.ts | 8 +- src/main/index.ts | 11 ++ src/main/persistence.test.ts | 55 +++++++++ src/main/persistence.ts | 14 +++ .../account-runtime-target-sync.test.ts | 108 ++++++++++++++++++ .../account-runtime-target-sync.ts | 70 ++++++++++++ .../claude-rate-limit-target.test.ts | 56 +++++++++ .../rate-limits/claude-rate-limit-target.ts | 11 ++ .../codex-rate-limit-target.test.ts | 56 +++++++++ .../rate-limits/codex-rate-limit-target.ts | 11 ++ .../components/settings/AccountsPane.test.tsx | 33 ++++++ .../src/components/settings/AccountsPane.tsx | 8 +- .../src/components/status-bar/StatusBar.tsx | 19 ++- .../status-bar-runtime-groups.test.ts | 22 +++- src/shared/constants.ts | 3 +- src/shared/local-account-runtime.test.ts | 66 +++++++++++ src/shared/local-account-runtime.ts | 40 +++++++ src/shared/types.ts | 6 +- 19 files changed, 648 insertions(+), 15 deletions(-) create mode 100644 src/main/rate-limits/account-runtime-target-sync.test.ts create mode 100644 src/main/rate-limits/account-runtime-target-sync.ts create mode 100644 src/shared/local-account-runtime.test.ts create mode 100644 src/shared/local-account-runtime.ts diff --git a/src/main/claude-accounts/runtime-auth-service.test.ts b/src/main/claude-accounts/runtime-auth-service.test.ts index c6c2043deab..a78bc99ec1c 100644 --- a/src/main/claude-accounts/runtime-auth-service.test.ts +++ b/src/main/claude-accounts/runtime-auth-service.test.ts @@ -3537,6 +3537,72 @@ describe('ClaudeRuntimeAuthService', () => { }) }) + it('uses the global WSL runtime for untargeted Claude preparation under auto', async () => { + setPlatform('win32') + vi.doMock('../wsl', () => ({ + getDefaultWslDistro: () => 'Ubuntu', + getWslHome: () => null, + toWindowsWslPath: (value: string) => value + })) + const ubuntuAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'ubuntu-account', + createClaudeCredentialsJson('ubuntu@example.com', 'ubuntu-token') + ) + const settings = createSettings({ + localAccountRuntime: 'auto', + localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' }, + claudeManagedAccounts: [ + createClaudeAccount('ubuntu-account', ubuntuAuthPath, { + managedAuthRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxAuthPath: '/home/alice/.local/share/orca/claude-accounts/ubuntu/auth' + }) + ], + activeClaudeManagedAccountId: null, + activeClaudeManagedAccountIdsByRuntime: { + host: null, + wsl: { Ubuntu: 'ubuntu-account' } + } + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + const preparation = await service.prepareForClaudeLaunch() + + expect(preparation).toMatchObject({ + runtime: 'wsl', + wslDistro: 'Ubuntu', + provenance: 'managed:ubuntu-account:wsl:Ubuntu' + }) + }) + + it('ignores a persisted WSL account-runtime pin on non-Windows hosts', async () => { + setPlatform('darwin') + vi.doMock('../wsl', () => ({ + getDefaultWslDistro: () => 'Ubuntu', + getWslHome: () => null, + toWindowsWslPath: (value: string) => value + })) + const settings = createSettings({ + localAccountRuntime: 'wsl', + localAccountWslDistro: 'Ubuntu' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + const preparation = await service.prepareForClaudeLaunch() + + expect(preparation).toMatchObject({ + runtime: 'host', + wslDistro: null, + provenance: 'system', + stripAuthEnv: false + }) + }) + it('keeps untargeted Claude preparation on host when account runtime is host', async () => { setPlatform('win32') vi.doMock('../wsl', () => ({ diff --git a/src/main/claude-accounts/runtime-auth-service.ts b/src/main/claude-accounts/runtime-auth-service.ts index 3d901f872c0..58328222528 100644 --- a/src/main/claude-accounts/runtime-auth-service.ts +++ b/src/main/claude-accounts/runtime-auth-service.ts @@ -13,6 +13,7 @@ import { writeClaudeManagedAuthFile } from './managed-auth-path' import { parseWslUncPath } from '../../shared/wsl-paths' +import { resolveLocalAccountRuntimeTarget } from '../../shared/local-account-runtime' import { getDefaultWslDistro, getWslHome, toWindowsWslPath } from '../wsl' import { buildEncodedWslBashCommand } from '../wsl-bash-command' import { hasLiveClaudePtys } from './live-pty-gate' @@ -683,9 +684,10 @@ export class ClaudeRuntimeAuthService { private getDefaultAccountSelectionTarget( settings = this.store.getSettings() ): ClaudeAccountSelectionTarget { - if (process.platform === 'win32' && settings.localAccountRuntime === 'wsl') { - // 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 } + // Why: Windows auth follows the resolved account runtime; stale cross-platform WSL pins must stay local-host. + const resolved = resolveLocalAccountRuntimeTarget(settings) + if (process.platform === 'win32' && resolved.runtime === 'wsl') { + return { runtime: 'wsl', wslDistro: resolved.wslDistro } } return { runtime: 'host' } } diff --git a/src/main/index.ts b/src/main/index.ts index af1163f7107..23d89720adb 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -113,6 +113,7 @@ import { RateLimitService } from './rate-limits/service' import { readMiniMaxSessionCookie } from './minimax/minimax-cookie-store' import { getInitialClaudeRateLimitTarget } from './rate-limits/claude-rate-limit-target' import { getInitialCodexRateLimitTarget } from './rate-limits/codex-rate-limit-target' +import { createAccountRuntimeTargetSettingsSync } from './rate-limits/account-runtime-target-sync' import { attachMainWindowServices, ensureAutoUpdaterConfigured @@ -1820,6 +1821,16 @@ app.whenReady().then(async () => { ) rateLimits.setCodexFetchTarget(getInitialCodexRateLimitTarget(store.getSettings())) rateLimits.setClaudeFetchTarget(getInitialClaudeRateLimitTarget(store.getSettings())) + const syncAccountRuntimeTargets = createAccountRuntimeTargetSettingsSync( + rateLimits, + store.getSettings() + ) + store.onSettingsChanged((updates, settings) => { + // Why: auto is a live policy; retarget only providers whose settings-derived runtime changed. + void syncAccountRuntimeTargets(updates, settings).catch((error) => + console.warn('[rate-limits] Failed to apply account runtime target:', error) + ) + }) rateLimits.setClaudeAuthPreparationResolver((target) => claudeRuntimeAuth!.prepareForRateLimitFetch(target) ) diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index ed2508be9f0..43f94be0942 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -549,6 +549,59 @@ describe('Store', () => { }) }) + it('migrates the legacy host account-runtime default to auto once', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: { + localAccountRuntime: 'host' + } + }) + + const store = await createStore() + + expect(store.getSettings().localAccountRuntime).toBe('auto') + expect(store.getSettings().localAccountRuntimeDefaultedToAutoForAllUsers).toBe(true) + store.flush() + const persisted = (readDataFile() as PersistedState).settings + expect(persisted.localAccountRuntime).toBe('auto') + expect(persisted.localAccountRuntimeDefaultedToAutoForAllUsers).toBe(true) + }) + + it('preserves an explicit WSL account-runtime pin through the migration', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: { + localAccountRuntime: 'wsl', + localAccountWslDistro: 'Ubuntu' + } + }) + + const store = await createStore() + + expect(store.getSettings().localAccountRuntime).toBe('wsl') + expect(store.getSettings().localAccountRuntimeDefaultedToAutoForAllUsers).toBe(true) + }) + + it('does not re-flip an explicit host pin chosen after migration', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: { + localAccountRuntime: 'host', + localAccountRuntimeDefaultedToAutoForAllUsers: true + } + }) + + const store = await createStore() + + expect(store.getSettings().localAccountRuntime).toBe('host') + }) + it('returns default settings when no data file exists', async () => { const store = await createStore() const settings = store.getSettings() @@ -565,6 +618,8 @@ describe('Store', () => { expect(settings.terminalScrollSensitivity).toBe(1.15) expect(settings.terminalFastScrollSensitivity).toBe(5) expect(settings.terminalTuiScrollSensitivity).toBe(1) + expect(settings.localAccountRuntime).toBe('auto') + expect(settings.localAccountRuntimeDefaultedToAutoForAllUsers).toBe(true) expect(settings.terminalTuiScrollSensitivityDefaultedToOne).toBe(true) expect(settings.terminalUseSeparateLightTheme).toBe(true) expect(settings.rightSidebarOpenByDefault).toBe(true) diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 0fb057c8c7b..a1b3f307441 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -2960,6 +2960,18 @@ export class Store { ) { this.loadNeedsSave = true } + // Why (#9537): migrate the indistinguishable legacy host default once so WSL-default users follow their runtime. + const localAccountRuntimeAlreadyMigrated = + parsed.settings?.localAccountRuntimeDefaultedToAutoForAllUsers === true + const migratedLocalAccountRuntime: GlobalSettings['localAccountRuntime'] = + localAccountRuntimeAlreadyMigrated + ? (parsed.settings?.localAccountRuntime ?? defaults.settings.localAccountRuntime) + : parsed.settings?.localAccountRuntime === 'wsl' + ? 'wsl' + : 'auto' + if (!localAccountRuntimeAlreadyMigrated) { + this.loadNeedsSave = true + } if (!autoRenameBranchFromWorkDefaultedOn) { this.loadNeedsSave = true } @@ -3039,6 +3051,8 @@ export class Store { terminalMacOptionAsAlt: migratedOptionAsAlt, terminalMacOptionAsAltMigrated: true, localWindowsRuntimeDefault: migratedWindowsRuntimeDefault, + localAccountRuntime: migratedLocalAccountRuntime, + localAccountRuntimeDefaultedToAutoForAllUsers: true, floatingTerminalEnabled: migratedFloatingTerminalEnabled, floatingTerminalDefaultedForAllUsers: true, floatingTerminalCwd: migratedFloatingTerminalCwd, diff --git a/src/main/rate-limits/account-runtime-target-sync.test.ts b/src/main/rate-limits/account-runtime-target-sync.test.ts new file mode 100644 index 00000000000..1277c89114e --- /dev/null +++ b/src/main/rate-limits/account-runtime-target-sync.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it, vi } from 'vitest' +import { getDefaultSettings } from '../../shared/constants' +import type { RateLimitState } from '../../shared/rate-limit-types' +import { createAccountRuntimeTargetSettingsSync } from './account-runtime-target-sync' + +function createServiceTargets( + claudeTarget: RateLimitState['claudeTarget'], + codexTarget: RateLimitState['codexTarget'] +) { + const state = { claudeTarget, codexTarget } as RateLimitState + return { + getState: vi.fn(() => state), + refreshClaudeForTarget: vi.fn(async () => state), + refreshCodexForTarget: vi.fn(async () => state) + } +} + +describe('createAccountRuntimeTargetSettingsSync', () => { + it('retargets only Claude and Codex when auto changes to WSL', async () => { + const service = createServiceTargets( + { runtime: 'host', wslDistro: null }, + { runtime: 'host', wslDistro: null } + ) + const settings = { + ...getDefaultSettings('/tmp'), + localAccountRuntime: 'auto' as const, + localWindowsRuntimeDefault: { kind: 'wsl' as const, distro: 'Ubuntu' } + } + const syncSettings = createAccountRuntimeTargetSettingsSync( + service, + getDefaultSettings('/tmp'), + 'win32' + ) + + await syncSettings( + { localWindowsRuntimeDefault: settings.localWindowsRuntimeDefault }, + settings + ) + + const expectedTarget = { runtime: 'wsl', wslDistro: 'Ubuntu' } + expect(service.refreshClaudeForTarget).toHaveBeenCalledOnce() + expect(service.refreshClaudeForTarget).toHaveBeenCalledWith(expectedTarget) + expect(service.refreshCodexForTarget).toHaveBeenCalledOnce() + expect(service.refreshCodexForTarget).toHaveBeenCalledWith(expectedTarget) + }) + + it('does no work for unrelated settings updates', async () => { + const service = createServiceTargets( + { runtime: 'host', wslDistro: null }, + { runtime: 'host', wslDistro: null } + ) + const settings = getDefaultSettings('/tmp') + const syncSettings = createAccountRuntimeTargetSettingsSync(service, settings, 'win32') + + await syncSettings({ theme: 'dark' }, settings) + + expect(service.getState).not.toHaveBeenCalled() + expect(service.refreshClaudeForTarget).not.toHaveBeenCalled() + expect(service.refreshCodexForTarget).not.toHaveBeenCalled() + }) + + it('preserves a manual runtime when the settings-derived policy does not change', async () => { + const service = createServiceTargets( + { runtime: 'wsl', wslDistro: 'Ubuntu' }, + { runtime: 'wsl', wslDistro: 'Ubuntu' } + ) + const initialSettings = { + ...getDefaultSettings('/tmp'), + localAccountRuntime: 'host' as const + } + const settings = { + ...initialSettings, + localWindowsRuntimeDefault: { kind: 'wsl' as const, distro: 'Ubuntu' } + } + const syncSettings = createAccountRuntimeTargetSettingsSync(service, initialSettings, 'win32') + + await syncSettings( + { localWindowsRuntimeDefault: settings.localWindowsRuntimeDefault }, + settings + ) + + expect(service.getState).not.toHaveBeenCalled() + expect(service.refreshClaudeForTarget).not.toHaveBeenCalled() + expect(service.refreshCodexForTarget).not.toHaveBeenCalled() + }) + + it('refreshes only the provider whose current target differs', async () => { + const service = createServiceTargets( + { runtime: 'host', wslDistro: null }, + { runtime: 'wsl', wslDistro: 'Ubuntu' } + ) + const initialSettings = { + ...getDefaultSettings('/tmp'), + localWindowsRuntimeDefault: { kind: 'wsl' as const, distro: 'Ubuntu' } + } + const settings = getDefaultSettings('/tmp') + const syncSettings = createAccountRuntimeTargetSettingsSync(service, initialSettings, 'win32') + + await syncSettings( + { localWindowsRuntimeDefault: settings.localWindowsRuntimeDefault }, + settings + ) + + expect(service.refreshClaudeForTarget).not.toHaveBeenCalled() + expect(service.refreshCodexForTarget).toHaveBeenCalledOnce() + expect(service.refreshCodexForTarget).toHaveBeenCalledWith({ runtime: 'host' }) + }) +}) diff --git a/src/main/rate-limits/account-runtime-target-sync.ts b/src/main/rate-limits/account-runtime-target-sync.ts new file mode 100644 index 00000000000..d78a4b68910 --- /dev/null +++ b/src/main/rate-limits/account-runtime-target-sync.ts @@ -0,0 +1,70 @@ +import type { GlobalSettings } from '../../shared/types' +import type { RateLimitState } from '../../shared/rate-limit-types' +import type { RateLimitService } from './service' +import { getInitialClaudeRateLimitTarget } from './claude-rate-limit-target' +import { getInitialCodexRateLimitTarget } from './codex-rate-limit-target' + +type AccountRuntimeRateLimitService = Pick< + RateLimitService, + 'getState' | 'refreshClaudeForTarget' | 'refreshCodexForTarget' +> + +type RuntimeTarget = { + runtime?: 'host' | 'wsl' + wslDistro?: string | null +} + +export function createAccountRuntimeTargetSettingsSync( + rateLimits: AccountRuntimeRateLimitService, + initialSettings: GlobalSettings, + platform: NodeJS.Platform = process.platform +): (updates: Partial, settings: GlobalSettings) => Promise { + let settingsTargets = getSettingsTargets(initialSettings, platform) + + return async (updates, settings): Promise => { + if (!containsAccountRuntimeTargetUpdate(updates)) { + return + } + + const nextSettingsTargets = getSettingsTargets(settings, platform) + const claudePolicyChanged = !isSameTarget(settingsTargets.claude, nextSettingsTargets.claude) + const codexPolicyChanged = !isSameTarget(settingsTargets.codex, nextSettingsTargets.codex) + settingsTargets = nextSettingsTargets + if (!claudePolicyChanged && !codexPolicyChanged) { + return + } + + const current = rateLimits.getState() + const refreshes: Promise[] = [] + if (claudePolicyChanged && !isSameTarget(current.claudeTarget, nextSettingsTargets.claude)) { + refreshes.push(rateLimits.refreshClaudeForTarget(nextSettingsTargets.claude)) + } + if (codexPolicyChanged && !isSameTarget(current.codexTarget, nextSettingsTargets.codex)) { + refreshes.push(rateLimits.refreshCodexForTarget(nextSettingsTargets.codex)) + } + + await Promise.all(refreshes) + } +} + +function getSettingsTargets(settings: GlobalSettings, platform: NodeJS.Platform) { + return { + claude: getInitialClaudeRateLimitTarget(settings, platform), + codex: getInitialCodexRateLimitTarget(settings, platform) + } +} + +function containsAccountRuntimeTargetUpdate(updates: Partial): boolean { + return ( + 'localAccountRuntime' in updates || + 'localAccountWslDistro' in updates || + 'localWindowsRuntimeDefault' in updates + ) +} + +function isSameTarget(current: RuntimeTarget, next: RuntimeTarget): boolean { + return ( + (current.runtime ?? 'host') === (next.runtime ?? 'host') && + (current.wslDistro ?? null) === (next.wslDistro ?? null) + ) +} diff --git a/src/main/rate-limits/claude-rate-limit-target.test.ts b/src/main/rate-limits/claude-rate-limit-target.test.ts index e6789814f41..969124655ad 100644 --- a/src/main/rate-limits/claude-rate-limit-target.test.ts +++ b/src/main/rate-limits/claude-rate-limit-target.test.ts @@ -103,6 +103,49 @@ describe('getInitialClaudeRateLimitTarget', () => { ).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) }) + it('auto follows the global WSL project runtime default', () => { + expect( + getInitialClaudeRateLimitTarget( + { + ...getDefaultSettings('/tmp'), + localAccountRuntime: 'auto', + localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' } + }, + 'win32' + ) + ).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + }) + + it('auto resolves to host when the global project runtime is windows-host', () => { + expect( + getInitialClaudeRateLimitTarget( + { + ...getDefaultSettings('/tmp'), + localAccountRuntime: 'auto', + localWindowsRuntimeDefault: { kind: 'windows-host' } + }, + 'win32' + ) + ).toEqual({ runtime: 'host' }) + }) + + it('does not let a stale WSL-only selection override an auto host target', () => { + expect( + getInitialClaudeRateLimitTarget( + { + ...getDefaultSettings('/tmp'), + localAccountRuntime: 'auto', + localWindowsRuntimeDefault: { kind: 'windows-host' }, + activeClaudeManagedAccountIdsByRuntime: { + host: null, + wsl: { Ubuntu: 'wsl-account-1' } + } + }, + 'win32' + ) + ).toEqual({ runtime: 'host' }) + }) + it('keeps explicit host runtime on host', () => { expect( getInitialClaudeRateLimitTarget( @@ -120,4 +163,17 @@ describe('getInitialClaudeRateLimitTarget', () => { ) ).toEqual({ runtime: 'host' }) }) + + it('ignores a stale explicit WSL runtime on non-Windows hosts', () => { + expect( + getInitialClaudeRateLimitTarget( + { + ...getDefaultSettings('/tmp'), + localAccountRuntime: 'wsl', + localAccountWslDistro: 'Ubuntu' + }, + 'linux' + ) + ).toEqual({ runtime: 'host' }) + }) }) diff --git a/src/main/rate-limits/claude-rate-limit-target.ts b/src/main/rate-limits/claude-rate-limit-target.ts index f98a3e2645a..199250cd3e5 100644 --- a/src/main/rate-limits/claude-rate-limit-target.ts +++ b/src/main/rate-limits/claude-rate-limit-target.ts @@ -1,4 +1,5 @@ import type { GlobalSettings } from '../../shared/types' +import { resolveLocalAccountRuntimeTarget } from '../../shared/local-account-runtime' import { getClaudeWslSelectionKey, normalizeClaudeRuntimeSelection, @@ -29,6 +30,9 @@ export function getInitialClaudeRateLimitTarget( return { runtime: 'host' } } if (settings.localAccountRuntime === 'wsl') { + if (platform !== 'win32') { + return { runtime: 'host' } + } return { runtime: 'wsl', wslDistro: @@ -36,7 +40,14 @@ export function getInitialClaudeRateLimitTarget( getSingleSelectedWslDistro(settings) } } + if (settings.localAccountRuntime === 'auto') { + const target = resolveLocalAccountRuntimeTarget(settings, platform) + return target.runtime === 'wsl' + ? { runtime: 'wsl', wslDistro: target.wslDistro } + : { runtime: 'host' } + } + // Why: pre-setting profiles used account selection as their startup fallback. const projectRuntimeTarget = getProjectRuntimeRateLimitTarget(settings, platform) if (projectRuntimeTarget) { return projectRuntimeTarget diff --git a/src/main/rate-limits/codex-rate-limit-target.test.ts b/src/main/rate-limits/codex-rate-limit-target.test.ts index fbe87616a5a..b3b39f027f9 100644 --- a/src/main/rate-limits/codex-rate-limit-target.test.ts +++ b/src/main/rate-limits/codex-rate-limit-target.test.ts @@ -103,6 +103,49 @@ describe('getInitialCodexRateLimitTarget', () => { ).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) }) + it('auto follows the global WSL project runtime default', () => { + expect( + getInitialCodexRateLimitTarget( + { + ...getDefaultSettings('/tmp'), + localAccountRuntime: 'auto', + localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' } + }, + 'win32' + ) + ).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + }) + + it('auto resolves to host when the global project runtime is windows-host', () => { + expect( + getInitialCodexRateLimitTarget( + { + ...getDefaultSettings('/tmp'), + localAccountRuntime: 'auto', + localWindowsRuntimeDefault: { kind: 'windows-host' } + }, + 'win32' + ) + ).toEqual({ runtime: 'host' }) + }) + + it('does not let a stale WSL-only selection override an auto host target', () => { + expect( + getInitialCodexRateLimitTarget( + { + ...getDefaultSettings('/tmp'), + localAccountRuntime: 'auto', + localWindowsRuntimeDefault: { kind: 'windows-host' }, + activeCodexManagedAccountIdsByRuntime: { + host: null, + wsl: { Ubuntu: 'wsl-account-1' } + } + }, + 'win32' + ) + ).toEqual({ runtime: 'host' }) + }) + it('keeps explicit host runtime on host', () => { expect( getInitialCodexRateLimitTarget( @@ -120,4 +163,17 @@ describe('getInitialCodexRateLimitTarget', () => { ) ).toEqual({ runtime: 'host' }) }) + + it('ignores a stale explicit WSL runtime on non-Windows hosts', () => { + expect( + getInitialCodexRateLimitTarget( + { + ...getDefaultSettings('/tmp'), + localAccountRuntime: 'wsl', + localAccountWslDistro: 'Ubuntu' + }, + 'darwin' + ) + ).toEqual({ runtime: 'host' }) + }) }) diff --git a/src/main/rate-limits/codex-rate-limit-target.ts b/src/main/rate-limits/codex-rate-limit-target.ts index 67bf04fe9db..65c459ad7d3 100644 --- a/src/main/rate-limits/codex-rate-limit-target.ts +++ b/src/main/rate-limits/codex-rate-limit-target.ts @@ -1,4 +1,5 @@ import type { GlobalSettings } from '../../shared/types' +import { resolveLocalAccountRuntimeTarget } from '../../shared/local-account-runtime' import { getWslSelectionKey, normalizeCodexRuntimeSelection, @@ -29,6 +30,9 @@ export function getInitialCodexRateLimitTarget( return { runtime: 'host' } } if (settings.localAccountRuntime === 'wsl') { + if (platform !== 'win32') { + return { runtime: 'host' } + } return { runtime: 'wsl', wslDistro: @@ -36,7 +40,14 @@ export function getInitialCodexRateLimitTarget( getSingleSelectedWslDistro(settings) } } + if (settings.localAccountRuntime === 'auto') { + const target = resolveLocalAccountRuntimeTarget(settings, platform) + return target.runtime === 'wsl' + ? { runtime: 'wsl', wslDistro: target.wslDistro } + : { runtime: 'host' } + } + // Why: pre-setting profiles used account selection as their startup fallback. const projectRuntimeTarget = getProjectRuntimeRateLimitTarget(settings, platform) if (projectRuntimeTarget) { return projectRuntimeTarget diff --git a/src/renderer/src/components/settings/AccountsPane.test.tsx b/src/renderer/src/components/settings/AccountsPane.test.tsx index d694b39ffc0..859bcb8897b 100644 --- a/src/renderer/src/components/settings/AccountsPane.test.tsx +++ b/src/renderer/src/components/settings/AccountsPane.test.tsx @@ -51,6 +51,39 @@ describe('AccountsPane', () => { expect(markup).toContain('role="radio" aria-checked="true" disabled=""') }) + it('selects the WSL account location under auto when the global project runtime is WSL', () => { + // Why: navigator.userAgent is a read-only prototype getter, so shadow it with + // a configurable own property and remove that shadow afterward to restore it. + const originalOwnUserAgent = Object.getOwnPropertyDescriptor( + globalThis.navigator, + 'userAgent' + ) + Object.defineProperty(globalThis.navigator, 'userAgent', { + value: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', + configurable: true + }) + try { + const markup = renderPane( + { + ...getDefaultSettings('/tmp'), + localAccountRuntime: 'auto', + localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' } + }, + { wslSupportedPlatform: true, wslCapabilitiesLoading: true } + ) + + expect(markup).toContain('aria-label="Account location"') + // The resolved WSL radio is the checked option (disabled while capabilities load). + expect(markup).toContain('role="radio" aria-checked="true" disabled=""') + } finally { + if (originalOwnUserAgent) { + Object.defineProperty(globalThis.navigator, 'userAgent', originalOwnUserAgent) + } else { + delete (globalThis.navigator as { userAgent?: string }).userAgent + } + } + }) + it('keeps the runtime label inside the localized account copy', () => { const markup = renderPane(getDefaultSettings('/tmp')) diff --git a/src/renderer/src/components/settings/AccountsPane.tsx b/src/renderer/src/components/settings/AccountsPane.tsx index 651b03140b3..5e065c483c7 100644 --- a/src/renderer/src/components/settings/AccountsPane.tsx +++ b/src/renderer/src/components/settings/AccountsPane.tsx @@ -10,6 +10,8 @@ import type { CodexSystemDefaultIdentity, GlobalSettings } from '../../../../shared/types' +import { resolveLocalAccountRuntimeTarget } from '../../../../shared/local-account-runtime' +import { getRendererAppPlatform } from '../../lib/renderer-app-platform' import { Badge } from '../ui/badge' import { Button } from '../ui/button' import { Input } from '../ui/input' @@ -290,14 +292,16 @@ function getSelectedAccountRuntime( wslDistros: string[], wslCapabilitiesLoading: boolean ): LocalAccountRuntime { - if (wslSupportedPlatform && settings.localAccountRuntime === 'wsl') { + // Why: the two-option control displays the concrete target behind the persisted auto policy. + const resolvedRuntime = resolveLocalAccountRuntimeTarget(settings, getRendererAppPlatform()) + if (wslSupportedPlatform && resolvedRuntime.runtime === 'wsl') { if (!wslAvailable && !wslCapabilitiesLoading) { return { runtime: 'wsl', label: translate('auto.components.settings.AccountsPane.8619f9afa9', 'WSL') } } - const configuredDistro = settings.localAccountWslDistro?.trim() || null + const configuredDistro = resolvedRuntime.wslDistro?.trim() || null const selectedDistro = configuredDistro && (wslCapabilitiesLoading || wslDistros.includes(configuredDistro)) ? configuredDistro diff --git a/src/renderer/src/components/status-bar/StatusBar.tsx b/src/renderer/src/components/status-bar/StatusBar.tsx index 3fd5a3d72c0..d8de0d1ef41 100644 --- a/src/renderer/src/components/status-bar/StatusBar.tsx +++ b/src/renderer/src/components/status-bar/StatusBar.tsx @@ -48,6 +48,8 @@ import type { RateLimitRuntimeTarget, RateLimitWindow } from '../../../../shared/rate-limit-types' +import { resolveLocalAccountRuntimeTarget } from '../../../../shared/local-account-runtime' +import { getRendererAppPlatform } from '../../lib/renderer-app-platform' import { ProviderIcon, ProviderPanel, @@ -208,17 +210,24 @@ function toCodexStatusRuntimeTarget( export function getStatusBarPreferredWslDistro( settings: GlobalSettings | null | undefined, - wslDistros: string[] + wslDistros: string[], + platform: NodeJS.Platform = getRendererAppPlatform() ): string | null { - const configuredDistro = settings?.localAccountWslDistro?.trim() || null - if (configuredDistro) { - return configuredDistro + if (settings) { + const target = resolveLocalAccountRuntimeTarget(settings, platform) + if (target.runtime === 'wsl' && target.wslDistro) { + return target.wslDistro + } } return wslDistros.length === 1 ? wslDistros[0] : null } function shouldIncludeSettingsWslRuntime(settings: GlobalSettings | null | undefined): boolean { - return settings?.localAccountRuntime === 'wsl' + if (!settings) { + return false + } + // Why: the fallback group must match the concrete runtime used for account polling. + return resolveLocalAccountRuntimeTarget(settings, getRendererAppPlatform()).runtime === 'wsl' } function getSingleConcreteCodexWslDistro(state: CodexRateLimitAccountsState): string | null { diff --git a/src/renderer/src/components/status-bar/status-bar-runtime-groups.test.ts b/src/renderer/src/components/status-bar/status-bar-runtime-groups.test.ts index db8b96abaa2..262cf1595f1 100644 --- a/src/renderer/src/components/status-bar/status-bar-runtime-groups.test.ts +++ b/src/renderer/src/components/status-bar/status-bar-runtime-groups.test.ts @@ -116,10 +116,12 @@ describe('status bar runtime switch groups', () => { expect( getStatusBarPreferredWslDistro( { + localAccountRuntime: 'wsl', localAccountWslDistro: null, terminalWindowsWslDistro: 'Debian' } as GlobalSettings, - ['Ubuntu'] + ['Ubuntu'], + 'win32' ) ).toBe('Ubuntu') }) @@ -128,14 +130,30 @@ describe('status bar runtime switch groups', () => { expect( getStatusBarPreferredWslDistro( { + localAccountRuntime: 'wsl', localAccountWslDistro: 'Fedora', terminalWindowsWslDistro: 'Debian' } as GlobalSettings, - ['Ubuntu'] + ['Ubuntu'], + 'win32' ) ).toBe('Fedora') }) + it('uses the auto runtime distro instead of a stale account-runtime distro', () => { + expect( + getStatusBarPreferredWslDistro( + { + localAccountRuntime: 'auto', + localAccountWslDistro: 'Fedora', + localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' } + } as GlobalSettings, + ['Fedora', 'Ubuntu'], + 'win32' + ) + ).toBe('Ubuntu') + }) + it('labels the host account group with the active remote server name', () => { const state: CodexRateLimitAccountsState = { accounts: [], diff --git a/src/shared/constants.ts b/src/shared/constants.ts index a9dc020ac3f..1ccd9e4883f 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -229,7 +229,8 @@ export function getDefaultSettings(homedir: string): GlobalSettings { terminalRightClickToPasteDefaultedForPlatform: true, terminalWindowsShell: 'powershell.exe', terminalWindowsWslDistro: null, - localAccountRuntime: 'host', + localAccountRuntime: 'auto', + localAccountRuntimeDefaultedToAutoForAllUsers: true, localAccountWslDistro: null, localWindowsRuntimeDefault: { kind: 'windows-host' }, // Why: prefer modern PowerShell when installed, falling back to inbox Windows PowerShell. diff --git a/src/shared/local-account-runtime.test.ts b/src/shared/local-account-runtime.test.ts new file mode 100644 index 00000000000..d881d18f28f --- /dev/null +++ b/src/shared/local-account-runtime.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import { getDefaultSettings } from './constants' +import { resolveLocalAccountRuntimeTarget } from './local-account-runtime' + +describe('resolveLocalAccountRuntimeTarget', () => { + it('honors an explicit host pin', () => { + expect( + resolveLocalAccountRuntimeTarget( + { ...getDefaultSettings('/tmp'), localAccountRuntime: 'host' }, + 'win32' + ) + ).toEqual({ runtime: 'host', wslDistro: null }) + }) + + it('honors an explicit WSL pin and its distro', () => { + expect( + resolveLocalAccountRuntimeTarget( + { + ...getDefaultSettings('/tmp'), + localAccountRuntime: 'wsl', + localAccountWslDistro: 'Ubuntu' + }, + 'win32' + ) + ).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + }) + + it('auto follows the global WSL project runtime default', () => { + expect( + resolveLocalAccountRuntimeTarget( + { + ...getDefaultSettings('/tmp'), + localAccountRuntime: 'auto', + localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' } + }, + 'win32' + ) + ).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + }) + + it('auto resolves to host when the global project runtime is windows-host', () => { + expect( + resolveLocalAccountRuntimeTarget( + { + ...getDefaultSettings('/tmp'), + localAccountRuntime: 'auto', + localWindowsRuntimeDefault: { kind: 'windows-host' } + }, + 'win32' + ) + ).toEqual({ runtime: 'host', wslDistro: null }) + }) + + it('auto resolves to host on non-Windows platforms even with a WSL default', () => { + expect( + resolveLocalAccountRuntimeTarget( + { + ...getDefaultSettings('/tmp'), + localAccountRuntime: 'auto', + localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' } + }, + 'linux' + ) + ).toEqual({ runtime: 'host', wslDistro: null }) + }) +}) diff --git a/src/shared/local-account-runtime.ts b/src/shared/local-account-runtime.ts new file mode 100644 index 00000000000..2e2b8b000da --- /dev/null +++ b/src/shared/local-account-runtime.ts @@ -0,0 +1,40 @@ +import type { GlobalSettings } from './types' +import { normalizeGlobalWindowsRuntimeDefault } from './project-execution-runtime' + +export type LocalAccountRuntimeTarget = { + runtime: 'host' | 'wsl' + wslDistro: string | null +} + +type LocalAccountRuntimeSettings = Pick< + GlobalSettings, + 'localAccountRuntime' | 'localAccountWslDistro' | 'localWindowsRuntimeDefault' +> + +/** Resolves the persisted account policy to a concrete host or WSL target. */ +export function resolveLocalAccountRuntimeTarget( + settings: LocalAccountRuntimeSettings, + platform: NodeJS.Platform = process.platform +): LocalAccountRuntimeTarget { + if (settings.localAccountRuntime === 'host') { + return { runtime: 'host', wslDistro: null } + } + if (settings.localAccountRuntime === 'wsl') { + return { runtime: 'wsl', wslDistro: normalizeDistro(settings.localAccountWslDistro) } + } + + // 'auto' (or any unset legacy value): follow the global Windows runtime default. + if (platform !== 'win32') { + return { runtime: 'host', wslDistro: null } + } + const runtimeDefault = normalizeGlobalWindowsRuntimeDefault(settings.localWindowsRuntimeDefault) + if (runtimeDefault.kind === 'wsl') { + return { runtime: 'wsl', wslDistro: runtimeDefault.distro } + } + return { runtime: 'host', wslDistro: null } +} + +function normalizeDistro(value: string | null | undefined): string | null { + const trimmed = value?.trim() + return trimmed ? trimmed : null +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 438a831648b..fdacc483133 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2672,9 +2672,11 @@ export type GlobalSettings = { terminalWindowsShell: string /** Pins the WSL distro for terminals/agent scans instead of WSL's current global default. */ terminalWindowsWslDistro?: string | null - /** Account/auth location independent from the terminal shell (e.g. WSL terminals but Windows-scoped accounts). */ - localAccountRuntime: 'host' | 'wsl' + /** Account/auth location; auto follows the global Windows runtime while host/wsl pin it. */ + localAccountRuntime: 'auto' | 'host' | 'wsl' localAccountWslDistro?: string | null + /** One-shot guard for migrating the legacy host default to auto. */ + localAccountRuntimeDefaultedToAutoForAllUsers?: boolean /** Independent from the terminal shell so users can inspect Windows vs WSL agent PATH state without changing it. */ localAgentRuntime?: 'host' | 'wsl' localAgentWslDistro?: string | null From aad34cbb3287d4fe65e57b636b8b342adc65215e Mon Sep 17 00:00:00 2001 From: Yunqian Fan Date: Tue, 21 Jul 2026 09:38:39 +0800 Subject: [PATCH 15/58] docs(headless-server): add upgrade SOP for orca serve on Linux (#9575) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(headless-server): add upgrade SOP for orca serve on Linux The headless Linux guide covered install/run/systemd but had no upgrade section, leaving operators to guess how to move to a new AppImage without losing state. Add an "Upgrade" section documenting the manual SOP (serve mode never auto-updates) and one troubleshooting bullet: - State lives under the service user's ~/.config (orca + Orca dirs), independent of /opt/orca, and orca-data.json is forward-migrated on load, so a forward upgrade is safe. - Replace the binary with an atomic same-filesystem rename (download to .new, verify, mv) — never curl -o over the FUSE-mounted live binary. - Back up the whole .config before upgrading, because rollback is NOT binary-only safe: an older build strips newer orca-data.json fields it doesn't recognize, and the .bak.* ring is corruption-recovery, not a pre-upgrade copy. - Note there is no headless version command; track the release tag instead. Co-Authored-By: Claude Opus 4.8 (1M context) * docs(headless-server): harden the orca serve upgrade/rollback runbook Address CodeRabbit review on #9575: - Fail closed: run the upgrade block under `set -euo pipefail`, remove any stale `.new` file before download, and gate the atomic `mv` on an explicit ELF check so a failed/partial/non-ELF download can never be promoted. - Keep /opt/orca/VERSION tied to the installed binary: a single `TAG` variable drives both the download URL and the recorded VERSION, saved as VERSION.prev on upgrade and restored on rollback so the audit file never drifts. - Crash-loop troubleshooting now points to Roll back first (restores the pre-upgrade orca-data.json) instead of re-running Upgrade. Co-Authored-By: Claude Opus 4.8 (1M context) * docs(headless): harden server upgrade SOP --------- Co-authored-by: fanyunqian.1 Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- docs/reference/headless-linux-server.md | 442 +++++++++++++++++++++++- 1 file changed, 439 insertions(+), 3 deletions(-) diff --git a/docs/reference/headless-linux-server.md b/docs/reference/headless-linux-server.md index e6267e84f8a..316f0759ea0 100644 --- a/docs/reference/headless-linux-server.md +++ b/docs/reference/headless-linux-server.md @@ -15,7 +15,7 @@ Install the AppImage runtime dependency and Xvfb: ```bash sudo apt-get update -sudo apt-get install -y curl libfuse2 xvfb +sudo apt-get install -y curl file libfuse2 xvfb ``` Download and make the AppImage executable: @@ -56,11 +56,14 @@ The command prints the runtime endpoint and pairing URL. Stop it with `Ctrl+C`. ## Systemd Service Create a dedicated service user and install directory. Run the service as this -user instead of root so the AppImage can keep Chromium's sandbox enabled. +user instead of root so the AppImage can keep Chromium's sandbox enabled. Keep +the install directory root-owned: the service needs to read and execute the +AppImage, but must not be able to replace it or the rollback artifacts. ```bash sudo useradd --system --create-home --shell /usr/sbin/nologin orca -sudo chown -R orca:orca /opt/orca +sudo chown root:root /opt/orca /opt/orca/orca-linux.AppImage +sudo chmod 755 /opt/orca /opt/orca/orca-linux.AppImage ``` For most hosts, one `orca serve` service is enough because Orca starts Xvfb on @@ -165,6 +168,436 @@ If you later install the desktop CLI from Orca settings, use that CLI for normal shell workflows. Keep the AppImage path in systemd so service restarts do not depend on an interactive shell profile. +## Upgrade + +`orca serve` never updates itself. In headless mode Orca wires up no auto-updater +at all — the built-in updater only runs in the desktop GUI, and no paired mobile +or web client can trigger it remotely. Upgrading is always a deliberate step: +replace the AppImage and restart the service. + +Two facts make this safe and predictable: + +- **State lives in the service user's home, not next to the binary.** Persisted + data is under `/home/orca/.config/` (Orca uses both an `orca` and an `Orca` + directory there), fully independent of `/opt/orca/orca-linux.AppImage`. + Replacing the binary never touches projects, worktree metadata, terminal + history, orchestration state, or paired-device keys — so mobile and web + clients reconnect after an upgrade without re-pairing. +- **New builds migrate old state on load.** Orca loads older `orca-data.json` + state into the current schema and writes it back in the current shape, so a + forward upgrade needs no manual data step. + +Rolling back is the case that needs care — see [Roll back](#roll-back). + +### Record the version you deploy + +Orca has no headless version command: there is no `--version` flag or `version` +subcommand, and `orca serve` prints only its endpoint. Choose a release tag +explicitly instead of following the `latest` URL, and record it next to the +binary so upgrades are auditable. The steps below keep that record in +`/opt/orca/VERSION`. + +### Upgrade steps + +Never download straight onto `/opt/orca/orca-linux.AppImage`. The AppImage is +FUSE-mounted, so overwriting it in place while the service runs can crash or +corrupt the live process — and even with the service stopped, a failed or partial +download would clobber the working binary. Instead download to a temporary name +on the same filesystem, verify it, then swap it in with an atomic rename. + +Check capacity before starting: + +```bash +sudo chown root:root /opt/orca +sudo chmod 755 /opt/orca +sudo test ! -L /opt/orca/orca-linux.AppImage +sudo chown root:root /opt/orca/orca-linux.AppImage +sudo chmod 755 /opt/orca/orca-linux.AppImage +# Clear predictable staging names left by an older attempt after locking the directory +sudo rm -f /opt/orca/orca-linux.AppImage.new /opt/orca/VERSION.new \ + /opt/orca/orca-linux.AppImage.recovering /opt/orca/VERSION.recovering +sudo du -sh /home/orca/.config +df -h /opt/orca /home/orca +``` + +`/opt/orca` needs room for the compressed Orca profile archive, the staged +build, and the rollback binary. A rollback extracts the old profile and preserves +the post-upgrade Orca profile directories, so `/home` needs room for both copies. + +Run the following block as one Bash script so its fail-fast and recovery traps +remain active for the whole operation: + +```bash +set -euo pipefail + +# Replace this example with the release tag you intend to deploy +ORCA_VERSION=v1.4.147 + +# Select the release asset on the server where Orca runs +case "$(uname -m)" in + x86_64) + ORCA_ASSET=orca-linux.AppImage + ORCA_FILE_MACHINE=x86-64 + ;; + aarch64 | arm64) + ORCA_ASSET=orca-linux-arm64.AppImage + ORCA_FILE_MACHINE='ARM aarch64' + ;; + *) + echo "Unsupported architecture: $(uname -m)" >&2 + exit 1 + ;; +esac + +ORCA_ROLLBACK_NEW= +ORCA_ROLLBACK= +ORCA_SERVICE_STOPPED=0 +ORCA_BINARY_PROMOTED=0 +recover_failed_upgrade() { + exit_status=$? + trap - EXIT + set +e + if ((exit_status != 0)); then + sudo rm -f /opt/orca/orca-linux.AppImage.new /opt/orca/VERSION.new \ + /opt/orca/orca-linux.AppImage.recovering /opt/orca/VERSION.recovering + fi + if ((exit_status != 0)) && [[ -n "$ORCA_ROLLBACK_NEW" ]] && \ + sudo test -d "$ORCA_ROLLBACK_NEW"; then + sudo rm -rf -- "$ORCA_ROLLBACK_NEW" + fi + if ((exit_status != 0 && ORCA_SERVICE_STOPPED)); then + recovery_ok=1 + if ((ORCA_BINARY_PROMOTED)); then + if ! sudo cp -a "$ORCA_ROLLBACK/orca-linux.AppImage" \ + /opt/orca/orca-linux.AppImage.recovering || \ + ! sudo mv -f /opt/orca/orca-linux.AppImage.recovering \ + /opt/orca/orca-linux.AppImage; then + recovery_ok=0 + fi + if sudo test -f "$ORCA_ROLLBACK/VERSION"; then + if ! sudo cp -a "$ORCA_ROLLBACK/VERSION" /opt/orca/VERSION.recovering || \ + ! sudo mv -f /opt/orca/VERSION.recovering /opt/orca/VERSION; then + recovery_ok=0 + fi + elif ! sudo rm -f /opt/orca/VERSION; then + recovery_ok=0 + fi + fi + sudo rm -f /opt/orca/orca-linux.AppImage.recovering \ + /opt/orca/VERSION.recovering + if ((recovery_ok)); then + sudo systemctl start orca-serve.service || true + else + echo 'Upgrade recovery failed; service remains stopped' >&2 + fi + fi + exit "$exit_status" +} +trap recover_failed_upgrade EXIT + +# 1. Stage and verify the new build while the server stays online +sudo curl -fL --retry 3 "https://github.com/stablyai/orca/releases/download/${ORCA_VERSION}/${ORCA_ASSET}" \ + -o /opt/orca/orca-linux.AppImage.new +sudo chown root:root /opt/orca/orca-linux.AppImage.new +sudo chmod 755 /opt/orca/orca-linux.AppImage.new + +# Both checks must match; either grep stops this fail-fast block otherwise +ORCA_FILE_INFO=$(LC_ALL=C file /opt/orca/orca-linux.AppImage.new) +grep 'ELF .* executable' <<<"$ORCA_FILE_INFO" +grep -F "$ORCA_FILE_MACHINE" <<<"$ORCA_FILE_INFO" + +# 2. Assemble the prior binary and version in a root-only rollback bundle +ORCA_ROLLBACK_BASE=/opt/orca/orca-rollback-$(date +%F-%H%M%S-%N) +ORCA_ROLLBACK_NEW=${ORCA_ROLLBACK_BASE}.new +ORCA_ROLLBACK=${ORCA_ROLLBACK_BASE}.ready +sudo install -d -m 700 "$ORCA_ROLLBACK_NEW" +sudo cp -a /opt/orca/orca-linux.AppImage "$ORCA_ROLLBACK_NEW/orca-linux.AppImage" +if sudo test -f /opt/orca/VERSION; then + sudo cp -a /opt/orca/VERSION "$ORCA_ROLLBACK_NEW/VERSION" +fi + +# Stage the new version record before the stop window +printf '%s\n' "$ORCA_VERSION" | sudo tee /opt/orca/VERSION.new >/dev/null +sudo chown root:root /opt/orca/VERSION.new +sudo chmod 644 /opt/orca/VERSION.new + +# 3. Stop the server so the profile backup is consistent +ORCA_SERVICE_STOPPED=1 +sudo systemctl stop orca-serve.service + +# Add only Orca-owned profile directories, then publish the complete bundle +ORCA_PROFILE_DIRS=() +for profile_dir in orca Orca; do + if sudo test -L "/home/orca/.config/$profile_dir"; then + echo "Refusing symlinked Orca profile: /home/orca/.config/$profile_dir" >&2 + exit 1 + fi + if sudo test -d "/home/orca/.config/$profile_dir"; then + if [[ "$profile_dir" == Orca ]] && \ + sudo test /home/orca/.config/orca -ef /home/orca/.config/Orca; then + continue + fi + ORCA_PROFILE_DIRS+=("$profile_dir") + fi +done +if ((${#ORCA_PROFILE_DIRS[@]} == 0)); then + echo 'No Orca profile directory found under /home/orca/.config' >&2 + exit 1 +fi +sudo tar czf "$ORCA_ROLLBACK_NEW/profile.tgz" \ + -C /home/orca/.config "${ORCA_PROFILE_DIRS[@]}" +sudo chmod 600 "$ORCA_ROLLBACK_NEW/profile.tgz" +sudo mv "$ORCA_ROLLBACK_NEW" "$ORCA_ROLLBACK" + +# 4. Atomically replace the binary and version record, then start +ORCA_BINARY_PROMOTED=1 +sudo mv -f /opt/orca/orca-linux.AppImage.new /opt/orca/orca-linux.AppImage +sudo mv -f /opt/orca/VERSION.new /opt/orca/VERSION +sudo systemctl start orca-serve.service +ORCA_SERVICE_STOPPED=0 +trap - EXIT +``` + +The profile archive created in step 3 captures both Orca profile directory names +when present without rewinding unrelated tools under `/home/orca/.config`. The +`.ready` suffix is published only after the prior binary, version record, and +profile archive are complete. If you run the managed Xvfb unit, only +`orca-serve.service` needs restarting — leave `orca-xvfb.service` running. + +### Verify + +```bash +sudo journalctl -u orca-serve.service -f +``` + +A healthy start prints `Orca server ready: ws://0.0.0.0:6768` (with your port). +Confirm a client reconnects before you discard the backup. The timestamped +rollback bundles are not pruned automatically. After the new version satisfies +your retention policy, select and inspect the newest complete bundle before +removing it: + +```bash +shopt -s nullglob +ORCA_ROLLBACK_SETS=(/opt/orca/orca-rollback-*.ready) +((${#ORCA_ROLLBACK_SETS[@]} > 0)) +ORCA_ROLLBACK=${ORCA_ROLLBACK_SETS[${#ORCA_ROLLBACK_SETS[@]} - 1]} +printf 'Removing rollback bundle: %s\n' "$ORCA_ROLLBACK" +sudo test -d "$ORCA_ROLLBACK" +sudo rm -rf -- "$ORCA_ROLLBACK" +``` + +Each `.ready` directory is a self-contained rollback generation; never combine +files from different bundles. + +### Roll back + +A rollback is **not** binary-only safe. Once a newer build has started, it can +rewrite `orca-data.json` in the current schema. If an older build then writes +that file, it can discard fields it does not recognize. The rolling +`orca-data.json.bak.*` files are corruption-recovery snapshots, not a dedicated +pre-upgrade copy, and normal writes can rotate them away. To roll back cleanly, +restore the backup from step 3 **and** swap the binary back. Run this block as one +Bash script: + +```bash +set -euo pipefail + +# Select and validate one complete generation before taking the service offline +shopt -s nullglob +ORCA_ROLLBACK_SETS=(/opt/orca/orca-rollback-*.ready) +((${#ORCA_ROLLBACK_SETS[@]} > 0)) +ORCA_ROLLBACK=${ORCA_ROLLBACK_SETS[${#ORCA_ROLLBACK_SETS[@]} - 1]} +sudo test -f "$ORCA_ROLLBACK/orca-linux.AppImage" +sudo tar tzf "$ORCA_ROLLBACK/profile.tgz" >/dev/null + +# Extract and validate the old profile while the current server stays online +sudo test ! -L /home +ORCA_HOME_OWNER=$(sudo stat -c %u /home) +ORCA_HOME_MODE=$(sudo stat -c %a /home) +if [[ "$ORCA_HOME_OWNER" != 0 ]] || ((8#$ORCA_HOME_MODE & 0022)) || \ + sudo -u orca test -w /home; then + echo 'Refusing rollback because /home is not root-controlled' >&2 + exit 1 +fi +ORCA_RESTORE=$(sudo mktemp -d /home/.orca-restore.XXXXXX) +ORCA_SERVICE_STOPPED=0 +ORCA_MOVED_CURRENT_DIRS=() +ORCA_INSTALLED_RESTORE_DIRS=() +ORCA_CURRENT_BINARY_MOVED=0 +ORCA_CURRENT_VERSION_MOVED=0 +ORCA_VERSION_REPLACEMENT_STARTED=0 +ORCA_POST_UPGRADE= +ORCA_ROLLBACK_BINARY_STAGED= +ORCA_ROLLBACK_VERSION_STAGED= +ORCA_ROLLBACK_HAS_VERSION=0 +restart_after_rollback_error() { + exit_status=$? + trap - EXIT + set +e + if ((exit_status != 0 && ORCA_SERVICE_STOPPED)); then + recovery_ok=1 + if ((${#ORCA_INSTALLED_RESTORE_DIRS[@]})); then + for profile_dir in "${ORCA_INSTALLED_RESTORE_DIRS[@]}"; do + if sudo test -d "/home/orca/.config/$profile_dir"; then + if ! sudo mv "/home/orca/.config/$profile_dir" \ + "$ORCA_RESTORE/$profile_dir.failed"; then + recovery_ok=0 + fi + fi + done + fi + if ((${#ORCA_MOVED_CURRENT_DIRS[@]})); then + for profile_dir in "${ORCA_MOVED_CURRENT_DIRS[@]}"; do + if sudo test -d "$ORCA_POST_UPGRADE/$profile_dir"; then + if ! sudo mv "$ORCA_POST_UPGRADE/$profile_dir" /home/orca/.config/; then + recovery_ok=0 + fi + elif ! sudo test -d "/home/orca/.config/$profile_dir"; then + recovery_ok=0 + fi + done + fi + if [[ -n "$ORCA_POST_UPGRADE" ]]; then + sudo rmdir "$ORCA_POST_UPGRADE" 2>/dev/null || true + fi + if ((ORCA_CURRENT_BINARY_MOVED)); then + if sudo test -f "$ORCA_CURRENT_BINARY"; then + if ! sudo mv -f "$ORCA_CURRENT_BINARY" /opt/orca/orca-linux.AppImage; then + recovery_ok=0 + fi + elif ! sudo test -f /opt/orca/orca-linux.AppImage; then + recovery_ok=0 + fi + fi + if ((ORCA_CURRENT_VERSION_MOVED)); then + if sudo test -f "$ORCA_CURRENT_VERSION"; then + if ! sudo mv -f "$ORCA_CURRENT_VERSION" /opt/orca/VERSION; then + recovery_ok=0 + fi + elif ! sudo test -f /opt/orca/VERSION; then + recovery_ok=0 + fi + elif ((ORCA_VERSION_REPLACEMENT_STARTED)); then + if ! sudo rm -f /opt/orca/VERSION; then + recovery_ok=0 + fi + fi + if ((recovery_ok)); then + sudo systemctl start orca-serve.service || true + else + echo 'Rollback recovery failed; service remains stopped' >&2 + fi + fi + if [[ -n "$ORCA_ROLLBACK_BINARY_STAGED" ]]; then + sudo rm -f -- "$ORCA_ROLLBACK_BINARY_STAGED" + fi + if [[ -n "$ORCA_ROLLBACK_VERSION_STAGED" ]]; then + sudo rm -f -- "$ORCA_ROLLBACK_VERSION_STAGED" + fi + sudo rm -rf -- "$ORCA_RESTORE" + exit "$exit_status" +} +trap restart_after_rollback_error EXIT + +if [[ "$(sudo stat -c %d "$ORCA_RESTORE")" != \ + "$(sudo stat -c %d /home/orca/.config)" ]]; then + echo 'Refusing rollback because staging and the Orca profile are on different filesystems' >&2 + exit 1 +fi +sudo tar xzf "$ORCA_ROLLBACK/profile.tgz" -C "$ORCA_RESTORE" +ORCA_RESTORE_DIRS=() +for profile_dir in orca Orca; do + if sudo test -L "$ORCA_RESTORE/$profile_dir"; then + echo "Rollback bundle contains a symlinked profile: $profile_dir" >&2 + exit 1 + fi + if sudo test -d "$ORCA_RESTORE/$profile_dir"; then + if [[ "$profile_dir" == Orca ]] && \ + sudo test "$ORCA_RESTORE/orca" -ef "$ORCA_RESTORE/Orca"; then + continue + fi + ORCA_RESTORE_DIRS+=("$profile_dir") + fi +done +if ((${#ORCA_RESTORE_DIRS[@]} == 0)); then + echo "Rollback bundle has no Orca profile directories: $ORCA_ROLLBACK" >&2 + exit 1 +fi +for profile_dir in "${ORCA_RESTORE_DIRS[@]}"; do + sudo chown -R orca:orca "$ORCA_RESTORE/$profile_dir" +done + +ORCA_ROLLBACK_STAMP=$(date +%F-%H%M%S-%N) +ORCA_ROLLBACK_BINARY_STAGED=/opt/orca/orca-linux.AppImage.rollback-staged-$ORCA_ROLLBACK_STAMP +sudo cp -a "$ORCA_ROLLBACK/orca-linux.AppImage" "$ORCA_ROLLBACK_BINARY_STAGED" +if sudo test -f "$ORCA_ROLLBACK/VERSION"; then + ORCA_ROLLBACK_HAS_VERSION=1 + ORCA_ROLLBACK_VERSION_STAGED=/opt/orca/VERSION.rollback-staged-$ORCA_ROLLBACK_STAMP + sudo cp -a "$ORCA_ROLLBACK/VERSION" "$ORCA_ROLLBACK_VERSION_STAGED" +fi + +ORCA_SERVICE_STOPPED=1 +sudo systemctl stop orca-serve.service + +# Preserve and replace only Orca-owned profile directories +ORCA_CURRENT_DIRS=() +for profile_dir in orca Orca; do + if sudo test -L "/home/orca/.config/$profile_dir"; then + echo "Refusing symlinked Orca profile: /home/orca/.config/$profile_dir" >&2 + exit 1 + fi + if sudo test -d "/home/orca/.config/$profile_dir"; then + if [[ "$profile_dir" == Orca ]] && \ + sudo test /home/orca/.config/orca -ef /home/orca/.config/Orca; then + continue + fi + ORCA_CURRENT_DIRS+=("$profile_dir") + fi +done +ORCA_POST_UPGRADE=/home/orca/.config/orca-rollback-$ORCA_ROLLBACK_STAMP +sudo install -d -o orca -g orca -m 700 "$ORCA_POST_UPGRADE" +if ((${#ORCA_CURRENT_DIRS[@]})); then + for profile_dir in "${ORCA_CURRENT_DIRS[@]}"; do + ORCA_MOVED_CURRENT_DIRS+=("$profile_dir") + sudo mv "/home/orca/.config/$profile_dir" "$ORCA_POST_UPGRADE/" + done +fi +for profile_dir in "${ORCA_RESTORE_DIRS[@]}"; do + ORCA_INSTALLED_RESTORE_DIRS+=("$profile_dir") + sudo mv "$ORCA_RESTORE/$profile_dir" /home/orca/.config/ +done + +ORCA_CURRENT_BINARY=/opt/orca/orca-linux.AppImage.rollback-current-$ORCA_ROLLBACK_STAMP +ORCA_CURRENT_BINARY_MOVED=1 +sudo mv /opt/orca/orca-linux.AppImage "$ORCA_CURRENT_BINARY" +sudo mv -f "$ORCA_ROLLBACK_BINARY_STAGED" /opt/orca/orca-linux.AppImage + +ORCA_CURRENT_VERSION=/opt/orca/VERSION.rollback-current-$ORCA_ROLLBACK_STAMP +if sudo test -f /opt/orca/VERSION; then + ORCA_CURRENT_VERSION_MOVED=1 + sudo mv /opt/orca/VERSION "$ORCA_CURRENT_VERSION" +fi +ORCA_VERSION_REPLACEMENT_STARTED=1 +if ((ORCA_ROLLBACK_HAS_VERSION)); then + sudo mv -f "$ORCA_ROLLBACK_VERSION_STAGED" /opt/orca/VERSION +else + sudo rm -f /opt/orca/VERSION +fi +sudo systemctl start orca-serve.service +ORCA_SERVICE_STOPPED=0 +sudo rm -rf -- "$ORCA_RESTORE" +trap - EXIT +``` + +Restoring the backup is required, not optional: swapping only the binary leaves +the newer `orca-data.json` in place, where an older build can discard state it +does not understand. Keep the pre-upgrade backup until the new version is proven +on your host. The `orca-rollback-*` directory inside `.config` is also retained +deliberately. The post-upgrade binary and version record are retained in +`/opt/orca` with the same `rollback-current-` suffix. Inspect these +artifacts and remove them according to your retention policy after the rollback +is resolved. + ## Troubleshooting - `dlopen(): error loading libfuse.so.2`: install `libfuse2`. @@ -178,6 +611,9 @@ depend on an interactive shell profile. `orca` user and that `/opt/orca` is readable by that user. - Clients cannot connect: make sure `--pairing-address` is an address reachable from the client, and make sure firewalls allow the selected `--port`. +- Service crash-loops right after an upgrade: use [Roll back](#roll-back) with + the pre-upgrade `.ready` bundle. Do not rerun the upgrade first; doing so would + make the crashing version the next rollback binary. - Diagnosing other missing libraries: extract the AppImage without launching it with `./orca-linux.AppImage --appimage-extract`, then run `ldd squashfs-root/orca` to list any shared libraries the host is missing. From dd20c11e9d91a53a78da11a06c4fe1c77e326c25 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 20 Jul 2026 18:44:24 -0700 Subject: [PATCH 16/58] cleaning up files --- agent-dashboard-mocks/RESEARCH.md | 33 -- agent-dashboard-mocks/index.html | 788 ------------------------------ 2 files changed, 821 deletions(-) delete mode 100644 agent-dashboard-mocks/RESEARCH.md delete mode 100644 agent-dashboard-mocks/index.html diff --git a/agent-dashboard-mocks/RESEARCH.md b/agent-dashboard-mocks/RESEARCH.md deleted file mode 100644 index da546889c1e..00000000000 --- a/agent-dashboard-mocks/RESEARCH.md +++ /dev/null @@ -1,33 +0,0 @@ -# How people like to see all their agents at once — research + how it shaped the mocks - -Grounded in shipping tools (Warp Agent Panel, herdr, Claude Code Agent View, Claude Squad, uzi, Conductor/Crystal, Vibe Kanban, k9s/Lens, Datadog Host Map, Buildkite, pm2/btop) and notification/triage UX literature. This drives `index.html`. - -## The core finding -**"Waiting for input" is the whole reason this view exists.** Everything else is ambient. Every serious tool makes the needs-a-human state the loudest thing on screen and lets you resolve it without a full context switch (Warp "jump to pane", Claude Agent View "reply without attaching"). Practical parallelism tops out around **4–8 agents** (occasionally ~30), so the default should optimize glanceability for a handful, with a density fallback for many. - -## Design principles applied to the mocks -1. **Waiting-for-input is loudest.** Amber + gentle pulse, top-of-sort priority, an always-visible aggregate ("2 need you"), a one-line *reason label*, and inline **Answer**/**Open** actions. → the **Needs you** inbox is the purest form; every other view surfaces it too. -2. **State is the primary channel, encoded redundantly.** Color + icon + text label (never color alone — accessibility). Colors map to Orca's `main.css` tokens (success green, destructive red) with a mock-local amber/blue for waiting/working. -3. **Motion = "needs you or is changing," never decoration.** Only *working* (subtle) and *waiting* (pulse) animate; done/idle/error are static. Respects `prefers-reduced-motion`. -4. **Density adapts to count.** Cards (≤8) → table (8–20) → tiles/grid (20+). The switcher lets you feel each. -5. **Group by project with roll-up, but let urgency flatten it.** Gallery/tree group by repo; the inbox is the flat, urgency-sorted triage view. -6. **Per-agent info priority:** state → reason (when blocked) → what it's working on → last activity → elapsed → provider badge → branch. Shown progressively (1–4 always, 5–7 on hover/detail). -7. **Live preview on hover, dots at rest.** Don't mount 30 live xterms (perf) — rest = status; **hover → terminal tail popover** with a jump-in affordance. This is the shared popover in every variant. -8. **Notify on transitions, tiered.** waiting/error = interruptive (toast + unread badge, and Orca's mobile push); done = ambient; working/idle = silent. - -## The seven variants ↔ research concepts -| Variant | Maps to | Best for | -|---|---|---| -| **Repo › worktree › agent** (nested, default) | herdr state roll-up; k9s XRay tree; your own idea | Mirrors Orca's real hierarchy; roll-up dot on each repo/worktree tells you if anything inside needs you; agents sorted by urgency | -| **Needs you** (inbox) | Inbox-zero triage; Warp attention state | Any count — the "just tell me what needs me" view | -| **Card gallery** (default) | Conductor/Lens cards + progressive disclosure | Typical 5–12 — best glanceability/info balance | -| **Kanban board** | Vibe Kanban (columns = state) | 8–15; managing rather than co-coding | -| **Mission control** (table) | uzi `ls -w`, k9s | 15–30; terminal-native power users | -| **Terminal wall** (mosaic) | tmux wall, Claude Squad panes | 2–6; comparison runs / ambient monitor | -| **Tree + focus** (master-detail) | k9s XRay tree, Conductor, herdr roll-up | Supervise + dive-in; strong general default | -| **Radial map** | Datadog Host Map / your original concentric dashboard | Stretch/overview concept; homage to the movable-UI branch | - -**Recommendation from the research:** ship **Card gallery** as default + **Tree + focus** as the working surface, with **Needs you** as a one-key filter and **Mission control** as the "I have 25 agents" density toggle. Wall/radial are focus/overview modes. - -## Sources -Warp [managing-agents](https://docs.warp.dev/agents/using-agents/managing-agents) · herdr [github](https://github.com/ogulcancelik/herdr), [docs](https://herdr.dev/docs/agents/) · Claude Code [agent-teams](https://code.claude.com/docs/en/agent-teams), [Agent View cockpit](https://www.contextstudios.ai/blog/claude-code-agent-view-multi-agent-cockpit) · Claude Squad [github](https://github.com/smtg-ai/claude-squad) · uzi [github](https://github.com/devflowinc/uzi) · Conductor [docs](https://www.conductor.build/docs/guides/parallel-agents/run-multiple-claude-code-sessions) · Vibe Kanban [github](https://github.com/BloopAI/vibe-kanban) · k9s [site](https://k9scli.io/), [palark](https://palark.com/blog/k9s-the-powerful-terminal-ui-for-kubernetes/) · Datadog [host map](https://docs.datadoghq.com/dashboards/widgets/hostmap/), [widget colors](https://docs.datadoghq.com/dashboards/guide/widget_colors/) · Buildkite [dashboard](https://buildkite.com/docs/pipelines/dashboard-walkthrough) · pm2/btop [monitoring](https://pm2.keymetrics.io/docs/usage/monitoring/) · Triage UX [Smart Interface Design Patterns](https://smart-interface-design-patterns.com/articles/notifications/), [Missive inbox zero](https://missiveapp.com/blog/inbox-zero) diff --git a/agent-dashboard-mocks/index.html b/agent-dashboard-mocks/index.html deleted file mode 100644 index b0d95b6d2b0..00000000000 --- a/agent-dashboard-mocks/index.html +++ /dev/null @@ -1,788 +0,0 @@ - - - - - -Orca · Agent Dashboard — UI explorations - - - -
-
OrcaAgents
-
-
- -
- -
-
- Eight ways to see every agent across all your worktrees at once. Hover any agent to peek its live terminal - in a popover (move into the popover to interact). Layout switcher is top-right; ◐ toggles light/dark. -
- -
- - - - - - - From 9680b22e60ada5458b4656cea558c879bb8783e9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 20 Jul 2026 18:54:44 -0700 Subject: [PATCH 17/58] fix(terminal): heal stale PTY grids on window wake (#9626) * fix(terminal): heal stale PTY grids on window wake * fix(terminal): bound window-wake size recovery --- config/reliability-gates.jsonc | 63 ++++-- src/main/ipc/pty.test.ts | 12 ++ src/main/ipc/pty.ts | 9 +- src/main/providers/ssh-pty-applied-size.ts | 49 +++++ src/main/providers/ssh-pty-provider.test.ts | 27 +++ src/main/providers/ssh-pty-provider.ts | 3 + src/relay/pty-handler.test.ts | 18 ++ src/relay/pty-handler.ts | 11 + .../components/terminal-pane/TerminalPane.tsx | 1 + .../terminal-pane/pty-connection.ts | 4 + .../use-terminal-pane-global-effects.ts | 6 +- .../use-terminal-window-wake-recovery.test.ts | 53 +++++ .../use-terminal-window-wake-recovery.ts | 20 +- tests/e2e/ssh-terminal-stale-grid-probe.ts | 159 ++++++++++++++ ...minal-window-wake-stale-grid-repro.spec.ts | 195 ++++++++++++++++++ ...minal-window-wake-stale-grid-repro.spec.ts | 71 +++++++ 16 files changed, 680 insertions(+), 21 deletions(-) create mode 100644 src/main/providers/ssh-pty-applied-size.ts create mode 100644 tests/e2e/ssh-terminal-stale-grid-probe.ts create mode 100644 tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts create mode 100644 tests/e2e/terminal-window-wake-stale-grid-repro.spec.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 3c49afcb582..60ad659d376 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "updatedAt": "2026-07-19", + "updatedAt": "2026-07-20", "policy": { "maturityLevels": [ "experimental", @@ -1224,7 +1224,8 @@ "PTY sizing", "split layout", "restore", - "hidden-to-visible transitions" + "hidden-to-visible transitions", + "window wake" ], "platforms": [ "macos", @@ -1241,7 +1242,7 @@ "macos" ], "coveredProviders": [], - "coverageNotes": "Local macOS evidence on main@1282f5c2d, including #7192's runtime-mirror geometry authority slice. Shell-visible size, SSH/remote geometry, Windows ConPTY readback, and resume-time reassertion coverage arrive with the pending stack and #7006.", + "coverageNotes": "Local macOS evidence on main@1282f5c2d, including #7192's runtime-mirror geometry authority slice. Deterministic provider-contract coverage now includes settled window-wake reassertion and SSH relay applied-size readback. Live shell-visible SSH/remote geometry and Windows ConPTY readback remain non-blocking gaps.", "motivatingLinks": [ "https://github.com/stablyai/orca/pull/6644", "https://github.com/stablyai/orca/pull/6649", @@ -1252,15 +1253,19 @@ "https://github.com/stablyai/orca/pull/7192" ], "invariant": "A visible desktop-owned terminal cannot trust 0x0, stale requested size, or renderer-only size; xterm, fit/proposed size, applied PTY size, shell-visible size, and the runtime mirror's parse dimensions must converge or enter explicit degraded state, and mirror resize reflow must stay ordered with queued output writes.", - "oracle": "The current executable slice uses deterministic frame schedulers and fake providers to force 0x0 first fit, delayed layout settle, dropped resize/readback drift, and hidden-to-visible activation, then asserts the renderer forwards a usable size, pty:getSize reports applied rather than merely requested size where available, and visibility resume reasserts real drift without hot listSessions. Shell-visible stty/echo-wrap convergence remains a live-gate follow-up.", + "oracle": "The current executable slice uses deterministic frame schedulers and fake providers to force 0x0 first fit, delayed layout settle, dropped resize/readback drift, hidden-to-visible activation, and window wake. It asserts the renderer forwards a usable size, pty:getSize reports applied rather than merely requested size where available, visibility resume reasserts real drift without hot listSessions, one settled wake produces one geometry-only readback, and SSH relay readback is authoritative with bounded fallback. Shell-visible stty/echo-wrap convergence remains a live-gate follow-up.", "commands": [ - "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-size-reconcile.test.ts src/renderer/src/components/terminal-pane/split-right-white-screen.test.ts src/main/ipc/pty.test.ts src/main/runtime/orca-runtime.test.ts" + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-size-reconcile.test.ts src/renderer/src/components/terminal-pane/pty-size-reassertion.test.ts src/renderer/src/components/terminal-pane/split-right-white-screen.test.ts src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts src/main/ipc/pty.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/runtime/orca-runtime.test.ts src/relay/pty-handler.test.ts" ], "testFiles": [ "src/renderer/src/components/terminal-pane/pty-size-reconcile.test.ts", + "src/renderer/src/components/terminal-pane/pty-size-reassertion.test.ts", "src/renderer/src/components/terminal-pane/split-right-white-screen.test.ts", + "src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts", "src/main/ipc/pty.test.ts", - "src/main/runtime/orca-runtime.test.ts" + "src/main/providers/ssh-pty-provider.test.ts", + "src/main/runtime/orca-runtime.test.ts", + "src/relay/pty-handler.test.ts" ], "assertionRefs": [ { @@ -1271,6 +1276,13 @@ "applied-size readback divergence is re-forwarded before handoff" ] }, + { + "file": "src/renderer/src/components/terminal-pane/pty-size-reassertion.test.ts", + "assertions": [ + "only one applied-size read stays in flight while the grid changes", + "stale in-flight targets never resize the PTY back to an older grid" + ] + }, { "file": "src/renderer/src/components/terminal-pane/split-right-white-screen.test.ts", "assertions": [ @@ -1279,11 +1291,26 @@ "hidden 0x0 background spawn is not forced to phantom desktop size" ] }, + { + "file": "src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts", + "assertions": [ + "window focus reasserts PTY size after fitting", + "the immediate and settled wake fits produce exactly one geometry-only reassertion" + ] + }, { "file": "src/main/ipc/pty.test.ts", "assertions": [ "accepted desktop resizes fan out to the runtime after provider resize", - "rejected desktop resizes do not fan out to the runtime" + "rejected desktop resizes do not fan out to the runtime", + "provider-owned null remains unverified instead of falling back to requested size" + ] + }, + { + "file": "src/main/providers/ssh-pty-provider.test.ts", + "assertions": [ + "SSH applied-size readback uses the relay with a one-second timeout", + "old-relay method-not-found is cached while transient failures remain retryable" ] }, { @@ -1292,17 +1319,24 @@ "the headless mirror resizes after an accepted desktop PTY resize (#7192)", "headless mirror resizes are ordered behind queued PTY writes (#7192)" ] + }, + { + "file": "src/relay/pty-handler.test.ts", + "assertions": [ + "relay readback reports the grid actually applied by node-pty", + "missing relay PTYs return an unverified null size" + ] } ], "evidenceRuns": [ { - "date": "2026-07-03", + "date": "2026-07-20", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-size-reconcile.test.ts src/renderer/src/components/terminal-pane/split-right-white-screen.test.ts src/main/ipc/pty.test.ts src/main/runtime/orca-runtime.test.ts", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-size-reconcile.test.ts src/renderer/src/components/terminal-pane/pty-size-reassertion.test.ts src/renderer/src/components/terminal-pane/split-right-white-screen.test.ts src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts src/main/ipc/pty.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/runtime/orca-runtime.test.ts src/relay/pty-handler.test.ts", "result": "passed", - "durationSeconds": 8.4, - "summary": "4 test file(s) passed, 777 tests passed on main@1282f5c2d in a clean checkout." + "durationSeconds": 18.54, + "summary": "8 test files passed, 1,272 tests passed on the PR branch." } ], "runtimeBudget": { @@ -1319,16 +1353,15 @@ }, "performanceBudget": { "required": true, - "evidence": "Must include resize/throughput budget so convergence loops do not add frame or SIGWINCH churn." + "evidence": "The wake hook count test permits two visual fits but exactly one applied-size read per visible pane and no broad visibility/agent lifecycle call. SSH readback has a one-second timeout, caches stable method-not-found, and retries only transient failures. No polling, listSessions scan, subprocess, or unbounded retry was added." }, "promotionCriteria": [ "Build deterministic fault hooks before adding broad E2E coverage.", "Keep stress variants non-blocking until runtime and flake history are known." ], "knownGaps": [ - "No live Electron geometry command yet beyond the local live PTY resize slice.", - "The runtime mirror geometry slice is unregistered: #7192's red tests (orca-runtime.test.ts headless-mirror resize and ordering, pty.test.ts desktop resize fan-out) exist on fresh main but not at this branch's merge-base, so they can only be registered during the rebase.", - "Does not yet prove shell-visible size, provider-applied size, and xterm size converge across SSH/remote paths.", + "Local and Docker-backed SSH Electron wake specs exist but remain manual/non-blocking pending runtime and flake history.", + "The deterministic gate proves the SSH provider/relay readback contract, not live shell-visible convergence across every remote path.", "Does not yet prove Windows ConPTY geometry/readback.", "Current command uses deterministic fake providers for the main/renderer contracts, not a real remote PTY." ], diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 85a84fe098b..5d147a9eebd 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -4703,6 +4703,18 @@ describe('registerPtyHandlers', () => { expect(reported).toEqual({ cols: 80, rows: 24 }) }) + it('preserves provider-owned null so the renderer re-forwards an unverified size', async () => { + setupProviderWithAppliedSize({ applied: null, getAppliedSize: async () => null }) + handlers.clear() + registerPtyHandlers(mainWindow as never) + const spawn = await handlers.get('pty:spawn')!(null, { cols: 100, rows: 30, env: {} }) + const id = (spawn as { id: string }).id + resizeListener()(mainWindowIpcEvent, { id, cols: 80, rows: 24 }) + + const reported = await handlers.get('pty:getSize')!(null, { id }) + expect(reported).toBeNull() + }) + it('falls back to the requested size when getAppliedSize throws', async () => { // A dead daemon/relay must never throw across the IPC boundary or block. setupProviderWithAppliedSize({ diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 573506367ea..c14ba872cf6 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -4814,10 +4814,13 @@ export function registerPtyHandlers( ipcMain.handle( 'pty:getSize', async (_event, args: { id: string }): Promise<{ cols: number; rows: number } | null> => { + const provider = tryGetProviderForPty(args.id) try { - const applied = await tryGetProviderForPty(args.id)?.getAppliedSize?.(args.id) - if (applied) { - return applied + if (provider?.getAppliedSize) { + // Why: a provider-owned null means it could not verify the applied + // grid; preserve null so the renderer re-forwards instead of trusting + // the requested-size cache that may describe a dropped resize. + return await provider.getAppliedSize(args.id) } } catch { // Fall through to the requested-size cache so a dead daemon/relay can't throw across the IPC boundary. diff --git a/src/main/providers/ssh-pty-applied-size.ts b/src/main/providers/ssh-pty-applied-size.ts new file mode 100644 index 00000000000..f725f7c2a09 --- /dev/null +++ b/src/main/providers/ssh-pty-applied-size.ts @@ -0,0 +1,49 @@ +import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' +import { JsonRpcErrorCode } from '../ssh/relay-protocol' +import { toRelaySshPtyId } from './ssh-pty-id' + +type AppliedPtySize = { cols: number; rows: number } +// Why: wake repair can safely re-forward on failure, so never inherit the generic 30-second RPC timeout. +const SSH_PTY_APPLIED_SIZE_TIMEOUT_MS = 1_000 + +export function createSshPtyAppliedSizeReader( + mux: SshChannelMultiplexer, + connectionId: string +): (id: string) => Promise { + let supported: boolean | null = null + return async (id) => { + if (supported === false) { + return null + } + try { + const result = (await mux.request( + 'pty.getSize', + { + id: toRelaySshPtyId(connectionId, id) + }, + { timeoutMs: SSH_PTY_APPLIED_SIZE_TIMEOUT_MS } + )) as { + cols?: unknown + rows?: unknown + } | null + supported = true + if ( + !result || + !Number.isInteger(result.cols) || + !Number.isInteger(result.rows) || + Number(result.cols) <= 0 || + Number(result.rows) <= 0 + ) { + return null + } + return { cols: Number(result.cols), rows: Number(result.rows) } + } catch (error) { + if ((error as { code?: unknown })?.code === JsonRpcErrorCode.MethodNotFound) { + // Why: old relays lack pty.getSize; remember that per SSH provider so + // each wake re-forwards once without repeatedly probing the same host. + supported = false + } + return null + } + } +} diff --git a/src/main/providers/ssh-pty-provider.test.ts b/src/main/providers/ssh-pty-provider.test.ts index 209218fa1f0..4a2bf02546a 100644 --- a/src/main/providers/ssh-pty-provider.test.ts +++ b/src/main/providers/ssh-pty-provider.test.ts @@ -470,6 +470,33 @@ describe('SshPtyProvider', () => { expect(mux.notify).toHaveBeenCalledWith('pty.resize', { id: 'pty-1', cols: 120, rows: 40 }) }) + it('reads the applied PTY size from the relay', async () => { + mux.request.mockResolvedValue({ cols: 120, rows: 40 }) + + await expect(provider.getAppliedSize(scopedPty1)).resolves.toEqual({ cols: 120, rows: 40 }) + expect(mux.request).toHaveBeenCalledWith('pty.getSize', { id: 'pty-1' }, { timeoutMs: 1_000 }) + }) + + it('caches only an old relay method-not-found response', async () => { + mux.request.mockRejectedValue(Object.assign(new Error('Method not found'), { code: -32601 })) + + await expect(provider.getAppliedSize(scopedPty1)).resolves.toBeNull() + await expect(provider.getAppliedSize(scopedPty1)).resolves.toBeNull() + expect(mux.request).toHaveBeenCalledTimes(1) + }) + + it('retries an applied-size read after a transient relay failure', async () => { + mux.request + .mockRejectedValueOnce( + Object.assign(new Error('connection lost'), { code: 'CONNECTION_LOST' }) + ) + .mockResolvedValueOnce({ cols: 100, rows: 30 }) + + await expect(provider.getAppliedSize(scopedPty1)).resolves.toBeNull() + await expect(provider.getAppliedSize(scopedPty1)).resolves.toEqual({ cols: 100, rows: 30 }) + expect(mux.request).toHaveBeenCalledTimes(2) + }) + it('shutdown sends pty.shutdown request', async () => { await provider.shutdown(scopedPty1, { immediate: true }) expect(mux.request).toHaveBeenCalledWith( diff --git a/src/main/providers/ssh-pty-provider.ts b/src/main/providers/ssh-pty-provider.ts index df6d0540d47..48240e27e25 100644 --- a/src/main/providers/ssh-pty-provider.ts +++ b/src/main/providers/ssh-pty-provider.ts @@ -3,6 +3,7 @@ import type { IPtyProvider, PtyProcessInfo, PtySpawnOptions, PtySpawnResult } fr import { toAppSshPtyId, toRelaySshPtyId } from './ssh-pty-id' import { seedPowerlevel10kWizardEnv } from '../pty/powerlevel10k-wizard-env' import { PTY_STARTUP_INGRESS_VERSION } from '../../shared/pty-startup-ingress' +import { createSshPtyAppliedSizeReader } from './ssh-pty-applied-size' type DataCallback = (payload: { id: string @@ -56,6 +57,7 @@ export class SshPtyProvider implements IPtyProvider { // multiplexer. Without this, notification callbacks keep firing after // the provider is torn down on disconnect, routing events to stale state. private unsubscribeNotifications: (() => void) | null = null + readonly getAppliedSize: NonNullable constructor( connectionId: string, @@ -64,6 +66,7 @@ export class SshPtyProvider implements IPtyProvider { ) { this.connectionId = connectionId this.mux = mux + this.getAppliedSize = createSshPtyAppliedSizeReader(mux, connectionId) // Subscribe to relay notifications for PTY events this.unsubscribeNotifications = mux.onNotification((method, params) => { diff --git a/src/relay/pty-handler.test.ts b/src/relay/pty-handler.test.ts index 0c0b33d9b94..d7242234053 100644 --- a/src/relay/pty-handler.test.ts +++ b/src/relay/pty-handler.test.ts @@ -1274,6 +1274,24 @@ describe('PtyHandler', () => { expect(mockResize).toHaveBeenCalledWith(120, 40) }) + it('reports the PTY grid actually applied by node-pty', async () => { + mockPtySpawn.mockReturnValue({ + ...mockPtyInstance, + cols: 132, + rows: 43, + onData: vi.fn(), + onExit: vi.fn() + }) + + const spawned = (await dispatcher.callRequest('pty.spawn', {})) as { id: string } + + await expect(dispatcher.callRequest('pty.getSize', { id: spawned.id })).resolves.toEqual({ + cols: 132, + rows: 43 + }) + await expect(dispatcher.callRequest('pty.getSize', { id: 'missing' })).resolves.toBeNull() + }) + it('kills PTY on shutdown with SIGTERM by default', async () => { const mockKill = vi.fn() mockPtySpawn.mockReturnValue({ diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 28df63b7960..ebdb7cea9e0 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -575,6 +575,7 @@ export class PtyHandler { this.dispatcher.onRequest('pty.sendSignal', (p) => this.sendSignal(p)) this.dispatcher.onRequest('pty.getCwd', (p) => this.getCwd(p)) this.dispatcher.onRequest('pty.getInitialCwd', (p) => this.getInitialCwd(p)) + this.dispatcher.onRequest('pty.getSize', (p) => this.getSize(p)) this.dispatcher.onRequest('pty.clearBuffer', (p) => this.clearBuffer(p)) this.dispatcher.onRequest('pty.hasChildProcesses', (p) => this.hasChildProcesses(p)) this.dispatcher.onRequest('pty.getForegroundProcess', (p) => this.getForegroundProcess(p)) @@ -1021,6 +1022,16 @@ export class PtyHandler { } } + private async getSize( + params: Record + ): Promise<{ cols: number; rows: number } | null> { + const managed = this.ptys.get(params.id as string) + if (!managed || managed.disposed) { + return null + } + return { cols: managed.pty.cols, rows: managed.pty.rows } + } + private async shutdown(params: Record): Promise { const id = params.id as string const immediate = params.immediate as boolean diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 0997a11c0e7..e80119bbef5 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -1720,6 +1720,7 @@ export default function TerminalPane({ managerRef, containerRef, paneTransportsRef, + panePtyBindingsRef, isActiveRef, isVisibleRef, toggleExpandPane diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 8e81e9b645b..78b96cdafc9 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -625,6 +625,7 @@ let inactiveForegroundImmediateBudgetWindowStart = 0 type PanePtyBinding = IDisposable & { syncProcessTracking: () => void noteVisibilityResume: () => void + reassertPtySizeAfterWindowWake: () => void /** Navigation-free hibernation wake: fires the armed cold-restore --resume * without the size-reassert/foreground-sample side effects of a real reveal. * Used by the mobile wake fanout so a hidden hibernated pane resumes with no @@ -8037,6 +8038,9 @@ export function connectPanePty( requestKnownDroidReconfirmation() sampleVisiblePaneForegroundAgent() }, + reassertPtySizeAfterWindowWake() { + ptySizeReassertion.request({ fit: false }) + }, // Why: mobile wake reaches this pane while it's hidden on the desktop, so consume only the armed hibernation wake — no size/foreground reads. wakeHibernatedAgentIfArmed(claimedProviderSessions) { if (hibernatedWakeInFlightClaimKey) { diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts index 09033df6264..95f9198e7ea 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts @@ -8,6 +8,7 @@ import { } from '@/constants/terminal' import type { PaneManager } from '@/lib/pane-manager/pane-manager' import type { PtyTransport } from './pty-transport' +import type { IDisposable } from '@xterm/xterm' import { handleTerminalFileDrop } from './terminal-drop-handler' import { handleFocusTerminalPaneDetail } from './focus-terminal-pane-event' import { surfaceStaleAgentRow } from './stale-agent-row' @@ -38,6 +39,7 @@ type UseTerminalPaneGlobalEffectsArgs = { managerRef: React.RefObject containerRef: React.RefObject paneTransportsRef: React.RefObject> + panePtyBindingsRef?: React.RefObject> isActiveRef: React.RefObject isVisibleRef: React.RefObject toggleExpandPane: (paneId: number) => void @@ -70,6 +72,7 @@ export function useTerminalPaneGlobalEffects({ managerRef, containerRef, paneTransportsRef, + panePtyBindingsRef, isActiveRef, isVisibleRef, toggleExpandPane @@ -120,7 +123,8 @@ export function useTerminalPaneGlobalEffects({ isVisible: rendererVisible, managerRef, isActiveRef, - isVisibleRef + isVisibleRef, + panePtyBindingsRef }) useEffect(() => { diff --git a/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts index c83cdde9288..4862bc145e7 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts @@ -97,6 +97,59 @@ describe('useTerminalWindowWakeRecovery', () => { ]) }) + it('reasserts pane PTY sizes after the window-focus fit', () => { + const reassertPtySizeAfterWindowWake = vi.fn() + renderHook(() => + useTerminalWindowWakeRecovery({ + isVisible: true, + managerRef: { current: manager }, + isActiveRef: { current: true }, + isVisibleRef: { current: true }, + panePtyBindingsRef: { + current: new Map([[1, { dispose: vi.fn(), reassertPtySizeAfterWindowWake }]]) as never + } + }) + ) + + window.dispatchEvent(new Event('focus')) + + expect(reassertPtySizeAfterWindowWake).toHaveBeenCalledTimes(1) + expect(recoverVisibleTerminalWindowWakeMock.mock.invocationCallOrder[0]).toBeLessThan( + reassertPtySizeAfterWindowWake.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY + ) + }) + + it('reasserts once after the settled fit when animation frames are available', () => { + const scheduled: { settle: FrameRequestCallback | null } = { settle: null } + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + scheduled.settle = callback + return 1 + }) + const reassertPtySizeAfterWindowWake = vi.fn() + renderHook(() => + useTerminalWindowWakeRecovery({ + isVisible: true, + managerRef: { current: manager }, + isActiveRef: { current: true }, + isVisibleRef: { current: true }, + panePtyBindingsRef: { + current: new Map([[1, { dispose: vi.fn(), reassertPtySizeAfterWindowWake }]]) as never + } + }) + ) + + window.dispatchEvent(new Event('focus')) + expect(reassertPtySizeAfterWindowWake).not.toHaveBeenCalled() + expect(scheduled.settle).not.toBeNull() + + scheduled.settle?.(performance.now()) + + expect(reassertPtySizeAfterWindowWake).toHaveBeenCalledTimes(1) + expect(recoverVisibleTerminalWindowWakeMock.mock.invocationCallOrder.at(-1)).toBeLessThan( + reassertPtySizeAfterWindowWake.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY + ) + }) + it('unsubscribes from the system resume event on cleanup', () => { const { unmount } = renderWakeRecoveryHook() expect(onSystemResumed).toHaveBeenCalledTimes(1) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.ts b/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.ts index 839b6184696..7405f04345b 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.ts @@ -2,19 +2,26 @@ import { useEffect } from 'react' import type { PaneManager } from '@/lib/pane-manager/pane-manager' import { recoverVisibleTerminalWindowWake } from './terminal-visibility-resume' import { recordTerminalFreezeBreadcrumb } from './terminal-freeze-breadcrumbs' +import type { IDisposable } from '@xterm/xterm' type UseTerminalWindowWakeRecoveryArgs = { isVisible: boolean managerRef: React.RefObject isActiveRef: React.RefObject isVisibleRef: React.RefObject + panePtyBindingsRef?: React.RefObject> +} + +type WindowWakePtyBinding = IDisposable & { + reassertPtySizeAfterWindowWake?: () => void } export function useTerminalWindowWakeRecovery({ isVisible, managerRef, isActiveRef, - isVisibleRef + isVisibleRef, + panePtyBindingsRef }: UseTerminalWindowWakeRecoveryArgs): void { useEffect(() => { if (!isVisible) { @@ -30,6 +37,13 @@ export function useTerminalWindowWakeRecovery({ cancelAnimationFrame(wakeRecoveryFrameId) wakeRecoveryFrameId = null } + const reassertPanePtySizes = (): void => { + for (const binding of panePtyBindingsRef?.current.values() ?? []) { + // Why: one settled read avoids duplicate SSH RPCs while still detecting a dropped resize. + const windowWakeBinding = binding as WindowWakePtyBinding + windowWakeBinding.reassertPtySizeAfterWindowWake?.() + } + } const recoverVisibleWake = ( clearGlyphAtlases: boolean, source: 'focus' | 'visibilitychange' | 'system-resumed' @@ -60,6 +74,7 @@ export function useTerminalWindowWakeRecovery({ clearGlyphAtlases }) if (typeof requestAnimationFrame !== 'function') { + reassertPanePtySizes() return } settledClearGlyphAtlases = clearGlyphAtlases @@ -76,6 +91,7 @@ export function useTerminalWindowWakeRecovery({ isActive: isActiveRef.current, clearGlyphAtlases: clearGlyphAtlasesOnSettle }) + reassertPanePtySizes() }) } // Why: plain refocus (alt-tab, devtools) is frequent and often lands while @@ -115,5 +131,5 @@ export function useTerminalWindowWakeRecovery({ } unsubscribeSystemResumed?.() } - }, [isActiveRef, isVisible, isVisibleRef, managerRef]) + }, [isActiveRef, isVisible, isVisibleRef, managerRef, panePtyBindingsRef]) } diff --git a/tests/e2e/ssh-terminal-stale-grid-probe.ts b/tests/e2e/ssh-terminal-stale-grid-probe.ts new file mode 100644 index 00000000000..c246ab4a81d --- /dev/null +++ b/tests/e2e/ssh-terminal-stale-grid-probe.ts @@ -0,0 +1,159 @@ +import type { Page, TestInfo } from '@stablyai/playwright-test' +import { + execDockerSshRelayTargetCommand, + type DockerSshRelayTarget, + writeDockerSshRelayTargetFile +} from './helpers/docker-ssh-relay-target' + +export const REMOTE_MONITOR_PATH = '/tmp/orca-idle-grid-monitor.mjs' +export const REMOTE_STATE_PATH = '/tmp/orca-idle-grid-state.json' + +export type Grid = { cols: number; rows: number } + +export type RemoteGridState = Grid & { + draws: number + pid: number + updatedAt: number + winches: number +} + +export type RendererGridState = { + applied: Grid | null + xterm: Grid | null +} + +export type ReproSample = { + cycle: number + elapsedMs: number + remote: RemoteGridState + renderer: RendererGridState +} + +function idleGridMonitorScript(): string { + return ` +import fs from 'node:fs' + +const statePath = process.argv[2] +let draws = 0 +let winches = 0 +let previousRow = null + +function readGrid() { + const [cols, rows] = process.stdout.getWindowSize() + return { cols, rows } +} + +function persist() { + const grid = readGrid() + const state = { ...grid, draws, pid: process.pid, updatedAt: Date.now(), winches } + const temporaryPath = statePath + '.' + process.pid + '.tmp' + fs.writeFileSync(temporaryPath, JSON.stringify(state)) + fs.renameSync(temporaryPath, statePath) + return grid +} + +function draw() { + draws += 1 + const { cols, rows } = persist() + const text = ('REMOTE_BOTTOM_BAR rows=' + rows + ' cols=' + cols + ' ' + '='.repeat(240)).slice(0, Math.max(1, cols - 1)) + const clearPrevious = previousRow && previousRow !== rows ? '\\x1b[' + previousRow + ';1H\\x1b[2K' : '' + process.stdout.write('\\x1b7' + clearPrevious + '\\x1b[' + rows + ';1H\\x1b[2K' + text + '\\x1b8') + previousRow = rows +} + +process.on('SIGWINCH', () => { + winches += 1 + draw() +}) + +draw() +setInterval(persist, 25) +setTimeout(() => process.exit(0), 600000) +` +} + +export function installIdleGridMonitor(target: DockerSshRelayTarget): void { + writeDockerSshRelayTargetFile(target, REMOTE_MONITOR_PATH, idleGridMonitorScript()) +} + +export function readRemoteGrid(target: DockerSshRelayTarget): RemoteGridState { + const json = execDockerSshRelayTargetCommand(target, `cat ${REMOTE_STATE_PATH}`) + return JSON.parse(json) as RemoteGridState +} + +export async function readRendererGrid(page: Page, ptyId: string): Promise { + return page.evaluate(async (id) => { + let xterm: Grid | null = null + for (const manager of window.__paneManagers?.values() ?? []) { + for (const pane of manager.getPanes?.() ?? []) { + if (pane.container?.dataset?.ptyId === id) { + xterm = { cols: pane.terminal.cols, rows: pane.terminal.rows } + } + } + } + return { + applied: (await window.api.pty.getSize(id)) ?? null, + xterm + } + }, ptyId) +} + +export function actualGridMatchesXterm( + remote: RemoteGridState, + renderer: RendererGridState +): boolean { + return ( + renderer.xterm !== null && + renderer.xterm.cols > 0 && + renderer.xterm.rows > 0 && + remote.cols === renderer.xterm.cols && + remote.rows === renderer.xterm.rows + ) +} + +export async function sampleRemoteConvergence(args: { + cycle: number + page: Page + ptyId: string + target: DockerSshRelayTarget + timeoutMs?: number +}): Promise<{ last: ReproSample; stale: ReproSample[] }> { + const startedAt = Date.now() + const stale: ReproSample[] = [] + let last: ReproSample | null = null + while (Date.now() - startedAt < (args.timeoutMs ?? 6_000)) { + const remote = readRemoteGrid(args.target) + const renderer = await readRendererGrid(args.page, args.ptyId) + last = { + cycle: args.cycle, + elapsedMs: Date.now() - startedAt, + remote, + renderer + } + if (actualGridMatchesXterm(remote, renderer)) { + return { last, stale } + } + stale.push(last) + await args.page.waitForTimeout(100) + } + if (!last) { + throw new Error('Remote grid convergence sampling produced no samples') + } + return { last, stale } +} + +export async function attachStaleGridEvidence( + page: Page, + testInfo: TestInfo, + label: string, + samples: ReproSample[] +): Promise { + await testInfo.attach(`${label}.json`, { + body: Buffer.from(JSON.stringify(samples, null, 2)), + contentType: 'application/json' + }) + await testInfo.attach(`${label}.png`, { + body: await page.screenshot({ fullPage: true }), + contentType: 'image/png' + }) +} diff --git a/tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts b/tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts new file mode 100644 index 00000000000..dc0822e899b --- /dev/null +++ b/tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts @@ -0,0 +1,195 @@ +import type { Page } from '@stablyai/playwright-test' +import path from 'node:path' +import { test, expect } from './helpers/orca-app' +import { connectDockerSshRelayTarget } from './helpers/docker-ssh-relay-connection' +import { + cleanupDockerSshRelayTarget, + DOCKER_SSH_RELAY_REMOTE_REPO_PATH, + execDockerSshRelayTargetCommand, + startDockerSshRelayTarget, + type DockerSshRelayTarget +} from './helpers/docker-ssh-relay-target' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { + execInTerminal, + waitForActivePanePtyId, + waitForActiveTerminalManager, + waitForTerminalOutput +} from './helpers/terminal' +import { + actualGridMatchesXterm, + attachStaleGridEvidence, + installIdleGridMonitor, + readRemoteGrid, + readRendererGrid, + REMOTE_MONITOR_PATH, + REMOTE_STATE_PATH, + sampleRemoteConvergence, + type Grid +} from './ssh-terminal-stale-grid-probe' + +const RUN_DOCKER_SSH = process.env.ORCA_E2E_SSH_DOCKER === '1' +const BASE_VIEWPORT = { width: 1160, height: 760 } + +async function startRemoteMonitor(page: Page, ptyId: string): Promise { + const marker = `ORCA_SSH_WAKE_READY_${Date.now()}` + await execInTerminal(page, ptyId, `printf '${marker}\\n'`) + await waitForTerminalOutput(page, marker, 20_000, 60_000) + await execInTerminal(page, ptyId, `node ${REMOTE_MONITOR_PATH} ${REMOTE_STATE_PATH}`) +} + +function chooseStaleGrid(current: Grid): Grid { + return { + cols: Math.max(40, current.cols - 19), + rows: Math.max(12, current.rows - 7) + } +} + +test.describe('SSH terminal window-wake stale PTY grid repro', () => { + test.skip(!RUN_DOCKER_SSH, 'Set ORCA_E2E_SSH_DOCKER=1 to run Docker-backed SSH repro.') + test.skip(process.platform === 'win32', 'Docker SSH repro uses POSIX SSH tooling.') + + test('window focus heals a remote PTY whose applied grid drifted from xterm', async ({ + orcaPage + }, testInfo) => { + test.setTimeout(240_000) + let target: DockerSshRelayTarget | null = null + try { + target = startDockerSshRelayTarget(testInfo) + const pageErrors: string[] = [] + orcaPage.on('pageerror', (error) => pageErrors.push(error.message)) + installIdleGridMonitor(target) + await orcaPage.setViewportSize(BASE_VIEWPORT) + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + const identity = await orcaPage.evaluate(() => window.api.app.getIdentity()) + expect(identity.isDev).toBe(true) + expect(identity.devWorktreeName).toBe(path.basename(process.cwd())) + + await connectDockerSshRelayTarget(orcaPage, target, { relayGracePeriodSeconds: 300 }) + await ensureTerminalVisible(orcaPage, 60_000) + await waitForActiveTerminalManager(orcaPage, 60_000) + const ptyId = await waitForActivePanePtyId(orcaPage, 60_000) + await startRemoteMonitor(orcaPage, ptyId) + await expect + .poll( + () => { + try { + return readRemoteGrid(target!).pid > 0 + } catch { + return false + } + }, + { timeout: 30_000, message: 'Idle remote grid monitor did not start' } + ) + .toBe(true) + + await expect + .poll( + async () => + actualGridMatchesXterm( + readRemoteGrid(target!), + await readRendererGrid(orcaPage, ptyId) + ), + { timeout: 15_000, message: 'Remote PTY and xterm did not establish a matching baseline' } + ) + .toBe(true) + + const baseline = await readRendererGrid(orcaPage, ptyId) + if (!baseline.xterm) { + throw new Error('Active xterm grid unavailable') + } + const staleGrid = chooseStaleGrid(baseline.xterm) + await orcaPage.evaluate(({ id, grid }) => window.api.pty.resize(id, grid.cols, grid.rows), { + id: ptyId, + grid: staleGrid + }) + await expect.poll(() => readRemoteGrid(target!).cols, { timeout: 5_000 }).toBe(staleGrid.cols) + await expect.poll(() => readRemoteGrid(target!).rows, { timeout: 5_000 }).toBe(staleGrid.rows) + + const drifted = await readRendererGrid(orcaPage, ptyId) + expect(drifted.xterm).toEqual(baseline.xterm) + expect(drifted.applied).toEqual(staleGrid) + + await orcaPage.evaluate(() => window.dispatchEvent(new Event('focus'))) + const wakeResult = await sampleRemoteConvergence({ + cycle: 0, + page: orcaPage, + ptyId, + target, + timeoutMs: 3_000 + }) + + if (!actualGridMatchesXterm(wakeResult.last.remote, wakeResult.last.renderer)) { + await attachStaleGridEvidence( + orcaPage, + testInfo, + 'ssh-window-focus-stale-grid', + wakeResult.stale + ) + // Manual resize is the field workaround and proves the remote channel + // can still deliver the corrective SIGWINCH. + await orcaPage.setViewportSize({ + width: BASE_VIEWPORT.width + 24, + height: BASE_VIEWPORT.height + 24 + }) + const manualResize = await sampleRemoteConvergence({ + cycle: 1, + page: orcaPage, + ptyId, + target, + timeoutMs: 6_000 + }) + expect(actualGridMatchesXterm(manualResize.last.remote, manualResize.last.renderer)).toBe( + true + ) + } + + expect( + actualGridMatchesXterm(wakeResult.last.remote, wakeResult.last.renderer), + `Window-focus recovery left the Linux PTY stale: ${JSON.stringify(wakeResult.last)}` + ).toBe(true) + expect(pageErrors).toEqual([]) + + const remoteGitStatus = execDockerSshRelayTargetCommand( + target, + `git -C ${DOCKER_SSH_RELAY_REMOTE_REPO_PATH} status --short --branch` + ) + const remoteWorktrees = execDockerSshRelayTargetCommand( + target, + `git -C ${DOCKER_SSH_RELAY_REMOTE_REPO_PATH} worktree list --porcelain` + ) + expect(remoteGitStatus).toContain('## master') + expect(remoteWorktrees).toContain(DOCKER_SSH_RELAY_REMOTE_REPO_PATH) + expect( + execDockerSshRelayTargetCommand( + target, + `kill -0 ${wakeResult.last.remote.pid} && printf alive` + ) + ).toBe('alive') + + const evidence = { + identity, + target: { containerName: target.containerName, port: target.port }, + baseline, + injectedRemoteGrid: staleGrid, + recovered: wakeResult.last, + remoteGitStatus, + remoteWorktrees + } + console.log(`[ssh-window-wake-stale-grid] ${JSON.stringify(evidence)}`) + testInfo.annotations.push({ + type: 'ssh-window-wake-stale-grid', + description: JSON.stringify(evidence) + }) + const healedScreenshot = testInfo.outputPath('ssh-window-focus-healed.png') + await orcaPage.screenshot({ path: healedScreenshot, fullPage: true }) + await testInfo.attach('ssh-window-focus-healed.png', { + path: healedScreenshot, + contentType: 'image/png' + }) + } finally { + cleanupDockerSshRelayTarget(target) + } + }) +}) diff --git a/tests/e2e/terminal-window-wake-stale-grid-repro.spec.ts b/tests/e2e/terminal-window-wake-stale-grid-repro.spec.ts new file mode 100644 index 00000000000..d76e242132f --- /dev/null +++ b/tests/e2e/terminal-window-wake-stale-grid-repro.spec.ts @@ -0,0 +1,71 @@ +import type { Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { waitForActivePanePtyId, waitForActiveTerminalManager } from './helpers/terminal' +import { waitForPtyShellEcho } from './terminal-pty-readiness' + +type Grid = { cols: number; rows: number } +type GridSnapshot = { applied: Grid | null; xterm: Grid | null } + +async function readGridSnapshot(page: Page, ptyId: string): Promise { + return page.evaluate(async (id) => { + let xterm: Grid | null = null + for (const manager of window.__paneManagers?.values() ?? []) { + for (const pane of manager.getPanes?.() ?? []) { + if (pane.container?.dataset?.ptyId === id) { + xterm = { cols: pane.terminal.cols, rows: pane.terminal.rows } + } + } + } + return { applied: (await window.api.pty.getSize(id)) ?? null, xterm } + }, ptyId) +} + +function chooseStaleGrid(current: Grid): Grid { + return { + cols: Math.max(40, current.cols - 19), + rows: Math.max(12, current.rows - 7) + } +} + +test.describe('terminal window-wake stale grid repro', () => { + test('window focus heals a local PTY whose applied grid drifted from xterm', async ({ + orcaPage + }) => { + test.setTimeout(120_000) + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + const ptyId = await waitForActivePanePtyId(orcaPage) + await waitForPtyShellEcho(orcaPage, ptyId, 15_000) + + const baseline = await readGridSnapshot(orcaPage, ptyId) + expect(baseline.xterm).not.toBeNull() + expect(baseline.applied).toEqual(baseline.xterm) + const staleGrid = chooseStaleGrid(baseline.xterm!) + + // Why: model the field state directly—xterm is fitted, but the idle PTY + // still has an older grid and produces no output that could self-heal it. + await orcaPage.evaluate(({ id, grid }) => window.api.pty.resize(id, grid.cols, grid.rows), { + id: ptyId, + grid: staleGrid + }) + await expect + .poll(async () => (await readGridSnapshot(orcaPage, ptyId)).applied, { timeout: 10_000 }) + .toEqual(staleGrid) + expect((await readGridSnapshot(orcaPage, ptyId)).xterm).toEqual(baseline.xterm) + + await orcaPage.evaluate(() => window.dispatchEvent(new Event('focus'))) + + await expect + .poll( + async () => { + const snapshot = await readGridSnapshot(orcaPage, ptyId) + return snapshot.applied && snapshot.xterm ? snapshot : null + }, + { timeout: 10_000, message: 'Window focus should converge the local PTY to xterm' } + ) + .toEqual({ applied: baseline.xterm, xterm: baseline.xterm }) + }) +}) From 71544450d50ec11850a9850eec76b4d6e4f0cddd Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 20 Jul 2026 18:54:45 -0700 Subject: [PATCH 18/58] fix(terminal): gate xterm contrast correction by terminal background luminance (#7934) (#9599) * fix(terminal): gate xterm contrast correction by theme mode (#7934) minimumContrastRatio: 4.5 (added in #5986 to rescue invisible white/bright-white ANSI text on light backgrounds) was applied globally, over-correcting vibrant ANSI colors on dark themes. Keep 4.5 on light themes; disable it (1) on dark themes. applyTerminalAppearance re-gates it in its per-pane loop, so live auto/system dark<->light flips are handled for free. buildDefaultTerminalOptions keeps 4.5 as the initial value so the first paint on a light theme never flashes low-contrast text. Supersedes #7939. Co-authored-by: Orca * fix(terminal): gate contrast correction by terminal background luminance Contrast correction rescues white text on light backgrounds but over-corrects on dark. The previous mode-based gate failed when the terminal theme didn't match the app mode (e.g., terminalUseSeparateLightTheme=false). Now gates by the theme's background luminance via isTerminalBackgroundLight() instead. * refactor(terminal): extract contrast correction gating into shared helpe Centralizes the minimumContrastRatio gating logic (#7934) into a dedicated module so both the settings preview and live terminal appearance apply the same contrast-by-luminance rule, preventing drift. Adds value-gating to skip redundant writes that would clear xterm's contrast cache on re-applies. --------- Co-authored-by: Orca --- .../settings/TerminalSettingsPreview.tsx | 8 +- .../terminal-pane/terminal-appearance.test.ts | 74 +++++++++++++++++++ .../terminal-pane/terminal-appearance.ts | 11 +++ .../lib/pane-manager/pane-lifecycle.test.ts | 2 +- .../lib/pane-manager/pane-terminal-options.ts | 6 +- .../src/lib/terminal-contrast-correction.ts | 18 +++++ 6 files changed, 114 insertions(+), 5 deletions(-) create mode 100644 src/renderer/src/lib/terminal-contrast-correction.ts diff --git a/src/renderer/src/components/settings/TerminalSettingsPreview.tsx b/src/renderer/src/components/settings/TerminalSettingsPreview.tsx index f21b8b9390e..d9dee715dd7 100644 --- a/src/renderer/src/components/settings/TerminalSettingsPreview.tsx +++ b/src/renderer/src/components/settings/TerminalSettingsPreview.tsx @@ -8,6 +8,7 @@ import { buildDefaultTerminalOptions } from '@/lib/pane-manager/pane-terminal-op import { buildFontFamily } from '@/components/terminal-pane/layout-serialization' import { composeActiveTerminalTheme } from '@/components/terminal-pane/terminal-appearance' import { clampNumber, resolveEffectiveTerminalAppearance } from '@/lib/terminal-theme' +import { resolveTerminalMinimumContrastRatio } from '@/lib/terminal-contrast-correction' import { resolveTerminalFontWeights } from '../../../../shared/terminal-fonts' import { resolveTerminalLigaturesEnabled } from '../../../../shared/terminal-ligatures' import { normalizeTerminalLineHeight } from '../../../../shared/terminal-line-height-settings' @@ -195,6 +196,11 @@ export function TerminalSettingsPreview({ return } terminal.options.theme = composedTheme + // Why: share applyTerminalAppearance's gating helper (#7934) so the preview can't drift from live panes. + terminal.options.minimumContrastRatio = resolveTerminalMinimumContrastRatio( + composedTheme.background, + effectiveMode + ) // Why: xterm renders an alpha-channel background opaque unless allowTransparency is set (matches applyTerminalAppearance). terminal.options.allowTransparency = settings.terminalBackgroundOpacity !== undefined && settings.terminalBackgroundOpacity < 1 @@ -205,7 +211,7 @@ export function TerminalSettingsPreview({ // Why reset() not clear(): buffer ends mid-line on the prompt, so clear()+write would duplicate the trailing fragment. terminal.reset() terminal.write(PREVIEW_BUFFER) - }, [composedTheme, settings.terminalBackgroundOpacity]) + }, [composedTheme, effectiveMode, settings.terminalBackgroundOpacity]) useEffect(() => { const terminal = terminalRef.current diff --git a/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts b/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts index 68f20a382fd..fdf3da8e271 100644 --- a/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts @@ -405,6 +405,80 @@ describe('applyTerminalAppearance theme assignment', () => { expect(pane.terminal.options.theme).not.toBe(firstTheme) expect(pane.terminal.options.theme?.background).toBe('#102030') }) + + // #7934: contrast correction rescues invisible white text on light backgrounds but over-corrects on dark; + // gate by the composed theme's background luminance (either theme slot can hold either kind of theme). + it('keeps xterm contrast correction on light themes', () => { + const pane = makePane(1) + const settings = getDefaultSettings('/tmp') + + apply(pane, { ...settings, theme: 'light' }) + + expect(pane.terminal.options.minimumContrastRatio).toBe(4.5) + }) + + it('disables xterm contrast correction on dark themes', () => { + const pane = makePane(1) + const settings = getDefaultSettings('/tmp') + + apply(pane, { ...settings, theme: 'dark' }) + + expect(pane.terminal.options.minimumContrastRatio).toBe(1) + }) + + it('re-gates contrast correction when the theme flips live', () => { + const pane = makePane(1) + const settings = getDefaultSettings('/tmp') + + apply(pane, { ...settings, theme: 'light' }) + expect(pane.terminal.options.minimumContrastRatio).toBe(4.5) + + apply(pane, { ...settings, theme: 'dark' }) + expect(pane.terminal.options.minimumContrastRatio).toBe(1) + }) + + it('disables contrast correction in light mode when the terminal matches dark mode', () => { + // terminalUseSeparateLightTheme=false keeps the dark terminal theme in light app mode; the gate must follow the background. + const pane = makePane(1) + const settings = getDefaultSettings('/tmp') + + apply(pane, { ...settings, theme: 'light', terminalUseSeparateLightTheme: false }) + + expect(pane.terminal.options.minimumContrastRatio).toBe(1) + }) + + it('keeps contrast correction in dark mode when a light theme fills the dark slot', () => { + const pane = makePane(1) + const settings = getDefaultSettings('/tmp') + + apply(pane, { ...settings, theme: 'dark', terminalThemeDark: 'Builtin Tango Light' }) + + expect(pane.terminal.options.minimumContrastRatio).toBe(4.5) + }) + + it('skips the minimumContrastRatio write on a no-op re-apply (preserves xterm contrast cache)', () => { + const pane = makePane(1) + let writes = 0 + let stored: number | undefined + Object.defineProperty(pane.terminal.options, 'minimumContrastRatio', { + configurable: true, + enumerable: true, + get: () => stored, + set: (value: number) => { + stored = value + writes += 1 + } + }) + const settings = getDefaultSettings('/tmp') + + apply(pane, { ...settings, theme: 'dark' }) + const writesAfterFirst = writes + + apply(pane, { ...settings, theme: 'dark' }) + + // The value-gate must not rewrite an unchanged ratio — each write clears xterm's contrast cache. + expect(writes).toBe(writesAfterFirst) + }) }) describe('publishTerminalViewAttributesAtAppStart', () => { diff --git a/src/renderer/src/components/terminal-pane/terminal-appearance.ts b/src/renderer/src/components/terminal-pane/terminal-appearance.ts index 40564024d9b..fa93552388a 100644 --- a/src/renderer/src/components/terminal-pane/terminal-appearance.ts +++ b/src/renderer/src/components/terminal-pane/terminal-appearance.ts @@ -24,6 +24,7 @@ import type { TerminalViewAttributes } from '../../../../shared/terminal-view-at import { publishTerminalViewAttributes } from './terminal-view-attributes-publisher' import { normalizeTerminalLineHeight } from '../../../../shared/terminal-line-height-settings' import { maybePushMode2031Flip } from './terminal-mode-2031-replies' +import { resolveTerminalMinimumContrastRatio } from '@/lib/terminal-contrast-correction' // Why Pick over a hand-rolled type: stays tied to xterm's canonical signature so upstream tightening surfaces here. type Mode2031Parser = Pick @@ -207,6 +208,16 @@ export function applyTerminalAppearance( if (theme && !composedTerminalThemesEqual(pane.terminal.options.theme, theme)) { pane.terminal.options.theme = theme } + // Gate off the configured theme background; the live OSC-11 background is deliberately preserved by the + // theme write above, so a TUI that repaints its background at runtime won't re-gate (known limitation). + // Why value-gated: writing minimumContrastRatio clears xterm's contrast cache, so skip on no-op re-applies. + const minimumContrastRatio = resolveTerminalMinimumContrastRatio( + theme?.background, + appearance.mode + ) + if (pane.terminal.options.minimumContrastRatio !== minimumContrastRatio) { + pane.terminal.options.minimumContrastRatio = minimumContrastRatio + } // Why clear explicitly: allowTransparency has rendering cost and a stale `true` could bleed in from a prior opacity. pane.terminal.options.allowTransparency = settings.terminalBackgroundOpacity !== undefined && settings.terminalBackgroundOpacity < 1 diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts index c69600e8a35..607ad1cdb5d 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts @@ -114,7 +114,7 @@ describe('buildDefaultTerminalOptions', () => { expect(normalizeTerminalFastScrollSensitivity(25)).toBe(20) }) - it('enables xterm contrast correction for low-contrast CLI colors', () => { + it('defaults minimumContrastRatio to the light-background value (applyTerminalAppearance re-gates it)', () => { expect(buildDefaultTerminalOptions().minimumContrastRatio).toBe(4.5) }) diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-options.ts b/src/renderer/src/lib/pane-manager/pane-terminal-options.ts index 911d3fa7545..1fc47513483 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-options.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-options.ts @@ -1,5 +1,6 @@ import type { ITerminalOptions } from '@xterm/xterm' import { DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT } from '../../../../shared/terminal-scrollback-policy' +import { LIGHT_BG_MIN_CONTRAST } from '@/lib/terminal-contrast-correction' type TerminalCursorStyle = NonNullable type TerminalCursorInactiveStyle = NonNullable @@ -47,9 +48,8 @@ export function buildDefaultTerminalOptions(): ITerminalOptions { scrollSensitivity: DEFAULT_TERMINAL_SCROLL_SENSITIVITY, fastScrollSensitivity: DEFAULT_TERMINAL_FAST_SCROLL_SENSITIVITY, allowTransparency: false, - // Why: agent CLIs sometimes render body text with ANSI white/bright-white - // on light themes; xterm can keep those cells readable across renderers. - minimumContrastRatio: 4.5, + // Initial value only; applyTerminalAppearance re-gates by background luminance (#7934) before any content paints. + minimumContrastRatio: LIGHT_BG_MIN_CONTRAST, // Why: on macOS, non-US layouts rely on Option to compose characters like @ and €. macOptionIsMeta: false, macOptionClickForcesSelection: true, diff --git a/src/renderer/src/lib/terminal-contrast-correction.ts b/src/renderer/src/lib/terminal-contrast-correction.ts new file mode 100644 index 00000000000..4cd147af812 --- /dev/null +++ b/src/renderer/src/lib/terminal-contrast-correction.ts @@ -0,0 +1,18 @@ +import { isTerminalBackgroundLight } from '@/lib/terminal-title-contrast' + +// xterm minimumContrastRatio tuning (#7934). Light backgrounds keep WCAG-AA correction so invisible +// white/bright-white ANSI body text stays readable; dark backgrounds disable it (ratio 1) because +// correction over-brightens vibrant ANSI colors. +export const LIGHT_BG_MIN_CONTRAST = 4.5 +export const DARK_BG_MIN_CONTRAST = 1 + +// Why gate by background luminance, not app mode (#7934): either theme slot can hold either kind of +// theme (match-dark-mode, or a light theme in the dark slot), so follow the composed background. +export function resolveTerminalMinimumContrastRatio( + background: string | undefined, + appSurface: 'dark' | 'light' +): number { + return isTerminalBackgroundLight(background, { appSurface }) + ? LIGHT_BG_MIN_CONTRAST + : DARK_BG_MIN_CONTRAST +} From 971b1675480afe59f043dd11542842521d1dcc7a Mon Sep 17 00:00:00 2001 From: Wonjae Lee Date: Tue, 21 Jul 2026 10:55:45 +0900 Subject: [PATCH 19/58] fix(github): load PR diffs for Enterprise remotes (#8932) * fix(github): load PR diffs for Enterprise remotes * fix(github): encode PR content paths by segment * Fix PR review actions failing on GitHub Enterprise remotes - Threads GitHub host identity (not just owner/repo) through the client, work-item-details, issues, and RPC layers so gh commands target the correct Enterprise server instead of silently falling back to github.com - Adds a shared github-api-repository helper to resolve/host-qualify repo identity consistently across REST, GraphQL, and CLI shorthand calls - Scopes the gh rate-limit breaker and singleton rate-limit snapshot by host/runtime so a github.com block or probe can't affect GHES or WSL - Coalesces concurrent host-auth probes and paginates PR file fetching beyond 100 results - Propagates `host` through renderer PR caches, checks-panel keys, and preload IPC types so Enterprise and github.com data never collide * Route gh host qualification through runner options instead of argv sniff Move GHES/GH_HOST resolution from parsing --hostname/--repo out of gh argv to an explicit options.host passed through ghExecFileAsync, since SSH-backed repos spawn gh with no cwd and argv sniffing couldn't reliably detect the target host. The runner now injects --hostname and qualifies --repo/-R at spawn time from options.host, and rate-limit scoping/guards use the same explicit host instead of inferring it. Also adds a shared githubRepoIdentityKey helper to keep cache/store keys consistent with the new host-aware repository identity. * Fix gh CLI GHES host pinning and rate-limit scope leaks - Pin `--host` on every gh call site so a process-level GH_HOST can't silently redirect requests, and qualify `-R`/`-R=` repo shorthand alongside the existing `--repo=` handling. - Check the target scope for an active rate-limit block before each WSL/native or host fallback retry, not just on the initial attempt, so a blocked scope can't be hit again through a fallback path. - Compute idempotency once per call instead of re-deriving it after fallback reassigns args. * Fix GitHub Enterprise host identity loss across PR/work-item paths - Thread `host` through mobile PR RPC params, IPC work-item lookups, and RPC schemas so GHES identity survives the renderer/mobile/main boundary instead of silently falling back to a same-named github.com repo. - Qualify `--repo`/`-R` args for github.com too (not just GHES), since gh resolves bare shorthand against a process-level GH_HOST that can redirect pinned github.com commands. - Cache `getOriginGitHubApiRepository` to avoid a per-call uncached `git remote get-url` round trip on connection-backed repos. - Add a local-fork fallback in `getWorkItemDetails` so PRs living on a base repo (not visible via the origin slug) still resolve via cwd. - Centralize the github.com-vs-GHES host predicate in `isDefaultGitHubHost` so cache keys, quota scoping, and identity checks can't drift out of sync. * Make repository identity host-aware across all GitHub surfaces Generalize the auth-gated enterprise resolver to any remote and build a cached hosted-identity family (origin/issue/candidates/source) on top of it, then migrate every github.com-only consumer: Tasks listing/counting, branch-to-PR discovery, push targets, fork upstream, issue operations, Projects, web links, avatars, and PR-link facts. Scope the rate-limit breaker probe per runtime:host and classify WSL UNC cwds correctly. Co-authored-by: Orca * Fix expected slug to include host field in GitHub PR link test Updates the smart-source paste-intent test fixture to match the repository slug shape that now carries a `host` field, keeping GHES host identity intact through the paste-intent parsing path. * Surface per-host gh auth state for GitHub Enterprise diagnoseGhAuth accepts the host a surface needs credentials for, scopes the account/scope diagnosis to that host, and reports whether gh has any login there; GhAuthErrorHelp renders host-qualified login/refresh commands so an unauthenticated GHES host stops masquerading as a github.com scope problem. Also fixes the mobile paste-intent expectation for host-carrying parsed links. Co-authored-by: Orca * Bound GHES identity caches and preserve non-default ports in host identity Cap the origin-repo and host-auth caches like ownerRepoCache; keep ports from remote/link URLs so GHES on a non-default port is a distinct identity; make positional github.com slugs explicit against GH_HOST; compare work-item sources by host-aware identity key; bail cwd-less branch lookups when no repository candidate resolved; thread host through the renderer work-item slug lookup. Co-authored-by: Orca * Thread GitHub host through issue detail requests Incorporates ghes-issue-host-support (ed6bb96ef): one hosted issue repository identity is resolved before the details fan-out so comments, timeline, participants, and mention lookups cannot drift across hosts, with SSH guards so unresolved issue/PR repositories never fall through to gh's default host. Co-authored-by: Orca * Scope remaining GitHub rate-limit accounting * Resolve typed PR lookups across hosted repository candidates getWorkItem's PR path probes upstream-then-origin hosted candidates instead of origin alone, so fork checkouts resolve the base repo's PR with the right host; issue detail resolution reuses the up-front hosted identity and keeps the SSH unresolved-host guards. Co-authored-by: Orca * Refactor GitHub repository execution setup * Carry host on smart-submit link intents Co-authored-by: Orca * Carry the project host on GitHub item dialog origins Co-authored-by: Orca * Keep GHES web ports but drop SSH transport ports in host identity Supersedes PR #9118 on this branch: http(s) remote ports identify the Enterprise web/API endpoint and are preserved, while ssh/git transport ports (including ssh.github.com:443) never leak into gh's host identity. Replaces the ssh.github.com:443 special case with the structural protocol split and ports the PR's parsing test suite. Co-authored-by: Orca * Support GitHub Enterprise diffs and mutations with host-scoped caches Parse GitHub host identity from work-item URLs and carry it through PR/issue mutations, labels, and assignments. Bound rate-limit and scope-probe caches (1024 and 512 entries) to prevent unbounded growth when interacting with multiple GHES instances. Normalize repository identity keys to include host so github.com and GHES slugs don't collide in cache and equality checks. * Support GitHub Enterprise diffs and mutations with host-scoped caches - Carry host identity through PR mutations and reads so fork PRs on different GHES instances don't collide in cache or state tracking. - Validate host authentication before routing requests to unconfigured Enterprise servers; ambient credentials must never reach untrusted hosts. - Scope rate-limit guards and spend tracking per host so GHES quota stays independent from github.com quota. - Respect explicit --hostname arguments in gh CLI calls ahead of GH_HOST or ambient defaults, so breaker state follows the actual request target. - Detect implicit WSL runtimes from UNC paths for consistent host auth and execution-options scoping across mobile and desktop clients. * Support GitHub Enterprise work-item diffs with host-scoped execution Enterprise PRs must use their selected host consistently across diff, comments, and file-content loads. Validate repository slugs before authenticated execution to prevent path-injection via renderer overrides. Scope project browsing cache and rate-limit tracking by host to prevent cross-host pollution. Use parsed URLs as authoritative over ambient hosts for project resolution. * Support GitHub Enterprise work-item diffs with host-scoped execution Preserve host identity on PR/issue work items throughout the mutation and diff pipeline so Enterprise instances (including ported endpoints like github.acme.test:8443) can execute mutations without ambiguity. Rate-limit gh commands by the pre-qualified --repo host, cache auth state per ported host, and surface Enterprise hosts in project metadata and error messages. * fix(review): drop dead rateLimitGuard/noteRateLimitSpend re-export Both callers (project-view.ts, mutations.ts) moved to the host-scoped repositoryRateLimitGuard/noteRepositoryRateLimitSpend; the bucket-only re-export in internals.ts had zero importers left. Co-authored-by: Orca * fix(ci): split Enterprise host work-item tests under max-lines Move GHES/SSH host-routing cases out of work-item-details.test.ts so the suite stays within the 800-line test max-lines budget. * test(github): align mocks with host-scoped repository resolution - Route origin repository resolution through getOwnerRepoForRemote, not getOwnerRepo, to match production path - Pin github.com host on origin results so host-less fixtures pass host gate in resolveGitHubApiRepository - Add generation-based invalidation to prevent stale slug-cache writes from in-flight resolutions - Fix ref-sync race in ProjectPicker: use useLayoutEffect so committed tree owns browse cache key - Defer handledCrossRepoUrlRef assignment in SmartWorkspaceNameField until resolution succeeds - Update Enterprise host routing: found work items must not silently fall back to default host when unresolved - Normalize GHES avatar URLs: accept explicit port 443 as canonical form, not a fallback trigger --------- Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Co-authored-by: Orca --- mobile/app/h/[hostId]/tasks.tsx | 240 +++---- .../components/pr-sidebar/PRCommentCard.tsx | 2 +- .../src/session/github-pr-mutations.test.ts | 51 +- mobile/src/session/github-pr-mutations.ts | 96 ++- mobile/src/session/github-pr-rpc.test.ts | 40 +- mobile/src/session/github-pr-rpc.ts | 34 +- .../session/github-pr-value-readers.test.ts | 8 + mobile/src/session/github-pr-value-readers.ts | 5 +- .../session/pr-action-mutation-contract.ts | 38 ++ mobile/src/session/pr-actions-engine.test.ts | 39 ++ mobile/src/session/pr-actions-engine.ts | 49 +- mobile/src/session/pr-comment-actions.test.ts | 10 +- mobile/src/session/pr-comment-actions.ts | 29 +- .../src/session/use-mobile-pr-actions.test.ts | 102 ++- .../session/use-mobile-pr-comment-actions.ts | 12 +- ...github-project-host-routing-source.test.ts | 42 ++ .../tasks/github-project-reference.test.ts | 39 ++ mobile/src/tasks/github-project-reference.ts | 84 +++ .../tasks/github-project-repo-match.test.ts | 60 +- mobile/src/tasks/github-project-repo-match.ts | 40 +- .../tasks/smart-source-paste-intent.test.ts | 52 +- mobile/src/tasks/smart-source-paste-intent.ts | 14 +- .../src/tasks/use-smart-workspace-source.ts | 12 +- src/main/git/gh-rate-limit-breaker.test.ts | 89 ++- src/main/git/gh-rate-limit-breaker.ts | 101 ++- src/main/git/runner-gh-host-args.test.ts | 142 +++++ .../git/runner-gh-rate-limit-breaker.test.ts | 121 +++- src/main/git/runner-wsl-gh-fallback.test.ts | 136 ++++ src/main/git/runner.ts | 166 ++++- src/main/github/auth-diagnose.test.ts | 67 +- src/main/github/auth-diagnose.ts | 19 +- src/main/github/client-create-pr.test.ts | 40 +- src/main/github/client-file-viewed.test.ts | 92 ++- src/main/github/client-issue-source.test.ts | 130 +++- .../github/client-pr-check-details.test.ts | 85 ++- src/main/github/client-pr-checks.test.ts | 72 ++- .../github/client-pr-local-runtime.test.ts | 347 +++++++++- .../client-work-items-query-paging.test.ts | 302 +++++++++ src/main/github/client-work-items.test.ts | 188 ++---- src/main/github/client.test.ts | 398 ++++++++++-- src/main/github/client.ts | 593 ++++++++++++------ .../github/default-branch-stale-pr.test.ts | 7 +- src/main/github/github-api-repository.test.ts | 205 ++++++ src/main/github/github-api-repository.ts | 360 +++++++++++ .../github-enterprise-repository.test.ts | 162 ++++- .../github/github-enterprise-repository.ts | 216 +++++-- .../github-remote-identity-parsing.test.ts | 109 ++++ .../github/github-remote-identity-parsing.ts | 11 +- ...epository-identity.fork-owner-repo.test.ts | 3 +- src/main/github/github-repository-identity.ts | 3 +- src/main/github/issues.test.ts | 105 +++- src/main/github/issues.ts | 108 +++- .../github/merged-pr-commit-membership.ts | 15 +- .../github/pr-refresh-coordinator.test.ts | 109 +++- src/main/github/pr-refresh-coordinator.ts | 42 +- .../github/project-view-host-auth.test.ts | 125 ++++ src/main/github/project-view.test.ts | 61 +- src/main/github/project-view.ts | 225 ++++--- src/main/github/project-view/internals.ts | 258 +++----- .../github/project-view/mutations.test.ts | 147 ++++- src/main/github/project-view/mutations.ts | 140 +++-- .../project-error-classification.ts | 177 ++++++ src/main/github/rate-limit.test.ts | 236 ++++++- src/main/github/rate-limit.ts | 131 +++- .../work-item-details-concurrency.test.ts | 159 +++++ .../work-item-details-enterprise-host.test.ts | 313 +++++++++ .../work-item-details-file-viewed.test.ts | 54 +- .../github/work-item-details-pr-files.test.ts | 359 +++++++++++ src/main/github/work-item-details.test.ts | 204 ++++-- src/main/github/work-item-details.ts | 422 +++++++------ src/main/ipc/github.test.ts | 36 +- src/main/ipc/github.ts | 58 +- src/main/ipc/repos-remote.test.ts | 55 ++ src/main/ipc/repos.ts | 3 +- src/main/repo-icon-autodetect.test.ts | 3 +- src/main/runtime/orca-runtime.test.ts | 61 +- src/main/runtime/orca-runtime.ts | 44 +- .../runtime/rpc/methods/client-ui-schemas.ts | 3 +- .../runtime/rpc/methods/client-ui.test.ts | 16 +- src/main/runtime/rpc/methods/github.test.ts | 49 +- src/main/runtime/rpc/methods/github.ts | 89 ++- src/main/runtime/runtime-rpc.test.ts | 16 +- .../source-control/forge-provider.test.ts | 13 +- src/main/source-control/forge-provider.ts | 25 +- ...hosted-review-creation-eligibility.test.ts | 17 +- .../hosted-review-creation.test.ts | 21 +- .../source-control/hosted-review-creation.ts | 6 +- src/preload/api-types.ts | 19 +- src/preload/index.ts | 36 +- .../src/components/GitHubItemDialog.tsx | 197 ++++-- .../src/components/PullRequestPage.tsx | 192 ++++-- src/renderer/src/components/TaskPage.tsx | 67 +- ...b-enterprise-slug-routing-boundary.test.ts | 63 ++ ...github-item-dialog-source-boundary.test.ts | 19 +- .../github-project/GhAuthErrorHelp.test.ts | 60 ++ .../github-project/GhAuthErrorHelp.tsx | 61 +- .../components/github-project/ProjectCell.tsx | 38 +- .../github-project/ProjectPicker.tsx | 196 ++++-- .../components/github-project/ProjectRow.tsx | 3 + .../github-project/ProjectViewList.tsx | 1 + .../github-project/ProjectViewWrapper.tsx | 132 ++-- .../github-project-picker-filter.test.ts | 18 +- .../github-project-picker-filter.ts | 5 +- .../github-project/project-dialog-state.ts | 13 +- .../project-picker-input.test.ts | 38 ++ .../project-row-filtering.test.ts | 17 +- .../github-project/project-row-filtering.ts | 18 +- ...ew-wrapper-source-context-boundary.test.ts | 35 ++ .../slug-dialog/AssigneesEditor.tsx | 5 +- .../github-project/slug-dialog/Comments.tsx | 24 +- .../slug-dialog/LabelsEditor.tsx | 9 +- .../slug-dialog/SlugDialogBody.tsx | 12 +- .../github/IssueSourceIndicator.tsx | 28 +- .../components/github/PRFilterDropdowns.tsx | 6 +- ...rkspaceNameField-repo-slug-routing.test.ts | 75 +++ .../new-workspace/SmartWorkspaceNameField.tsx | 123 ++-- .../pull-request-page-host-boundary.test.ts | 14 +- .../components/right-sidebar/ChecksPanel.tsx | 3 + .../checks-panel-async-result-key.test.ts | 17 + .../checks-panel-async-result-key.ts | 6 +- .../hosted-review-github-actions.ts | 3 + .../use-hosted-review-actions.ts | 2 + .../settings/repository-icon-github.test.ts | 15 + .../settings/repository-icon-github.ts | 3 +- .../parked-terminal-byte-watcher.test.ts | 2 +- .../terminal-pane/pty-connection.test.ts | 2 +- ...poserState-host-context-boundaries.test.ts | 12 + src/renderer/src/hooks/useComposerState.ts | 16 +- .../src/hooks/useGitHubSlugMetadata.test.tsx | 27 +- .../src/hooks/useGitHubSlugMetadata.ts | 27 +- src/renderer/src/lib/github-links.test.ts | 28 +- .../src/lib/github-work-item-source-lookup.ts | 3 + .../src/lib/project-host-clone-url.test.ts | 33 + .../src/lib/project-host-clone-url.ts | 23 +- src/renderer/src/lib/repo-slug-cache.test.ts | 57 ++ src/renderer/src/lib/repo-slug-cache.ts | 69 +- src/renderer/src/lib/repo-slug-index.ts | 153 +++-- .../src/lib/smart-github-submit.test.ts | 20 + src/renderer/src/lib/smart-github-submit.ts | 11 +- .../src/store/slices/github-checks.test.ts | 30 + .../src/store/slices/github-checks.ts | 6 +- .../store/slices/github-project-row-owner.ts | 8 +- src/renderer/src/store/slices/github.test.ts | 89 ++- src/renderer/src/store/slices/github.ts | 41 +- src/renderer/src/web/web-preload-api.test.ts | 3 +- src/renderer/src/web/web-preload-api.ts | 5 +- src/shared/github-auth-types.ts | 7 + src/shared/github-links.ts | 12 +- src/shared/github-project-identity.test.ts | 22 + src/shared/github-project-identity.ts | 23 + src/shared/github-project-types.ts | 34 +- .../github-repository-identity-key.test.ts | 14 + src/shared/github-repository-identity-key.ts | 19 + .../project-host-setup-projection.test.ts | 121 ++++ src/shared/project-host-setup-projection.ts | 123 +++- src/shared/repo-icon.test.ts | 38 +- src/shared/repo-icon.ts | 32 +- src/shared/task-source-context.test.ts | 13 + src/shared/task-source-context.ts | 3 +- .../terminal-github-pr-link-detector.test.ts | 21 +- src/shared/types.ts | 6 +- 161 files changed, 9934 insertions(+), 1959 deletions(-) create mode 100644 mobile/src/session/pr-action-mutation-contract.ts create mode 100644 mobile/src/tasks/github-project-host-routing-source.test.ts create mode 100644 mobile/src/tasks/github-project-reference.test.ts create mode 100644 mobile/src/tasks/github-project-reference.ts create mode 100644 src/main/git/runner-gh-host-args.test.ts create mode 100644 src/main/github/client-work-items-query-paging.test.ts create mode 100644 src/main/github/github-api-repository.test.ts create mode 100644 src/main/github/github-api-repository.ts create mode 100644 src/main/github/github-remote-identity-parsing.test.ts create mode 100644 src/main/github/project-view-host-auth.test.ts create mode 100644 src/main/github/project-view/project-error-classification.ts create mode 100644 src/main/github/work-item-details-concurrency.test.ts create mode 100644 src/main/github/work-item-details-enterprise-host.test.ts create mode 100644 src/main/github/work-item-details-pr-files.test.ts create mode 100644 src/renderer/src/components/github-enterprise-slug-routing-boundary.test.ts create mode 100644 src/renderer/src/components/github-project/GhAuthErrorHelp.test.ts create mode 100644 src/renderer/src/components/github-project/project-picker-input.test.ts create mode 100644 src/renderer/src/components/new-workspace/SmartWorkspaceNameField-repo-slug-routing.test.ts create mode 100644 src/renderer/src/lib/repo-slug-cache.test.ts create mode 100644 src/shared/github-project-identity.test.ts create mode 100644 src/shared/github-project-identity.ts create mode 100644 src/shared/github-repository-identity-key.test.ts create mode 100644 src/shared/github-repository-identity-key.ts diff --git a/mobile/app/h/[hostId]/tasks.tsx b/mobile/app/h/[hostId]/tasks.tsx index 191a47038f2..a9d24ae637a 100644 --- a/mobile/app/h/[hostId]/tasks.tsx +++ b/mobile/app/h/[hostId]/tasks.tsx @@ -77,6 +77,15 @@ import { findRepoForGitHubProjectRepository, type GitHubRepoSlugCacheEntry } from '../../../src/tasks/github-project-repo-match' +import { + parseGitHubProjectInput as parseProjectInput, + type GitHubProjectOwnerType, + type GitHubProjectPartialFailure, + type GitHubProjectRef, + type GitHubProjectSettings, + type GitHubProjectSummary, + type GitHubProjectViewSummary +} from '../../../src/tasks/github-project-reference' import { extractGitHubIssueSourceFallback, extractGitHubIssueSourceError, @@ -130,11 +139,16 @@ import { } from '../../../src/tasks/mobile-task-copy-feedback-timer' import type { BaseRefSearchResult, + GitHubOwnerRepo, PersistedTrustedOrcaHooks, SparsePreset, TuiAgent } from '../../../../src/shared/types' import type { SshConnectionState } from '../../../../src/shared/ssh-types' +import { + githubProjectHost, + githubProjectIdentityKey as githubProjectKey +} from '../../../../src/shared/github-project-identity' type RepoSummary = { id: string @@ -148,11 +162,6 @@ type RepoSummary = { type IssueSourcePreference = 'upstream' | 'origin' | 'auto' -type GitHubOwnerRepo = { - owner: string - repo: string -} - type GitHubWorkItem = { id: string type: 'issue' | 'pr' @@ -444,34 +453,6 @@ type LinearStatusResponse = { activeWorkspaceId?: string | null } -type GitHubProjectOwnerType = 'organization' | 'user' -type GitHubProjectRef = { - owner: string - ownerType: GitHubProjectOwnerType - number: number -} -type GitHubProjectSettings = { - pinned: GitHubProjectRef[] - recent: Array - lastViewByProject: Record - activeProject: GitHubProjectRef | null -} -type GitHubProjectSummary = GitHubProjectRef & { - id: string - title: string - url: string - source: string -} -type GitHubProjectPartialFailure = { - owner: string - message: string -} -type GitHubProjectViewSummary = { - id: string - number: number - name: string - layout: 'TABLE_LAYOUT' | 'BOARD_LAYOUT' | 'ROADMAP_LAYOUT' -} type GitHubIssueType = { id: string name: string @@ -951,48 +932,6 @@ function githubKindFromQuery(query: string, fallbackPreset: GitHubPreset): GitHu : 'issues' } -function githubProjectKey(project: GitHubProjectRef): string { - return `${project.ownerType}:${project.owner}:${project.number}` -} - -function parseProjectInput( - input: string -): { owner: string; number: number; viewNumber?: number } | null { - const trimmed = input.trim() - if (!trimmed) { - return null - } - const short = /^([A-Za-z0-9][A-Za-z0-9-]*)\/(\d+)$/.exec(trimmed) - if (short) { - return { owner: short[1]!, number: Number(short[2]) } - } - try { - const url = new URL(trimmed) - if (url.hostname !== 'github.com') { - return null - } - const parts = url.pathname.split('/').filter(Boolean) - if ((parts[0] === 'orgs' || parts[0] === 'users') && parts[2] === 'projects' && parts[3]) { - const number = Number(parts[3]) - if (!Number.isInteger(number) || number < 1) { - return null - } - const viewNumber = - parts[4] === 'views' && parts[5] && Number.isInteger(Number(parts[5])) - ? Number(parts[5]) - : undefined - return { - owner: parts[1]!, - number, - ...(viewNumber && viewNumber > 0 ? { viewNumber } : {}) - } - } - } catch { - return null - } - return null -} - function projectRowType(row: GitHubProjectRow): 'issue' | 'pr' | null { if (row.itemType === 'ISSUE') { return 'issue' @@ -1014,6 +953,11 @@ function splitRepositorySlug(slug: string | null): { owner: string; repo: string return owner && repo ? { owner, repo } : null } +function projectRowGitHubRepository(row: GitHubProjectRow, host: string): GitHubOwnerRepo | null { + const slug = splitRepositorySlug(row.content.repository) + return slug ? { ...slug, host } : null +} + const GITHUB_PROJECT_OPTION_COLORS: Record = { GRAY: '#8b949e', RED: '#f85149', @@ -2468,6 +2412,10 @@ export default function MobileTasksScreen() { tasksSupportState.kind === 'unsupported' && tasksSupportState.client === client const taskUiReady = tasksSupported && taskStateHydrated + const activeGitHubProject = githubProjectSettings.activeProject + const activeGitHubProjectHost = githubProjectHost( + githubProjectTable?.project.host ?? activeGitHubProject?.host + ) const hostedRepos = useMemo(() => repos.filter(isHostedTaskRepo), [repos]) const workspaceRepos = useMemo(() => repos.filter((repo) => repo.kind !== 'folder'), [repos]) const reposById = useMemo(() => new Map(repos.map((repo) => [repo.id, repo])), [repos]) @@ -2483,9 +2431,10 @@ export default function MobileTasksScreen() { findRepoForGitHubProjectRepository( row.content.repository, hostedRepos, - githubRepoSlugCache + githubRepoSlugCache, + activeGitHubProjectHost ) as RepoSummary | null, - [githubRepoSlugCache, hostedRepos] + [activeGitHubProjectHost, githubRepoSlugCache, hostedRepos] ) const githubProjectRepoSlugReady = useMemo( () => @@ -2501,10 +2450,11 @@ export default function MobileTasksScreen() { ? (filterGitHubProjectRowsForRepos( githubProjectTable.rows, hostedRepos, - githubRepoSlugCache + githubRepoSlugCache, + activeGitHubProjectHost ) as GitHubProjectRow[]) : [], - [githubProjectTable, githubRepoSlugCache, hostedRepos] + [activeGitHubProjectHost, githubProjectTable, githubRepoSlugCache, hostedRepos] ) const visibleGitHubProjectGroups = useMemo(() => { if (!githubProjectTable) { @@ -2599,10 +2549,10 @@ export default function MobileTasksScreen() { return { repoId: repo.id, path: repo.path, - slug: result ? `${result.owner}/${result.repo}` : null + repository: result } } catch { - return { repoId: repo.id, path: repo.path, slug: null } + return { repoId: repo.id, path: repo.path, repository: null } } }).then((entries) => { if (cancelled) { @@ -2611,7 +2561,7 @@ export default function MobileTasksScreen() { setGithubRepoSlugCache((current) => { const next = { ...current } for (const entry of entries) { - next[entry.repoId] = { path: entry.path, slug: entry.slug } + next[entry.repoId] = { path: entry.path, repository: entry.repository } } return next }) @@ -2630,7 +2580,6 @@ export default function MobileTasksScreen() { taskStateHydrated, tasksSupported ]) - const activeGitHubProject = githubProjectSettings.activeProject const activeGitHubProjectKey = activeGitHubProject ? githubProjectKey(activeGitHubProject) : null const activeGitHubProjectViewId = activeGitHubProjectKey ? githubProjectSettings.lastViewByProject[activeGitHubProjectKey]?.viewId @@ -3738,7 +3687,9 @@ export default function MobileTasksScreen() { } setGithubProjectError('') setGithubProjectPartialFailures([]) - const response = await client.sendRequest('github.project.listAccessible', {}) + const response = await client.sendRequest('github.project.listAccessible', { + host: 'github.com' + }) if (!isSuccess(response)) { throw new Error(response.error.message) } @@ -3763,6 +3714,7 @@ export default function MobileTasksScreen() { } const response = await client.sendRequest('github.project.listViews', { owner: project.owner, + host: githubProjectHost(project.host), ownerType: project.ownerType, projectNumber: project.number }) @@ -3800,6 +3752,7 @@ export default function MobileTasksScreen() { 'github.project.viewTable', { owner: activeGitHubProject.owner, + host: activeGitHubProjectHost, ownerType: activeGitHubProject.ownerType, projectNumber: activeGitHubProject.number, viewId: activeGitHubProjectViewId, @@ -3838,7 +3791,14 @@ export default function MobileTasksScreen() { setGithubProjectLoading(false) } }, - [activeGitHubProject, activeGitHubProjectViewId, client, connState, tasksSupported] + [ + activeGitHubProject, + activeGitHubProjectHost, + activeGitHubProjectViewId, + client, + connState, + tasksSupported + ] ) const commitGitHubProjectView = useCallback( @@ -3932,7 +3892,8 @@ export default function MobileTasksScreen() { return } const input = githubProjectPasteInput.trim() - if (!parseProjectInput(input)) { + const parsed = parseProjectInput(input) + if (!parsed) { setGithubProjectPasteError('Expected a project URL or owner/number.') return } @@ -3940,7 +3901,10 @@ export default function MobileTasksScreen() { setGithubProjectPasteError('') setGithubProjectError('') try { - const response = await client.sendRequest('github.project.resolveRef', { input }) + const response = await client.sendRequest('github.project.resolveRef', { + input, + host: githubProjectHost(parsed.host) + }) if (!isSuccess(response)) { throw new Error(response.error.message) } @@ -3951,6 +3915,7 @@ export default function MobileTasksScreen() { ownerType: GitHubProjectOwnerType number: number title: string + host?: string viewNumber?: number } | { ok: false; error: { message: string } } @@ -3964,7 +3929,8 @@ export default function MobileTasksScreen() { { owner: result.owner, ownerType: result.ownerType, - number: result.number + number: result.number, + host: githubProjectHost(result.host ?? parsed.host) }, { viewNumber: result.viewNumber } ) @@ -4536,6 +4502,7 @@ export default function MobileTasksScreen() { { owner: slug.owner, repo: slug.repo, + host: activeGitHubProjectHost, number: projectRowItem.content.number, type }, @@ -4610,7 +4577,14 @@ export default function MobileTasksScreen() { return () => { stale = true } - }, [client, githubProjectTable, projectRowDetailRefreshSeq, projectRowItem, tasksSupported]) + }, [ + activeGitHubProjectHost, + client, + githubProjectTable, + projectRowDetailRefreshSeq, + projectRowItem, + tasksSupported + ]) useEffect(() => { const slug = splitRepositorySlug(projectMetadataRepository) @@ -4628,7 +4602,7 @@ export default function MobileTasksScreen() { void client .sendRequest( 'github.project.listLabelsBySlug', - { owner: slug.owner, repo: slug.repo }, + { owner: slug.owner, repo: slug.repo, host: activeGitHubProjectHost }, { timeoutMs: 30_000 } ) .then((response) => { @@ -4660,7 +4634,7 @@ export default function MobileTasksScreen() { return () => { stale = true } - }, [client, projectMetadataRepository, tasksSupported]) + }, [activeGitHubProjectHost, client, projectMetadataRepository, tasksSupported]) useEffect(() => { const slug = splitRepositorySlug(projectMetadataRepository) @@ -4681,6 +4655,7 @@ export default function MobileTasksScreen() { { owner: slug.owner, repo: slug.repo, + host: activeGitHubProjectHost, ...(projectMetadataSeedLogins ? { seedLogins: projectMetadataSeedLogins.split(',') } : {}) }, { timeoutMs: 30_000 } @@ -4716,7 +4691,13 @@ export default function MobileTasksScreen() { return () => { stale = true } - }, [client, projectMetadataRepository, projectMetadataSeedLogins, tasksSupported]) + }, [ + activeGitHubProjectHost, + client, + projectMetadataRepository, + projectMetadataSeedLogins, + tasksSupported + ]) useEffect(() => { const slug = splitRepositorySlug(projectIssueTypeRepository) @@ -4734,7 +4715,7 @@ export default function MobileTasksScreen() { void client .sendRequest( 'github.project.listIssueTypesBySlug', - { owner: slug.owner, repo: slug.repo }, + { owner: slug.owner, repo: slug.repo, host: activeGitHubProjectHost }, { timeoutMs: 30_000 } ) .then((response) => { @@ -4768,7 +4749,7 @@ export default function MobileTasksScreen() { return () => { stale = true } - }, [client, projectIssueTypeRepository, tasksSupported]) + }, [activeGitHubProjectHost, client, projectIssueTypeRepository, tasksSupported]) const getWorkspaceTargetRepo = useCallback( (item: ActionableTaskItem, repoIdOverride?: string): RepoSummary | null => { @@ -5729,6 +5710,7 @@ export default function MobileTasksScreen() { { owner: slug.owner, repo: slug.repo, + host: activeGitHubProjectHost, number: row.content.number, updates }, @@ -5790,7 +5772,7 @@ export default function MobileTasksScreen() { setProjectMutating(false) } }, - [client, projectMutating] + [activeGitHubProjectHost, client, projectMutating] ) const addProjectRowComment = useCallback( @@ -5810,6 +5792,7 @@ export default function MobileTasksScreen() { { owner: slug.owner, repo: slug.repo, + host: activeGitHubProjectHost, number: row.content.number, body }, @@ -5838,7 +5821,7 @@ export default function MobileTasksScreen() { setProjectMutating(false) } }, - [client, projectCommentDraft, projectMutating] + [activeGitHubProjectHost, client, projectCommentDraft, projectMutating] ) const updateProjectRowComment = useCallback( @@ -5861,6 +5844,7 @@ export default function MobileTasksScreen() { { owner: slug.owner, repo: slug.repo, + host: activeGitHubProjectHost, commentId, body }, @@ -5898,7 +5882,7 @@ export default function MobileTasksScreen() { setProjectMutating(false) } }, - [client, projectEditingCommentDraft, projectMutating] + [activeGitHubProjectHost, client, projectEditingCommentDraft, projectMutating] ) const deleteProjectRowComment = useCallback( @@ -5920,6 +5904,7 @@ export default function MobileTasksScreen() { { owner: slug.owner, repo: slug.repo, + host: activeGitHubProjectHost, commentId }, { timeoutMs: 30_000 } @@ -5956,7 +5941,7 @@ export default function MobileTasksScreen() { setProjectMutating(false) } }, - [client, projectEditingCommentId, projectMutating] + [activeGitHubProjectHost, client, projectEditingCommentId, projectMutating] ) const toggleProjectGitHubReviewThread = useCallback( @@ -5979,6 +5964,7 @@ export default function MobileTasksScreen() { 'github.resolveReviewThread', { repo: `id:${repo.id}`, + prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost), threadId: comment.threadId, resolve }, @@ -6010,7 +5996,7 @@ export default function MobileTasksScreen() { setProjectMutating(false) } }, - [client, findProjectRowRepo, projectMutating] + [activeGitHubProjectHost, client, findProjectRowRepo, projectMutating] ) const replyToProjectGitHubComment = useCallback( @@ -6038,6 +6024,7 @@ export default function MobileTasksScreen() { { repo: `id:${repo.id}`, prNumber: row.content.number, + prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost), commentId: comment.id, body, threadId: comment.threadId, @@ -6051,6 +6038,7 @@ export default function MobileTasksScreen() { { repo: `id:${repo.id}`, number: row.content.number, + prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost), body: `@${commentAuthor(comment)} ${body}`, type: projectRowType(row) ?? 'issue' }, @@ -6092,7 +6080,7 @@ export default function MobileTasksScreen() { setProjectMutating(false) } }, - [client, findProjectRowRepo, itemReplyDrafts, projectMutating] + [activeGitHubProjectHost, client, findProjectRowRepo, itemReplyDrafts, projectMutating] ) const mutateProjectRowMetadata = useCallback( @@ -6120,6 +6108,7 @@ export default function MobileTasksScreen() { { owner: slug.owner, repo: slug.repo, + host: activeGitHubProjectHost, number: row.content.number, updates }, @@ -6204,7 +6193,7 @@ export default function MobileTasksScreen() { setProjectMutating(false) } }, - [client, projectMutating] + [activeGitHubProjectHost, client, projectMutating] ) const mutateProjectRowField = useCallback( @@ -6223,11 +6212,13 @@ export default function MobileTasksScreen() { value === null ? { projectId: githubProjectTable.project.id, + host: activeGitHubProjectHost, itemId: row.id, fieldId: field.id } : { projectId: githubProjectTable.project.id, + host: activeGitHubProjectHost, itemId: row.id, fieldId: field.id, value @@ -6274,7 +6265,7 @@ export default function MobileTasksScreen() { setProjectMutating(false) } }, - [client, githubProjectTable, projectMutating] + [activeGitHubProjectHost, client, githubProjectTable, projectMutating] ) const mutateProjectRowIssueType = useCallback( @@ -6294,6 +6285,7 @@ export default function MobileTasksScreen() { { owner: slug.owner, repo: slug.repo, + host: activeGitHubProjectHost, number: row.content.number, issueTypeId: issueType?.id ?? null }, @@ -6329,7 +6321,7 @@ export default function MobileTasksScreen() { setProjectMutating(false) } }, - [client, projectMutating] + [activeGitHubProjectHost, client, projectMutating] ) const requestProjectGitHubReviewers = useCallback( @@ -6350,6 +6342,7 @@ export default function MobileTasksScreen() { { repo: `id:${repo.id}`, prNumber: row.content.number, + prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost), reviewers }, { timeoutMs: 30_000 } @@ -6397,7 +6390,14 @@ export default function MobileTasksScreen() { setProjectMutating(false) } }, - [client, findProjectRowRepo, projectMutating, projectReviewersDraft, projectRowDetail] + [ + activeGitHubProjectHost, + client, + findProjectRowRepo, + projectMutating, + projectReviewersDraft, + projectRowDetail + ] ) const refreshProjectGitHubChecks = useCallback( @@ -6420,6 +6420,7 @@ export default function MobileTasksScreen() { { repo: `id:${repo.id}`, prNumber: row.content.number, + prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost), headSha: projectRowDetail?.provider === 'github' ? projectRowDetail.headSha : undefined, noCache: true }, @@ -6441,7 +6442,7 @@ export default function MobileTasksScreen() { setProjectMutating(false) } }, - [client, findProjectRowRepo, projectMutating, projectRowDetail] + [activeGitHubProjectHost, client, findProjectRowRepo, projectMutating, projectRowDetail] ) const rerunProjectGitHubChecks = useCallback( @@ -6464,6 +6465,7 @@ export default function MobileTasksScreen() { { repo: `id:${repo.id}`, prNumber: row.content.number, + prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost), headSha: projectRowDetail?.provider === 'github' ? projectRowDetail.headSha : undefined, failedOnly }, @@ -6483,7 +6485,7 @@ export default function MobileTasksScreen() { setProjectMutating(false) } }, - [client, findProjectRowRepo, projectMutating, projectRowDetail] + [activeGitHubProjectHost, client, findProjectRowRepo, projectMutating, projectRowDetail] ) const toggleProjectGitHubFileViewed = useCallback( @@ -6504,6 +6506,7 @@ export default function MobileTasksScreen() { 'github.setPRFileViewed', { repo: `id:${repo.id}`, + prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost), pullRequestId: projectRowDetail.pullRequestId, path: file.path, viewed @@ -6536,7 +6539,7 @@ export default function MobileTasksScreen() { setProjectMutating(false) } }, - [client, findProjectRowRepo, projectMutating, projectRowDetail] + [activeGitHubProjectHost, client, findProjectRowRepo, projectMutating, projectRowDetail] ) const toggleProjectGitHubFileExpansion = useCallback( @@ -6570,6 +6573,7 @@ export default function MobileTasksScreen() { { repo: `id:${repo.id}`, prNumber: row.content.number, + prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost), path: file.path, oldPath: file.oldPath, status: file.status ?? 'modified', @@ -6593,7 +6597,14 @@ export default function MobileTasksScreen() { setPrFileLoadingPath(null) } }, - [client, expandedPrFilePath, findProjectRowRepo, prFileContents, projectRowDetail] + [ + activeGitHubProjectHost, + client, + expandedPrFilePath, + findProjectRowRepo, + prFileContents, + projectRowDetail + ] ) const addProjectGitHubFileReviewComment = useCallback( @@ -6625,6 +6636,7 @@ export default function MobileTasksScreen() { { repo: `id:${repo.id}`, prNumber: row.content.number, + prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost), commitId: projectRowDetail.headSha, path: file.path, line, @@ -6669,7 +6681,14 @@ export default function MobileTasksScreen() { setProjectMutating(false) } }, - [client, findProjectRowRepo, prFileCommentDrafts, projectMutating, projectRowDetail] + [ + activeGitHubProjectHost, + client, + findProjectRowRepo, + prFileCommentDrafts, + projectMutating, + projectRowDetail + ] ) const mergeProjectGitHubPullRequest = useCallback( @@ -6695,6 +6714,7 @@ export default function MobileTasksScreen() { { repo: `id:${repo.id}`, prNumber: row.content.number, + prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost), method }, { timeoutMs: 60_000 } @@ -6731,7 +6751,7 @@ export default function MobileTasksScreen() { setProjectMutating(false) } }, - [client, findProjectRowRepo, projectMutating] + [activeGitHubProjectHost, client, findProjectRowRepo, projectMutating] ) const toggleGitHubStatus = useCallback( diff --git a/mobile/src/components/pr-sidebar/PRCommentCard.tsx b/mobile/src/components/pr-sidebar/PRCommentCard.tsx index 4f5d186c7e4..ce1ef33cb96 100644 --- a/mobile/src/components/pr-sidebar/PRCommentCard.tsx +++ b/mobile/src/components/pr-sidebar/PRCommentCard.tsx @@ -10,7 +10,7 @@ import { PRCommentComposer } from './PRCommentComposer' import { formatPrCommentRelativeTime } from './pr-comment-time' import { prCommentsStyles as styles } from './pr-comments-styles' -export type PRCommentRepoSlug = { owner: string; repo: string } +export type PRCommentRepoSlug = { owner: string; repo: string; host?: string } // Action handlers are passed from the comment actions hook (stable callbacks), so // adding them keeps the memo'd card from re-rendering on unrelated timeline changes. diff --git a/mobile/src/session/github-pr-mutations.test.ts b/mobile/src/session/github-pr-mutations.test.ts index d7d722ea966..52a6ef824ff 100644 --- a/mobile/src/session/github-pr-mutations.test.ts +++ b/mobile/src/session/github-pr-mutations.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it, vi } from 'vitest' import type { RpcResponse } from '../transport/types' import { + fetchAddIssueComment, + fetchAddPRReviewCommentReply, fetchDeleteIssueComment, fetchMergePR, fetchResolveReviewThread, @@ -29,6 +31,7 @@ function clientRejecting(message: string) { } const WORKTREE_ID = 'repo-42::/path/to/wt' +const ENTERPRISE_PR_REPO = { owner: 'o', repo: 'r', host: 'github.acme.test' } describe('fetchResolveReviewThread / fetchUpdatePRTitle — bare-boolean host result', () => { it('treats an explicit true as success', async () => { @@ -102,12 +105,51 @@ describe('mutation transport rejection normalization', () => { }) }) +describe('Enterprise PR repo forwarding', () => { + it('forwards the host through title, root-comment, and review-reply RPCs', async () => { + const titleClient = clientReturning(okResponse(true)) + await fetchUpdatePRTitle(titleClient, WORKTREE_ID, { + prNumber: 7, + title: 'New title', + prRepo: ENTERPRISE_PR_REPO + }) + expect(titleClient.sendRequest).toHaveBeenCalledWith( + 'github.updatePRTitle', + expect.objectContaining({ prRepo: ENTERPRISE_PR_REPO }) + ) + + const rootClient = clientReturning(okResponse({ ok: true })) + await fetchAddIssueComment(rootClient, WORKTREE_ID, { + prNumber: 7, + body: 'Root comment', + prRepo: ENTERPRISE_PR_REPO + }) + expect(rootClient.sendRequest).toHaveBeenCalledWith( + 'github.addIssueComment', + expect.objectContaining({ prRepo: ENTERPRISE_PR_REPO }) + ) + + const replyClient = clientReturning(okResponse({ ok: true })) + await fetchAddPRReviewCommentReply(replyClient, WORKTREE_ID, { + prNumber: 7, + commentId: 42, + body: 'Reply', + prRepo: ENTERPRISE_PR_REPO + }) + expect(replyClient.sendRequest).toHaveBeenCalledWith( + 'github.addPRReviewCommentReply', + expect.objectContaining({ prRepo: ENTERPRISE_PR_REPO }) + ) + }) +}) + describe('fetchUpdateIssueComment / fetchDeleteIssueComment — slug-addressed envelope', () => { it('sends owner/repo/commentId(+body) and reads the { ok } envelope', async () => { const editClient = clientReturning(okResponse({ ok: true })) const edit = await fetchUpdateIssueComment(editClient, { owner: 'o', repo: 'r', + host: 'github.acme.test', commentId: 5, body: 'edited' }) @@ -115,16 +157,23 @@ describe('fetchUpdateIssueComment / fetchDeleteIssueComment — slug-addressed e expect(editClient.sendRequest).toHaveBeenCalledWith('github.project.updateIssueCommentBySlug', { owner: 'o', repo: 'r', + host: 'github.acme.test', commentId: 5, body: 'edited' }) const delClient = clientReturning(okResponse({ ok: true })) - const del = await fetchDeleteIssueComment(delClient, { owner: 'o', repo: 'r', commentId: 5 }) + const del = await fetchDeleteIssueComment(delClient, { + owner: 'o', + repo: 'r', + host: 'github.acme.test', + commentId: 5 + }) expect(del).toEqual({ ok: true }) expect(delClient.sendRequest).toHaveBeenCalledWith('github.project.deleteIssueCommentBySlug', { owner: 'o', repo: 'r', + host: 'github.acme.test', commentId: 5 }) }) diff --git a/mobile/src/session/github-pr-mutations.ts b/mobile/src/session/github-pr-mutations.ts index 7d79518f089..d9fa04ce06a 100644 --- a/mobile/src/session/github-pr-mutations.ts +++ b/mobile/src/session/github-pr-mutations.ts @@ -1,6 +1,6 @@ import type { GitHubPRMergeMethod } from '../../../src/shared/types' import type { RpcClient } from '../transport/rpc-client' -import { buildGithubPrParams, type GitHubPrRepoSlug } from './github-pr-rpc' +import { buildGithubPrParams, githubPrRepoSlugParam, type GitHubPrRepoSlug } from './github-pr-rpc' // Mutation wrappers for the github.* PR surface, split out so github-pr-rpc.ts // stays under the max-lines budget. They mirror the read wrappers' shape but @@ -101,16 +101,10 @@ export async function fetchUpdatePRTitle( args: { prNumber: number; title: string; prRepo?: GitHubPrRepoSlug | null } ): Promise { const params: Record = { prNumber: args.prNumber, title: args.title } - // updatePRTitle accepts prRepo for fork PRs, but it is not in the centralized - // METHODS_ACCEPTING_PR_REPO read allow-list — pass it explicitly so it reaches the - // host schema (which declares it optional/nullable). - if (args.prRepo) { - params.prRepo = { owner: args.prRepo.owner, repo: args.prRepo.repo } - } const response = await sendRaw( client, 'github.updatePRTitle', - buildGithubPrParams('github.updatePRTitle', worktreeId, params) + buildGithubPrParams('github.updatePRTitle', worktreeId, params, { prRepo: args.prRepo }) ) if (!response.ok) { return { ok: false, error: response.error || 'Request failed: github.updatePRTitle' } @@ -147,48 +141,51 @@ export async function fetchSetPRAutoMerge( export async function fetchUpdatePRState( client: Pick, worktreeId: string, - args: { prNumber: number; state: 'open' | 'closed' } + args: { prNumber: number; state: 'open' | 'closed'; prRepo?: GitHubPrRepoSlug | null } ): Promise { - // updatePRState does NOT accept prRepo (KTD3) — buildGithubPrParams omits it. return sendGithubPrMutation( client, 'github.updatePRState', - buildGithubPrParams('github.updatePRState', worktreeId, { - prNumber: args.prNumber, - updates: { state: args.state } - }) + buildGithubPrParams( + 'github.updatePRState', + worktreeId, + { prNumber: args.prNumber, updates: { state: args.state } }, + { prRepo: args.prRepo } + ) ) } export async function fetchRequestPRReviewers( client: Pick, worktreeId: string, - args: { prNumber: number; reviewers: string[] } + args: { prNumber: number; reviewers: string[]; prRepo?: GitHubPrRepoSlug | null } ): Promise { - // requestPRReviewers does NOT accept prRepo (KTD3). return sendGithubPrMutation( client, 'github.requestPRReviewers', - buildGithubPrParams('github.requestPRReviewers', worktreeId, { - prNumber: args.prNumber, - reviewers: args.reviewers - }) + buildGithubPrParams( + 'github.requestPRReviewers', + worktreeId, + { prNumber: args.prNumber, reviewers: args.reviewers }, + { prRepo: args.prRepo } + ) ) } export async function fetchRemovePRReviewers( client: Pick, worktreeId: string, - args: { prNumber: number; reviewers: string[] } + args: { prNumber: number; reviewers: string[]; prRepo?: GitHubPrRepoSlug | null } ): Promise { - // removePRReviewers does NOT accept prRepo (KTD3). return sendGithubPrMutation( client, 'github.removePRReviewers', - buildGithubPrParams('github.removePRReviewers', worktreeId, { - prNumber: args.prNumber, - reviewers: args.reviewers - }) + buildGithubPrParams( + 'github.removePRReviewers', + worktreeId, + { prNumber: args.prNumber, reviewers: args.reviewers }, + { prRepo: args.prRepo } + ) ) } @@ -222,16 +219,12 @@ export async function fetchAddPRReviewCommentReply( if (typeof args.line === 'number') { params.line = args.line } - // addPRReviewCommentReply accepts prRepo for fork PRs, but it is not in the - // centralized METHODS_ACCEPTING_PR_REPO allow-list (read-focused) — pass it - // explicitly so it reaches the host schema, which declares it optional. - if (args.prRepo) { - params.prRepo = { owner: args.prRepo.owner, repo: args.prRepo.repo } - } return sendGithubPrMutation( client, 'github.addPRReviewCommentReply', - buildGithubPrParams('github.addPRReviewCommentReply', worktreeId, params) + buildGithubPrParams('github.addPRReviewCommentReply', worktreeId, params, { + prRepo: args.prRepo + }) ) } @@ -246,13 +239,10 @@ export async function fetchAddIssueComment( body: args.body, type: 'pr' } - if (args.prRepo) { - params.prRepo = { owner: args.prRepo.owner, repo: args.prRepo.repo } - } return sendGithubPrMutation( client, 'github.addIssueComment', - buildGithubPrParams('github.addIssueComment', worktreeId, params) + buildGithubPrParams('github.addIssueComment', worktreeId, params, { prRepo: args.prRepo }) ) } @@ -262,15 +252,17 @@ export async function fetchAddIssueComment( export async function fetchResolveReviewThread( client: Pick, worktreeId: string, - args: { threadId: string; resolve: boolean } + args: { threadId: string; resolve: boolean; prRepo?: GitHubPrRepoSlug | null } ): Promise { const response = await sendRaw( client, 'github.resolveReviewThread', - buildGithubPrParams('github.resolveReviewThread', worktreeId, { - threadId: args.threadId, - resolve: args.resolve - }) + buildGithubPrParams( + 'github.resolveReviewThread', + worktreeId, + { threadId: args.threadId, resolve: args.resolve }, + { prRepo: args.prRepo } + ) ) if (!response.ok) { return { @@ -292,11 +284,10 @@ export async function fetchResolveReviewThread( // GitHubProjectMutationResult `{ ok }` envelope sendGithubPrMutation reads. export async function fetchUpdateIssueComment( client: Pick, - args: { owner: string; repo: string; commentId: number; body: string } + args: { owner: string; repo: string; host?: string; commentId: number; body: string } ): Promise { return sendGithubPrMutation(client, 'github.project.updateIssueCommentBySlug', { - owner: args.owner, - repo: args.repo, + ...githubPrRepoSlugParam(args), commentId: args.commentId, body: args.body }) @@ -305,11 +296,10 @@ export async function fetchUpdateIssueComment( // Delete a root conversation (issue) comment. Slug-addressed like the edit wrapper. export async function fetchDeleteIssueComment( client: Pick, - args: { owner: string; repo: string; commentId: number } + args: { owner: string; repo: string; host?: string; commentId: number } ): Promise { return sendGithubPrMutation(client, 'github.project.deleteIssueCommentBySlug', { - owner: args.owner, - repo: args.repo, + ...githubPrRepoSlugParam(args), commentId: args.commentId }) } @@ -317,9 +307,13 @@ export async function fetchDeleteIssueComment( export async function fetchRerunPRChecks( client: Pick, worktreeId: string, - args: { prNumber: number; headSha?: string | null; failedOnly?: boolean } + args: { + prNumber: number + headSha?: string | null + failedOnly?: boolean + prRepo?: GitHubPrRepoSlug | null + } ): Promise { - // rerunPRChecks does NOT accept prRepo (KTD3); headSha is a plain param here. const params: Record = { prNumber: args.prNumber } if (args.failedOnly !== undefined) { params.failedOnly = args.failedOnly @@ -330,6 +324,6 @@ export async function fetchRerunPRChecks( return sendGithubPrMutation( client, 'github.rerunPRChecks', - buildGithubPrParams('github.rerunPRChecks', worktreeId, params) + buildGithubPrParams('github.rerunPRChecks', worktreeId, params, { prRepo: args.prRepo }) ) } diff --git a/mobile/src/session/github-pr-rpc.test.ts b/mobile/src/session/github-pr-rpc.test.ts index 56904e482cd..a287aa4f616 100644 --- a/mobile/src/session/github-pr-rpc.test.ts +++ b/mobile/src/session/github-pr-rpc.test.ts @@ -103,13 +103,17 @@ describe('readPRForBranch', () => { const parsed = readPRForBranch({ number: 3, state: 'open', - prRepo: { owner: 'forkOwner', repo: 'forkRepo' }, + prRepo: { owner: 'forkOwner', repo: 'forkRepo', host: 'github.acme.test' }, mergeMethodSettings: { defaultMethod: 'squash', allowedMethods: { merge: false, squash: true, rebase: true } } }) - expect(parsed?.prRepo).toEqual({ owner: 'forkOwner', repo: 'forkRepo' }) + expect(parsed?.prRepo).toEqual({ + owner: 'forkOwner', + repo: 'forkRepo', + host: 'github.acme.test' + }) expect(parsed?.mergeMethodSettings).toEqual({ defaultMethod: 'squash', allowedMethods: { merge: false, squash: true, rebase: true } @@ -266,7 +270,7 @@ describe('readAssignableUsers', () => { }) describe('buildGithubPrParams — method-aware prRepo / headSha', () => { - const fork = { owner: 'forkOwner', repo: 'forkRepo' } + const fork = { owner: 'forkOwner', repo: 'forkRepo', host: 'github.acme.test' } it('reuses mobileRepoSelectorFromWorktreeId for the repo selector', () => { const params = buildGithubPrParams('github.prChecks', WORKTREE_ID, { prNumber: 1 }) @@ -278,9 +282,20 @@ describe('buildGithubPrParams — method-aware prRepo / headSha', () => { for (const method of [ 'github.prChecks', 'github.prCheckDetails', + 'github.rerunPRChecks', + 'github.resolveReviewThread', + 'github.setPRFileViewed', + 'github.updatePRState', + 'github.requestPRReviewers', + 'github.removePRReviewers', 'github.mergePR', 'github.setPRAutoMerge', - 'github.prComments' + 'github.updatePRTitle', + 'github.prComments', + 'github.prFileContents', + 'github.addPRReviewComment', + 'github.addIssueComment', + 'github.addPRReviewCommentReply' ]) { const params = buildGithubPrParams(method, WORKTREE_ID, { prNumber: 1 }, { prRepo: fork }) expect(params.prRepo).toEqual(fork) @@ -288,13 +303,7 @@ describe('buildGithubPrParams — method-aware prRepo / headSha', () => { }) it('omits prRepo for methods that reject it', () => { - for (const method of [ - 'github.updatePRState', - 'github.requestPRReviewers', - 'github.removePRReviewers', - 'github.listAssignableUsers', - 'github.rerunPRChecks' - ]) { + for (const method of ['github.repoSlug', 'github.prForBranch', 'github.listAssignableUsers']) { const params = buildGithubPrParams(method, WORKTREE_ID, { prNumber: 1 }, { prRepo: fork }) expect('prRepo' in params).toBe(false) } @@ -380,12 +389,15 @@ describe('fetch wrappers', () => { expect('headSha' in (params as object)).toBe(false) }) - it('fetchGithubRepoSlug returns the slug for a github repo, null otherwise', async () => { + it('fetchGithubRepoSlug preserves an Enterprise host, and returns null otherwise', async () => { const found = await fetchGithubRepoSlug( - mockClient(okResponse({ owner: 'o', repo: 'r' })).client, + mockClient(okResponse({ owner: 'o', repo: 'r', host: 'github.acme.test' })).client, WORKTREE_ID ) - expect(found).toEqual({ ok: true, result: { owner: 'o', repo: 'r' } }) + expect(found).toEqual({ + ok: true, + result: { owner: 'o', repo: 'r', host: 'github.acme.test' } + }) const none = await fetchGithubRepoSlug(mockClient(okResponse(null)).client, WORKTREE_ID) expect(none).toEqual({ ok: true, result: null }) }) diff --git a/mobile/src/session/github-pr-rpc.ts b/mobile/src/session/github-pr-rpc.ts index 8fd11f710a8..0ae009d926a 100644 --- a/mobile/src/session/github-pr-rpc.ts +++ b/mobile/src/session/github-pr-rpc.ts @@ -31,20 +31,35 @@ export { // Why: a fork PR's head lives in a different owner/repo; the host's SlugRepo // (`{ owner, repo }`) identifies it. Only a subset of github.* methods accept it. -export type GitHubPrRepoSlug = { owner: string; repo: string } +// Why: `host` must survive the RPC boundary or GHES actions on the host fall +// back to a same-named github.com repo (src/shared/types.ts identity contract). +export type GitHubPrRepoSlug = { owner: string; repo: string; host?: string } + +export function githubPrRepoSlugParam(slug: GitHubPrRepoSlug): Record { + return { owner: slug.owner, repo: slug.repo, ...(slug.host ? { host: slug.host } : {}) } +} export type GitHubPrReadOutcome = { ok: true; result: T } | { ok: false; error: string } -// Why: `prRepo` is method-asymmetric (KTD3). These are the only github.* methods -// whose host schema (SlugRepo on PullRequest/PullRequestChecks/PullRequestCheckDetails) -// accepts it; the rest reject the key. Centralizing the allow-list keeps a fork's -// prRepo from leaking into a schema that would reject it. +// Why: `prRepo` remains method-asymmetric. Keep the RPC schema allow-list here +// so fork/GHES identity reaches every PR-scoped read or mutation that accepts it. const METHODS_ACCEPTING_PR_REPO = new Set([ 'github.prChecks', 'github.prCheckDetails', + 'github.rerunPRChecks', + 'github.resolveReviewThread', + 'github.setPRFileViewed', + 'github.updatePRState', + 'github.requestPRReviewers', + 'github.removePRReviewers', 'github.mergePR', 'github.setPRAutoMerge', - 'github.prComments' + 'github.updatePRTitle', + 'github.prComments', + 'github.prFileContents', + 'github.addPRReviewComment', + 'github.addIssueComment', + 'github.addPRReviewCommentReply' ]) // Why: only github.prChecks declares a `headSha` param (PullRequestCheckDetails @@ -62,7 +77,7 @@ export function buildGithubPrParams( ...params } if (options?.prRepo && METHODS_ACCEPTING_PR_REPO.has(method) && !('prRepo' in built)) { - built.prRepo = { owner: options.prRepo.owner, repo: options.prRepo.repo } + built.prRepo = githubPrRepoSlugParam(options.prRepo) } if (options?.headSha && METHODS_ACCEPTING_HEAD_SHA.has(method) && !('headSha' in built)) { built.headSha = options.headSha @@ -107,7 +122,10 @@ export async function fetchGithubRepoSlug( const record = value as Record const owner = record.owner const repo = record.repo - return typeof owner === 'string' && typeof repo === 'string' ? { owner, repo } : null + const host = record.host + return typeof owner === 'string' && typeof repo === 'string' + ? { owner, repo, ...(typeof host === 'string' && host ? { host } : {}) } + : null } ) } diff --git a/mobile/src/session/github-pr-value-readers.test.ts b/mobile/src/session/github-pr-value-readers.test.ts index cfd3142cff7..0b78d11dbb1 100644 --- a/mobile/src/session/github-pr-value-readers.test.ts +++ b/mobile/src/session/github-pr-value-readers.test.ts @@ -9,6 +9,14 @@ describe('readRepoIdentity', () => { }) }) + it('preserves an Enterprise host', () => { + expect(readRepoIdentity({ owner: 'octo', repo: 'orca', host: 'github.acme.test' })).toEqual({ + owner: 'octo', + repo: 'orca', + host: 'github.acme.test' + }) + }) + it('drops a non-record value', () => { expect(readRepoIdentity(null)).toBeUndefined() expect(readRepoIdentity('octo/orca')).toBeUndefined() diff --git a/mobile/src/session/github-pr-value-readers.ts b/mobile/src/session/github-pr-value-readers.ts index 6ba8ccd6954..1756d51c59a 100644 --- a/mobile/src/session/github-pr-value-readers.ts +++ b/mobile/src/session/github-pr-value-readers.ts @@ -153,7 +153,10 @@ export function readRepoIdentity(value: unknown): GitHubRepositoryIdentity | und if (!owner || !repo) { return undefined } - return { owner, repo } + // Why: dropping `host` here would strip the GHES identity before every + // subsequent PR RPC, forcing the host to re-derive it per call. + const host = readString(value.host) + return { owner, repo, ...(host ? { host } : {}) } } function readMergeMethod(value: unknown): GitHubPRMergeMethod | undefined { diff --git a/mobile/src/session/pr-action-mutation-contract.ts b/mobile/src/session/pr-action-mutation-contract.ts new file mode 100644 index 00000000000..a2fcdb271df --- /dev/null +++ b/mobile/src/session/pr-action-mutation-contract.ts @@ -0,0 +1,38 @@ +import type { GitHubPRMergeMethod } from '../../../src/shared/types' +import type { GitHubPrMutationOutcome } from './github-pr-mutations' +import type { GitHubPrRepoSlug } from './github-pr-rpc' + +export type PrActionMutations = { + mergePR: (args: { + prNumber: number + method?: GitHubPRMergeMethod + prRepo?: GitHubPrRepoSlug | null + }) => Promise + setPRAutoMerge: (args: { + prNumber: number + enabled: boolean + method?: GitHubPRMergeMethod + prRepo?: GitHubPrRepoSlug | null + }) => Promise + updatePRState: (args: { + prNumber: number + state: 'open' | 'closed' + prRepo?: GitHubPrRepoSlug | null + }) => Promise + requestReviewers: (args: { + prNumber: number + reviewers: string[] + prRepo?: GitHubPrRepoSlug | null + }) => Promise + removeReviewers: (args: { + prNumber: number + reviewers: string[] + prRepo?: GitHubPrRepoSlug | null + }) => Promise + rerunChecks: (args: { + prNumber: number + headSha?: string | null + failedOnly?: boolean + prRepo?: GitHubPrRepoSlug | null + }) => Promise +} diff --git a/mobile/src/session/pr-actions-engine.test.ts b/mobile/src/session/pr-actions-engine.test.ts index d879454c50c..3656b2732f6 100644 --- a/mobile/src/session/pr-actions-engine.test.ts +++ b/mobile/src/session/pr-actions-engine.test.ts @@ -198,4 +198,43 @@ describe('PrActionsEngine — PR identity changes', () => { await action expect(refetch).not.toHaveBeenCalled() }) + + it('clears in-flight state when only the GitHub host changes', async () => { + const slow = deferred() + const mutations: PrActionMutations = { + mergePR: async () => ({ ok: true }), + setPRAutoMerge: async () => slow.promise, + updatePRState: async () => ({ ok: true }), + requestReviewers: async () => ({ ok: true }), + removeReviewers: async () => ({ ok: true }), + rerunChecks: async () => ({ ok: true }) + } + const refetch = vi.fn() + const onChange = vi.fn() + const baseConfig = { + mutations, + prNumber: 1, + refetch, + onChange + } + const engine = new PrActionsEngine({ + ...baseConfig, + prRepo: { owner: 'acme', repo: 'widgets' } + }) + + const action = engine.setAutoMerge(true) + expect(engine.resolveAutoMerge(false)).toBe(true) + expect(engine.isBusy({ kind: 'autoMerge' })).toBe(true) + + engine.updateConfig({ + ...baseConfig, + prRepo: { owner: 'acme', repo: 'widgets', host: 'github.acme.test' } + }) + expect(engine.resolveAutoMerge(false)).toBe(false) + expect(engine.busy).toBeNull() + + slow.resolve({ ok: true }) + await action + expect(refetch).not.toHaveBeenCalled() + }) }) diff --git a/mobile/src/session/pr-actions-engine.ts b/mobile/src/session/pr-actions-engine.ts index 7c34954534d..ce19c9bbbf9 100644 --- a/mobile/src/session/pr-actions-engine.ts +++ b/mobile/src/session/pr-actions-engine.ts @@ -1,45 +1,18 @@ import type { GitHubPRMergeMethod, PRState } from '../../../src/shared/types' +import { githubRepoIdentityKey } from '../../../src/shared/github-repository-identity-key' import { classifyPrSidebarFailure } from './mobile-pr-sidebar-state' import { createOptimisticField, type OptimisticField } from './optimistic-write-sequence' import type { GitHubPrMutationOutcome } from './github-pr-mutations' import type { GitHubPrRepoSlug } from './github-pr-rpc' +import type { PrActionMutations } from './pr-action-mutation-contract' + +export type { PrActionMutations } from './pr-action-mutation-contract' // Pure (React-free) engine for the PR mutation actions: owns optimistic fields, // busy/error/blocked state, and the success/transient/permanent routing. The hook // is a thin adapter that subscribes to `onChange` and exposes these methods. Kept // React-free so the U6 action logic is unit-testable with injected fakes. -export type PrActionMutations = { - mergePR: (args: { - prNumber: number - method?: GitHubPRMergeMethod - prRepo?: GitHubPrRepoSlug | null - }) => Promise - setPRAutoMerge: (args: { - prNumber: number - enabled: boolean - method?: GitHubPRMergeMethod - prRepo?: GitHubPrRepoSlug | null - }) => Promise - updatePRState: (args: { - prNumber: number - state: 'open' | 'closed' - }) => Promise - requestReviewers: (args: { - prNumber: number - reviewers: string[] - }) => Promise - removeReviewers: (args: { - prNumber: number - reviewers: string[] - }) => Promise - rerunChecks: (args: { - prNumber: number - headSha?: string | null - failedOnly?: boolean - }) => Promise -} - export type PrActionBusyKey = | { kind: 'merge' } | { kind: 'autoMerge' } @@ -68,7 +41,7 @@ export type PrActionsEngineConfig = { } function prActionsIdentity(cfg: PrActionsEngineConfig): string { - const repo = cfg.prRepo ? `${cfg.prRepo.owner}/${cfg.prRepo.repo}` : '' + const repo = cfg.prRepo ? githubRepoIdentityKey(cfg.prRepo) : '' return `${cfg.prNumber}:${repo}` } @@ -260,7 +233,8 @@ export class PrActionsEngine { try { const outcome = await cfg.mutations.updatePRState({ prNumber: cfg.prNumber, - state + state, + prRepo: cfg.prRepo }) await this.settle(identity, outcome, { onSuccess: () => this.stateField.settleSuccess(seq), @@ -281,7 +255,8 @@ export class PrActionsEngine { try { const outcome = await cfg.mutations.requestReviewers({ prNumber: cfg.prNumber, - reviewers: [login] + reviewers: [login], + prRepo: cfg.prRepo }) await this.settle(identity, outcome, { onSuccess: () => field.settleSuccess(seq), @@ -302,7 +277,8 @@ export class PrActionsEngine { try { const outcome = await cfg.mutations.removeReviewers({ prNumber: cfg.prNumber, - reviewers: [login] + reviewers: [login], + prRepo: cfg.prRepo }) await this.settle(identity, outcome, { onSuccess: () => field.settleSuccess(seq), @@ -322,7 +298,8 @@ export class PrActionsEngine { const outcome = await cfg.mutations.rerunChecks({ prNumber: cfg.prNumber, headSha: cfg.headSha, - failedOnly: true + failedOnly: true, + prRepo: cfg.prRepo }) await this.settle(identity, outcome, { onSuccess: () => {}, onRevert: () => {} }) } finally { diff --git a/mobile/src/session/pr-comment-actions.test.ts b/mobile/src/session/pr-comment-actions.test.ts index 4d70428845d..bb6f1758b24 100644 --- a/mobile/src/session/pr-comment-actions.test.ts +++ b/mobile/src/session/pr-comment-actions.test.ts @@ -134,13 +134,19 @@ describe('canEditComment / canDeleteComment', () => { describe('buildEditCommentParams / buildDeleteCommentParams', () => { it('build slug-addressed params', () => { - const slug = { owner: 'o', repo: 'r' } + const slug = { owner: 'o', repo: 'r', host: 'github.acme.test' } expect(buildEditCommentParams(slug, 42, 'new body')).toEqual({ owner: 'o', repo: 'r', + host: 'github.acme.test', commentId: 42, body: 'new body' }) - expect(buildDeleteCommentParams(slug, 42)).toEqual({ owner: 'o', repo: 'r', commentId: 42 }) + expect(buildDeleteCommentParams(slug, 42)).toEqual({ + owner: 'o', + repo: 'r', + host: 'github.acme.test', + commentId: 42 + }) }) }) diff --git a/mobile/src/session/pr-comment-actions.ts b/mobile/src/session/pr-comment-actions.ts index 195d2480d87..012500c62da 100644 --- a/mobile/src/session/pr-comment-actions.ts +++ b/mobile/src/session/pr-comment-actions.ts @@ -1,4 +1,4 @@ -import type { PRComment, PRState } from '../../../src/shared/types' +import type { GitHubOwnerRepo, PRComment, PRState } from '../../../src/shared/types' // Pure helpers for the interactive PR comment timeline (reply / resolve / add // root comment). Kept free of React/native imports so they unit-test under the @@ -87,35 +87,46 @@ export function isMutablePRConversationComment( // can hide the affordance rather than firing a doomed request. export function canEditComment( comment: Pick, - prRepo: { owner: string; repo: string } | null | undefined + prRepo: GitHubOwnerRepo | null | undefined ): boolean { return Boolean(prRepo) && isMutablePRConversationComment(comment) } export function canDeleteComment( comment: Pick, - prRepo: { owner: string; repo: string } | null | undefined + prRepo: GitHubOwnerRepo | null | undefined ): boolean { return Boolean(prRepo) && isMutablePRConversationComment(comment) } -export type EditCommentParams = { owner: string; repo: string; commentId: number; body: string } +export type EditCommentParams = GitHubOwnerRepo & { commentId: number; body: string } export function buildEditCommentParams( - prRepo: { owner: string; repo: string }, + prRepo: GitHubOwnerRepo, commentId: number, body: string ): EditCommentParams { - return { owner: prRepo.owner, repo: prRepo.repo, commentId, body } + return { + owner: prRepo.owner, + repo: prRepo.repo, + ...(prRepo.host ? { host: prRepo.host } : {}), + commentId, + body + } } -export type DeleteCommentParams = { owner: string; repo: string; commentId: number } +export type DeleteCommentParams = GitHubOwnerRepo & { commentId: number } export function buildDeleteCommentParams( - prRepo: { owner: string; repo: string }, + prRepo: GitHubOwnerRepo, commentId: number ): DeleteCommentParams { - return { owner: prRepo.owner, repo: prRepo.repo, commentId } + return { + owner: prRepo.owner, + repo: prRepo.repo, + ...(prRepo.host ? { host: prRepo.host } : {}), + commentId + } } // The composer disables submit on empty/whitespace input (host rejects empty body). diff --git a/mobile/src/session/use-mobile-pr-actions.test.ts b/mobile/src/session/use-mobile-pr-actions.test.ts index e8efa60094d..dd022bb25f7 100644 --- a/mobile/src/session/use-mobile-pr-actions.test.ts +++ b/mobile/src/session/use-mobile-pr-actions.test.ts @@ -10,6 +10,7 @@ import { } from './github-pr-mutations' const WORKTREE_ID = 'repo-42::/path/to/wt' +const ENTERPRISE_PR_REPO = { owner: 'fork', repo: 'proj', host: 'github.acme.test' } function okStatus(): RpcResponse { return { id: 'x', ok: true, result: { ok: true }, _meta: { runtimeId: 'r' } } @@ -46,13 +47,17 @@ function fakeMutations(overrides: Partial = {}): PrActionMuta } } -function makeEngine(mutations: PrActionMutations, refetch = vi.fn(async () => {})) { +function makeEngine( + mutations: PrActionMutations, + refetch = vi.fn(async () => {}), + prRepo: typeof ENTERPRISE_PR_REPO | null = null +) { const onChange = vi.fn() const engine = new PrActionsEngine({ mutations, prNumber: 7, headSha: 'abc123', - prRepo: null, + prRepo, refetch, onChange }) @@ -67,7 +72,7 @@ describe('mutation wrappers — prRepo + param shapes', () => { await fetchMergePR(client, WORKTREE_ID, { prNumber: 7, method: 'squash', - prRepo: { owner: 'fork', repo: 'proj' } + prRepo: ENTERPRISE_PR_REPO }) expect(sendRequest).toHaveBeenCalledWith( 'github.mergePR', @@ -75,17 +80,25 @@ describe('mutation wrappers — prRepo + param shapes', () => { repo: 'id:repo-42', prNumber: 7, method: 'squash', - prRepo: { owner: 'fork', repo: 'proj' } + prRepo: ENTERPRISE_PR_REPO }) ) }) - it('updatePRState close carries NO prRepo and the state update', async () => { + it('updatePRState carries the host-qualified PR repo and state update', async () => { const { client, sendRequest } = mockClient(okStatus()) - await fetchUpdatePRState(client, WORKTREE_ID, { prNumber: 7, state: 'closed' }) + await fetchUpdatePRState(client, WORKTREE_ID, { + prNumber: 7, + state: 'closed', + prRepo: ENTERPRISE_PR_REPO + }) const [, params] = sendRequest.mock.calls[0] - expect(params).toEqual({ repo: 'id:repo-42', prNumber: 7, updates: { state: 'closed' } }) - expect(params).not.toHaveProperty('prRepo') + expect(params).toEqual({ + repo: 'id:repo-42', + prNumber: 7, + updates: { state: 'closed' }, + prRepo: ENTERPRISE_PR_REPO + }) }) it('updatePRState reopen passes state:open', async () => { @@ -95,27 +108,43 @@ describe('mutation wrappers — prRepo + param shapes', () => { expect((params.updates as { state: string }).state).toBe('open') }) - it('request/remove reviewers carry NO prRepo even when a fork is in play', async () => { + it('request/remove reviewers carry the host-qualified PR repo', async () => { const { client, sendRequest } = mockClient(okStatus()) - await fetchRequestPRReviewers(client, WORKTREE_ID, { prNumber: 7, reviewers: ['alice'] }) - await fetchRemovePRReviewers(client, WORKTREE_ID, { prNumber: 7, reviewers: ['bob'] }) - for (const [, params] of sendRequest.mock.calls) { - expect(params).not.toHaveProperty('prRepo') - } - expect(sendRequest.mock.calls[0][1]).toMatchObject({ reviewers: ['alice'] }) - expect(sendRequest.mock.calls[1][1]).toMatchObject({ reviewers: ['bob'] }) + await fetchRequestPRReviewers(client, WORKTREE_ID, { + prNumber: 7, + reviewers: ['alice'], + prRepo: ENTERPRISE_PR_REPO + }) + await fetchRemovePRReviewers(client, WORKTREE_ID, { + prNumber: 7, + reviewers: ['bob'], + prRepo: ENTERPRISE_PR_REPO + }) + expect(sendRequest.mock.calls[0][1]).toMatchObject({ + reviewers: ['alice'], + prRepo: ENTERPRISE_PR_REPO + }) + expect(sendRequest.mock.calls[1][1]).toMatchObject({ + reviewers: ['bob'], + prRepo: ENTERPRISE_PR_REPO + }) }) - it('rerunPRChecks carries failedOnly + headSha and NO prRepo', async () => { + it('rerunPRChecks carries failedOnly, headSha, and the host-qualified PR repo', async () => { const { client, sendRequest } = mockClient(okStatus()) await fetchRerunPRChecks(client, WORKTREE_ID, { prNumber: 7, headSha: 'abc', - failedOnly: true + failedOnly: true, + prRepo: ENTERPRISE_PR_REPO }) const [, params] = sendRequest.mock.calls[0] - expect(params).toMatchObject({ prNumber: 7, failedOnly: true, headSha: 'abc' }) - expect(params).not.toHaveProperty('prRepo') + expect(params).toMatchObject({ + prNumber: 7, + failedOnly: true, + headSha: 'abc', + prRepo: ENTERPRISE_PR_REPO + }) }) it('a host { ok:false, error } result surfaces as a failure outcome', async () => { @@ -155,14 +184,14 @@ describe('PrActionsEngine — auto-merge optimistic revert', () => { const mutations = fakeMutations({ setPRAutoMerge: vi.fn(async () => ({ ok: false as const, error: 'connection lost' })) }) - const { engine } = makeEngine(mutations) + const { engine } = makeEngine(mutations, undefined, ENTERPRISE_PR_REPO) // authoritative = false; user enables. await engine.setAutoMerge(true, 'squash') expect(mutations.setPRAutoMerge).toHaveBeenCalledWith({ prNumber: 7, enabled: true, method: 'squash', - prRepo: null + prRepo: ENTERPRISE_PR_REPO }) // Reverted to authoritative after transient failure. expect(engine.resolveAutoMerge(false)).toBe(false) @@ -182,20 +211,32 @@ describe('PrActionsEngine — auto-merge optimistic revert', () => { describe('PrActionsEngine — updateState close/reopen', () => { it('close fires state:closed and reopen fires state:open', async () => { const mutations = fakeMutations() - const { engine } = makeEngine(mutations) + const { engine } = makeEngine(mutations, undefined, ENTERPRISE_PR_REPO) await engine.updateState('closed') await engine.updateState('open') - expect(mutations.updatePRState).toHaveBeenNthCalledWith(1, { prNumber: 7, state: 'closed' }) - expect(mutations.updatePRState).toHaveBeenNthCalledWith(2, { prNumber: 7, state: 'open' }) + expect(mutations.updatePRState).toHaveBeenNthCalledWith(1, { + prNumber: 7, + state: 'closed', + prRepo: ENTERPRISE_PR_REPO + }) + expect(mutations.updatePRState).toHaveBeenNthCalledWith(2, { + prNumber: 7, + state: 'open', + prRepo: ENTERPRISE_PR_REPO + }) }) }) describe('PrActionsEngine — reviewers', () => { - it('requestReviewer adds via requestReviewers with a single login (no prRepo at engine level)', async () => { + it('requestReviewer adds via requestReviewers with a host-qualified PR repo', async () => { const mutations = fakeMutations() - const { engine } = makeEngine(mutations) + const { engine } = makeEngine(mutations, undefined, ENTERPRISE_PR_REPO) await engine.requestReviewer('alice') - expect(mutations.requestReviewers).toHaveBeenCalledWith({ prNumber: 7, reviewers: ['alice'] }) + expect(mutations.requestReviewers).toHaveBeenCalledWith({ + prNumber: 7, + reviewers: ['alice'], + prRepo: ENTERPRISE_PR_REPO + }) }) it('removeReviewer optimistic revert on transient failure', async () => { @@ -213,12 +254,13 @@ describe('PrActionsEngine — reviewers', () => { describe('PrActionsEngine — rerun checks', () => { it('fires failedOnly:true with headSha and refetches on success', async () => { const mutations = fakeMutations() - const { engine, refetch } = makeEngine(mutations) + const { engine, refetch } = makeEngine(mutations, undefined, ENTERPRISE_PR_REPO) await engine.rerunFailingChecks() expect(mutations.rerunChecks).toHaveBeenCalledWith({ prNumber: 7, headSha: 'abc123', - failedOnly: true + failedOnly: true, + prRepo: ENTERPRISE_PR_REPO }) expect(refetch).toHaveBeenCalledOnce() }) diff --git a/mobile/src/session/use-mobile-pr-comment-actions.ts b/mobile/src/session/use-mobile-pr-comment-actions.ts index f0909b9e03a..38096bad833 100644 --- a/mobile/src/session/use-mobile-pr-comment-actions.ts +++ b/mobile/src/session/use-mobile-pr-comment-actions.ts @@ -30,7 +30,11 @@ export type PrCommentMutations = { line?: number prRepo?: GitHubPrRepoSlug | null }) => Promise - resolveThread: (args: { threadId: string; resolve: boolean }) => Promise + resolveThread: (args: { + threadId: string + resolve: boolean + prRepo?: GitHubPrRepoSlug | null + }) => Promise addRootComment: (args: { prNumber: number body: string @@ -39,12 +43,14 @@ export type PrCommentMutations = { editComment: (args: { owner: string repo: string + host?: string commentId: number body: string }) => Promise deleteComment: (args: { owner: string repo: string + host?: string commentId: number }) => Promise } @@ -169,9 +175,9 @@ export function useMobilePrCommentActions(input: PrCommentActionsInput) { if (!mutations || !params) { return Promise.resolve(false) } - return run(resolveKey(params.threadId), () => mutations.resolveThread(params)) + return run(resolveKey(params.threadId), () => mutations.resolveThread({ ...params, prRepo })) }, - [mutations, run] + [mutations, prRepo, run] ) const addRootComment = useCallback( diff --git a/mobile/src/tasks/github-project-host-routing-source.test.ts b/mobile/src/tasks/github-project-host-routing-source.test.ts new file mode 100644 index 00000000000..05781d9f6f8 --- /dev/null +++ b/mobile/src/tasks/github-project-host-routing-source.test.ts @@ -0,0 +1,42 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' + +const source = readFileSync(new URL('../../app/h/[hostId]/tasks.tsx', import.meta.url), 'utf8') + +describe('mobile GitHub Project host routing boundary', () => { + it('host-qualifies every Project RPC request', () => { + const calls = [...source.matchAll(/['"](github\.project\.[^'"]+)['"]/g)] + expect(calls.length).toBeGreaterThan(10) + for (const call of calls) { + const request = source.slice(call.index, call.index + 700) + expect(request, `${call[1]} must carry a host`).toMatch(/\bhost\s*:/) + } + }) + + it('pins Project-row PR actions to the row repository identity', () => { + const actions = source.slice(source.indexOf('const toggleProjectGitHubReviewThread')) + for (const method of [ + 'github.resolveReviewThread', + 'github.addPRReviewCommentReply', + 'github.addIssueComment', + 'github.requestPRReviewers', + 'github.prChecks', + 'github.rerunPRChecks', + 'github.setPRFileViewed', + 'github.prFileContents', + 'github.addPRReviewComment', + 'github.mergePR' + ]) { + const offset = actions.indexOf(`'${method}'`) + expect(offset, `${method} must remain wired in the Project action path`).toBeGreaterThan(-1) + expect(actions.slice(offset, offset + 700), `${method} must carry prRepo`).toContain( + 'prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost)' + ) + } + }) + + it('pins discovery to github.com while pasted URLs supply their parsed host', () => { + expect(source).toContain("'github.project.listAccessible', {\n host: 'github.com'") + expect(source).toContain('host: githubProjectHost(parsed.host)') + }) +}) diff --git a/mobile/src/tasks/github-project-reference.test.ts b/mobile/src/tasks/github-project-reference.test.ts new file mode 100644 index 00000000000..d083d96ccc7 --- /dev/null +++ b/mobile/src/tasks/github-project-reference.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import { parseGitHubProjectInput } from './github-project-reference' + +describe('parseGitHubProjectInput', () => { + it('keeps legacy owner/number input hostless', () => { + expect(parseGitHubProjectInput('Acme/42')).toEqual({ owner: 'Acme', number: 42 }) + }) + + it('preserves GitHub Enterprise hosts and view numbers', () => { + expect( + parseGitHubProjectInput('https://GitHub.Acme-Corp.com:8443/orgs/Acme/projects/42/views/7') + ).toEqual({ + owner: 'Acme', + number: 42, + host: 'github.acme-corp.com:8443', + viewNumber: 7 + }) + }) + + it('accepts github.com user Project URLs', () => { + expect(parseGitHubProjectInput('http://github.com/users/octocat/projects/3')).toEqual({ + owner: 'octocat', + number: 3, + host: 'github.com' + }) + }) + + it('rejects credentials, invalid routes, and unsafe integers', () => { + for (const input of [ + 'https://user:token@github.acme.test/orgs/acme/projects/1', + 'https://github.acme.test/acme/projects/1', + 'https://github.acme.test/orgs/acme/projects/1/files', + 'https://github.acme.test/orgs/acme/projects/9007199254740992', + 'https://github.acme.test/orgs/acme/projects/1/views/0' + ]) { + expect(parseGitHubProjectInput(input)).toBeNull() + } + }) +}) diff --git a/mobile/src/tasks/github-project-reference.ts b/mobile/src/tasks/github-project-reference.ts new file mode 100644 index 00000000000..00047f15f53 --- /dev/null +++ b/mobile/src/tasks/github-project-reference.ts @@ -0,0 +1,84 @@ +import type { GitHubProjectIdentity } from '../../../src/shared/github-project-identity' + +export type GitHubProjectOwnerType = GitHubProjectIdentity['ownerType'] +export type GitHubProjectRef = GitHubProjectIdentity +export type GitHubProjectSettings = { + pinned: GitHubProjectRef[] + recent: Array + lastViewByProject: Record + activeProject: GitHubProjectRef | null +} +export type GitHubProjectSummary = GitHubProjectRef & { + id: string + title: string + url: string + source: string +} +export type GitHubProjectPartialFailure = { owner: string; message: string } +export type GitHubProjectViewSummary = { + id: string + number: number + name: string + layout: 'TABLE_LAYOUT' | 'BOARD_LAYOUT' | 'ROADMAP_LAYOUT' +} + +export type ParsedGitHubProjectInput = { + owner: string + number: number + host?: string + viewNumber?: number +} + +const OWNER_RE = /^[A-Za-z0-9][A-Za-z0-9-]*$/ + +function positiveInteger(value: string | undefined): number | null { + if (!value || !/^\d+$/.test(value)) { + return null + } + const parsed = Number(value) + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null +} + +export function parseGitHubProjectInput(input: string): ParsedGitHubProjectInput | null { + const trimmed = input.trim() + const short = /^([A-Za-z0-9][A-Za-z0-9-]*)\/(\d+)$/.exec(trimmed) + if (short) { + const number = positiveInteger(short[2]) + return number ? { owner: short[1]!, number } : null + } + + try { + const url = new URL(trimmed) + if ( + (url.protocol !== 'https:' && url.protocol !== 'http:') || + url.username || + url.password || + !url.host + ) { + return null + } + const parts = url.pathname.split('/').filter(Boolean) + const hasView = parts.length === 6 && parts[4] === 'views' + if ( + (parts[0] !== 'orgs' && parts[0] !== 'users') || + !OWNER_RE.test(parts[1] ?? '') || + parts[2] !== 'projects' || + (parts.length !== 4 && !hasView) + ) { + return null + } + const number = positiveInteger(parts[3]) + const viewNumber = hasView ? positiveInteger(parts[5]) : null + if (!number || (hasView && !viewNumber)) { + return null + } + return { + owner: parts[1]!, + number, + host: url.host.toLowerCase(), + ...(viewNumber ? { viewNumber } : {}) + } + } catch { + return null + } +} diff --git a/mobile/src/tasks/github-project-repo-match.test.ts b/mobile/src/tasks/github-project-repo-match.test.ts index 7979f690d6e..bce71393dab 100644 --- a/mobile/src/tasks/github-project-repo-match.test.ts +++ b/mobile/src/tasks/github-project-repo-match.test.ts @@ -20,7 +20,10 @@ describe('GitHub project repo matching', () => { it('matches project rows by resolved repo slug before path/display heuristics', () => { expect( findRepoForGitHubProjectRepository('stablyai/orca', repos, { - 'repo-1': { path: '/Users/me/orca', slug: 'stablyai/orca' } + 'repo-1': { + path: '/Users/me/orca', + repository: { owner: 'stablyai', repo: 'orca' } + } }) ).toBe(repos[0]) }) @@ -28,8 +31,14 @@ describe('GitHub project repo matching', () => { it('does not pick a repo when resolved slugs are ambiguous', () => { expect( findRepoForGitHubProjectRepository('stablyai/orca', repos, { - 'repo-1': { path: '/Users/me/orca', slug: 'stablyai/orca' }, - 'repo-2': { path: '/Users/me/other', slug: 'stablyai/orca' } + 'repo-1': { + path: '/Users/me/orca', + repository: { owner: 'stablyai', repo: 'orca' } + }, + 'repo-2': { + path: '/Users/me/other', + repository: { owner: 'stablyai', repo: 'orca' } + } }) ).toBeNull() }) @@ -56,7 +65,10 @@ describe('GitHub project repo matching', () => { 'stablyai/orca', [{ id: 'repo-1', path: '/Users/me/stablyai/orca', displayName: 'orca' }], { - 'repo-1': { path: '/Users/me/stablyai/orca', slug: 'fork/orca' } + 'repo-1': { + path: '/Users/me/stablyai/orca', + repository: { owner: 'fork', repo: 'orca' } + } } ) ).toBeNull() @@ -71,8 +83,46 @@ describe('GitHub project repo matching', () => { expect( filterGitHubProjectRowsForRepos(rows, repos, { - 'repo-1': { path: '/Users/me/orca', slug: 'stablyai/orca' } + 'repo-1': { + path: '/Users/me/orca', + repository: { owner: 'stablyai', repo: 'orca' } + } }).map((row) => row.id) ).toEqual(['row-1']) }) + + it('matches same-named repositories only on the active Project host', () => { + expect( + findRepoForGitHubProjectRepository( + 'stablyai/orca', + repos, + { + 'repo-1': { + path: '/Users/me/orca', + repository: { owner: 'stablyai', repo: 'orca', host: 'github.com' } + }, + 'repo-2': { + path: '/Users/me/other', + repository: { + owner: 'stablyai', + repo: 'orca', + host: 'github.acme-corp.com' + } + } + }, + 'github.acme-corp.com' + ) + ).toBe(repos[1]) + }) + + it('does not use hostless path heuristics for Enterprise Project rows', () => { + expect( + findRepoForGitHubProjectRepository( + 'stablyai/orca', + [{ id: 'repo-1', path: '/Users/me/stablyai/orca', displayName: 'orca' }], + {}, + 'github.acme-corp.com' + ) + ).toBeNull() + }) }) diff --git a/mobile/src/tasks/github-project-repo-match.ts b/mobile/src/tasks/github-project-repo-match.ts index bf70f71dab7..c4d1e1d2ea6 100644 --- a/mobile/src/tasks/github-project-repo-match.ts +++ b/mobile/src/tasks/github-project-repo-match.ts @@ -1,3 +1,8 @@ +import { + githubRepoIdentityKey, + isDefaultGitHubHost +} from '../../../src/shared/github-repository-identity-key' + export type GitHubProjectRepoMatch = { id: string path: string @@ -6,13 +11,13 @@ export type GitHubProjectRepoMatch = { export type GitHubRepoSlugCacheEntry = { path: string - slug: string | null + repository: { owner: string; repo: string; host?: string } | null } type CachedSlugState = | { status: 'missing' } | { status: 'stale' } - | { status: 'resolved'; slug: string | null } + | { status: 'resolved'; repository: GitHubRepoSlugCacheEntry['repository'] } export function normalizeGitHubRepositorySlug(value: string | null | undefined): string | null { const trimmed = value?.trim() @@ -37,13 +42,14 @@ function cachedSlugStateForRepo( if (cached.path !== repo.path) { return { status: 'stale' } } - return { status: 'resolved', slug: normalizeGitHubRepositorySlug(cached.slug) } + return { status: 'resolved', repository: cached.repository } } export function findRepoForGitHubProjectRepository( repository: string | null | undefined, repos: GitHubProjectRepoMatch[], - slugsByRepoId: Record = {} + slugsByRepoId: Record = {}, + projectHost?: string ): GitHubProjectRepoMatch | null { const slug = normalizeGitHubRepositorySlug(repository) if (!slug) { @@ -53,9 +59,18 @@ export function findRepoForGitHubProjectRepository( const slugStates = new Map( repos.map((repo) => [repo.id, cachedSlugStateForRepo(repo, slugsByRepoId)]) ) + const requestedIdentityKey = githubRepoIdentityKey({ + owner: slug.split('/')[0]!, + repo: slug.split('/')[1]!, + ...(projectHost ? { host: projectHost } : {}) + }) const slugMatches = repos.filter((repo) => { const state = slugStates.get(repo.id) - return state?.status === 'resolved' && state.slug === slug + return ( + state?.status === 'resolved' && + state.repository !== null && + githubRepoIdentityKey(state.repository) === requestedIdentityKey + ) }) if (slugMatches.length === 1) { return slugMatches[0]! @@ -64,10 +79,16 @@ export function findRepoForGitHubProjectRepository( return null } + if (!isDefaultGitHubHost(projectHost)) { + // Why: display names and local paths contain no host evidence, so using + // them for GHES rows could bind an Enterprise item to a github.com repo. + return null + } + return ( repos.find((repo) => { const state = slugStates.get(repo.id) - if (state?.status === 'resolved' && state.slug !== null) { + if (state?.status === 'resolved' && state.repository !== null) { return false } const display = repo.displayName.trim().toLowerCase() @@ -82,9 +103,12 @@ export function filterGitHubProjectRowsForRepos< >( rows: readonly Row[], repos: GitHubProjectRepoMatch[], - slugsByRepoId: Record = {} + slugsByRepoId: Record = {}, + projectHost?: string ): Row[] { return rows.filter((row) => - Boolean(findRepoForGitHubProjectRepository(row.content.repository, repos, slugsByRepoId)) + Boolean( + findRepoForGitHubProjectRepository(row.content.repository, repos, slugsByRepoId, projectHost) + ) ) } diff --git a/mobile/src/tasks/smart-source-paste-intent.test.ts b/mobile/src/tasks/smart-source-paste-intent.test.ts index f7c724e7637..4cd34cb0057 100644 --- a/mobile/src/tasks/smart-source-paste-intent.test.ts +++ b/mobile/src/tasks/smart-source-paste-intent.test.ts @@ -1,8 +1,9 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { deriveRepoSlug, findRepoMatchingSlug, findRepoMatchingSlugForPaste, + lookupGitHubItemByOwnerRepo, resolvePasteIntent, type PasteRepoCandidate } from './smart-source-paste-intent' @@ -12,7 +13,7 @@ describe('resolvePasteIntent', () => { it('classifies a GitHub issue/PR URL as a github-link', () => { expect(resolvePasteIntent('https://github.com/acme/widgets/pull/12')).toEqual({ kind: 'github-link', - link: { slug: { owner: 'acme', repo: 'widgets' }, number: 12, type: 'pr' } + link: { slug: { owner: 'acme', repo: 'widgets', host: 'github.com' }, number: 12, type: 'pr' } }) }) @@ -47,6 +48,14 @@ describe('deriveRepoSlug', () => { }) }) + it('preserves an Enterprise upstream host', () => { + expect( + deriveRepoSlug({ + upstream: { owner: 'up', repo: 'stream', host: 'github.corp.example' } + }) + ).toEqual({ owner: 'up', repo: 'stream', host: 'github.corp.example' }) + }) + it('parses an SSH remote URL', () => { expect( deriveRepoSlug({ gitRemoteIdentity: { remoteUrl: 'git@github.com:acme/widgets.git' } }) @@ -78,6 +87,16 @@ describe('findRepoMatchingSlug', () => { expect(findRepoMatchingSlug(repos, { owner: 'other', repo: 'thing' })).toBeNull() }) + it('does not match a GHES link to a same-named github.com repo', () => { + expect( + findRepoMatchingSlug(repos, { + owner: 'acme', + repo: 'widgets', + host: 'github.corp.example' + }) + ).toBeNull() + }) + it('falls back to the host-aware repo slug RPC for SSH and enterprise repos', async () => { const calls: string[] = [] const client = { @@ -86,7 +105,10 @@ describe('findRepoMatchingSlug', () => { calls.push(repo) return { ok: true, - result: repo === 'id:b' ? { owner: 'enterprise', repo: 'widgets' } : null + result: + repo === 'id:b' + ? { owner: 'enterprise', repo: 'widgets', host: 'github.corp.example' } + : null } } } as unknown as RpcClient @@ -94,7 +116,7 @@ describe('findRepoMatchingSlug', () => { findRepoMatchingSlugForPaste( client, repos, - { owner: 'enterprise', repo: 'widgets' }, + { owner: 'enterprise', repo: 'widgets', host: 'github.corp.example' }, new Map() ) ).resolves.toMatchObject({ id: 'b' }) @@ -139,4 +161,26 @@ describe('findRepoMatchingSlug', () => { ) ).resolves.toBeNull() }) + + it('sends the pasted host through the exact owner/repo lookup RPC', async () => { + const sendRequest = vi.fn().mockResolvedValue({ ok: true, result: null }) + const client = { sendRequest } as unknown as RpcClient + + await lookupGitHubItemByOwnerRepo( + client, + 'repo-1', + { owner: 'acme', repo: 'widgets', host: 'github.corp.example' }, + 7, + 'pr' + ) + + expect(sendRequest).toHaveBeenCalledWith('github.workItemByOwnerRepo', { + repo: 'id:repo-1', + owner: 'acme', + ownerRepo: 'widgets', + host: 'github.corp.example', + number: 7, + type: 'pr' + }) + }) }) diff --git a/mobile/src/tasks/smart-source-paste-intent.ts b/mobile/src/tasks/smart-source-paste-intent.ts index 72c23a3737f..00f4c0d9ee7 100644 --- a/mobile/src/tasks/smart-source-paste-intent.ts +++ b/mobile/src/tasks/smart-source-paste-intent.ts @@ -9,6 +9,7 @@ import { parseGitLabIssueOrMRLink } from '../../../src/shared/new-workspace/gitl import { isSmartWorkspaceSourceQueryWithinLimit } from '../../../src/shared/new-workspace/smart-workspace-source-results' import type { RpcClient } from '../transport/rpc-client' import type { RpcSuccess } from '../transport/types' +import { githubRepoIdentityKey } from '../../../src/shared/github-repository-identity-key' // A repo the picker can switch to for a cross-repo GitHub paste. Slug is derived // best-effort from the repo's remote metadata. @@ -58,11 +59,15 @@ export function resolvePasteIntent(query: string): PasteIntent { // Pure: derive an owner/repo slug from a repo's remote metadata so a pasted // cross-repo URL can be matched to a locally known repo. export function deriveRepoSlug(repo: { - upstream?: { owner: string; repo: string } | null + upstream?: { owner: string; repo: string; host?: string } | null gitRemoteIdentity?: { remoteUrl?: string; canonicalKey?: string } | null }): RepoSlug | null { if (repo.upstream?.owner && repo.upstream.repo) { - return { owner: repo.upstream.owner, repo: repo.upstream.repo } + return { + owner: repo.upstream.owner, + repo: repo.upstream.repo, + ...(repo.upstream.host ? { host: repo.upstream.host } : {}) + } } const source = repo.gitRemoteIdentity?.remoteUrl ?? repo.gitRemoteIdentity?.canonicalKey ?? '' const match = /(?:github\.com[/:]|^)([^/\s:]+)\/([^/\s]+?)(?:\.git)?$/i.exec(source) @@ -76,9 +81,7 @@ function slugsEqual(a: RepoSlug | null, b: RepoSlug | null): boolean { if (!a || !b) { return false } - return ( - a.owner.toLowerCase() === b.owner.toLowerCase() && a.repo.toLowerCase() === b.repo.toLowerCase() - ) + return githubRepoIdentityKey(a) === githubRepoIdentityKey(b) } export function findRepoMatchingSlug( @@ -148,6 +151,7 @@ export async function lookupGitHubItemByOwnerRepo( repo: `id:${repoId}`, owner: slug.owner, ownerRepo: slug.repo, + ...(slug.host ? { host: slug.host } : {}), number, type }) diff --git a/mobile/src/tasks/use-smart-workspace-source.ts b/mobile/src/tasks/use-smart-workspace-source.ts index 1d0c88dafd5..42edcd48e8a 100644 --- a/mobile/src/tasks/use-smart-workspace-source.ts +++ b/mobile/src/tasks/use-smart-workspace-source.ts @@ -22,7 +22,11 @@ const DEBOUNCE_MS = 200 const RESULT_LIMIT = 36 export type SmartCrossRepoPrompt = { - link: { slug: { owner: string; repo: string }; number: number; type: 'issue' | 'pr' } + link: { + slug: { owner: string; repo: string; host?: string } + number: number + type: 'issue' | 'pr' + } matchingRepo: PasteRepoCandidate } @@ -73,7 +77,9 @@ export function useSmartWorkspaceSource(args: UseSmartWorkspaceSourceArgs) { // the mode/repo changes so one provider's rows never render under another tab. const scopeRef = useRef('') const dismissedPasteRef = useRef('') - const repoSlugCacheRef = useRef>(new Map()) + const repoSlugCacheRef = useRef< + Map + >(new Map()) useEffect(() => { if (!client || !enabled || mode === 'text') { @@ -185,7 +191,7 @@ async function runSmartSearch(args: { linearWorkspaceId: string | null | undefined repos: readonly PasteRepoCandidate[] dismissedPasteRef: { current: string } - repoSlugCache: Map + repoSlugCache: Map }): Promise<{ fan: SmartFanOutResult paste: PasteResolved diff --git a/src/main/git/gh-rate-limit-breaker.test.ts b/src/main/git/gh-rate-limit-breaker.test.ts index 0975dd325ae..8fa8d667dc5 100644 --- a/src/main/git/gh-rate-limit-breaker.test.ts +++ b/src/main/git/gh-rate-limit-breaker.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { + _getGhRateLimitBlockCount, _resetGhRateLimitBreaker, classifyGhRateLimitBucket, createGhRateLimitBlockedError, @@ -7,6 +8,7 @@ import { isGhPrimaryRateLimitStderr, isGhRateLimitProbe, notifyGhPrimaryRateLimit, + parseGhRateLimitScopeKey, recordGhPrimaryRateLimit, registerGhRateLimitResetProbe } from './gh-rate-limit-breaker' @@ -41,6 +43,7 @@ describe('isGhRateLimitProbe', () => { it('recognizes the exempt rate_limit endpoint only', () => { expect(isGhRateLimitProbe(['api', 'rate_limit'])).toBe(true) expect(isGhRateLimitProbe(['api', '/rate_limit'])).toBe(true) + expect(isGhRateLimitProbe(['api', '--hostname', 'github.com', 'rate_limit'])).toBe(true) expect(isGhRateLimitProbe(['api', 'search/issues?q=x'])).toBe(false) expect(isGhRateLimitProbe(['pr', 'list'])).toBe(false) }) @@ -58,6 +61,36 @@ describe('isGhPrimaryRateLimitStderr', () => { }) }) +describe('parseGhRateLimitScopeKey', () => { + it('parses native and wsl scopes, keeping ported hosts intact', () => { + expect(parseGhRateLimitScopeKey('native:github.com')).toEqual({ + runtime: 'native', + host: 'github.com' + }) + expect(parseGhRateLimitScopeKey('native:ghe.corp:8443')).toEqual({ + runtime: 'native', + host: 'ghe.corp:8443' + }) + expect(parseGhRateLimitScopeKey('wsl:ubuntu:github.com')).toEqual({ + runtime: 'wsl', + wslDistro: 'ubuntu', + host: 'github.com' + }) + expect(parseGhRateLimitScopeKey('wsl:ubuntu:ghe.corp:8443')).toEqual({ + runtime: 'wsl', + wslDistro: 'ubuntu', + host: 'ghe.corp:8443' + }) + }) + + it('rejects malformed scopes', () => { + expect(parseGhRateLimitScopeKey('native:')).toBeNull() + expect(parseGhRateLimitScopeKey('wsl:ubuntu')).toBeNull() + expect(parseGhRateLimitScopeKey('wsl:ubuntu:')).toBeNull() + expect(parseGhRateLimitScopeKey('ssh:github.com')).toBeNull() + }) +}) + describe('breaker state', () => { it('blocks until the recorded time, then expires', () => { const now = 1_000_000 @@ -76,14 +109,68 @@ describe('breaker state', () => { expect(getGhRateLimitBlockedUntilMs('core', now)).toBe(now + 60_000) }) + it('isolates blocks by host and execution runtime scope', () => { + const now = 1_000_000 + recordGhPrimaryRateLimit('core', now + 60_000, 'native:github.com') + + expect(getGhRateLimitBlockedUntilMs('core', now, 'native:github.com')).toBe(now + 60_000) + expect(getGhRateLimitBlockedUntilMs('core', now, 'native:github.acme-corp.com')).toBeNull() + expect(getGhRateLimitBlockedUntilMs('core', now, 'wsl:ubuntu:github.com')).toBeNull() + }) + + it('bounds retained blocks across user-supplied Enterprise host scopes', () => { + const now = Date.now() + for (let i = 0; i < 1_200; i += 1) { + recordGhPrimaryRateLimit('core', now + 60_000, `native:ghe-${i}.example.test`) + } + + expect(_getGhRateLimitBlockCount()).toBe(1024) + expect(getGhRateLimitBlockedUntilMs('core', now, 'native:ghe-1199.example.test')).toBe( + now + 60_000 + ) + expect(getGhRateLimitBlockedUntilMs('core', now, 'native:ghe-0.example.test')).toBeNull() + }) + + it('keeps a recently-read active scope when the bounded map evicts', () => { + const now = Date.now() + const hotScope = 'native:active.example.test' + recordGhPrimaryRateLimit('core', now + 60_000, hotScope) + for (let i = 0; i < 1_023; i += 1) { + recordGhPrimaryRateLimit('core', now + 60_000, `native:cold-${i}.example.test`) + } + + expect(getGhRateLimitBlockedUntilMs('core', now, hotScope)).toBe(now + 60_000) + recordGhPrimaryRateLimit('core', now + 60_000, 'native:new.example.test') + + expect(getGhRateLimitBlockedUntilMs('core', now, hotScope)).toBe(now + 60_000) + expect(getGhRateLimitBlockedUntilMs('core', now, 'native:cold-0.example.test')).toBeNull() + }) + it('notifyGhPrimaryRateLimit applies a fallback block and fires the reset probe', () => { const probe = vi.fn() registerGhRateLimitResetProbe(probe) notifyGhPrimaryRateLimit('search') - expect(probe).toHaveBeenCalledWith('search') + expect(probe).toHaveBeenCalledWith('search', 'native:github.com') expect(getGhRateLimitBlockedUntilMs('search')).toBeGreaterThan(Date.now()) }) + it('fires the reset probe with the tripping scope for non-default scopes', () => { + // Why: a WSL-only Windows install (or GHES) would otherwise stay on the + // blunt 5-minute fallback forever — the probe must learn which scope to + // query. + const probe = vi.fn() + registerGhRateLimitResetProbe(probe) + + notifyGhPrimaryRateLimit('core', 'native:github.acme-corp.com') + notifyGhPrimaryRateLimit('core', 'wsl:ubuntu:github.com') + + expect(probe).toHaveBeenNthCalledWith(1, 'core', 'native:github.acme-corp.com') + expect(probe).toHaveBeenNthCalledWith(2, 'core', 'wsl:ubuntu:github.com') + expect( + getGhRateLimitBlockedUntilMs('core', Date.now(), 'native:github.acme-corp.com') + ).not.toBeNull() + }) + it('creates an error that classifies as rate_limited, not permission_denied', () => { const error = createGhRateLimitBlockedError('search', Date.now() + 30_000) expect(error.stderr.toLowerCase()).toContain('rate limit') diff --git a/src/main/git/gh-rate-limit-breaker.ts b/src/main/git/gh-rate-limit-breaker.ts index 94bef46ff7a..8bc4276490a 100644 --- a/src/main/git/gh-rate-limit-breaker.ts +++ b/src/main/git/gh-rate-limit-breaker.ts @@ -25,8 +25,45 @@ const FALLBACK_BLOCK_MS: Record = { graphql: 5 * 60_000 } -const blockedUntilMsByBucket = new Map() -let resetProbe: ((bucket: GhRateLimitBucket) => void) | null = null +// Why: the runner derives scopes with this builder too — a format drift would +// silently split blocks from the default-scope reads below. +export function ghRateLimitScopeKey(runtime: string, host: string): string { + return `${runtime}:${host.toLowerCase()}` +} + +export type GhRateLimitScopeParts = + | { runtime: 'native'; host: string } + | { runtime: 'wsl'; wslDistro: string; host: string } + +// Why: the reset probe must spawn gh against the tripping scope's runtime and +// host. Parsing lives next to the builder so the key format stays private to +// this module instead of being string-split at call sites. +export function parseGhRateLimitScopeKey(scope: string): GhRateLimitScopeParts | null { + if (scope.startsWith('native:')) { + const host = scope.slice('native:'.length) + return host ? { runtime: 'native', host } : null + } + if (scope.startsWith('wsl:')) { + // Hosts may carry a port (host:port), so only the first colon after the + // distro is a separator. + const rest = scope.slice('wsl:'.length) + const sep = rest.indexOf(':') + if (sep <= 0 || sep === rest.length - 1) { + return null + } + return { runtime: 'wsl', wslDistro: rest.slice(0, sep), host: rest.slice(sep + 1) } + } + return null +} + +const DEFAULT_SCOPE = ghRateLimitScopeKey('native', 'github.com') +const GH_RATE_LIMIT_BLOCK_MAX_ENTRIES = 1024 +const blockedUntilMsByScopeAndBucket = new Map() +let resetProbe: ((bucket: GhRateLimitBucket, scope: string) => void) | null = null + +function breakerKey(bucket: GhRateLimitBucket, scope = DEFAULT_SCOPE): string { + return `${scope}\0${bucket}` +} // gh api flags that take a separate value, so the endpoint arg can be found. const GH_API_VALUE_FLAGS = new Set([ @@ -103,43 +140,77 @@ export function isGhPrimaryRateLimitStderr(stderr: string): boolean { return s.includes('api rate limit exceeded') && !s.includes('secondary rate limit') } -export function recordGhPrimaryRateLimit(bucket: GhRateLimitBucket, blockedUntilMs: number): void { - const existing = blockedUntilMsByBucket.get(bucket) ?? 0 - blockedUntilMsByBucket.set(bucket, Math.max(existing, blockedUntilMs)) +export function recordGhPrimaryRateLimit( + bucket: GhRateLimitBucket, + blockedUntilMs: number, + scope = DEFAULT_SCOPE +): void { + const key = breakerKey(bucket, scope) + const existing = blockedUntilMsByScopeAndBucket.get(key) ?? 0 + // Why: host scopes can originate from pasted GHES URLs. Refresh insertion + // order and cap retained blocks so a stream of failing hosts cannot grow the + // main-process map for the lifetime of the app. + blockedUntilMsByScopeAndBucket.delete(key) + blockedUntilMsByScopeAndBucket.set(key, Math.max(existing, blockedUntilMs)) + while (blockedUntilMsByScopeAndBucket.size > GH_RATE_LIMIT_BLOCK_MAX_ENTRIES) { + const oldestKey = blockedUntilMsByScopeAndBucket.keys().next().value + if (oldestKey === undefined) { + break + } + blockedUntilMsByScopeAndBucket.delete(oldestKey) + } } -export function clearGhRateLimitBlock(bucket: GhRateLimitBucket): void { - blockedUntilMsByBucket.delete(bucket) +export function clearGhRateLimitBlock(bucket: GhRateLimitBucket, scope = DEFAULT_SCOPE): void { + blockedUntilMsByScopeAndBucket.delete(breakerKey(bucket, scope)) } export function getGhRateLimitBlockedUntilMs( bucket: GhRateLimitBucket, - nowMs: number = Date.now() + nowMs: number = Date.now(), + scope = DEFAULT_SCOPE ): number | null { - const blockedUntil = blockedUntilMsByBucket.get(bucket) + for (const [key, blockedUntil] of blockedUntilMsByScopeAndBucket) { + if (blockedUntil <= nowMs) { + blockedUntilMsByScopeAndBucket.delete(key) + } + } + const key = breakerKey(bucket, scope) + const blockedUntil = blockedUntilMsByScopeAndBucket.get(key) if (blockedUntil === undefined) { return null } if (blockedUntil <= nowMs) { - blockedUntilMsByBucket.delete(bucket) + blockedUntilMsByScopeAndBucket.delete(key) return null } + // Why: active blocks are hot entries; refresh their insertion order so a + // stream of one-off hostile hosts evicts cold scopes before an active one. + blockedUntilMsByScopeAndBucket.delete(key) + blockedUntilMsByScopeAndBucket.set(key, blockedUntil) return blockedUntil } +/** @internal — cache-bound tests only. */ +export function _getGhRateLimitBlockCount(): number { + return blockedUntilMsByScopeAndBucket.size +} + /** Register the (single) callback that refreshes precise reset times after a * breaker trip. Registered by the github rate-limit prober at module load. */ export function registerGhRateLimitResetProbe( - probe: ((bucket: GhRateLimitBucket) => void) | null + probe: ((bucket: GhRateLimitBucket, scope: string) => void) | null ): void { resetProbe = probe } /** Called by the runner when a gh spawn came back with a primary 403. */ -export function notifyGhPrimaryRateLimit(bucket: GhRateLimitBucket): void { - recordGhPrimaryRateLimit(bucket, Date.now() + FALLBACK_BLOCK_MS[bucket]) +export function notifyGhPrimaryRateLimit(bucket: GhRateLimitBucket, scope = DEFAULT_SCOPE): void { + recordGhPrimaryRateLimit(bucket, Date.now() + FALLBACK_BLOCK_MS[bucket], scope) + // The probe receives the tripping scope so it can run gh against that + // runtime/host and refine the fallback block into the real reset time. try { - resetProbe?.(bucket) + resetProbe?.(bucket, scope) } catch { // best-effort refinement; the fallback block stands } @@ -161,6 +232,6 @@ export function createGhRateLimitBlockedError( /** @internal — test-only */ export function _resetGhRateLimitBreaker(): void { - blockedUntilMsByBucket.clear() + blockedUntilMsByScopeAndBucket.clear() resetProbe = null } diff --git a/src/main/git/runner-gh-host-args.test.ts b/src/main/git/runner-gh-host-args.test.ts new file mode 100644 index 00000000000..0bee44eac74 --- /dev/null +++ b/src/main/git/runner-gh-host-args.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from 'vitest' + +import { applyGhHostToArgs } from './runner' + +describe('applyGhHostToArgs', () => { + it('returns args unchanged when no host is given', () => { + const args = ['api', 'repos/a/b/pulls'] + expect(applyGhHostToArgs(args)).toBe(args) + expect(applyGhHostToArgs(['pr', 'list', '--repo', 'a/b'])).toEqual([ + 'pr', + 'list', + '--repo', + 'a/b' + ]) + }) + + it('injects --hostname right after `api` for any host', () => { + expect(applyGhHostToArgs(['api', 'rate_limit'], 'github.com')).toEqual([ + 'api', + '--hostname', + 'github.com', + 'rate_limit' + ]) + expect(applyGhHostToArgs(['api', 'repos/a/b/pulls'], 'github.acme-corp.com')).toEqual([ + 'api', + '--hostname', + 'github.acme-corp.com', + 'repos/a/b/pulls' + ]) + }) + + it('does not double-inject when --hostname is already present', () => { + expect( + applyGhHostToArgs(['api', '--hostname', 'github.com', 'rate_limit'], 'github.acme-corp.com') + ).toEqual(['api', '--hostname', 'github.com', 'rate_limit']) + expect( + applyGhHostToArgs(['api', '--hostname=github.com', 'rate_limit'], 'github.acme-corp.com') + ).toEqual(['api', '--hostname=github.com', 'rate_limit']) + }) + + it('qualifies bare owner/repo in --repo / -R for a GHES host', () => { + expect(applyGhHostToArgs(['pr', 'list', '--repo', 'a/b'], 'github.acme-corp.com')).toEqual([ + 'pr', + 'list', + '--repo', + 'github.acme-corp.com/a/b' + ]) + expect(applyGhHostToArgs(['pr', 'list', '-R', 'a/b'], 'github.acme-corp.com')).toEqual([ + 'pr', + 'list', + '-R', + 'github.acme-corp.com/a/b' + ]) + }) + + it('qualifies the inline --repo= form for a GHES host', () => { + expect(applyGhHostToArgs(['pr', 'list', '--repo=a/b'], 'github.acme-corp.com')).toEqual([ + 'pr', + 'list', + '--repo=github.acme-corp.com/a/b' + ]) + }) + + it('leaves combined -R short forms untouched (indistinguishable from free-text values)', () => { + expect(applyGhHostToArgs(['pr', 'list', '-R=a/b'], 'github.acme-corp.com')).toEqual([ + 'pr', + 'list', + '-R=a/b' + ]) + expect(applyGhHostToArgs(['pr', 'list', '-Ra/b'], 'github.acme-corp.com')).toEqual([ + 'pr', + 'list', + '-Ra/b' + ]) + }) + + it('does not rewrite free-text flag values that merely start with -R', () => { + expect( + applyGhHostToArgs( + ['pr', 'edit', '7', '--title', '-Refactor foo/bar', '--repo', 'a/b'], + 'github.acme-corp.com' + ) + ).toEqual([ + 'pr', + 'edit', + '7', + '--title', + '-Refactor foo/bar', + '--repo', + 'github.acme-corp.com/a/b' + ]) + }) + + it('qualifies --repo for the github.com host so GH_HOST cannot redirect it', () => { + expect(applyGhHostToArgs(['pr', 'list', '--repo', 'a/b'], 'github.com')).toEqual([ + 'pr', + 'list', + '--repo', + 'github.com/a/b' + ]) + expect(applyGhHostToArgs(['pr', 'list', '--repo', 'a/b'], 'GitHub.com')).toEqual([ + 'pr', + 'list', + '--repo', + 'GitHub.com/a/b' + ]) + }) + + it('passes through URL and already-qualified 3-part --repo values', () => { + expect( + applyGhHostToArgs( + ['pr', 'list', '--repo', 'https://github.acme-corp.com/a/b'], + 'github.acme-corp.com' + ) + ).toEqual(['pr', 'list', '--repo', 'https://github.acme-corp.com/a/b']) + expect( + applyGhHostToArgs( + ['pr', 'list', '--repo', 'github.acme-corp.com/a/b'], + 'github.acme-corp.com' + ) + ).toEqual(['pr', 'list', '--repo', 'github.acme-corp.com/a/b']) + expect( + applyGhHostToArgs( + ['pr', 'list', '-R', 'https://github.acme-corp.com/a/b'], + 'github.acme-corp.com' + ) + ).toEqual(['pr', 'list', '-R', 'https://github.acme-corp.com/a/b']) + }) + + it('injects --hostname and qualifies --repo together for GHES api calls', () => { + expect( + applyGhHostToArgs(['api', 'repos/a/b/pulls', '--repo', 'a/b'], 'github.acme-corp.com') + ).toEqual([ + 'api', + '--hostname', + 'github.acme-corp.com', + 'repos/a/b/pulls', + '--repo', + 'github.acme-corp.com/a/b' + ]) + }) +}) diff --git a/src/main/git/runner-gh-rate-limit-breaker.test.ts b/src/main/git/runner-gh-rate-limit-breaker.test.ts index dc22bb54f4e..17e14b8e7d6 100644 --- a/src/main/git/runner-gh-rate-limit-breaker.test.ts +++ b/src/main/git/runner-gh-rate-limit-breaker.test.ts @@ -13,7 +13,11 @@ vi.mock('child_process', () => ({ })) import { ghExecFileAsync } from './runner' -import { _resetGhRateLimitBreaker } from './gh-rate-limit-breaker' +import { + _resetGhRateLimitBreaker, + getGhRateLimitBlockedUntilMs, + registerGhRateLimitResetProbe +} from './gh-rate-limit-breaker' const PRIMARY_RATE_LIMIT_STDERR = 'gh: API rate limit exceeded for user ID 1775218. Please wait. (HTTP 403)' @@ -71,11 +75,124 @@ describe('ghExecFileAsync rate-limit breaker', () => { expect(execFileMock).toHaveBeenCalledTimes(2) }) + it('keeps other GitHub hosts and WSL runtimes working when github.com is blocked', async () => { + mockGhFailure(PRIMARY_RATE_LIMIT_STDERR) + await expect(ghExecFileAsync(['api', 'repos/a/b/pulls'])).rejects.toThrow() + + // The breaker scope now comes from options.host / GH_HOST / wslDistro, not + // from sniffing argv — so an Enterprise host must be routed via options.host. + mockGhSuccess('[]') + await expect( + ghExecFileAsync(['api', 'repos/a/b/pulls'], { host: 'github.acme-corp.com' }) + ).resolves.toMatchObject({ stdout: '[]' }) + await expect( + ghExecFileAsync(['pr', 'list', '--repo', 'a/b'], { host: 'github.acme-corp.com' }) + ).resolves.toMatchObject({ stdout: '[]' }) + await expect( + ghExecFileAsync(['api', 'repos/a/b/pulls'], { + env: { ...process.env, GH_HOST: 'github.acme-corp.com' } + }) + ).resolves.toMatchObject({ stdout: '[]' }) + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + try { + await expect( + ghExecFileAsync(['api', 'repos/a/b/pulls'], { wslDistro: 'Ubuntu' }) + ).resolves.toMatchObject({ stdout: '[]' }) + } finally { + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + } + expect(execFileMock).toHaveBeenCalledTimes(5) + }) + + it.each([ + ['separate', ['--hostname', 'github.com']], + ['inline', ['--hostname=github.com']] + ])('scopes an explicit %s hostname ahead of process GH_HOST', async (_name, hostnameArgs) => { + const originalGhHost = process.env.GH_HOST + process.env.GH_HOST = 'github.acme-corp.com' + try { + mockGhFailure(PRIMARY_RATE_LIMIT_STDERR) + await expect(ghExecFileAsync(['api', ...hostnameArgs, 'repos/a/b/pulls'])).rejects.toThrow( + 'rate limit' + ) + + expect(getGhRateLimitBlockedUntilMs('core', Date.now(), 'native:github.com')).not.toBeNull() + expect( + getGhRateLimitBlockedUntilMs('core', Date.now(), 'native:github.acme-corp.com') + ).toBeNull() + + mockGhSuccess('[]') + await expect(ghExecFileAsync(['api', 'repos/a/b/pulls'])).resolves.toMatchObject({ + stdout: '[]' + }) + } finally { + if (originalGhHost === undefined) { + delete process.env.GH_HOST + } else { + process.env.GH_HOST = originalGhHost + } + } + }) + + it.each([ + ['--repo', ['pr', 'list', '--repo', 'github.acme-corp.com/acme/widgets']], + ['-R', ['pr', 'list', '-R', 'github.acme-corp.com/acme/widgets']], + ['--repo=', ['pr', 'list', '--repo=github.acme-corp.com/acme/widgets']] + ])('scopes a pre-qualified %s value to its repository host', async (_name, args) => { + mockGhFailure(PRIMARY_RATE_LIMIT_STDERR) + + await expect( + ghExecFileAsync(args, { env: { ...process.env, GH_HOST: 'github.com' } }) + ).rejects.toThrow('rate limit') + + expect( + getGhRateLimitBlockedUntilMs('core', Date.now(), 'native:github.acme-corp.com') + ).not.toBeNull() + expect(getGhRateLimitBlockedUntilMs('core', Date.now(), 'native:github.com')).toBeNull() + }) + + it('scopes a WSL UNC-cwd 403 to the distro runtime and probes that scope', async () => { + // Why: a \\wsl.localhost\... cwd routes gh through wsl.exe, so the 403 + // belongs to the distro's account — the native scope must stay usable and + // the reset probe must be told which scope tripped. + const probe = vi.fn() + registerGhRateLimitResetProbe(probe) + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + try { + mockGhFailure(PRIMARY_RATE_LIMIT_STDERR) + await expect( + ghExecFileAsync(['api', 'repos/a/b/pulls'], { + cwd: '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo' + }) + ).rejects.toThrow() + + expect(probe).toHaveBeenCalledWith('core', 'wsl:ubuntu:github.com') + expect( + getGhRateLimitBlockedUntilMs('core', Date.now(), 'wsl:ubuntu:github.com') + ).not.toBeNull() + expect(getGhRateLimitBlockedUntilMs('core', Date.now())).toBeNull() + + mockGhSuccess('[]') + await expect(ghExecFileAsync(['api', 'repos/a/b/pulls'])).resolves.toMatchObject({ + stdout: '[]' + }) + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + it('never blocks the exempt rate_limit probe', async () => { mockGhFailure(PRIMARY_RATE_LIMIT_STDERR) await expect(ghExecFileAsync(['api', 'repos/a/b/pulls'])).rejects.toThrow() mockGhSuccess('{"resources":{}}') - await expect(ghExecFileAsync(['api', 'rate_limit'])).resolves.toMatchObject({ + await expect( + ghExecFileAsync(['api', '--hostname', 'github.com', 'rate_limit']) + ).resolves.toMatchObject({ stdout: '{"resources":{}}' }) expect(execFileMock).toHaveBeenCalledTimes(2) diff --git a/src/main/git/runner-wsl-gh-fallback.test.ts b/src/main/git/runner-wsl-gh-fallback.test.ts index 8b802c2f0d6..4cd81cda230 100644 --- a/src/main/git/runner-wsl-gh-fallback.test.ts +++ b/src/main/git/runner-wsl-gh-fallback.test.ts @@ -21,6 +21,10 @@ vi.mock('../wsl', async (importOriginal) => ({ })) import { ghExecFileAsync, glabExecFileAsync } from './runner' +import { _resetGhRateLimitBreaker } from './gh-rate-limit-breaker' + +const PRIMARY_RATE_LIMIT_STDERR = + 'gh: API rate limit exceeded for user ID 1775218. Please wait. (HTTP 403)' type MockChildProcess = EventEmitter & { pid: number @@ -44,6 +48,7 @@ describe('ghExecFileAsync WSL fallback', () => { spawnMock.mockReset() getDefaultWslDistroMock.mockReset() getDefaultWslDistroMock.mockReturnValue(null) + _resetGhRateLimitBreaker() Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' @@ -52,6 +57,7 @@ describe('ghExecFileAsync WSL fallback', () => { afterEach(() => { vi.useRealTimers() + _resetGhRateLimitBreaker() Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform @@ -187,6 +193,42 @@ describe('ghExecFileAsync WSL fallback', () => { ) }) + it('falls back for repo view with an explicit positional repository', async () => { + execFileMock.mockImplementation((binary, _args, options, callback) => { + if (typeof options === 'function') { + callback = options + } + if (binary === 'wsl.exe') { + callback( + Object.assign(new Error('Command failed: wsl.exe'), { + stdout: '', + stderr: 'bash: line 1: gh: command not found\n' + }) + ) + return + } + callback(null, { stdout: '{"isFork":false}', stderr: '' }) + }) + + await expect( + ghExecFileAsync( + ['repo', 'view', 'github.acme-corp.com/stablyhq/noqa', '--json', 'isFork,parent'], + { + cwd: String.raw`\\wsl.localhost\Ubuntu\home\jinwoo\stably\noqa`, + host: 'github.acme-corp.com' + } + ) + ).resolves.toEqual({ stdout: '{"isFork":false}', stderr: '' }) + + expect(execFileMock).toHaveBeenNthCalledWith( + 2, + 'gh', + ['repo', 'view', 'github.acme-corp.com/stablyhq/noqa', '--json', 'isFork,parent'], + expect.objectContaining({ cwd: undefined }), + expect.any(Function) + ) + }) + it('does not fall back for gh api calls that depend on repo-context placeholders', async () => { execFileMock.mockImplementation((_binary, _args, _options, callback) => { callback( @@ -227,6 +269,43 @@ describe('ghExecFileAsync WSL fallback', () => { expect(execFileMock).toHaveBeenCalledTimes(2) }) + it('retries a host-pinned idempotent gh GraphQL query after host injection', async () => { + execFileMock + .mockImplementationOnce((_binary, _args, _options, callback) => { + callback( + Object.assign(new Error('HTTP 502 Bad Gateway'), { + stdout: '', + stderr: 'HTTP 502 Bad Gateway' + }) + ) + }) + .mockImplementationOnce((_binary, _args, _options, callback) => { + callback(null, { stdout: '{"data":{}}', stderr: '' }) + }) + + await expect( + ghExecFileAsync(['api', 'graphql', '-f', 'query=query { viewer { login } }'], { + host: 'github.acme-corp.com' + }) + ).resolves.toEqual({ stdout: '{"data":{}}', stderr: '' }) + + expect(execFileMock).toHaveBeenCalledTimes(2) + expect(execFileMock).toHaveBeenNthCalledWith( + 1, + 'gh', + [ + 'api', + '--hostname', + 'github.acme-corp.com', + 'graphql', + '-f', + 'query=query { viewer { login } }' + ], + expect.any(Object), + expect.any(Function) + ) + }) + it('does not retry non-idempotent gh API transient failures', async () => { execFileMock.mockImplementation((_binary, _args, _options, callback) => { callback( @@ -307,6 +386,63 @@ describe('ghExecFileAsync WSL fallback', () => { ) }) + it('checks a blocked WSL scope before repeating a native-to-WSL fallback', async () => { + getDefaultWslDistroMock.mockReturnValue('Ubuntu') + execFileMock.mockImplementation((binary, _args, _options, callback) => { + if (binary === 'gh') { + callback(Object.assign(new Error('spawn gh ENOENT'), { code: 'ENOENT', stderr: '' })) + return + } + callback( + Object.assign(new Error(PRIMARY_RATE_LIMIT_STDERR), { + stdout: '', + stderr: PRIMARY_RATE_LIMIT_STDERR + }) + ) + }) + + await expect(ghExecFileAsync(['api', 'repos/acme/widgets/pulls'])).rejects.toThrow('rate limit') + await expect(ghExecFileAsync(['api', 'repos/acme/widgets/pulls'])).rejects.toMatchObject({ + ghRateLimitBlocked: true + }) + + expect(execFileMock).toHaveBeenCalledTimes(3) + expect(execFileMock.mock.calls.map(([binary]) => binary)).toEqual(['gh', 'wsl.exe', 'gh']) + }) + + it('checks a blocked native scope before repeating a WSL-to-native fallback', async () => { + execFileMock.mockImplementation((binary, _args, _options, callback) => { + if (binary === 'wsl.exe') { + callback( + Object.assign(new Error('Command failed: wsl.exe'), { + stdout: '', + stderr: 'bash: line 1: gh: command not found\n' + }) + ) + return + } + callback( + Object.assign(new Error(PRIMARY_RATE_LIMIT_STDERR), { + stdout: '', + stderr: PRIMARY_RATE_LIMIT_STDERR + }) + ) + }) + + const options = { + cwd: String.raw`\\wsl.localhost\Ubuntu\home\jinwoo\stably\noqa` + } + await expect(ghExecFileAsync(['api', 'repos/acme/widgets/pulls'], options)).rejects.toThrow( + 'rate limit' + ) + await expect( + ghExecFileAsync(['api', 'repos/acme/widgets/pulls'], options) + ).rejects.toMatchObject({ ghRateLimitBlocked: true }) + + expect(execFileMock).toHaveBeenCalledTimes(3) + expect(execFileMock.mock.calls.map(([binary]) => binary)).toEqual(['wsl.exe', 'gh', 'wsl.exe']) + }) + it('does not retry non-idempotent glab transient failures', async () => { execFileMock.mockImplementation((_binary, _args, _options, callback) => { callback( diff --git a/src/main/git/runner.ts b/src/main/git/runner.ts index a4b51ff0624..ae4e93174e1 100644 --- a/src/main/git/runner.ts +++ b/src/main/git/runner.ts @@ -21,9 +21,11 @@ import { classifyGhRateLimitBucket, createGhRateLimitBlockedError, getGhRateLimitBlockedUntilMs, + ghRateLimitScopeKey, isGhPrimaryRateLimitStderr, isGhRateLimitProbe, - notifyGhPrimaryRateLimit + notifyGhPrimaryRateLimit, + type GhRateLimitBucket } from './gh-rate-limit-breaker' import { getDefaultWslDistro, parseWslPath, toWindowsWslPath, type WslPathInfo } from '../wsl' import { addWslEnvKeys } from '../wsl-env' @@ -111,6 +113,17 @@ function argsUseGhApiPlaceholders(args: string[]): boolean { ) } +function hasExplicitRepoViewTarget(args: string[]): boolean { + const target = args[2] + return ( + args[0] === 'repo' && + args[1] === 'view' && + typeof target === 'string' && + !target.startsWith('-') && + target.includes('/') + ) +} + function canRunGitHubCliWithoutRepoCwd(args: string[]): boolean { if (hasExplicitRepoArg(args)) { return true @@ -118,7 +131,7 @@ function canRunGitHubCliWithoutRepoCwd(args: string[]): boolean { if (args[0] === 'api') { return !argsUseGhApiPlaceholders(args) } - return args[0] === 'auth' + return args[0] === 'auth' || hasExplicitRepoViewTarget(args) } function isMissingCommandInWsl(stderr: string, command: string): boolean { @@ -1120,6 +1133,11 @@ type GhExecOptions = Omit & { cwd?: string wslDistro?: string idempotent?: boolean + // Why: `gh api` and `--repo OWNER/REPO` shorthand resolve against gh's + // default host, not the repo's remote. Carrying the host here lets the + // runner qualify every spawn once, so call sites can't silently fall back + // to github.com for GHES repos; it also scopes the rate-limit breaker. + host?: string } const NON_IDEMPOTENT_METHODS = new Set(['POST', 'PATCH', 'PUT', 'DELETE']) @@ -1257,6 +1275,128 @@ function nonInteractiveGhEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.Proce } } +function hasGhHostnameFlag(args: readonly string[]): boolean { + return args.some((arg) => arg === '--hostname' || arg.startsWith('--hostname=')) +} + +function hostQualifiedGhRepoValue(value: string, host: string): string { + // URLs and already-qualified HOST/OWNER/REPO values pass through untouched. + if (value.includes('://') || value.split('/').length !== 2) { + return value + } + return `${host}/${value}` +} + +/** + * Host-qualify a gh invocation from `options.host`: `--hostname` for `api` + * calls, `HOST/OWNER/REPO` for `--repo`/`-R` shorthand. SSH-backed repos run + * gh with no cwd, so this is their only host signal (#8312). + * + * @internal exported for tests. + */ +export function applyGhHostToArgs(args: string[], host?: string): string[] { + if (!host) { + return args + } + let result = args + if (result[0] === 'api' && !hasGhHostnameFlag(result)) { + result = ['api', '--hostname', host, ...result.slice(1)] + } + // Why: bare OWNER/REPO shorthand resolves against gh's default host — GH_HOST + // when set — so github.com must be qualified too, not just GHES, or a + // process-level GH_HOST redirects pinned github.com commands. + // Combined short forms (`-Ra/b`, `-R=a/b`) are deliberately not rewritten: + // no call site uses them, and prefix-matching `-R` corrupts free-text values + // of other flags (e.g. a --title that happens to start with `-R`). + const qualified: string[] = [] + for (let i = 0; i < result.length; i += 1) { + const arg = result[i] + if (arg === '--repo' || arg === '-R') { + qualified.push(arg) + const value = result[i + 1] + if (value !== undefined) { + qualified.push(hostQualifiedGhRepoValue(value, host)) + i += 1 + } + continue + } + if (arg.startsWith('--repo=')) { + qualified.push(`--repo=${hostQualifiedGhRepoValue(arg.slice('--repo='.length), host)}`) + continue + } + qualified.push(arg) + } + return qualified +} + +function explicitGhHostname(args: readonly string[]): string | undefined { + for (let i = 0; i < args.length; i += 1) { + if (args[i] === '--hostname') { + const value = args[i + 1]?.trim() + return value || undefined + } + if (args[i].startsWith('--hostname=')) { + const value = args[i].slice('--hostname='.length).trim() + return value || undefined + } + } + return undefined +} + +function explicitGhRepoHostname(args: readonly string[]): string | undefined { + for (let i = 0; i < args.length; i += 1) { + let value: string | undefined + if (args[i] === '--repo' || args[i] === '-R') { + value = args[i + 1] + } else if (args[i].startsWith('--repo=')) { + value = args[i].slice('--repo='.length) + } + const parts = value?.trim().split('/') + if (parts?.length === 3 && parts.every(Boolean)) { + return parts[0] + } + } + return undefined +} + +function ghRateLimitScope( + args: readonly string[], + options: GhExecOptions, + resolved: ResolvedCommand +): string { + const runtime = resolved.wsl ? `wsl:${resolved.wsl.distro.toLowerCase()}` : 'native' + // Why: an explicit argv hostname controls the actual gh request even when + // GH_HOST or options.host disagree, so breaker state must follow that host. + const host = + explicitGhHostname(args) ?? + options.host ?? + explicitGhRepoHostname(args) ?? + options.env?.GH_HOST ?? + process.env.GH_HOST ?? + 'github.com' + return ghRateLimitScopeKey(runtime, host) +} + +function assertGhRateLimitScopeAvailable( + args: readonly string[], + options: GhExecOptions, + resolved: ResolvedCommand, + bucket: GhRateLimitBucket, + exemptProbe: boolean +): void { + if (exemptProbe) { + return + } + const blockedUntilMs = getGhRateLimitBlockedUntilMs( + bucket, + Date.now(), + ghRateLimitScope(args, options, resolved) + ) + if (blockedUntilMs !== null) { + throw createGhRateLimitBlockedError(bucket, blockedUntilMs) + } +} + /** * Async gh CLI execution. Drop-in replacement for * `execFileAsync('gh', args, { cwd, encoding, ... })`. @@ -1268,15 +1408,15 @@ export async function ghExecFileAsync( args: string[], options: GhExecOptions = {} ): Promise<{ stdout: string; stderr: string }> { - // 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) - if (blockedUntilMs !== null) { - throw createGhRateLimitBlockedError(rateLimitBucket, blockedUntilMs) - } - } + // Why: retry safety must reflect the original call even when fallbacks replace the resolved command. + const idempotent = options.idempotent ?? argsLookIdempotent(args) + args = applyGhHostToArgs(args, options.host) let resolved = resolveCommand('gh', args, options.cwd, options.wslDistro) + // Why: while a bucket is rate-limited every spawn returns 403 — fail fast; the probe is exempt so the breaker can learn the reset. + // Why: scope by runtime and host so unrelated github.com, GHES, and WSL quotas cannot block each other. + const rateLimitBucket = classifyGhRateLimitBucket(args) + const rateLimitProbe = isGhRateLimitProbe(args) + assertGhRateLimitScopeAvailable(args, options, resolved, rateLimitBucket, rateLimitProbe) let lastError: unknown let attemptedHostFallback = false let attemptedDefaultWslFallback = false @@ -1295,7 +1435,7 @@ export async function ghExecFileAsync( lastError = err const { stderr } = extractExecError(err) if (isGhPrimaryRateLimitStderr(stderr)) { - notifyGhPrimaryRateLimit(rateLimitBucket) + notifyGhPrimaryRateLimit(rateLimitBucket, ghRateLimitScope(args, options, resolved)) } if ( process.platform === 'win32' && @@ -1310,6 +1450,7 @@ export async function ghExecFileAsync( // 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 + assertGhRateLimitScopeAvailable(args, options, resolved, rateLimitBucket, rateLimitProbe) attempt = -1 continue } @@ -1317,12 +1458,11 @@ export async function ghExecFileAsync( if (!attemptedHostFallback && canFallBackToHostGitHubCli('gh', args, resolved, stderr)) { resolved = resolveHostGitHubCli('gh', args) attemptedHostFallback = true + assertGhRateLimitScopeAvailable(args, options, resolved, rateLimitBucket, rateLimitProbe) attempt = -1 continue } const isLastAttempt = attempt >= GH_RETRY_DELAYS_MS.length - // 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: 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) diff --git a/src/main/github/auth-diagnose.test.ts b/src/main/github/auth-diagnose.test.ts index 35e3775b478..7735910dc83 100644 --- a/src/main/github/auth-diagnose.test.ts +++ b/src/main/github/auth-diagnose.test.ts @@ -1,5 +1,11 @@ -import { describe, expect, it } from 'vitest' -import { parseAuthStatus } from './auth-diagnose' +import { describe, expect, it, vi } from 'vitest' + +const { ghExecFileAsyncMock } = vi.hoisted(() => ({ ghExecFileAsyncMock: vi.fn() })) + +vi.mock('../git/runner', () => ({ + ghExecFileAsync: ghExecFileAsyncMock +})) +import { diagnoseGhAuth, parseAuthStatus } from './auth-diagnose' describe('parseAuthStatus', () => { it('parses an env-shadowed login alongside a keyring login (real gh output)', () => { @@ -82,6 +88,18 @@ ghe.acme.io expect(accounts[1].scopes).toContain('project') }) + it('parses a ported GHES auth-status section', () => { + const text = `ghe.acme.io:8443 + ✓ Logged in to ghe.acme.io:8443 account bob (keyring) + - Active account: true + - Token scopes: 'project', 'repo' +` + + expect(parseAuthStatus(text)).toEqual([ + expect.objectContaining({ host: 'ghe.acme.io:8443', user: 'bob', active: true }) + ]) + }) + it('recovers host from the Logged-in line when the section header is missing', () => { // gh prints a colon after the host on some versions; we tolerate it, // but if the regex ever fails to match the header we still want @@ -95,3 +113,48 @@ ghe.acme.io expect(accounts[0].host).toBe('github.acme.io') }) }) + +describe('diagnoseGhAuth host scoping', () => { + const TWO_HOST_STATUS = `github.com + ✓ Logged in to github.com account alice (keyring) + - Active account: true + - Token scopes: 'project', 'read:org', 'repo' +ghe.acme.io + ✓ Logged in to ghe.acme.io account bob (keyring) + - Active account: true + - Token scopes: 'repo' +` + + it('reports an unauthenticated required host and keeps scope advice host-scoped', async () => { + ghExecFileAsyncMock.mockResolvedValue({ stdout: TWO_HOST_STATUS, stderr: '' }) + + const diag = await diagnoseGhAuth('github.other-corp.com') + + expect(diag.requiredHost).toBe('github.other-corp.com') + expect(diag.requiredHostAuthenticated).toBe(false) + expect(diag.activeAccount).toBeNull() + }) + + it("scopes the diagnosis to the required host's account", async () => { + ghExecFileAsyncMock.mockResolvedValue({ stdout: TWO_HOST_STATUS, stderr: '' }) + + const diag = await diagnoseGhAuth('GHE.Acme.IO') + + expect(diag.requiredHost).toBe('ghe.acme.io') + expect(diag.requiredHostAuthenticated).toBe(true) + expect(diag.activeAccount?.user).toBe('bob') + // Why: bob's GHES token lacks project/read:org even though alice's + // github.com token has them — advice must reflect the required host. + expect(diag.missingScopes).toEqual(['project', 'read:org']) + }) + + it('keeps legacy host-less behavior byte-identical', async () => { + ghExecFileAsyncMock.mockResolvedValue({ stdout: TWO_HOST_STATUS, stderr: '' }) + + const diag = await diagnoseGhAuth() + + expect(diag.requiredHost).toBeNull() + expect(diag.requiredHostAuthenticated).toBeNull() + expect(diag.activeAccount?.user).toBe('alice') + }) +}) diff --git a/src/main/github/auth-diagnose.ts b/src/main/github/auth-diagnose.ts index aeaf4b9e284..8051af7f8b4 100644 --- a/src/main/github/auth-diagnose.ts +++ b/src/main/github/auth-diagnose.ts @@ -39,7 +39,7 @@ export function parseAuthStatus(text: string): GhAuthAccount[] { // (internal GHES like `github` or `ghe-internal`); we also recover the // host from the `Logged in to ` line below if this header was // missed, so a parser miss never silently drops every account. - const hostMatch = line.match(/^([a-z0-9][a-z0-9.-]*)\s*:?\s*$/i) + const hostMatch = line.match(/^([a-z0-9][a-z0-9.-]*(?::\d+)?)\s*:?\s*$/i) if (hostMatch && !/^logged\b/i.test(line)) { currentHost = hostMatch[1] continue @@ -90,7 +90,7 @@ export function parseAuthStatus(text: string): GhAuthAccount[] { return accounts } -export async function diagnoseGhAuth(): Promise { +export async function diagnoseGhAuth(requiredHost?: string): Promise { let raw = '' let ghAvailable = true try { @@ -118,7 +118,16 @@ export async function diagnoseGhAuth(): Promise { } } const accounts = parseAuthStatus(raw) - const active = accounts.find((a) => a.active) ?? accounts[0] ?? null + // Why: when the caller names a host (a GHES origin), scope the diagnosis to + // that host's account — the github.com account's scopes are irrelevant to it. + const normalizedRequiredHost = requiredHost?.trim().toLowerCase() || null + const hostAccounts = normalizedRequiredHost + ? accounts.filter((a) => a.host.toLowerCase() === normalizedRequiredHost) + : accounts + const active = + hostAccounts.find((a) => a.active) ?? + hostAccounts[0] ?? + (normalizedRequiredHost ? null : (accounts.find((a) => a.active) ?? accounts[0] ?? null)) const envTokenInProcess: 'GITHUB_TOKEN' | 'GH_TOKEN' | null = process.env.GH_TOKEN ? 'GH_TOKEN' : process.env.GITHUB_TOKEN @@ -141,6 +150,8 @@ export async function diagnoseGhAuth(): Promise { envTokenInProcess, missingScopes, requiredScopes: [...REQUIRED_SCOPES], - hasKeyringFallback: Boolean(keyringFallback && keyringFallback !== active) + hasKeyringFallback: Boolean(keyringFallback && keyringFallback !== active), + requiredHost: normalizedRequiredHost, + requiredHostAuthenticated: normalizedRequiredHost ? hostAccounts.length > 0 : null } } diff --git a/src/main/github/client-create-pr.test.ts b/src/main/github/client-create-pr.test.ts index 34b0d801aeb..c3efb986bad 100644 --- a/src/main/github/client-create-pr.test.ts +++ b/src/main/github/client-create-pr.test.ts @@ -57,11 +57,20 @@ vi.mock('../git/runner', () => ({ })) vi.mock('./github-enterprise-repository', () => ({ - getEnterpriseGitHubRepoSlug: getEnterpriseGitHubRepoSlugMock + getEnterpriseGitHubRepoSlug: getEnterpriseGitHubRepoSlugMock, + isGitHubHostAuthenticated: vi.fn().mockResolvedValue(true) })) import { createGitHubPullRequest } from './client' +import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository' + +// The origin-repository cache is module-level state; reset it so slugs +// resolved by one test cannot leak into the next. +beforeEach(() => { + _resetOriginGitHubApiRepositoryCache() +}) + describe('createGitHubPullRequest', () => { beforeEach(() => { ghExecFileAsyncMock.mockReset() @@ -167,7 +176,7 @@ describe('createGitHubPullRequest', () => { expect(args[args.indexOf('--head') + 1]).toBe('my-branch') }) - it('host-qualifies --repo for a GHES remote so gh targets the Enterprise server (#8312)', async () => { + it('routes --repo to the Enterprise server via options.host for a GHES remote (#8312)', async () => { // github.com-only slug parsing misses GHES, so creation comes from the // enterprise resolver, which carries the host. getOwnerRepoMock.mockResolvedValueOnce(null) @@ -194,13 +203,14 @@ describe('createGitHubPullRequest', () => { url: 'https://github.acme-corp.com/team/orca/pull/7' }) - const [args] = ghExecFileAsyncMock.mock.calls[0] - // Bare "team/orca" would resolve against gh's default host (github.com); - // the host prefix pins the command to the Enterprise server. - expect(args[args.indexOf('--repo') + 1]).toBe('github.acme-corp.com/team/orca') + const [args, options] = ghExecFileAsyncMock.mock.calls[0] + // The runner host-qualifies argv at spawn time from options.host, so the + // mocked call sees a bare owner/repo plus the host in exec options. + expect(args[args.indexOf('--repo') + 1]).toBe('team/orca') + expect(options).toMatchObject({ host: 'github.acme-corp.com' }) }) - it('host-qualifies --repo for the GHES existing-PR fallback lookup (#8312)', async () => { + it('routes the GHES existing-PR fallback lookup through options.host (#8312)', async () => { getOwnerRepoMock.mockResolvedValue(null) getEnterpriseGitHubRepoSlugMock.mockResolvedValue({ owner: 'team', @@ -234,9 +244,10 @@ describe('createGitHubPullRequest', () => { existingReview: { number: 9, url: 'https://github.acme-corp.com/team/orca/pull/9' } }) - const [listArgs] = ghExecFileAsyncMock.mock.calls[1] + const [listArgs, listOptions] = ghExecFileAsyncMock.mock.calls[1] expect(listArgs).toEqual(expect.arrayContaining(['pr', 'list'])) - expect(listArgs[listArgs.indexOf('--repo') + 1]).toBe('github.acme-corp.com/team/orca') + expect(listArgs[listArgs.indexOf('--repo') + 1]).toBe('team/orca') + expect(listOptions).toMatchObject({ host: 'github.acme-corp.com' }) }) it('runs local WSL project pull request creation through the selected distro', async () => { @@ -301,7 +312,12 @@ describe('createGitHubPullRequest', () => { url: 'https://github.com/acme/widgets/pull/45' }) - expect(getOwnerRepoForRemoteMock).toHaveBeenCalledWith('/remote/repo-root', 'origin', 'ssh-1') + expect(getOwnerRepoForRemoteMock).toHaveBeenCalledWith( + '/remote/repo-root', + 'origin', + 'ssh-1', + {} + ) const [args, options] = ghExecFileAsyncMock.mock.calls[0] expect(args).toEqual( expect.arrayContaining([ @@ -459,7 +475,9 @@ describe('createGitHubPullRequest', () => { '--json', 'number,url' ], - { cwd: '/repo-root' } + // Why: dotcom slugs resolve with host:'github.com' so creation stays + // pinned against a process-level GH_HOST. + { cwd: '/repo-root', host: 'github.com' } ]) }) diff --git a/src/main/github/client-file-viewed.test.ts b/src/main/github/client-file-viewed.test.ts index c4345ffb6b6..a52a479e9f5 100644 --- a/src/main/github/client-file-viewed.test.ts +++ b/src/main/github/client-file-viewed.test.ts @@ -1,7 +1,15 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { ghExecFileAsyncMock, acquireMock, releaseMock } = vi.hoisted(() => ({ +const { + ghExecFileAsyncMock, + getOwnerRepoMock, + getEnterpriseGitHubRepoSlugMock, + acquireMock, + releaseMock +} = vi.hoisted(() => ({ ghExecFileAsyncMock: vi.fn(), + getOwnerRepoMock: vi.fn(), + getEnterpriseGitHubRepoSlugMock: vi.fn(), acquireMock: vi.fn(), releaseMock: vi.fn() })) @@ -10,9 +18,18 @@ vi.mock('./gh-utils', () => ({ execFileAsync: vi.fn(), ghExecFileAsync: ghExecFileAsyncMock, gitExecFileAsync: vi.fn(), - getOwnerRepo: vi.fn(), + getOwnerRepo: getOwnerRepoMock, getIssueOwnerRepo: vi.fn(), - getOwnerRepoForRemote: vi.fn(), + // Why: origin repository resolution calls getOwnerRepoForRemote, not getOwnerRepo. + getOwnerRepoForRemote: ( + repoPath: string, + remoteName: string, + connectionId?: string | null, + localGitOptions?: unknown + ) => + remoteName === 'origin' + ? getOwnerRepoMock(repoPath, connectionId, localGitOptions) + : Promise.resolve(null), resolveIssueSource: vi.fn(), ghRepoExecOptions: vi.fn((context) => context.connectionId @@ -31,11 +48,28 @@ vi.mock('./gh-utils', () => ({ _resetOwnerRepoCache: vi.fn() })) +vi.mock('./github-enterprise-repository', () => ({ + getEnterpriseGitHubRepoSlug: getEnterpriseGitHubRepoSlugMock, + isGitHubHostAuthenticated: vi.fn().mockResolvedValue(true) +})) + import { setPRFileViewed } from './client' +import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository' + +// The origin-repository cache is module-level state; reset it so slugs +// resolved by one test cannot leak into the next. +beforeEach(() => { + _resetOriginGitHubApiRepositoryCache() +}) + describe('setPRFileViewed', () => { beforeEach(() => { ghExecFileAsyncMock.mockReset() + getOwnerRepoMock.mockReset() + getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'orca' }) + getEnterpriseGitHubRepoSlugMock.mockReset() + getEnterpriseGitHubRepoSlugMock.mockResolvedValue(null) acquireMock.mockReset() releaseMock.mockReset() acquireMock.mockResolvedValue(undefined) @@ -59,7 +93,10 @@ describe('setPRFileViewed', () => { expect(args.find((arg: string) => arg.startsWith('query='))).toContain('markFileAsViewed') expect(args).toContain('pullRequestId=PR_kwDO123') expect(args).toContain('path=src/app.ts') - expect(ghExecFileAsyncMock.mock.calls[0][1]).toEqual({ cwd: '/repo-root' }) + expect(ghExecFileAsyncMock.mock.calls[0][1]).toEqual({ + cwd: '/repo-root', + host: 'github.com' + }) expect(releaseMock).toHaveBeenCalledTimes(1) }) @@ -95,7 +132,54 @@ describe('setPRFileViewed', () => { expect(ghExecFileAsyncMock.mock.calls[0][1]).toEqual({ cwd: '/repo-root', + host: 'github.com', wslDistro: 'Ubuntu' }) }) + + it('routes SSH-backed GitHub Enterprise file-viewed mutations to the Enterprise host', async () => { + getOwnerRepoMock.mockResolvedValueOnce(null) + getEnterpriseGitHubRepoSlugMock.mockResolvedValueOnce({ + owner: 'team', + repo: 'orca', + host: 'github.acme-corp.com' + }) + ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '{}' }) + + await expect( + setPRFileViewed({ + repoPath: '/remote/repo', + connectionId: 'ssh-1', + pullRequestId: 'PR_enterprise', + path: 'src/enterprise.ts', + viewed: true + }) + ).resolves.toBe(true) + + // The runner injects --hostname at spawn time from options.host, so the + // mocked call sees unqualified argv plus the Enterprise host in options. + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + expect.arrayContaining(['api', 'graphql', 'pullRequestId=PR_enterprise']), + { host: 'github.acme-corp.com' } + ) + const [args] = ghExecFileAsyncMock.mock.calls[0] + expect(args).not.toContain('--hostname') + }) + + it('refuses an unresolved local file-viewed mutation', async () => { + getOwnerRepoMock.mockResolvedValue(null) + getEnterpriseGitHubRepoSlugMock.mockResolvedValue(null) + + await expect( + setPRFileViewed({ + repoPath: '/repo-root', + prRepo: { owner: 'team', repo: 'orca' }, + pullRequestId: 'PR_unresolved', + path: 'src/app.ts', + viewed: true + }) + ).resolves.toBe(false) + + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + }) }) diff --git a/src/main/github/client-issue-source.test.ts b/src/main/github/client-issue-source.test.ts index a828a7759f7..5b867489797 100644 --- a/src/main/github/client-issue-source.test.ts +++ b/src/main/github/client-issue-source.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as GithubApiRepositoryModule from './github-api-repository' import type * as GhUtils from './gh-utils' const { @@ -7,6 +8,7 @@ const { getOwnerRepoMock, getIssueOwnerRepoMock, getOwnerRepoForRemoteMock, + resolvePRRepositoryCandidatesMock, resolveIssueSourceMock, rateLimitGuardMock, noteRateLimitSpendMock, @@ -18,6 +20,7 @@ const { getOwnerRepoMock: vi.fn(), getIssueOwnerRepoMock: vi.fn(), getOwnerRepoForRemoteMock: vi.fn(), + resolvePRRepositoryCandidatesMock: vi.fn(), resolveIssueSourceMock: vi.fn(), rateLimitGuardMock: vi.fn(() => ({ blocked: false })), noteRateLimitSpendMock: vi.fn(), @@ -44,9 +47,48 @@ vi.mock('./gh-utils', async () => { vi.mock('./rate-limit', () => ({ rateLimitGuard: rateLimitGuardMock, noteRateLimitSpend: noteRateLimitSpendMock, - getRateLimit: vi.fn(async () => ({ ok: false, error: 'not probed in tests' })) + getRateLimit: vi.fn(async () => ({ ok: false, error: 'not probed in tests' })), + repositoryRateLimitGuard: vi.fn(() => ({ blocked: false })), + noteRepositoryRateLimitSpend: vi.fn(), + spendsSharedGitHubComQuota: () => true })) +vi.mock('./github-api-repository', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + // Why: these suites drive source resolution through the legacy gh-utils + // mocks; bridge the hosted seams onto the same mocks. + resolveIssueGitHubApiRepositorySource: ( + repoPath: string, + preference: unknown, + connectionId?: string | null, + localGitOptions?: unknown + ) => resolveIssueSourceMock(repoPath, preference, connectionId, localGitOptions), + getIssueGitHubApiRepository: (repoPath: string, connectionId?: string | null) => + getIssueOwnerRepoMock(repoPath, connectionId), + getOriginGitHubApiRepository: ( + repoPath: string, + connectionId?: string | null, + localGitOptions?: unknown + ) => getOwnerRepoMock(repoPath, connectionId, localGitOptions), + getGitHubApiRepositoryForRemote: ( + repoPath: string, + remoteName: string, + connectionId?: string | null, + localGitOptions?: unknown + ) => + remoteName === 'origin' + ? getOwnerRepoMock(repoPath, connectionId, localGitOptions) + : getOwnerRepoForRemoteMock(repoPath, remoteName, connectionId, localGitOptions), + resolveGitHubApiRepositoryCandidates: ( + repoPath: string, + connectionId?: string | null, + localGitOptions?: unknown + ) => resolvePRRepositoryCandidatesMock(repoPath, connectionId, localGitOptions) + } +}) + import { countWorkItems, getWorkItem, listWorkItems, _resetOwnerRepoCache } from './client' const PR_LIST_FIELDS = @@ -107,6 +149,7 @@ describe('GitHub issue source split', () => { getOwnerRepoMock.mockReset() getIssueOwnerRepoMock.mockReset() getOwnerRepoForRemoteMock.mockReset() + resolvePRRepositoryCandidatesMock.mockReset() resolveIssueSourceMock.mockReset() rateLimitGuardMock.mockReset() rateLimitGuardMock.mockReturnValue({ blocked: false }) @@ -123,15 +166,17 @@ describe('GitHub issue source split', () => { source: await getIssueOwnerRepoMock(), fellBack: false })) - // Why: since #7331 `resolvePrWorkItemSource` fetches origin through - // `getOwnerRepoForRemote` (getOwnerRepo became upstream-first). Route the - // origin probe to `getOwnerRepoMock` so existing tests keep defining the - // origin candidate through it; default upstream to null. Tests that care - // about upstream override with their own implementation. + // Why: keep origin on the legacy mock while hosted candidate tests opt in + // to upstream behavior explicitly. getOwnerRepoForRemoteMock.mockImplementation( async (repoPath: string, remoteName: string, connectionId?: string | null, opts = {}) => remoteName === 'origin' ? getOwnerRepoMock(repoPath, connectionId, opts) : null ) + resolvePRRepositoryCandidatesMock.mockImplementation(async (repoPath, connectionId) => { + const origin = await getOwnerRepoMock(repoPath, connectionId) + const repository = origin ? { host: 'github.com', ...origin } : null + return { candidates: repository ? [repository] : [], headRepo: repository } + }) _resetOwnerRepoCache() }) @@ -426,11 +471,80 @@ describe('GitHub issue source split', () => { '--json', expect.stringContaining('reviewDecision') ], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) expect(item?.type).toBe('pr') }) + it('probes the upstream repository for a typed fork PR before origin', async () => { + const upstream = { owner: 'stablyai', repo: 'orca', host: 'github.com' } + const origin = { owner: 'fork', repo: 'orca', host: 'github.com' } + resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ + candidates: [upstream, origin], + headRepo: origin + }) + ghExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 42, + title: 'Upstream PR', + state: 'open', + url: 'https://github.com/stablyai/orca/pull/42', + labels: [], + updatedAt: '2026-04-02T00:00:00Z', + author: { login: 'octocat' }, + isDraft: false + }) + }) + + const item = await getWorkItem('/repo-root', 42, 'pr') + + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + [ + 'pr', + 'view', + '42', + '--repo', + 'stablyai/orca', + '--json', + expect.stringContaining('reviewDecision') + ], + { cwd: '/repo-root', host: 'github.com' } + ) + expect(item?.prRepo).toEqual(upstream) + }) + + it('does not run a bare gh lookup for an SSH repo without candidates', async () => { + resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ candidates: [], headRepo: null }) + + await expect(getWorkItem('/remote/repo', 42, 'pr', 'ssh-1')).resolves.toBeNull() + + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + }) + + it('does not probe a second PR repository after a non-not-found failure', async () => { + resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ + candidates: [ + { owner: 'stablyai', repo: 'orca', host: 'github.com' }, + { owner: 'fork', repo: 'orca', host: 'github.com' } + ], + headRepo: { owner: 'fork', repo: 'orca', host: 'github.com' } + }) + ghExecFileAsyncMock + .mockRejectedValueOnce(new Error('HTTP 500: server error')) + .mockRejectedValueOnce(new Error('HTTP 500: server error')) + + await expect(getWorkItem('/repo-root', 42, 'pr')).resolves.toBeNull() + + // The first candidate uses `pr view` and its REST compatibility fallback; + // neither failure is a 404, so a same-number PR on origin is never queried. + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) + expect( + ghExecFileAsyncMock.mock.calls.some(([args]) => + (args as string[]).some((arg) => arg.includes('fork/orca')) + ) + ).toBe(false) + }) + it('raw number lookup tries upstream issue before origin PR', async () => { getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) // Why: simulate a real gh 404 (the only error type that should fall through). @@ -469,7 +583,7 @@ describe('GitHub issue source split', () => { '--json', expect.stringContaining('reviewDecision') ], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) expect(item?.type).toBe('pr') }) diff --git a/src/main/github/client-pr-check-details.test.ts b/src/main/github/client-pr-check-details.test.ts index 429969b52f1..b092b01eb94 100644 --- a/src/main/github/client-pr-check-details.test.ts +++ b/src/main/github/client-pr-check-details.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as GithubEnterpriseRepositoryModule from './github-enterprise-repository' const { ghExecFileAsyncMock, getOwnerRepoMock, rateLimitGuardMock } = vi.hoisted(() => ({ ghExecFileAsyncMock: vi.fn(), @@ -16,6 +17,16 @@ vi.mock('./gh-utils', () => ({ ghRepoExecOptions: (context: { repoPath: string }) => ({ cwd: context.repoPath }), getOwnerRepo: getOwnerRepoMock, getIssueOwnerRepo: vi.fn(), + // Why: origin repository resolution calls getOwnerRepoForRemote, not getOwnerRepo. + getOwnerRepoForRemote: ( + repoPath: string, + remoteName: string, + connectionId?: string | null, + localGitOptions?: unknown + ) => + remoteName === 'origin' + ? getOwnerRepoMock(repoPath, connectionId, localGitOptions) + : Promise.resolve(null), extractExecError: vi.fn((err: unknown) => ({ stderr: String(err), stdout: '' })), acquire: vi.fn(), release: vi.fn(), @@ -26,9 +37,17 @@ vi.mock('../git/runner', () => ({ gitExecFileAsync: vi.fn() })) +vi.mock('./github-enterprise-repository', async (importOriginal) => ({ + ...(await importOriginal()), + isGitHubHostAuthenticated: vi.fn().mockResolvedValue(true) +})) + vi.mock('./rate-limit', () => ({ rateLimitGuard: rateLimitGuardMock, - noteRateLimitSpend: vi.fn() + noteRateLimitSpend: vi.fn(), + repositoryRateLimitGuard: vi.fn(() => ({ blocked: false })), + noteRepositoryRateLimitSpend: vi.fn(), + spendsSharedGitHubComQuota: vi.fn(() => true) })) import { getPRCheckDetails, _resetOwnerRepoCache } from './client' @@ -129,17 +148,17 @@ describe('getPRCheckDetails', () => { expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 1, ['api', 'repos/acme/widgets/check-runs/88'], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 2, ['api', 'repos/acme/widgets/check-runs/88/annotations?per_page=20'], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 3, ['api', 'repos/acme/widgets/actions/runs/77/jobs?per_page=100'], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) }) @@ -206,8 +225,64 @@ describe('getPRCheckDetails', () => { expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 4, ['api', 'repos/acme/widgets/actions/jobs/8801/logs'], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(4) }) + + it('isolates failed-job log tails for the same job ID on different GitHub hosts', async () => { + ghExecFileAsyncMock.mockImplementation(async (args: string[], options?: { host?: string }) => { + const endpoint = args.find((arg) => arg.startsWith('repos/')) ?? '' + const enterprise = options?.host === 'github.acme-corp.com' + if (endpoint.endsWith('/check-runs/99')) { + return { + stdout: JSON.stringify({ + name: 'verify', + status: 'completed', + conclusion: 'failure', + check_suite: { workflow_run: { id: 77 } } + }) + } + } + if (endpoint.endsWith('/check-runs/99/annotations?per_page=20')) { + return { stdout: '[]' } + } + if (endpoint.endsWith('/actions/runs/77/jobs?per_page=100')) { + return { + stdout: JSON.stringify({ + jobs: [ + { + id: 9901, + name: 'verify', + status: 'completed', + conclusion: 'failure', + steps: [] + } + ] + }) + } + } + if (endpoint.endsWith('/actions/jobs/9901/logs')) { + return { stdout: enterprise ? 'enterprise log' : 'github.com log' } + } + throw new Error(`unexpected gh call: ${args.join(' ')}`) + }) + + const githubDotCom = await getPRCheckDetails('/repo-root', { + checkRunId: 99, + prRepo: { owner: 'acme', repo: 'widgets', host: 'github.com' } + }) + const enterprise = await getPRCheckDetails('/repo-root', { + checkRunId: 99, + prRepo: { owner: 'acme', repo: 'widgets', host: 'github.acme-corp.com' } + }) + + expect(githubDotCom?.jobs[0]?.logTail).toBe('github.com log') + expect(enterprise?.jobs[0]?.logTail).toBe('enterprise log') + const logCalls = ghExecFileAsyncMock.mock.calls.filter(([args]) => + args.some((arg) => arg.endsWith('/actions/jobs/9901/logs')) + ) + expect(logCalls).toHaveLength(2) + expect(logCalls[1]?.[1]).toEqual(expect.objectContaining({ host: 'github.acme-corp.com' })) + }) }) diff --git a/src/main/github/client-pr-checks.test.ts b/src/main/github/client-pr-checks.test.ts index 151bd94fef6..72ca0f77cff 100644 --- a/src/main/github/client-pr-checks.test.ts +++ b/src/main/github/client-pr-checks.test.ts @@ -8,8 +8,9 @@ const { gitExecFileAsyncMock, extractExecErrorMock, getRateLimitMock, - rateLimitGuardMock, - noteRateLimitSpendMock, + repositoryRateLimitGuardMock, + noteRepositoryRateLimitSpendMock, + spendsSharedGitHubComQuotaMock, acquireMock, releaseMock } = vi.hoisted(() => ({ @@ -29,8 +30,11 @@ const { return { stderr: String(err), stdout: '' } }), getRateLimitMock: vi.fn(), - rateLimitGuardMock: vi.fn(() => ({ blocked: false })), - noteRateLimitSpendMock: vi.fn(), + repositoryRateLimitGuardMock: vi.fn(() => ({ blocked: false })), + noteRepositoryRateLimitSpendMock: vi.fn(), + spendsSharedGitHubComQuotaMock: vi.fn< + (repository?: { host?: string } | null, options?: { wslDistro?: string }) => boolean + >(() => true), acquireMock: vi.fn(), releaseMock: vi.fn() })) @@ -53,6 +57,16 @@ vi.mock('./gh-utils', () => ({ }), getOwnerRepo: getOwnerRepoMock, getIssueOwnerRepo: getIssueOwnerRepoMock, + // Why: origin repository resolution calls getOwnerRepoForRemote, not getOwnerRepo. + getOwnerRepoForRemote: ( + repoPath: string, + remoteName: string, + connectionId?: string | null, + localGitOptions?: unknown + ) => + remoteName === 'origin' + ? getOwnerRepoMock(repoPath, connectionId, localGitOptions) + : Promise.resolve(null), extractExecError: extractExecErrorMock, acquire: acquireMock, release: releaseMock, @@ -65,12 +79,21 @@ vi.mock('../git/runner', () => ({ vi.mock('./rate-limit', () => ({ getRateLimit: getRateLimitMock, - rateLimitGuard: rateLimitGuardMock, - noteRateLimitSpend: noteRateLimitSpendMock + repositoryRateLimitGuard: repositoryRateLimitGuardMock, + noteRepositoryRateLimitSpend: noteRepositoryRateLimitSpendMock, + spendsSharedGitHubComQuota: spendsSharedGitHubComQuotaMock })) import { getPRChecks, rerunPRChecks, _resetOwnerRepoCache } from './client' +import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository' + +// The origin-repository cache is module-level state; reset it so slugs +// resolved by one test cannot leak into the next. +beforeEach(() => { + _resetOriginGitHubApiRepositoryCache() +}) + function graphQLChecksResponse({ contexts = [], checkSuites = [], @@ -194,9 +217,14 @@ describe('getPRChecks', () => { extractExecErrorMock.mockClear() getRateLimitMock.mockReset() getRateLimitMock.mockResolvedValue({ resources: {} }) - rateLimitGuardMock.mockReset() - rateLimitGuardMock.mockReturnValue({ blocked: false }) - noteRateLimitSpendMock.mockReset() + repositoryRateLimitGuardMock.mockReset() + repositoryRateLimitGuardMock.mockReturnValue({ blocked: false }) + noteRepositoryRateLimitSpendMock.mockReset() + spendsSharedGitHubComQuotaMock.mockReset() + spendsSharedGitHubComQuotaMock.mockImplementation( + (repository?: { host?: string } | null, options?: { wslDistro?: string }) => + (!repository?.host || repository.host.toLowerCase() === 'github.com') && !options?.wslDistro + ) acquireMock.mockReset() releaseMock.mockReset() acquireMock.mockResolvedValue(undefined) @@ -413,12 +441,12 @@ describe('getPRChecks', () => { expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 2, ['api', '--cache', '60s', 'repos/acme/widgets/commits/head-oid/check-runs?per_page=100'], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 3, ['api', '--cache', '60s', 'repos/acme/widgets/commits/head-oid/status?per_page=100'], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) expect(checks).toEqual([ { @@ -496,7 +524,7 @@ describe('getPRChecks', () => { expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 3, ['pr', 'checks', '42', '--json', 'name,state,link', '--repo', 'acme/widgets'], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) expect(checks).toEqual([ { @@ -533,7 +561,11 @@ describe('getPRChecks', () => { expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 2, ['api', '-X', 'POST', 'repos/acme/widgets/actions/runs/77/rerun-failed-jobs'], - { cwd: '/repo-root', env: { ...process.env, GH_PROMPT_DISABLED: '1' } } + { + cwd: '/repo-root', + env: { ...process.env, GH_PROMPT_DISABLED: '1' }, + host: 'github.com' + } ) }) @@ -581,6 +613,12 @@ describe('getPRChecks', () => { env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' }) }) ) + expect(getRateLimitMock).not.toHaveBeenCalled() + expect(repositoryRateLimitGuardMock).toHaveBeenCalledWith( + { owner: 'acme', repo: 'widgets', host: 'github.com' }, + 'graphql', + expect.objectContaining({ cwd: '/repo-root', wslDistro: 'Ubuntu', host: 'github.com' }) + ) }) it('uses explicit PR repo for rollup and gh pr checks fallback', async () => { @@ -591,14 +629,18 @@ describe('getPRChecks', () => { stdout: JSON.stringify([{ name: 'lint', state: 'PASS', link: 'https://example.com/lint' }]) }) - await getPRChecks('/repo-root', 42, undefined, { owner: 'acme', repo: 'widgets' }) + await getPRChecks('/repo-root', 42, undefined, { + owner: 'acme', + repo: 'widgets', + host: 'github.com' + }) expect(getOwnerRepoMock).not.toHaveBeenCalled() expectGraphQLRollupCall() expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 2, ['pr', 'checks', '42', '--json', 'name,state,link', '--repo', 'acme/widgets'], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) }) diff --git a/src/main/github/client-pr-local-runtime.test.ts b/src/main/github/client-pr-local-runtime.test.ts index 9da948f43c4..07a69a6a4ee 100644 --- a/src/main/github/client-pr-local-runtime.test.ts +++ b/src/main/github/client-pr-local-runtime.test.ts @@ -10,6 +10,7 @@ const { getOwnerRepoMock, getIssueOwnerRepoMock, getOwnerRepoForRemoteMock, + getEnterpriseGitHubRepoSlugMock, resolvePRRepositoryCandidatesMock, getRemoteUrlForRepoMock, gitExecFileAsyncMock, @@ -24,6 +25,7 @@ const { getOwnerRepoMock: vi.fn(), getIssueOwnerRepoMock: vi.fn(), getOwnerRepoForRemoteMock: vi.fn(), + getEnterpriseGitHubRepoSlugMock: vi.fn(), resolvePRRepositoryCandidatesMock: vi.fn(), getRemoteUrlForRepoMock: vi.fn(), gitExecFileAsyncMock: vi.fn(), @@ -76,6 +78,12 @@ vi.mock('../git/runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock })) +vi.mock('./github-enterprise-repository', () => ({ + getEnterpriseGitHubRepoSlug: getEnterpriseGitHubRepoSlugMock, + getEnterpriseGitHubRepoSlugForRemote: getEnterpriseGitHubRepoSlugMock, + isGitHubHostAuthenticated: vi.fn().mockResolvedValue(true) +})) + vi.mock('../providers/ssh-git-dispatch', () => ({ getSshGitProvider: vi.fn() })) @@ -87,15 +95,27 @@ vi.mock('./conflict-summary', () => ({ vi.mock('./rate-limit', () => ({ getRateLimit: getRateLimitMock, rateLimitGuard: rateLimitGuardMock, - noteRateLimitSpend: noteRateLimitSpendMock + noteRateLimitSpend: noteRateLimitSpendMock, + repositoryRateLimitGuard: vi.fn(() => ({ blocked: false })), + noteRepositoryRateLimitSpend: vi.fn(), + spendsSharedGitHubComQuota: ( + repository?: { host?: string } | null, + executionOptions?: { wslDistro?: string } + ) => + (!repository?.host || repository.host.toLowerCase() === 'github.com') && + !executionOptions?.wslDistro })) import { addPRReviewComment, addPRReviewCommentReply, + getPRCheckDetails, + getPRChecks, + getWorkItemByOwnerRepo, getPRComments, mergePR, removePRReviewers, + rerunPRChecks, requestPRReviewers, resolveReviewThread, setPRAutoMerge, @@ -104,6 +124,14 @@ import { updatePRTitle } from './client' +import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository' + +// The origin-repository cache is module-level state; reset it so slugs +// resolved by one test cannot leak into the next. +beforeEach(() => { + _resetOriginGitHubApiRepositoryCache() +}) + describe('GitHub PR local runtime routing', () => { beforeEach(() => { execFileAsyncMock.mockReset() @@ -111,6 +139,13 @@ describe('GitHub PR local runtime routing', () => { getOwnerRepoMock.mockReset() getIssueOwnerRepoMock.mockReset() getOwnerRepoForRemoteMock.mockReset() + // Why: host-less prRepo backfill and origin resolution use getOwnerRepoForRemote. + getOwnerRepoForRemoteMock.mockImplementation( + async (repoPath: string, remoteName: string, connectionId?: string | null, opts = {}) => + remoteName === 'origin' ? getOwnerRepoMock(repoPath, connectionId, opts) : null + ) + getEnterpriseGitHubRepoSlugMock.mockReset() + getEnterpriseGitHubRepoSlugMock.mockResolvedValue(null) resolvePRRepositoryCandidatesMock.mockReset() getRemoteUrlForRepoMock.mockReset() gitExecFileAsyncMock.mockReset() @@ -127,12 +162,28 @@ describe('GitHub PR local runtime routing', () => { it('routes PR details and mutations through the selected WSL distro', async () => { const localGitOptions = { wslDistro: 'Ubuntu' } const prRepo = { owner: 'acme', repo: 'orca' } + rateLimitGuardMock.mockReturnValue({ + blocked: true, + remaining: 0, + limit: 5000, + resetAt: 1_800_000_000 + }) getOwnerRepoMock.mockResolvedValue(prRepo) ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { const endpoint = args.find((arg) => arg.startsWith('repos/acme/orca/')) ?? '' const query = args.find((arg) => arg.startsWith('query=')) ?? '' if (args[0] === 'pr' && args[1] === 'view') { + const jsonFields = args[args.indexOf('--json') + 1] + if (jsonFields === 'id,headRefOid,baseRefName') { + return { + stdout: JSON.stringify({ + id: 'PR_local', + headRefOid: 'head-oid', + baseRefName: 'main' + }) + } + } return { stdout: JSON.stringify({ id: 'PR_kwDO123', @@ -184,7 +235,7 @@ describe('GitHub PR local runtime routing', () => { await getPRComments('/repo-root', 7, { prRepo }, null, localGitOptions) await expect( - resolveReviewThread('/repo-root', 'thread-1', true, null, localGitOptions) + resolveReviewThread('/repo-root', 'thread-1', true, null, prRepo, localGitOptions) ).resolves.toBe(true) await expect( addPRReviewCommentReply( @@ -205,6 +256,7 @@ describe('GitHub PR local runtime routing', () => { repoPath: '/repo-root', connectionId: null, localGitOptions, + prRepo, prNumber: 7, body: 'Inline', commitId: 'head-oid', @@ -219,13 +271,13 @@ describe('GitHub PR local runtime routing', () => { updatePRDetails('/repo-root', 7, { body: 'New body' }, null, prRepo, localGitOptions) ).resolves.toEqual({ ok: true }) await expect( - updatePRState('/repo-root', 7, { state: 'closed' }, null, localGitOptions) + updatePRState('/repo-root', 7, { state: 'closed' }, null, prRepo, localGitOptions) ).resolves.toEqual({ ok: true }) await expect( - requestPRReviewers('/repo-root', 7, ['octo'], null, localGitOptions) + requestPRReviewers('/repo-root', 7, ['octo'], null, prRepo, localGitOptions) ).resolves.toEqual({ ok: true }) await expect( - removePRReviewers('/repo-root', 7, ['octo'], null, localGitOptions) + removePRReviewers('/repo-root', 7, ['octo'], null, prRepo, localGitOptions) ).resolves.toEqual({ ok: true }) await expect( setPRAutoMerge('/repo-root', 7, true, 'squash', null, prRepo, localGitOptions) @@ -238,5 +290,290 @@ describe('GitHub PR local runtime routing', () => { expect(ghExecFileAsyncMock.mock.calls.every((call) => call[1]?.wslDistro === 'Ubuntu')).toBe( true ) + expect(getRateLimitMock).not.toHaveBeenCalled() + expect(rateLimitGuardMock).not.toHaveBeenCalled() + expect(noteRateLimitSpendMock).not.toHaveBeenCalled() + }) + + it('never falls through to the default gh host for an unresolved SSH repository', async () => { + const legacyRepo = { owner: 'team', repo: 'orca' } + getOwnerRepoMock.mockResolvedValue(null) + getEnterpriseGitHubRepoSlugMock.mockResolvedValue(null) + + await expect(getPRComments('/remote/repo', 7, { prRepo: legacyRepo }, 'ssh-1')).rejects.toThrow( + 'GitHub remote' + ) + await expect( + getPRChecks('/remote/repo', 7, undefined, legacyRepo, undefined, 'ssh-1') + ).rejects.toThrow('GitHub remote') + await expect(mergePR('/remote/repo', 7, 'squash', 'ssh-1', legacyRepo)).resolves.toMatchObject({ + ok: false + }) + await expect( + setPRAutoMerge('/remote/repo', 7, true, 'squash', 'ssh-1', legacyRepo) + ).resolves.toMatchObject({ ok: false }) + await expect(updatePRTitle('/remote/repo', 7, 'New title', 'ssh-1', legacyRepo)).resolves.toBe( + false + ) + await expect(requestPRReviewers('/remote/repo', 7, ['octo'], 'ssh-1')).resolves.toMatchObject({ + ok: false + }) + await expect(removePRReviewers('/remote/repo', 7, ['octo'], 'ssh-1')).resolves.toMatchObject({ + ok: false + }) + await expect( + addPRReviewCommentReply( + '/remote/repo', + 7, + 11, + 'Reply', + undefined, + undefined, + undefined, + 'ssh-1', + legacyRepo + ) + ).resolves.toMatchObject({ ok: false }) + + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + }) + + it('refuses unresolved local PR mutations instead of using ambient gh defaults', async () => { + const legacyRepo = { owner: 'team', repo: 'orca' } + getOwnerRepoMock.mockResolvedValue(null) + getEnterpriseGitHubRepoSlugMock.mockResolvedValue(null) + + await expect( + resolveReviewThread('/repo-root', 'thread-1', true, null, legacyRepo) + ).resolves.toBe(false) + await expect(updatePRTitle('/repo-root', 7, 'New title', null, legacyRepo)).resolves.toBe(false) + await expect( + requestPRReviewers('/repo-root', 7, ['octo'], null, legacyRepo) + ).resolves.toMatchObject({ ok: false }) + await expect( + removePRReviewers('/repo-root', 7, ['octo'], null, legacyRepo) + ).resolves.toMatchObject({ ok: false }) + await expect(mergePR('/repo-root', 7, 'squash', null, legacyRepo)).resolves.toMatchObject({ + ok: false + }) + await expect( + setPRAutoMerge('/repo-root', 7, true, 'squash', null, legacyRepo) + ).resolves.toMatchObject({ ok: false }) + + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + }) + + it('host-qualifies SSH-backed GitHub Enterprise review reads and mutations', async () => { + const enterpriseRepo = { + owner: 'team', + repo: 'orca', + host: 'github.acme-corp.com' + } + getOwnerRepoMock.mockResolvedValue(null) + getEnterpriseGitHubRepoSlugMock.mockResolvedValue(enterpriseRepo) + ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { + const endpoint = args.find((arg) => arg.startsWith('repos/team/orca/')) ?? '' + const query = args.find((arg) => arg.startsWith('query=')) ?? '' + if (args[0] === 'pr' && args[1] === 'checks') { + return { stdout: '[]' } + } + if (args[0] === 'pr' && args[1] === 'view') { + if (args.includes('id,headRefOid,baseRefName')) { + return { + stdout: JSON.stringify({ + id: 'PR_enterprise', + headRefOid: 'head-sha', + baseRefName: 'main' + }) + } + } + return { + stdout: JSON.stringify({ + number: 7, + title: 'Enterprise PR', + state: 'OPEN', + url: 'https://github.acme-corp.com/team/orca/pull/7', + labels: [], + updatedAt: '2026-07-16T00:00:00Z', + author: { login: 'pr-author' }, + headRefName: 'feature', + baseRefName: 'main' + }) + } + } + if (query.includes('reviewThreads')) { + return { + stdout: JSON.stringify({ + data: { + repository: { + pullRequest: { + reviewThreads: { nodes: [] }, + comments: { nodes: [] } + } + } + } + }) + } + } + if ( + endpoint.endsWith('/issues/7/comments?per_page=100') || + endpoint.endsWith('/pulls/7/reviews?per_page=100') + ) { + return { stdout: '[]' } + } + if (endpoint.endsWith('/commits/head-sha/check-runs?per_page=100')) { + return { + stdout: JSON.stringify({ + check_runs: [ + { + id: 88, + name: 'lint', + status: 'completed', + conclusion: 'failure', + details_url: 'https://github.acme-corp.com/team/orca/actions/runs/77/job/88' + } + ] + }) + } + } + if (endpoint.endsWith('/commits/head-sha/status?per_page=100')) { + return { stdout: JSON.stringify({ statuses: [] }) } + } + if (endpoint.endsWith('/commits/head-sha/check-suites?per_page=100')) { + return { stdout: JSON.stringify({ check_suites: [] }) } + } + if (endpoint.endsWith('/check-runs/88')) { + return { + stdout: JSON.stringify({ + id: 88, + name: 'lint', + status: 'completed', + conclusion: 'failure', + details_url: 'https://github.acme-corp.com/team/orca/actions/runs/77/job/88', + output: { title: 'Lint failed', summary: 'One error' } + }) + } + } + if (endpoint.endsWith('/check-runs/88/annotations?per_page=20')) { + return { stdout: '[]' } + } + if (query) { + return { stdout: JSON.stringify({ data: { repository: {} } }) } + } + return { + stdout: JSON.stringify({ + id: 13, + user: null, + body: 'Enterprise inline comment' + }) + } + }) + + await expect( + getWorkItemByOwnerRepo('/remote/repo', enterpriseRepo, 7, 'pr', 'ssh-1') + ).resolves.toMatchObject({ number: 7, title: 'Enterprise PR' }) + await expect( + getWorkItemByOwnerRepo('/remote/repo', { owner: 'team', repo: 'orca' }, 7, 'pr', 'ssh-1') + ).resolves.toMatchObject({ number: 7, title: 'Enterprise PR' }) + await expect( + getPRComments('/remote/repo', 7, { prRepo: enterpriseRepo }, 'ssh-1') + ).resolves.toEqual([]) + await expect( + getPRChecks('/remote/repo', 7, undefined, enterpriseRepo, undefined, 'ssh-1') + ).resolves.toEqual([]) + await expect( + getPRCheckDetails('/remote/repo', { checkRunId: 88, prRepo: enterpriseRepo }, 'ssh-1') + ).resolves.toMatchObject({ name: 'lint', conclusion: 'failure' }) + await expect( + rerunPRChecks( + '/remote/repo', + 7, + { headSha: 'head-sha', failedOnly: true, prRepo: enterpriseRepo }, + 'ssh-1' + ) + ).resolves.toEqual({ ok: true, count: 1 }) + await expect( + resolveReviewThread('/remote/repo', 'thread-1', true, 'ssh-1', enterpriseRepo) + ).resolves.toBe(true) + await expect( + addPRReviewCommentReply( + '/remote/repo', + 7, + 11, + 'Enterprise reply', + 'thread-1', + 'src/enterprise.ts', + 10, + 'ssh-1', + enterpriseRepo + ) + ).resolves.toMatchObject({ ok: true }) + await expect( + addPRReviewComment({ + repoPath: '/remote/repo', + connectionId: 'ssh-1', + prRepo: enterpriseRepo, + prNumber: 7, + body: 'Enterprise inline comment', + commitId: 'head-sha', + path: 'src/enterprise.ts', + line: 10 + }) + ).resolves.toMatchObject({ ok: true }) + await expect( + updatePRTitle('/remote/repo', 7, 'New title', 'ssh-1', enterpriseRepo) + ).resolves.toBe(true) + await expect( + updatePRDetails('/remote/repo', 7, { body: 'New body' }, 'ssh-1', enterpriseRepo) + ).resolves.toEqual({ ok: true }) + await expect( + updatePRState('/remote/repo', 7, { state: 'closed' }, 'ssh-1', enterpriseRepo) + ).resolves.toEqual({ + ok: true + }) + await expect( + requestPRReviewers('/remote/repo', 7, ['octo'], 'ssh-1', enterpriseRepo) + ).resolves.toEqual({ + ok: true + }) + await expect( + removePRReviewers('/remote/repo', 7, ['octo'], 'ssh-1', enterpriseRepo) + ).resolves.toEqual({ + ok: true + }) + await expect( + setPRAutoMerge('/remote/repo', 7, true, 'squash', 'ssh-1', enterpriseRepo) + ).resolves.toEqual({ ok: true }) + await expect( + setPRAutoMerge('/remote/repo', 7, false, 'squash', 'ssh-1', enterpriseRepo) + ).resolves.toEqual({ ok: true }) + await expect(mergePR('/remote/repo', 7, 'squash', 'ssh-1', enterpriseRepo)).resolves.toEqual({ + ok: true + }) + + const prViewCall = ghExecFileAsyncMock.mock.calls.find( + ([args]) => args[0] === 'pr' && args[1] === 'view' + ) + // The runner host-qualifies argv at spawn time from options.host, so the + // mocked call sees the unqualified --repo plus the host in exec options. + expect(prViewCall?.[0]).toEqual(expect.arrayContaining(['--repo', 'team/orca'])) + expect(prViewCall?.[1]).toEqual({ host: 'github.acme-corp.com' }) + const prCalls = ghExecFileAsyncMock.mock.calls.filter(([args]) => args[0] === 'pr') + expect( + prCalls.every( + ([args]) => args.includes('--repo') && args[args.indexOf('--repo') + 1] === 'team/orca' + ) + ).toBe(true) + const apiCalls = ghExecFileAsyncMock.mock.calls.filter(([args]) => args[0] === 'api') + expect(apiCalls.length).toBeGreaterThan(0) + expect( + apiCalls.every( + ([args, options]) => + !args.includes('--hostname') && + options.host === 'github.acme-corp.com' && + options.cwd === undefined && + options.wslDistro === undefined + ) + ).toBe(true) }) }) diff --git a/src/main/github/client-work-items-query-paging.test.ts b/src/main/github/client-work-items-query-paging.test.ts new file mode 100644 index 00000000000..b3f819c2143 --- /dev/null +++ b/src/main/github/client-work-items-query-paging.test.ts @@ -0,0 +1,302 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as GithubApiRepositoryModule from './github-api-repository' + +const { + execFileAsyncMock, + ghExecFileAsyncMock, + getOwnerRepoMock, + getIssueOwnerRepoMock, + getOwnerRepoForRemoteMock, + resolveIssueSourceMock, + gitExecFileAsyncMock, + rateLimitGuardMock, + noteRateLimitSpendMock, + repositoryRateLimitGuardMock, + noteRepositoryRateLimitSpendMock, + spendsSharedGitHubComQuotaMock, + acquireMock, + releaseMock +} = vi.hoisted(() => ({ + execFileAsyncMock: vi.fn(), + ghExecFileAsyncMock: vi.fn(), + getOwnerRepoMock: vi.fn(), + getIssueOwnerRepoMock: vi.fn(), + getOwnerRepoForRemoteMock: vi.fn(), + resolveIssueSourceMock: vi.fn(), + gitExecFileAsyncMock: vi.fn(), + rateLimitGuardMock: vi.fn((_bucket?: unknown) => ({ blocked: false })), + noteRateLimitSpendMock: vi.fn(), + repositoryRateLimitGuardMock: vi.fn((_repo: unknown, bucket: string) => + rateLimitGuardMock(bucket) + ), + noteRepositoryRateLimitSpendMock: vi.fn(), + spendsSharedGitHubComQuotaMock: vi.fn(() => true), + acquireMock: vi.fn(), + releaseMock: vi.fn() +})) + +vi.mock('./gh-utils', () => ({ + execFileAsync: execFileAsyncMock, + ghExecFileAsync: ghExecFileAsyncMock, + githubRepoContext: ( + repoPath: string, + connectionId?: string | null, + localGitOptions: { wslDistro?: string } = {} + ) => ({ + repoPath, + connectionId: connectionId ?? null, + ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}) + }), + ghRepoExecOptions: (context: { + repoPath: string + connectionId?: string | null + wslDistro?: string + }) => + context.connectionId + ? {} + : { cwd: context.repoPath, ...(context.wslDistro ? { wslDistro: context.wslDistro } : {}) }, + getOwnerRepo: getOwnerRepoMock, + getIssueOwnerRepo: getIssueOwnerRepoMock, + getOwnerRepoForRemote: getOwnerRepoForRemoteMock, + resolveIssueSource: resolveIssueSourceMock, + acquire: acquireMock, + release: releaseMock, + _resetOwnerRepoCache: vi.fn(), + classifyGhError: (stderr: string) => ({ type: 'unknown', message: stderr }), + classifyListIssuesError: (stderr: string) => ({ type: 'unknown', message: stderr }) +})) + +vi.mock('../git/runner', () => ({ + gitExecFileAsync: gitExecFileAsyncMock +})) + +vi.mock('./rate-limit', () => ({ + rateLimitGuard: rateLimitGuardMock, + noteRateLimitSpend: noteRateLimitSpendMock, + getRateLimit: vi.fn(async () => ({ ok: false, error: 'not probed in tests' })), + // Mirror production: shared-scope calls delegate to the global guard/spend. + repositoryRateLimitGuard: repositoryRateLimitGuardMock, + noteRepositoryRateLimitSpend: noteRepositoryRateLimitSpendMock, + spendsSharedGitHubComQuota: spendsSharedGitHubComQuotaMock +})) + +vi.mock('./github-api-repository', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + // Why: these suites drive source resolution through the legacy gh-utils + // mocks; bridge the hosted seams onto the same mocks. + resolveIssueGitHubApiRepositorySource: ( + repoPath: string, + preference: unknown, + connectionId?: string | null, + localGitOptions?: unknown + ) => resolveIssueSourceMock(repoPath, preference, connectionId, localGitOptions), + getIssueGitHubApiRepository: (repoPath: string, connectionId?: string | null) => + getIssueOwnerRepoMock(repoPath, connectionId), + getOriginGitHubApiRepository: ( + repoPath: string, + connectionId?: string | null, + localGitOptions?: unknown + ) => getOwnerRepoMock(repoPath, connectionId, localGitOptions), + getGitHubApiRepositoryForRemote: ( + repoPath: string, + remoteName: string, + connectionId?: string | null, + localGitOptions?: unknown + ) => + remoteName === 'origin' + ? getOwnerRepoMock(repoPath, connectionId, localGitOptions) + : getOwnerRepoForRemoteMock(repoPath, remoteName, connectionId, localGitOptions) + } +}) + +import { + countWorkItems, + listWorkItems, + _resetMergeQueueCacheForTests, + _resetOwnerRepoCache +} from './client' +import { _resetGhCwdRepoNegativeCache } from './gh-cwd-repo-negative-cache' +import { GITHUB_WORK_ITEMS_QUERY_MAX_BYTES } from '../../shared/github-work-items-query-bounds' + +import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository' + +// The origin-repository cache is module-level state; reset it so slugs +// resolved by one test cannot leak into the next. +beforeEach(() => { + _resetOriginGitHubApiRepositoryCache() +}) +// Why: split from client-work-items.test.ts to keep both suites under the +// max-lines cap; this file owns query/paging request-shaping cases. +describe('listWorkItems query paging', () => { + beforeEach(() => { + execFileAsyncMock.mockReset() + ghExecFileAsyncMock.mockReset() + getOwnerRepoMock.mockReset() + getIssueOwnerRepoMock.mockReset() + getOwnerRepoForRemoteMock.mockReset() + resolveIssueSourceMock.mockReset() + gitExecFileAsyncMock.mockReset() + rateLimitGuardMock.mockReset() + rateLimitGuardMock.mockReturnValue({ blocked: false }) + noteRateLimitSpendMock.mockReset() + repositoryRateLimitGuardMock.mockClear() + noteRepositoryRateLimitSpendMock.mockReset() + spendsSharedGitHubComQuotaMock.mockReset() + spendsSharedGitHubComQuotaMock.mockReturnValue(true) + acquireMock.mockReset() + releaseMock.mockReset() + acquireMock.mockResolvedValue(undefined) + // Why: preference-aware `listWorkItems` calls `resolveIssueSource`. + // Route through the same `getIssueOwnerRepoMock` so existing tests that + // only set up `getIssueOwnerRepoMock` continue to work. + resolveIssueSourceMock.mockImplementation(async () => ({ + source: await getIssueOwnerRepoMock(), + fellBack: false + })) + getOwnerRepoForRemoteMock.mockResolvedValue(null) + _resetOwnerRepoCache() + _resetMergeQueueCacheForTests() + _resetGhCwdRepoNegativeCache() + }) + + it('returns zero for oversized count queries before resolving repo sources', async () => { + const secret = 'main-github-work-items-secret' + const oversizedQuery = secret + 'x'.repeat(GITHUB_WORK_ITEMS_QUERY_MAX_BYTES) + + await expect(countWorkItems('/repo-root', oversizedQuery)).resolves.toBe(0) + + expect(resolveIssueSourceMock).not.toHaveBeenCalled() + expect(getIssueOwnerRepoMock).not.toHaveBeenCalled() + expect(getOwnerRepoMock).not.toHaveBeenCalled() + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + expect(acquireMock).not.toHaveBeenCalled() + expect(releaseMock).not.toHaveBeenCalled() + }) + + it('uses an implicit WSL UNC cwd for search quota accounting', async () => { + const repository = { owner: 'acme', repo: 'widgets', host: 'github.com' } + const repoPath = String.raw`\\wsl.localhost\Ubuntu\home\me\widgets` + getIssueOwnerRepoMock.mockResolvedValueOnce(repository) + getOwnerRepoMock.mockResolvedValueOnce(repository) + ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '3' }) + spendsSharedGitHubComQuotaMock.mockReturnValue(false) + + await expect(countWorkItems(repoPath, 'is:issue is:open')).resolves.toBe(3) + + const executionOptions = { cwd: repoPath, host: 'github.com' } + expect(spendsSharedGitHubComQuotaMock).toHaveBeenCalledWith(repository, executionOptions) + expect(repositoryRateLimitGuardMock).toHaveBeenCalledWith( + repository, + 'search', + executionOptions + ) + expect(noteRepositoryRateLimitSpendMock).toHaveBeenCalledWith( + repository, + 'search', + 1, + executionOptions + ) + }) + + it('passes review-requested as a --search qualifier (gh CLI has no dedicated flag)', async () => { + getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) + getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }) + + await listWorkItems('/repo-root', 10, 'review-requested:@me is:open') + + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + expect.arrayContaining(['--search', 'is:pr is:open review-requested:@me sort:created-desc']), + { cwd: '/repo-root' } + ) + expect(ghExecFileAsyncMock).not.toHaveBeenCalledWith( + expect.arrayContaining(['--review-requested']), + expect.anything() + ) + }) + + it('uses the requested numbered Search API page for issues', async () => { + getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) + getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }) + + await listWorkItems('/repo-root', 10, 'is:issue is:open', 2) + + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + [ + 'api', + '--cache', + '120s', + `search/issues?q=${encodeURIComponent('repo:acme/widgets is:issue is:open')}&sort=created&order=desc&per_page=10&page=2`, + '--jq', + '.items' + ], + { cwd: '/repo-root' } + ) + }) + + it('fetches and slices stable PR results for the requested numbered page', async () => { + getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) + getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify( + [1, 2, 3, 4].map((number) => ({ + number, + title: `PR ${number}`, + state: 'OPEN', + url: `https://github.com/acme/widgets/pull/${number}`, + labels: [], + updatedAt: `2026-07-0${number}T00:00:00Z`, + author: { login: 'octocat' }, + isDraft: false, + headRefName: `feature/${number}`, + headRefOid: `head-${number}`, + baseRefName: 'main' + })) + ) + }) + + const { items } = await listWorkItems('/repo-root', 2, 'is:pr is:open', 2) + + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + expect.arrayContaining(['--limit', '4', '--search', 'is:pr is:open sort:created-desc']), + { cwd: '/repo-root' } + ) + expect(items.map((item) => item.number)).toEqual([4, 3]) + }) + + it('filters pull request rows out of issue Search API results', async () => { + getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) + getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify([ + { + number: 2, + title: 'PR-shaped search row', + state: 'open', + html_url: 'https://github.com/acme/widgets/pull/2', + labels: [], + updated_at: '2026-07-02T00:00:00Z', + user: { login: 'octocat' }, + pull_request: { url: 'https://api.github.com/repos/acme/widgets/pulls/2' } + }, + { + number: 1, + title: 'Issue row', + state: 'open', + html_url: 'https://github.com/acme/widgets/issues/1', + labels: [], + updated_at: '2026-07-01T00:00:00Z', + user: { login: 'octocat' } + } + ]) + }) + + const { items } = await listWorkItems('/repo-root', 10, 'is:issue is:open') + + expect(items.map((item) => item.id)).toEqual(['issue:1']) + }) +}) diff --git a/src/main/github/client-work-items.test.ts b/src/main/github/client-work-items.test.ts index fa4f90dcd29..0ac756c9b54 100644 --- a/src/main/github/client-work-items.test.ts +++ b/src/main/github/client-work-items.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as GithubApiRepositoryModule from './github-api-repository' const { execFileAsyncMock, @@ -20,7 +21,7 @@ const { getOwnerRepoForRemoteMock: vi.fn(), resolveIssueSourceMock: vi.fn(), gitExecFileAsyncMock: vi.fn(), - rateLimitGuardMock: vi.fn(() => ({ blocked: false })), + rateLimitGuardMock: vi.fn((_bucket?: unknown) => ({ blocked: false })), noteRateLimitSpendMock: vi.fn(), acquireMock: vi.fn(), releaseMock: vi.fn() @@ -64,9 +65,46 @@ vi.mock('../git/runner', () => ({ vi.mock('./rate-limit', () => ({ rateLimitGuard: rateLimitGuardMock, noteRateLimitSpend: noteRateLimitSpendMock, - getRateLimit: vi.fn(async () => ({ ok: false, error: 'not probed in tests' })) + getRateLimit: vi.fn(async () => ({ ok: false, error: 'not probed in tests' })), + // Mirror production: shared-scope calls delegate to the global guard/spend. + repositoryRateLimitGuard: vi.fn((_repo: unknown, bucket: string) => rateLimitGuardMock(bucket)), + noteRepositoryRateLimitSpend: vi.fn((_repo: unknown, bucket: string, cost?: number) => + noteRateLimitSpendMock(bucket, cost) + ), + spendsSharedGitHubComQuota: () => true })) +vi.mock('./github-api-repository', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + // Why: these suites drive source resolution through the legacy gh-utils + // mocks; bridge the hosted seams onto the same mocks. + resolveIssueGitHubApiRepositorySource: ( + repoPath: string, + preference: unknown, + connectionId?: string | null, + localGitOptions?: unknown + ) => resolveIssueSourceMock(repoPath, preference, connectionId, localGitOptions), + getIssueGitHubApiRepository: (repoPath: string, connectionId?: string | null) => + getIssueOwnerRepoMock(repoPath, connectionId), + getOriginGitHubApiRepository: ( + repoPath: string, + connectionId?: string | null, + localGitOptions?: unknown + ) => getOwnerRepoMock(repoPath, connectionId, localGitOptions), + getGitHubApiRepositoryForRemote: ( + repoPath: string, + remoteName: string, + connectionId?: string | null, + localGitOptions?: unknown + ) => + remoteName === 'origin' + ? getOwnerRepoMock(repoPath, connectionId, localGitOptions) + : getOwnerRepoForRemoteMock(repoPath, remoteName, connectionId, localGitOptions) + } +}) + import { countWorkItems, listWorkItems, @@ -76,6 +114,14 @@ import { import { _resetGhCwdRepoNegativeCache } from './gh-cwd-repo-negative-cache' import { GITHUB_WORK_ITEMS_QUERY_MAX_BYTES } from '../../shared/github-work-items-query-bounds' +import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository' + +// The origin-repository cache is module-level state; reset it so slugs +// resolved by one test cannot leak into the next. +beforeEach(() => { + _resetOriginGitHubApiRepositoryCache() +}) + describe('listWorkItems', () => { beforeEach(() => { execFileAsyncMock.mockReset() @@ -111,6 +157,21 @@ describe('listWorkItems', () => { _resetGhCwdRepoNegativeCache() }) + it('routes GHES work-item listing through the Enterprise host', async () => { + const ghes = { owner: 'team', repo: 'orca', host: 'github.acme-corp.com' } + getIssueOwnerRepoMock.mockResolvedValue(ghes) + getOwnerRepoMock.mockResolvedValue(ghes) + ghExecFileAsyncMock.mockResolvedValue({ stdout: '[]' }) + + await listWorkItems('/repo-root', 10) + + const calls = ghExecFileAsyncMock.mock.calls + expect(calls.length).toBeGreaterThan(0) + // Why: every list spawn must pin options.host so the runner targets the + // Enterprise server instead of gh's default host. + expect(calls.every(([, options]) => options?.host === 'github.acme-corp.com')).toBe(true) + }) + it('stops re-spawning gh for a repo whose cwd resolution already failed with no remotes', async () => { getIssueOwnerRepoMock.mockResolvedValue(null) getOwnerRepoMock.mockResolvedValue(null) @@ -292,12 +353,9 @@ describe('listWorkItems', () => { null, localGitOptions ) - expect(getOwnerRepoForRemoteMock).toHaveBeenCalledWith( - '/repo-root', - 'origin', - null, - localGitOptions - ) + // Why: the suite bridge routes origin through getOwnerRepoMock and non-origin + // remotes through getOwnerRepoForRemoteMock. + expect(getOwnerRepoMock).toHaveBeenCalledWith('/repo-root', null, localGitOptions) expect(getOwnerRepoForRemoteMock).toHaveBeenCalledWith( '/repo-root', 'upstream', @@ -595,120 +653,6 @@ describe('listWorkItems', () => { expect(ghExecFileAsyncMock).not.toHaveBeenCalled() }) - it('returns zero for oversized count queries before resolving repo sources', async () => { - const secret = 'main-github-work-items-secret' - const oversizedQuery = secret + 'x'.repeat(GITHUB_WORK_ITEMS_QUERY_MAX_BYTES) - - await expect(countWorkItems('/repo-root', oversizedQuery)).resolves.toBe(0) - - expect(resolveIssueSourceMock).not.toHaveBeenCalled() - expect(getIssueOwnerRepoMock).not.toHaveBeenCalled() - expect(getOwnerRepoMock).not.toHaveBeenCalled() - expect(ghExecFileAsyncMock).not.toHaveBeenCalled() - expect(acquireMock).not.toHaveBeenCalled() - expect(releaseMock).not.toHaveBeenCalled() - }) - - it('passes review-requested as a --search qualifier (gh CLI has no dedicated flag)', async () => { - getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }) - - await listWorkItems('/repo-root', 10, 'review-requested:@me is:open') - - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) - expect(ghExecFileAsyncMock).toHaveBeenCalledWith( - expect.arrayContaining(['--search', 'is:pr is:open review-requested:@me sort:created-desc']), - { cwd: '/repo-root' } - ) - expect(ghExecFileAsyncMock).not.toHaveBeenCalledWith( - expect.arrayContaining(['--review-requested']), - expect.anything() - ) - }) - - it('uses the requested numbered Search API page for issues', async () => { - getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }) - - await listWorkItems('/repo-root', 10, 'is:issue is:open', 2) - - expect(ghExecFileAsyncMock).toHaveBeenCalledWith( - [ - 'api', - '--cache', - '120s', - `search/issues?q=${encodeURIComponent('repo:acme/widgets is:issue is:open')}&sort=created&order=desc&per_page=10&page=2`, - '--jq', - '.items' - ], - { cwd: '/repo-root' } - ) - }) - - it('fetches and slices stable PR results for the requested numbered page', async () => { - getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify( - [1, 2, 3, 4].map((number) => ({ - number, - title: `PR ${number}`, - state: 'OPEN', - url: `https://github.com/acme/widgets/pull/${number}`, - labels: [], - updatedAt: `2026-07-0${number}T00:00:00Z`, - author: { login: 'octocat' }, - isDraft: false, - headRefName: `feature/${number}`, - headRefOid: `head-${number}`, - baseRefName: 'main' - })) - ) - }) - - const { items } = await listWorkItems('/repo-root', 2, 'is:pr is:open', 2) - - expect(ghExecFileAsyncMock).toHaveBeenCalledWith( - expect.arrayContaining(['--limit', '4', '--search', 'is:pr is:open sort:created-desc']), - { cwd: '/repo-root' } - ) - expect(items.map((item) => item.number)).toEqual([4, 3]) - }) - - it('filters pull request rows out of issue Search API results', async () => { - getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify([ - { - number: 2, - title: 'PR-shaped search row', - state: 'open', - html_url: 'https://github.com/acme/widgets/pull/2', - labels: [], - updated_at: '2026-07-02T00:00:00Z', - user: { login: 'octocat' }, - pull_request: { url: 'https://api.github.com/repos/acme/widgets/pulls/2' } - }, - { - number: 1, - title: 'Issue row', - state: 'open', - html_url: 'https://github.com/acme/widgets/issues/1', - labels: [], - updated_at: '2026-07-01T00:00:00Z', - user: { login: 'octocat' } - } - ]) - }) - - const { items } = await listWorkItems('/repo-root', 10, 'is:issue is:open') - - expect(items.map((item) => item.id)).toEqual(['issue:1']) - }) - it('returns open issues and PRs for the all-open preset query', async () => { getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) diff --git a/src/main/github/client.test.ts b/src/main/github/client.test.ts index 21fc7cc60b8..70c4ea276b4 100644 --- a/src/main/github/client.test.ts +++ b/src/main/github/client.test.ts @@ -1,5 +1,6 @@ /* oxlint-disable max-lines -- Why: GitHub client fixtures cover local and SSH repo identity paths in one suite so mocked CLI behavior stays consistent. */ import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as GithubApiRepositoryModule from './github-api-repository' type RateLimitGuardResult = | { blocked: false } @@ -75,6 +76,9 @@ vi.mock('./gh-utils', () => ({ if (lower.includes('rate limit')) { return { type: 'rate_limited', message: stderr } } + if (lower.includes('resource not accessible')) { + return { type: 'permission_denied', message: stderr } + } return { type: 'unknown', message: stderr } }, parseGitHubOwnerRepo: (remoteUrl: string) => { @@ -101,9 +105,66 @@ vi.mock('./local-git-config-signature', () => ({ vi.mock('./rate-limit', () => ({ getRateLimit: getRateLimitMock, rateLimitGuard: rateLimitGuardMock, - noteRateLimitSpend: noteRateLimitSpendMock + noteRateLimitSpend: noteRateLimitSpendMock, + // Why: the repository-scoped guards share the same bucket-keyed budget as the + // legacy ones, so delegate to the existing mocks to keep per-bucket blocking + // and spend assertions working unchanged. + repositoryRateLimitGuard: (_repository: unknown, bucket: string) => rateLimitGuardMock(bucket), + noteRepositoryRateLimitSpend: (_repository: unknown, bucket: string) => + noteRateLimitSpendMock(bucket), + spendsSharedGitHubComQuota: () => true })) +vi.mock('./github-api-repository', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + // Why: these suites inject repo identities through the legacy gh-utils + // mocks; bridge the hosted seams onto the same mocks so per-test setups + // keep driving resolution without real enterprise probes. + resolveGitHubApiRepositoryCandidates: ( + repoPath: string, + connectionId?: string | null, + localGitOptions?: unknown + ) => resolvePRRepositoryCandidatesMock(repoPath, connectionId, localGitOptions), + getGitHubApiRepositoryForRemote: ( + repoPath: string, + remoteName: string, + connectionId?: string | null + ) => getOwnerRepoForRemoteMock(repoPath, remoteName, connectionId), + getOriginGitHubApiRepository: async ( + repoPath: string, + connectionId?: string | null, + localGitOptions?: unknown + ) => { + // Prefer the remote-specific mock (production origin path); fall back for + // suites that only configure getOwnerRepo. + const fromRemote = await getOwnerRepoForRemoteMock( + repoPath, + 'origin', + connectionId, + localGitOptions + ) + const slug = fromRemote ?? (await getOwnerRepoMock(repoPath, connectionId, localGitOptions)) + // Mirror production: dotcom origin slugs come back pinned to github.com. + return slug ? { host: 'github.com', ...slug } : slug + }, + getIssueGitHubApiRepository: async (repoPath: string, connectionId?: string | null) => { + const slug = await getIssueOwnerRepoMock(repoPath, connectionId) + // Mirror production: issue slugs come back host-qualified to github.com. + return slug ? { host: 'github.com', ...slug } : slug + }, + resolveIssueGitHubApiRepositorySource: async ( + repoPath: string, + _preference: unknown, + connectionId?: string | null + ) => { + const slug = await getIssueOwnerRepoMock(repoPath, connectionId) + return { source: slug ? { host: 'github.com', ...slug } : slug, fellBack: false } + } + } +}) + import { checkOrcaStarred, getPRComments, @@ -112,6 +173,7 @@ import { getRepoSlug, getRepoUpstream, getWorkItem, + getWorkItemByOwnerRepo, getPullRequestPushTarget, mergePR, resolveReviewThread, @@ -128,6 +190,14 @@ import { __resetPRConflictSummaryCachesForTests } from './conflict-summary' import { resetMergedPRCommitMembershipCacheForTest } from './merged-pr-commit-membership' import { __resetRepoDefaultBranchCacheForTests } from '../source-control/repo-default-branch' +import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository' + +// The origin-repository cache is module-level state; reset it so slugs +// resolved by one test cannot leak into the next. +beforeEach(() => { + _resetOriginGitHubApiRepositoryCache() +}) + describe('checkOrcaStarred', () => { beforeEach(() => { execFileAsyncMock.mockReset() @@ -174,6 +244,11 @@ describe('getPRForBranch', () => { getOwnerRepoMock.mockReset() getIssueOwnerRepoMock.mockReset() getOwnerRepoForRemoteMock.mockReset() + // Why: resolveGitHubRepoExecution probes origin via getOwnerRepoForRemote. + getOwnerRepoForRemoteMock.mockImplementation( + async (repoPath: string, remoteName: string, connectionId?: string | null, opts = {}) => + remoteName === 'origin' ? getOwnerRepoMock(repoPath, connectionId, opts) : null + ) resolvePRRepositoryCandidatesMock.mockReset() resolvePRRepositoryCandidatesMock.mockImplementation(async (repoPath, connectionId) => { const origin = await getOwnerRepoMock(repoPath, connectionId) @@ -3546,17 +3621,27 @@ describe('getPRForBranch', () => { ? { owner: 'fsdwen', repo: 'orca' } : { owner: 'stablyai', repo: 'orca' } ) + // Why: getRepoSlug imports getOriginGitHubApiRepository; the suite bridge + // prefers getOwnerRepoForRemote for origin, so set both seams. + getOwnerRepoMock.mockResolvedValue({ owner: 'fsdwen', repo: 'orca' }) - await expect(getRepoSlug('/repo-root')).resolves.toEqual({ owner: 'fsdwen', repo: 'orca' }) - expect(getOwnerRepoForRemoteMock).toHaveBeenCalledWith('/repo-root', 'origin', undefined) + await expect(getRepoSlug('/repo-root')).resolves.toEqual({ + owner: 'fsdwen', + repo: 'orca', + host: 'github.com' + }) }) it('resolves a distinct upstream remote as the repo upstream', async () => { // getRepoUpstream probes origin then upstream via getOwnerRepoForRemote (#7331). - getOwnerRepoForRemoteMock - .mockResolvedValueOnce({ owner: 'tmchow', repo: 'orca' }) - .mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) + getOwnerRepoMock.mockResolvedValue({ owner: 'tmchow', repo: 'orca' }) + getOwnerRepoForRemoteMock.mockImplementation(async (_repoPath: string, remoteName: string) => + remoteName === 'origin' + ? { owner: 'tmchow', repo: 'orca' } + : { owner: 'stablyai', repo: 'orca' } + ) + // Why: the suite bridge returns getOwnerRepoForRemote fixtures as-is (no host pin). await expect(getRepoUpstream('/repo-root')).resolves.toEqual({ owner: 'stablyai', repo: 'orca' @@ -3566,9 +3651,12 @@ describe('getPRForBranch', () => { }) it('does not treat a same-repository upstream remote as a fork', async () => { - getOwnerRepoForRemoteMock - .mockResolvedValueOnce({ owner: 'StablyAI', repo: 'Orca' }) - .mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) + getOwnerRepoMock.mockResolvedValue({ owner: 'StablyAI', repo: 'Orca' }) + getOwnerRepoForRemoteMock.mockImplementation(async (_repoPath: string, remoteName: string) => + remoteName === 'origin' + ? { owner: 'StablyAI', repo: 'Orca' } + : { owner: 'stablyai', repo: 'orca' } + ) ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: JSON.stringify({ isFork: false, parent: null }) }) @@ -3576,8 +3664,10 @@ describe('getPRForBranch', () => { await expect(getRepoUpstream('/repo-root')).resolves.toBeNull() expect(ghExecFileAsyncMock).toHaveBeenCalledWith( - ['repo', 'view', 'StablyAI/Orca', '--json', 'isFork,parent'], - { cwd: '/repo-root', timeout: 10_000 } + // Why: positional slugs are explicit about github.com too, so GH_HOST + // cannot redirect them. + ['repo', 'view', 'github.com/StablyAI/Orca', '--json', 'isFork,parent'], + { cwd: '/repo-root', host: 'github.com', timeout: 10_000 } ) }) @@ -3588,7 +3678,7 @@ describe('getPRForBranch', () => { await expect(getRepoUpstream('/repo-root')).resolves.toBeNull() expect(getOwnerRepoForRemoteMock).toHaveBeenCalledTimes(1) - expect(getOwnerRepoForRemoteMock).toHaveBeenCalledWith('/repo-root', 'origin', undefined) + expect(getOwnerRepoForRemoteMock).toHaveBeenCalledWith('/repo-root', 'origin', undefined, {}) expect(ghExecFileAsyncMock).not.toHaveBeenCalled() }) @@ -3605,7 +3695,75 @@ describe('getPRForBranch', () => { await expect(getRepoUpstream('/repo-root')).resolves.toEqual({ owner: 'stablyai', - repo: 'orca' + repo: 'orca', + // Why: fork parents live on the same server as the fork's origin. + host: 'github.com' + }) + }) + + it('routes GHES push-target probes through the Enterprise host', async () => { + const ghes = { owner: 'team', repo: 'orca', host: 'github.acme-corp.com' } + resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ + candidates: [ghes], + headRepo: ghes + }) + getOwnerRepoForRemoteMock.mockResolvedValueOnce(ghes) + ghExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ + head: { + ref: 'feature', + repo: { + full_name: 'team/orca', + name: 'orca', + clone_url: 'https://github.acme-corp.com/team/orca.git', + ssh_url: 'git@github.acme-corp.com:team/orca.git', + owner: { login: 'team' } + } + } + }) + }) + + await expect(getPullRequestPushTarget('/repo-root', 7)).resolves.toEqual({ + pushTarget: { remoteName: 'origin', branchName: 'feature' } + }) + // Why: the candidate probe must pin options.host so the runner targets the + // Enterprise server instead of gh's default host. + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + ['api', 'repos/team/orca/pulls/7'], + expect.objectContaining({ host: 'github.acme-corp.com' }) + ) + }) + + it('does not confuse same-slug PR repositories across GitHub hosts', async () => { + const enterprise = { owner: 'team', repo: 'orca', host: 'github.acme-corp.com' } + const dotCom = { owner: 'team', repo: 'orca', host: 'github.com' } + resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ + candidates: [enterprise, dotCom], + headRepo: dotCom + }) + getOwnerRepoForRemoteMock.mockResolvedValueOnce(dotCom) + getRemoteUrlForRepoMock.mockResolvedValueOnce('git@github.com:team/orca.git') + ghExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ + head: { + ref: 'feature', + repo: { + full_name: 'team/orca', + name: 'orca', + clone_url: 'https://github.acme-corp.com/team/orca.git', + ssh_url: 'git@github.acme-corp.com:team/orca.git', + owner: { login: 'team' } + } + } + }) + }) + + await expect(getPullRequestPushTarget('/repo-root', 7)).resolves.toEqual({ + pushTarget: { + remoteName: 'pr-team-orca', + branchName: 'feature', + remoteUrl: 'git@github.acme-corp.com:team/orca.git' + } }) }) @@ -3651,6 +3809,45 @@ describe('getPRForBranch', () => { ) }) + it('probes the next PR work-item candidate after a permission denial', async () => { + const upstream = { owner: 'upstream', repo: 'orca', host: 'github.com' } + const origin = { owner: 'fork', repo: 'orca', host: 'github.com' } + resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ + candidates: [upstream, origin], + headRepo: origin + }) + ghExecFileAsyncMock + .mockRejectedValueOnce(new Error('GraphQL: Resource not accessible by integration')) + .mockRejectedValueOnce(new Error('GraphQL: Resource not accessible by integration')) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 42, + title: 'Origin PR', + state: 'OPEN', + url: 'https://github.com/fork/orca/pull/42', + labels: [], + updatedAt: '2026-07-16T00:00:00Z', + author: { login: 'octo' }, + isDraft: false + }) + }) + .mockRejectedValueOnce(new Error('metadata unavailable')) + + await expect(getWorkItem('/repo-root', 42, 'pr')).resolves.toMatchObject({ + number: 42, + title: 'Origin PR', + prRepo: origin + }) + + expect(ghExecFileAsyncMock.mock.calls[0][0]).toEqual( + expect.arrayContaining(['pr', 'view', '--repo', 'upstream/orca']) + ) + expect(ghExecFileAsyncMock.mock.calls[1][0]).toEqual(['api', 'repos/upstream/orca/pulls/42']) + expect(ghExecFileAsyncMock.mock.calls[2][0]).toEqual( + expect.arrayContaining(['pr', 'view', '--repo', 'fork/orca']) + ) + }) + it('normalizes reviewer avatars from REST pull request payloads', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock.mockResolvedValueOnce({ @@ -3687,6 +3884,12 @@ describe('updatePRState', () => { beforeEach(() => { ghExecFileAsyncMock.mockReset() getOwnerRepoMock.mockReset() + getOwnerRepoForRemoteMock.mockReset() + // Why: updatePRState resolves origin through getOwnerRepoForRemote. + getOwnerRepoForRemoteMock.mockImplementation( + async (repoPath: string, remoteName: string, connectionId?: string | null, opts = {}) => + remoteName === 'origin' ? getOwnerRepoMock(repoPath, connectionId, opts) : null + ) ghRepoExecOptionsMock.mockClear() githubRepoContextMock.mockClear() acquireMock.mockReset() @@ -3705,7 +3908,7 @@ describe('updatePRState', () => { expect(ghExecFileAsyncMock).toHaveBeenCalledWith( ['pr', 'reopen', '3977', '--repo', 'stablyai/orca'], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) expect(acquireMock).toHaveBeenCalledTimes(1) expect(releaseMock).toHaveBeenCalledTimes(1) @@ -3721,7 +3924,7 @@ describe('updatePRState', () => { expect(ghExecFileAsyncMock).toHaveBeenCalledWith( ['pr', 'close', '3977', '--repo', 'stablyai/orca'], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) }) @@ -3737,7 +3940,7 @@ describe('updatePRState', () => { expect(ghExecFileAsyncMock).toHaveBeenCalledWith( ['pr', 'reopen', '3977', '--repo', 'stablyai/orca'], - {} + { host: 'github.com' } ) }) }) @@ -3749,6 +3952,11 @@ describe('GitHub GraphQL rate-limit guard', () => { getOwnerRepoMock.mockReset() getIssueOwnerRepoMock.mockReset() getOwnerRepoForRemoteMock.mockReset() + // Why: getPRComments and mutations resolve origin via getOwnerRepoForRemote. + getOwnerRepoForRemoteMock.mockImplementation( + async (repoPath: string, remoteName: string, connectionId?: string | null, opts = {}) => + remoteName === 'origin' ? getOwnerRepoMock(repoPath, connectionId, opts) : null + ) resolvePRRepositoryCandidatesMock.mockReset() resolvePRRepositoryCandidatesMock.mockImplementation(async (repoPath, connectionId) => { const origin = await getOwnerRepoMock(repoPath, connectionId) @@ -3815,18 +4023,23 @@ describe('GitHub GraphQL rate-limit guard', () => { }) .mockResolvedValueOnce({ stdout: '[]' }) - await getPRComments('/repo-root', 7, { prRepo: { owner: 'stablyai', repo: 'orca' } }, undefined) + await getPRComments( + '/repo-root', + 7, + { prRepo: { owner: 'stablyai', repo: 'orca', host: 'github.com' } }, + undefined + ) expect(getOwnerRepoMock).not.toHaveBeenCalled() expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 1, ['api', '--cache', '60s', 'repos/stablyai/orca/issues/7/comments?per_page=100'], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 2, ['api', '--cache', '60s', 'repos/stablyai/orca/pulls/7/reviews?per_page=100'], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) }) @@ -3850,10 +4063,18 @@ describe('GitHub GraphQL rate-limit guard', () => { .mockResolvedValue({ stdout: '', stderr: '' }) await expect( - mergePR('/repo-root', 7, 'squash', undefined, { owner: 'stablyai', repo: 'orca' }) + mergePR('/repo-root', 7, 'squash', undefined, { + owner: 'stablyai', + repo: 'orca', + host: 'github.com' + }) ).resolves.toEqual({ ok: true }) await expect( - updatePRTitle('/repo-root', 7, 'New title', undefined, { owner: 'stablyai', repo: 'orca' }) + updatePRTitle('/repo-root', 7, 'New title', undefined, { + owner: 'stablyai', + repo: 'orca', + host: 'github.com' + }) ).resolves.toBe(true) expect(getOwnerRepoMock).not.toHaveBeenCalled() @@ -3868,20 +4089,21 @@ describe('GitHub GraphQL rate-limit guard', () => { '--json', 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' ], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 2, ['pr', 'merge', '7', '--squash', '--repo', 'stablyai/orca'], expect.objectContaining({ cwd: '/repo-root', - env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' }) + env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' }), + host: 'github.com' }) ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 3, ['pr', 'edit', '7', '--title', 'New title', '--repo', 'stablyai/orca'], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) }) @@ -3895,20 +4117,22 @@ describe('GitHub GraphQL rate-limit guard', () => { await expect( setPRAutoMerge('/remote/repo-root', 7, true, 'squash', 'ssh-1', { owner: 'stablyai', - repo: 'orca' + repo: 'orca', + host: 'github.com' }) ).resolves.toEqual({ ok: true }) await expect( setPRAutoMerge('/remote/repo-root', 7, false, 'squash', 'ssh-1', { owner: 'stablyai', - repo: 'orca' + repo: 'orca', + host: 'github.com' }) ).resolves.toEqual({ ok: true }) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 1, ['pr', 'view', '7', '--json', 'id,headRefOid,baseRefName', '--repo', 'stablyai/orca'], - {} + { host: 'github.com' } ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 2, @@ -3923,14 +4147,16 @@ describe('GitHub GraphQL rate-limit guard', () => { 'expectedHeadOid=head-oid' ]), expect.objectContaining({ - env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' }) + env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' }), + host: 'github.com' }) ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 3, ['pr', 'merge', '7', '--disable-auto', '--repo', 'stablyai/orca'], expect.objectContaining({ - env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' }) + env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' }), + host: 'github.com' }) ) expect(ghExecFileAsyncMock.mock.calls[0]?.[1]).not.toHaveProperty('cwd') @@ -3946,7 +4172,8 @@ describe('GitHub GraphQL rate-limit guard', () => { await expect( setPRAutoMerge('/repo-root', 7, true, 'squash', undefined, { owner: 'stablyai', - repo: 'orca' + repo: 'orca', + host: 'github.com' }) ).resolves.toEqual({ ok: true }) @@ -3973,7 +4200,8 @@ describe('GitHub GraphQL rate-limit guard', () => { await expect( setPRAutoMerge('/repo-root', 7, true, 'squash', undefined, { owner: 'stablyai', - repo: 'orca' + repo: 'orca', + host: 'github.com' }) ).resolves.toEqual({ ok: false, @@ -3994,21 +4222,23 @@ describe('GitHub GraphQL rate-limit guard', () => { await expect( setPRAutoMerge('/repo-root', 7, true, 'squash', undefined, { owner: 'stablyai', - repo: 'orca' + repo: 'orca', + host: 'github.com' }) ).resolves.toEqual({ ok: true }) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 2, expect.arrayContaining(['api', 'graphql', '-f', 'branch=main']), - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 3, ['pr', 'merge', '7', '--auto', '--squash', '--repo', 'stablyai/orca'], expect.objectContaining({ cwd: '/repo-root', - env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' }) + env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' }), + host: 'github.com' }) ) expect( @@ -4039,7 +4269,11 @@ describe('GitHub GraphQL rate-limit guard', () => { }) await expect( - mergePR('/repo-root', 7, 'squash', undefined, { owner: 'stablyai', repo: 'orca' }) + mergePR('/repo-root', 7, 'squash', undefined, { + owner: 'stablyai', + repo: 'orca', + host: 'github.com' + }) ).resolves.toEqual({ ok: false, error: 'This pull request requires review approval before it can be merged.' @@ -4074,7 +4308,11 @@ describe('GitHub GraphQL rate-limit guard', () => { .mockResolvedValueOnce({ stdout: JSON.stringify(prView) }) await expect( - mergePR('/repo-root', 7, 'squash', undefined, { owner: 'stablyai', repo: 'orca' }) + mergePR('/repo-root', 7, 'squash', undefined, { + owner: 'stablyai', + repo: 'orca', + host: 'github.com' + }) ).resolves.toEqual({ ok: false, error: @@ -4163,6 +4401,82 @@ describe('GitHub GraphQL rate-limit guard', () => { expect(_getMergeQueueCacheSizeForTests()).toBe(256) }) + it('isolates merge metadata for the same slug on different GitHub hosts', async () => { + resolvePRRepositoryCandidatesMock.mockResolvedValue({ + candidates: [ + { owner: 'acme', repo: 'widgets', host: 'github.com' }, + { owner: 'acme', repo: 'widgets', host: 'github.acme-corp.com' } + ], + headRepo: { owner: 'acme', repo: 'widgets', host: 'github.com' } + }) + const prView = { + number: 7, + title: 'PR', + state: 'OPEN', + url: 'https://github.com/acme/widgets/pull/7', + statusCheckRollup: [], + updatedAt: '2026-07-16T00:00:00Z', + isDraft: false, + mergeable: 'MERGEABLE', + baseRefName: 'main', + headRefOid: 'head-oid' + } + ghExecFileAsyncMock.mockImplementation(async (args, options) => { + if (args.includes('graphql')) { + const enterprise = options?.host === 'github.acme-corp.com' + return { + stdout: JSON.stringify({ + data: { + repository: { + mergeQueue: null, + autoMergeAllowed: !enterprise + } + } + }) + } + } + return { stdout: JSON.stringify(prView) } + }) + + const githubDotCom = await getWorkItemByOwnerRepo( + '/repo-root', + { owner: 'acme', repo: 'widgets', host: 'github.com' }, + 7, + 'pr' + ) + const enterprise = await getWorkItemByOwnerRepo( + '/repo-root', + { owner: 'acme', repo: 'widgets', host: 'github.acme-corp.com' }, + 7, + 'pr' + ) + + expect(githubDotCom?.autoMergeAllowed).toBe(true) + expect(enterprise?.autoMergeAllowed).toBe(false) + const graphqlCalls = ghExecFileAsyncMock.mock.calls.filter(([args]) => args.includes('graphql')) + expect(graphqlCalls).toHaveLength(2) + expect(graphqlCalls[1]?.[1]).toEqual(expect.objectContaining({ host: 'github.acme-corp.com' })) + }) + + it('rejects explicit work-item lookups outside configured repository candidates', async () => { + resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ + candidates: [{ owner: 'acme', repo: 'widgets', host: 'github.com' }], + headRepo: { owner: 'acme', repo: 'widgets', host: 'github.com' } + }) + + await expect( + getWorkItemByOwnerRepo( + '/repo-root', + { owner: 'victim', repo: 'secrets', host: 'evil.example.test' }, + 7, + 'pr' + ) + ).resolves.toBeNull() + + expect(acquireMock).not.toHaveBeenCalled() + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + }) + it('returns conflicting file details instead of running gh merge when PR is dirty', async () => { ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: JSON.stringify({ @@ -4187,7 +4501,11 @@ describe('GitHub GraphQL rate-limit guard', () => { .mockResolvedValueOnce({ stdout: 'result-tree-oid\u0000src/conflict.ts\u0000' }) await expect( - mergePR('/repo-root', 7, 'squash', undefined, { owner: 'stablyai', repo: 'orca' }) + mergePR('/repo-root', 7, 'squash', undefined, { + owner: 'stablyai', + repo: 'orca', + host: 'github.com' + }) ).resolves.toEqual({ ok: false, error: @@ -4220,7 +4538,11 @@ describe('GitHub GraphQL rate-limit guard', () => { .mockResolvedValueOnce({ stdout: '', stderr: '' }) await expect( - mergePR('/remote/repo-root', 7, 'squash', 'ssh-1', { owner: 'stablyai', repo: 'orca' }) + mergePR('/remote/repo-root', 7, 'squash', 'ssh-1', { + owner: 'stablyai', + repo: 'orca', + host: 'github.com' + }) ).resolves.toEqual({ ok: true }) expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) diff --git a/src/main/github/client.ts b/src/main/github/client.ts index d67b3e4405b..6c05d286c2f 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -52,11 +52,6 @@ import { gitExecFileAsync, acquire, release, - getOwnerRepo, - getIssueOwnerRepo, - getOwnerRepoForRemote, - resolvePRRepositoryCandidates, - resolveIssueSource, classifyGhError, classifyListIssuesError, ghRepoExecOptions, @@ -85,7 +80,21 @@ import { rememberGhCwdResolutionFailure } from './gh-cwd-repo-negative-cache' import type { GitHubRepoContext } from './github-repository-identity' -import { getEnterpriseGitHubRepoSlug } from './github-enterprise-repository' +import { + getGitHubApiRepositoryForRemote, + getIssueGitHubApiRepository, + getOriginGitHubApiRepository, + githubHostExecOptions, + githubRepositorySlugArg, + githubRepositoryWebHost, + resolveGitHubApiRepository, + resolveGitHubApiRepositoryCandidates, + resolveGitHubRepoExecution, + resolveIssueGitHubApiRepositorySource, + type GitHubRepoExecOptions, + type GitHubApiRepository +} from './github-api-repository' +import { githubRepoIdentityKey } from '../../shared/github-repository-identity-key' export { _resetOwnerRepoCache } from './gh-utils' export { getIssue, @@ -109,12 +118,13 @@ import { import { mapGraphQLReactionGroups, type GitHubGraphQLReactionGroup } from './comment-reactions' import { getRateLimit, - noteRateLimitSpend, - rateLimitGuard, + noteRepositoryRateLimitSpend, + repositoryRateLimitGuard, + spendsSharedGitHubComQuota, type RateLimitBucketKind } from './rate-limit' -type GhExecOptions = ReturnType +type GhExecOptions = GitHubRepoExecOptions type HostedReviewLocalGitOptions = ReturnType const ORCA_REPO = 'stablyai/orca' @@ -175,9 +185,15 @@ function pruneRepositoryMergeMetadataCache(now = Date.now()): void { } } -async function assertRateLimitBudget(bucket: RateLimitBucketKind): Promise { - await getRateLimit() - const guard = rateLimitGuard(bucket) +async function assertRateLimitBudget( + bucket: RateLimitBucketKind, + repository?: GitHubApiRepository | null, + executionOptions?: Pick +): Promise { + if (spendsSharedGitHubComQuota(repository, executionOptions)) { + await getRateLimit() + } + const guard = repositoryRateLimitGuard(repository, bucket, executionOptions) if (guard.blocked) { throw new Error( `GitHub ${bucket} rate limit is low; retry after ${new Date(guard.resetAt * 1000).toLocaleTimeString()}` @@ -281,7 +297,7 @@ export async function getPullRequestPushTarget( ): Promise { const context = githubRepoContext(repoPath, connectionId, localGitOptions) const ghOptions = ghRepoExecOptions(context) - const { candidates } = await resolvePRRepositoryCandidates( + const { candidates } = await resolveGitHubApiRepositoryCandidates( repoPath, connectionId, localGitOptions @@ -293,15 +309,15 @@ export async function getPullRequestPushTarget( await acquire() try { let prStdout = '' + let matchedRepository: GitHubApiRepository | null = null for (const candidate of candidates) { try { const { stdout } = await ghExecFileAsync( ['api', `repos/${candidate.owner}/${candidate.repo}/pulls/${prNumber}`], - { - ...ghOptions - } + { ...ghOptions, ...githubHostExecOptions(candidate) } ) prStdout = stdout + matchedRepository = candidate break } catch (error) { // Why: origin is often the contributor fork while the PR belongs to upstream; probe all PR repos before giving up. @@ -311,10 +327,15 @@ export async function getPullRequestPushTarget( throw error } } - if (!prStdout) { + if (!prStdout || !matchedRepository) { return null } - const origin = await getOwnerRepoForRemote(repoPath, 'origin', connectionId, localGitOptions) + const origin = await getGitHubApiRepositoryForRemote( + repoPath, + 'origin', + connectionId, + localGitOptions + ) const pr = JSON.parse(prStdout) as { maintainer_can_modify?: boolean head?: { @@ -341,8 +362,8 @@ export async function getPullRequestPushTarget( } if ( origin && - origin.owner.toLowerCase() === owner.toLowerCase() && - origin.repo.toLowerCase() === repo.toLowerCase() + githubRepoIdentityKey(origin) === + githubRepoIdentityKey({ owner, repo, host: matchedRepository.host }) ) { return { pushTarget: { remoteName: 'origin', branchName }, @@ -723,7 +744,15 @@ function mapPullRequestWorkItem( ? String((item.base as { ref?: unknown }).ref ?? '') : String(item.baseRefName ?? ''), ...(headSha ? { headSha } : {}), - ...(baseOwnerRepo ? { prRepo: { owner: baseOwnerRepo.owner, repo: baseOwnerRepo.repo } } : {}), + ...(baseOwnerRepo + ? { + prRepo: { + owner: baseOwnerRepo.owner, + repo: baseOwnerRepo.repo, + ...(baseOwnerRepo.host ? { host: baseOwnerRepo.host } : {}) + } + } + : {}), ...(additions !== undefined ? { additions } : {}), ...(deletions !== undefined ? { deletions } : {}), ...(changedFiles !== undefined ? { changedFiles } : {}), @@ -787,12 +816,15 @@ async function hydrateWorkItemRepositoryMergeMetadata( async function fetchIssueWorkItem( repoPath: string, - ownerRepo: OwnerRepo | null, + ownerRepo: GitHubApiRepository | null, number: number, connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) + const ghOptions = { + ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), + ...githubHostExecOptions(ownerRepo) + } if (ownerRepo) { const { stdout } = await ghExecFileAsync( ['api', `repos/${ownerRepo.owner}/${ownerRepo.repo}/issues/${number}`], @@ -805,6 +837,12 @@ async function fetchIssueWorkItem( return mapIssueWorkItem(item) } + if (connectionId) { + // Why: SSH-backed gh has no repository cwd. A bare lookup could honor the + // local process GH_REPO/GH_HOST and return an unrelated repository item. + return null + } + const { stdout } = await ghExecFileAsync( ['issue', 'view', String(number), '--json', 'number,title,state,url,labels,updatedAt,author'], ghOptions @@ -817,7 +855,7 @@ const WORK_ITEM_PR_REVIEW_JSON_FIELDS = 'reviewRequests,latestReviews' async function fetchPullRequestReviewFields( number: number, - ownerRepo: OwnerRepo | null, + ownerRepo: GitHubApiRepository | null, ghOptions: GhExecOptions ): Promise> { try { @@ -849,12 +887,15 @@ async function fetchPullRequestReviewFields( async function fetchPullRequestWorkItem( repoPath: string, - ownerRepo: OwnerRepo | null, + ownerRepo: GitHubApiRepository | null, number: number, connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} -): Promise { - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) +): Promise { + const ghOptions = { + ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), + ...githubHostExecOptions(ownerRepo) + } if (ownerRepo) { try { const { stdout } = await ghExecFileAsync( @@ -902,6 +943,12 @@ async function fetchPullRequestWorkItem( } } + if (connectionId) { + // Why: connection-backed gh cannot infer a repository from cwd. Refuse a + // bare call so process-level GH_REPO/GH_HOST cannot redirect the lookup. + return null + } + const { stdout } = await ghExecFileAsync( ['pr', 'view', String(number), '--json', WORK_ITEM_PR_DETAIL_JSON_FIELDS], ghOptions @@ -909,6 +956,40 @@ async function fetchPullRequestWorkItem( return mapPullRequestWorkItem(JSON.parse(stdout) as Record) } +async function fetchPullRequestWorkItemFromCandidates( + repoPath: string, + number: number, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + const { candidates } = await resolveGitHubApiRepositoryCandidates( + repoPath, + connectionId, + localGitOptions + ) + if (candidates.length === 0) { + return fetchPullRequestWorkItem(repoPath, null, number, connectionId, localGitOptions) + } + for (const candidate of candidates) { + try { + return await fetchPullRequestWorkItem( + repoPath, + candidate, + number, + connectionId, + localGitOptions + ) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + const classification = classifyGhError(message).type + if (classification !== 'not_found' && classification !== 'permission_denied') { + throw err + } + } + } + return null +} + type WorkItemListRequest = { args: string[] offset: number @@ -1033,9 +1114,8 @@ 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). - getOwnerRepoForRemote(repoPath, 'origin', connectionId, localGitOptions), - getOwnerRepoForRemote(repoPath, 'upstream', connectionId, localGitOptions) + getOriginGitHubApiRepository(repoPath, connectionId, localGitOptions), + getGitHubApiRepositoryForRemote(repoPath, 'upstream', connectionId, localGitOptions) ]) const source = preference === 'upstream' ? (upstreamCandidate ?? originCandidate) : originCandidate @@ -1102,12 +1182,15 @@ async function listRecentWorkItems( // 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) + ? ghExecFileAsync(issueRequest.args, { + ...ghOptions, + ...githubHostExecOptions(issueOwnerRepo) + }) : requiresExplicitRepo ? Promise.resolve({ stdout: '[]' }) : ghCwdResolvedExec(repoContext, issueRequest.args, ghOptions), prOwnerRepo - ? ghExecFileAsync(prRequest.args, ghOptions) + ? ghExecFileAsync(prRequest.args, { ...ghOptions, ...githubHostExecOptions(prOwnerRepo) }) : requiresExplicitRepo ? Promise.resolve({ stdout: '[]' }) : ghCwdResolvedExec(repoContext, prRequest.args, ghOptions) @@ -1133,7 +1216,10 @@ async function listRecentWorkItems( prs = (JSON.parse(prsSettled.value.stdout) as Record[]) .slice(prRequest.offset, prRequest.offset + limit) .map((item) => mapPullRequestWorkItem(item, prOwnerRepo)) - prs = await hydrateWorkItemRepositoryMergeMetadata(prs, prOwnerRepo, ghOptions) + prs = await hydrateWorkItemRepositoryMergeMetadata(prs, prOwnerRepo, { + ...ghOptions, + ...githubHostExecOptions(prOwnerRepo) + }) } else { // 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. @@ -1213,7 +1299,10 @@ async function listQueriedWorkItems( }) try { const { stdout } = issueOwnerRepo - ? await ghExecFileAsync(request.args, ghOptions) + ? await ghExecFileAsync(request.args, { + ...ghOptions, + ...githubHostExecOptions(issueOwnerRepo) + }) : await ghCwdResolvedExec(repoContext, request.args, ghOptions) const items = (JSON.parse(stdout) as Record[]) .filter((item) => !('pull_request' in item)) @@ -1247,12 +1336,18 @@ async function listQueriedWorkItems( }) try { const { stdout } = prOwnerRepo - ? await ghExecFileAsync(request.args, ghOptions) + ? await ghExecFileAsync(request.args, { + ...ghOptions, + ...githubHostExecOptions(prOwnerRepo) + }) : await ghCwdResolvedExec(repoContext, request.args, ghOptions) const mapped = (JSON.parse(stdout) as Record[]) .slice(request.offset, request.offset + limit) .map((item) => mapPullRequestWorkItem(item, prOwnerRepo)) - const hydrated = await hydrateWorkItemRepositoryMergeMetadata(mapped, prOwnerRepo, ghOptions) + const hydrated = await hydrateWorkItemRepositoryMergeMetadata(mapped, prOwnerRepo, { + ...ghOptions, + ...githubHostExecOptions(prOwnerRepo) + }) successfulRequestCount += 1 if (query.state === 'closed') { return hydrated.filter((item) => item.state !== 'merged') @@ -1305,7 +1400,7 @@ export async function listWorkItems( } } const [issueResolved, prResolved] = await Promise.all([ - resolveIssueSource(repoPath, preference, connectionId, localGitOptions), + resolveIssueGitHubApiRepositorySource(repoPath, preference, connectionId, localGitOptions), resolvePrWorkItemSource(repoPath, preference, connectionId, localGitOptions) ]) const issueOwnerRepo = issueResolved.source @@ -1409,7 +1504,10 @@ async function countWorkItemsForQuery( localGitOptions: LocalGitExecOptions = {} ): Promise { const searchQ = buildSearchQueryString(ownerRepo, query) - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) + const ghOptions = { + ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), + ...githubHostExecOptions(ownerRepo) + } const { stdout } = await ghExecFileAsync( [ 'api', @@ -1422,16 +1520,13 @@ async function countWorkItemsForQuery( ghOptions ) // Why: over-counting cache hits is the safe direction — the next probe corrects the estimate. - noteRateLimitSpend('search') + noteRepositoryRateLimitSpend(ownerRepo, 'search', 1, ghOptions) return Number.parseInt(stdout.trim(), 10) || 0 } function sameOwnerRepo(left: OwnerRepo | null, right: OwnerRepo | null): boolean { - // 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() - ) + // Why: casing does not distinguish GitHub repos, but the same slug on different hosts does. + return Boolean(left && right && githubRepoIdentityKey(left) === githubRepoIdentityKey(right)) } function defaultOpenWorkItemQuery(): ParsedTaskQuery { @@ -1461,7 +1556,7 @@ export async function countWorkItems( return 0 } const [issueResolved, prResolved] = await Promise.all([ - resolveIssueSource(repoPath, preference, connectionId, localGitOptions), + resolveIssueGitHubApiRepositorySource(repoPath, preference, connectionId, localGitOptions), resolvePrWorkItemSource(repoPath, preference, connectionId, localGitOptions) ]) const issueOwnerRepo = issueResolved.source @@ -1473,10 +1568,16 @@ export async function countWorkItems( const parsedQuery = trimmedQuery ? parseTaskQuery(trimmedQuery) : null const effectiveQuery = parsedQuery ?? defaultOpenWorkItemQuery() + const ghOptions = { + ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), + ...githubHostExecOptions(ownerRepo) + } // 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) { + if (spendsSharedGitHubComQuota(ownerRepo, ghOptions)) { + await getRateLimit() + } + if (repositoryRateLimitGuard(ownerRepo, 'search', ghOptions).blocked) { return 0 } @@ -1548,13 +1649,11 @@ export async function getRepoSlug( repoPath: string, connectionId?: string | null, options: HostedReviewExecutionOptions = {} -): Promise<{ owner: string; repo: string } | null> { - // Why: the slug is the checkout's own identity, so it must stay origin-based (getOwnerRepo is upstream-first since #7331). - return getOwnerRepoForRemote( +): Promise { + return getOriginGitHubApiRepository( repoPath, - 'origin', connectionId, - ...hostedReviewLocalGitOptionArgs(options) + getHostedReviewLocalGitOptions(options) ) } @@ -1570,27 +1669,33 @@ export async function getRepoUpstream( ): Promise { const localGitArgs = hostedReviewLocalGitOptionArgs(options) const localGitOptions = localGitArgs[0] ?? {} - // 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) + const { ownerRepo: origin, ghOptions } = await resolveGitHubRepoExecution( + repoPath, + undefined, + connectionId, + localGitOptions + ) if (!origin) { return null } - const upstreamRemote = await getOwnerRepoForRemote( + const upstreamRemote = await getGitHubApiRepositoryForRemote( repoPath, 'upstream', connectionId, - ...localGitArgs + localGitOptions ) if (upstreamRemote && !sameOwnerRepo(upstreamRemote, origin)) { return upstreamRemote } await acquire() try { + // Why: positional slugs bypass the runner's --repo qualifier, so the slug + // itself must carry the Enterprise host. const { stdout } = await ghExecFileAsync( - ['repo', 'view', `${origin.owner}/${origin.repo}`, '--json', 'isFork,parent'], - // Why: cap latency so a stalled gh process can't hold up repo creation. + ['repo', 'view', githubRepositorySlugArg(origin), '--json', 'isFork,parent'], + // Why: cap this best-effort add-time lookup so a stalled gh process can't hold up repo creation. { - ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), + ...ghOptions, timeout: 10_000 } ) @@ -1600,7 +1705,10 @@ export async function getRepoUpstream( } const owner = data.parent?.owner?.login const repo = data.parent?.name - return data.isFork && owner && repo ? { owner, repo } : null + // Why: a fork parent lives on the same server as the fork. + return data.isFork && owner && repo + ? { owner, repo, ...(origin.host ? { host: origin.host } : {}) } + : null } catch { return null } finally { @@ -1681,16 +1789,9 @@ function parseCreatePRPayload(stdout: string): { number: number; url: string } | return { number: Number(urlMatch[1]), url: urlMatch[0] } } -// 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}` - : `${slug.owner}/${slug.repo}` -} - async function findOpenPRByHeadBase(args: { repoPath: string - repoArg: string + repo: GitHubApiRepository head: string base: string connectionId?: string | null @@ -1702,7 +1803,7 @@ async function findOpenPRByHeadBase(args: { 'pr', 'list', '--repo', - args.repoArg, + `${args.repo.owner}/${args.repo.repo}`, '--head', args.head, '--base', @@ -1716,7 +1817,8 @@ async function findOpenPRByHeadBase(args: { ], { ...ghRepoExecOptions(context), - ...(args.connectionId ? {} : getHostedReviewLocalGitOptions(args.options)) + ...(args.connectionId ? {} : getHostedReviewLocalGitOptions(args.options)), + ...githubHostExecOptions(args.repo) } ) const list = JSON.parse(stdout) as { number?: number; url?: string }[] @@ -1775,15 +1877,12 @@ export async function createGitHubPullRequest( } } - // 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, - 'origin', - connectionId, - ...hostedReviewLocalGitOptionArgs(options) - )) ?? (await getEnterpriseGitHubRepoSlug(repoPath, connectionId, options)) + // Why: creation targets the origin owning the unqualified head branch; the shared resolver preserves its host (#7331, #8312). + const ownerRepo = await getOriginGitHubApiRepository( + repoPath, + connectionId, + getHostedReviewLocalGitOptions(options) + ) if (!ownerRepo) { return { ok: false, @@ -1791,8 +1890,8 @@ export async function createGitHubPullRequest( error: 'Creating pull requests requires a GitHub remote.' } } - // Host-qualified for GHES so gh targets the Enterprise server, not github.com. - const repoArg = ghRepoArg(ownerRepo) + // The runner host-qualifies --repo from options.host for GHES (#8312). + const repoArg = `${ownerRepo.owner}/${ownerRepo.repo}` const base = normalizeHostedReviewBaseRef(input.base) const head = input.head ? normalizeHostedReviewHeadRef(input.head) || undefined : undefined @@ -1844,6 +1943,7 @@ export async function createGitHubPullRequest( const { stdout } = await ghExecFileAsync(createArgs, { ...ghRepoExecOptions(context), ...(connectionId ? {} : getHostedReviewLocalGitOptions(options)), + ...githubHostExecOptions(ownerRepo), timeout: 60_000, idempotent: false }) @@ -1854,7 +1954,7 @@ export async function createGitHubPullRequest( const found = head ? await findOpenPRByHeadBase({ repoPath, - repoArg, + repo: ownerRepo, head, base, connectionId, @@ -1878,7 +1978,7 @@ export async function createGitHubPullRequest( ) { const existing = await findOpenPRByHeadBase({ repoPath, - repoArg, + repo: ownerRepo, head, base, connectionId, @@ -1913,16 +2013,15 @@ export async function getWorkItem( if (type === 'issue') { return await fetchIssueWorkItem( repoPath, - await getIssueOwnerRepo(repoPath, connectionId, localGitOptions), + await getIssueGitHubApiRepository(repoPath, connectionId, localGitOptions), number, connectionId, localGitOptions ) } if (type === 'pr') { - return await fetchPullRequestWorkItem( + return await fetchPullRequestWorkItemFromCandidates( repoPath, - await getOwnerRepo(repoPath, connectionId, localGitOptions), number, connectionId, localGitOptions @@ -1932,7 +2031,7 @@ export async function getWorkItem( try { const issue = await fetchIssueWorkItem( repoPath, - await getIssueOwnerRepo(repoPath, connectionId, localGitOptions), + await getIssueGitHubApiRepository(repoPath, connectionId, localGitOptions), number, connectionId, localGitOptions @@ -1947,9 +2046,8 @@ export async function getWorkItem( throw err } } - return await fetchPullRequestWorkItem( + return await fetchPullRequestWorkItemFromCandidates( repoPath, - await getOwnerRepo(repoPath, connectionId, localGitOptions), number, connectionId, localGitOptions @@ -1963,20 +2061,47 @@ export async function getWorkItem( export async function getWorkItemByOwnerRepo( repoPath: string, - ownerRepo: OwnerRepo, + ownerRepo: GitHubApiRepository, number: number, type: 'issue' | 'pr', connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { + const requestedHost = ownerRepo.host?.trim().toLowerCase() + const requestedRepository = requestedHost + ? { ...ownerRepo, host: requestedHost } + : await resolveGitHubApiRepository(repoPath, ownerRepo, connectionId, localGitOptions) + if (!requestedRepository) { + return null + } + const { candidates } = await resolveGitHubApiRepositoryCandidates( + repoPath, + connectionId, + localGitOptions + ) + const requestedKey = githubRepoIdentityKey(requestedRepository) + const matchedRepository = candidates.find( + (candidate) => githubRepoIdentityKey(candidate) === requestedKey + ) + // Why: this lookup is reachable from pasted links. Restricting it to a + // configured remote prevents gh from sending credentials to an arbitrary host. + if (!matchedRepository) { + return null + } await acquire() try { if (type === 'issue') { - return await fetchIssueWorkItem(repoPath, ownerRepo, number, connectionId, localGitOptions) + return await fetchIssueWorkItem( + repoPath, + matchedRepository, + number, + connectionId, + localGitOptions + ) } return await fetchPullRequestWorkItem( repoPath, - ownerRepo, + matchedRepository, number, connectionId, localGitOptions @@ -2142,19 +2267,17 @@ function cacheRepositoryMergeMetadata( } async function detectRepositoryMergeMetadata( - ownerRepo: OwnerRepo, + ownerRepo: GitHubApiRepository, branchName: string | undefined, ghOptions: GhExecOptions ): Promise { - const cacheKey = `${ownerRepo.owner.toLowerCase()}/${ownerRepo.repo.toLowerCase()}:${ - branchName ?? '__repo__' - }` + const cacheKey = `${githubRepoIdentityKey(ownerRepo)}:${branchName ?? '__repo__'}` pruneRepositoryMergeMetadataCache() const cached = repositoryMergeMetadataCache.get(cacheKey) if (cached) { return cached.value } - const guard = rateLimitGuard('graphql') + const guard = repositoryRateLimitGuard(ownerRepo, 'graphql', ghOptions) if (guard.blocked) { return { mergeQueueRequired: null, autoMergeAllowed: null } } @@ -2179,7 +2302,7 @@ async function detectRepositoryMergeMetadata( } }` try { - noteRateLimitSpend('graphql') + noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions) const args = [ 'api', 'graphql', @@ -2193,7 +2316,10 @@ async function detectRepositoryMergeMetadata( if (branchName) { args.push('-f', `branch=${branchName}`) } - const { stdout } = await ghExecFileAsync(args, ghOptions) + const { stdout } = await ghExecFileAsync(args, { + ...ghOptions, + ...githubHostExecOptions(ownerRepo) + }) const parsed = JSON.parse(stdout) as { data?: { repository?: { @@ -2271,7 +2397,7 @@ async function hydrateBranchLookupWithExactPR( } async function getRestPRForBranch( - prRepo: OwnerRepo, + prRepo: GitHubApiRepository, headOwner: string, branchName: string, ghOptions: ReturnType @@ -2279,7 +2405,7 @@ async function getRestPRForBranch( const head = encodeURIComponent(`${headOwner}:${branchName}`) const { stdout } = await ghExecFileAsync( ['api', `repos/${prRepo.owner}/${prRepo.repo}/pulls?head=${head}&state=all&per_page=1`], - ghOptions + { ...ghOptions, ...githubHostExecOptions(prRepo) } ) const list = JSON.parse(stdout) as RestPullRequest[] const pr = list[0] @@ -2287,7 +2413,7 @@ async function getRestPRForBranch( } async function getFallbackPRListForBranch( - prRepo: OwnerRepo, + prRepo: GitHubApiRepository, branchName: string, ghOptions: ReturnType ): Promise { @@ -2306,7 +2432,7 @@ async function getFallbackPRListForBranch( '--json', PR_BRANCH_LIST_JSON_FIELDS ], - ghOptions + { ...ghOptions, ...githubHostExecOptions(prRepo) } ) const list = JSON.parse(stdout) as PullRequestLookupData[] return list[0] ?? null @@ -2395,10 +2521,6 @@ function parseTrackedUpstreamBranch(upstreamRef: string): TrackedUpstreamBranch return parsed } -function prOwnerRepoKey(ownerRepo: OwnerRepo): string { - return `${ownerRepo.owner.toLowerCase()}/${ownerRepo.repo.toLowerCase()}` -} - function shouldRetryTrackedUpstreamBranch( upstreamBranch: TrackedUpstreamBranch, branchName: string, @@ -2411,7 +2533,7 @@ function shouldRetryTrackedUpstreamBranch( if (!headRepo) { return true } - return prOwnerRepoKey(upstreamHeadRepo) !== prOwnerRepoKey(headRepo) + return githubRepoIdentityKey(upstreamHeadRepo) !== githubRepoIdentityKey(headRepo) } async function getTrackedUpstreamBranch( @@ -2695,19 +2817,19 @@ async function lookupPRByBranchName(args: { } async function getRestPRByNumber( - ownerRepo: OwnerRepo, + ownerRepo: GitHubApiRepository, number: number, ghOptions: ReturnType ): Promise { const { stdout } = await ghExecFileAsync( ['api', `repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${number}`], - ghOptions + { ...ghOptions, ...githubHostExecOptions(ownerRepo) } ) return mapRestPullRequest(JSON.parse(stdout) as RestPullRequest) } async function getPRByNumber( - ownerRepo: OwnerRepo, + ownerRepo: GitHubApiRepository, number: number, ghOptions: ReturnType ): Promise { @@ -2722,7 +2844,7 @@ async function getPRByNumber( '--json', PR_LOOKUP_JSON_FIELDS ], - ghOptions + { ...ghOptions, ...githubHostExecOptions(ownerRepo) } ) return hydratePullRequestLookupData( ownerRepo, @@ -2832,10 +2954,10 @@ export async function getPRForBranch( return outcome.kind === 'found' ? outcome.pr : null } -// 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. +// Why: exact-linked fallback has no dataRepo; derive its host-aware identity from the web URL for merged-PR membership checks. 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 + const match = url.match(/^https?:\/\/([^/\s]+)\/([^/\s]+)\/([^/\s]+)\/pull\/\d+/) + return match ? { owner: match[2], repo: match[3], host: match[1] } : null } export async function getPRForBranchOutcome( @@ -2858,11 +2980,16 @@ export async function getPRForBranchOutcome( await acquire() try { - const { candidates, headRepo } = await resolvePRRepositoryCandidates( + const { candidates, headRepo } = await resolveGitHubApiRepositoryCandidates( repoPath, connectionId, - ...localGitArgs + localGitOptions ) + // Why: connection-backed gh runs without a repository cwd. A bare lookup + // here can honor process GH_REPO/GH_HOST and return an unrelated PR. + if (connectionId && candidates.length === 0) { + return { kind: 'no-pr', fetchedAt: Date.now() } + } let data: PullRequestLookupData | null = null let dataRepo: OwnerRepo | null = null let dataHeadRepo: OwnerRepo | null = headRepo @@ -2972,11 +3099,11 @@ export async function getPRForBranchOutcome( ) if (upstreamBranch) { const upstreamHeadRepo = - (await getOwnerRepoForRemote( + (await getGitHubApiRepositoryForRemote( repoPath, upstreamBranch.remoteName, connectionId, - ...localGitArgs + localGitOptions )) ?? headRepo if ( upstreamHeadRepo && @@ -3321,7 +3448,7 @@ function mapRestCommitStatus(status: RestCommitStatus): PRCheckDetail | null { } function mapGraphQLPendingApprovalCheckSuite( - ownerRepo: OwnerRepo, + ownerRepo: GitHubApiRepository, suite: GraphQLCheckSuite, headSha: string | null | undefined, index: number @@ -3338,7 +3465,7 @@ function mapGraphQLPendingApprovalCheckSuite( } function mapGraphQLPRChecksResponse( - ownerRepo: OwnerRepo, + ownerRepo: GitHubApiRepository, response: GraphQLPRChecksResponse ): PRCheckDetail[] | null { const pullRequest = response.data?.repository?.pullRequest @@ -3380,7 +3507,7 @@ function mapGraphQLPRChecksResponse( } async function getPRChecksViaRestFallback( - ownerRepo: OwnerRepo, + ownerRepo: GitHubApiRepository, headSha: string | undefined, ghOptions: GhExecOptions, noCache?: boolean @@ -3389,7 +3516,7 @@ async function getPRChecksViaRestFallback( return null } try { - await assertRateLimitBudget('core') + await assertRateLimitBudget('core', ownerRepo, ghOptions) } catch (err) { console.warn('getPRChecks skipped REST fallback, falling back to gh pr checks:', err) return null @@ -3407,7 +3534,7 @@ async function getPRChecksViaRestFallback( ], ghOptions ) - noteRateLimitSpend('core') + noteRepositoryRateLimitSpend(ownerRepo, 'core', 1, ghOptions) const checkRunData = JSON.parse(stdout) as { check_runs?: RestCheckRun[] } @@ -3424,7 +3551,7 @@ async function getPRChecksViaRestFallback( ], ghOptions ) - noteRateLimitSpend('core') + noteRepositoryRateLimitSpend(ownerRepo, 'core', 1, ghOptions) const statusData = JSON.parse(statusResult.stdout) as { statuses?: RestCommitStatus[] } @@ -3446,7 +3573,7 @@ async function getPRChecksViaRestFallback( ], ghOptions ) - noteRateLimitSpend('core') + noteRepositoryRateLimitSpend(ownerRepo, 'core', 1, ghOptions) const suitesData = JSON.parse(suitesResult.stdout) as { check_suites?: RestCheckSuite[] } @@ -3481,16 +3608,23 @@ export async function getPRChecks( repoPath: string, prNumber: number, headSha?: string, - prRepo?: OwnerRepo | null, + prRepo?: GitHubApiRepository | null, options?: { noCache?: boolean }, connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { void headSha - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) - const ownerRepo = prRepo ?? (await getOwnerRepo(repoPath, connectionId, localGitOptions)) + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( + repoPath, + prRepo, + connectionId, + localGitOptions + ) + if (connectionId && !ownerRepo) { + throw new Error(GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE) + } const fallbackToPRChecks = async (): Promise => { - await assertRateLimitBudget('graphql') + await assertRateLimitBudget('graphql', ownerRepo, ghOptions) await acquire() try { const fallbackArgs = ['pr', 'checks', String(prNumber), '--json', 'name,state,link'] @@ -3505,7 +3639,7 @@ export async function getPRChecks( } throw err }) - noteRateLimitSpend('graphql') + noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions) const data = JSON.parse(stdout) as { name: string; state: string; link: string }[] return data.map((d) => ({ name: d.name, @@ -3522,7 +3656,7 @@ export async function getPRChecks( if (ownerRepo) { let canUseGraphQLRollup = true try { - await assertRateLimitBudget('graphql') + await assertRateLimitBudget('graphql', ownerRepo, ghOptions) } catch (err) { canUseGraphQLRollup = false console.warn('getPRChecks skipped GraphQL rollup, falling back to gh pr checks:', err) @@ -3548,7 +3682,7 @@ export async function getPRChecks( ], ghOptions ) - noteRateLimitSpend('graphql') + noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions) const checks = mapGraphQLPRChecksResponse( ownerRepo, JSON.parse(stdout) as GraphQLPRChecksResponse @@ -3608,11 +3742,11 @@ function getPendingApprovalCheckSuiteName( } function getPendingApprovalCheckSuiteUrl( - ownerRepo: OwnerRepo, + ownerRepo: GitHubApiRepository, headSha: string, suiteId: number | null | undefined ): string { - const base = `https://github.com/${ownerRepo.owner}/${ownerRepo.repo}/commits/${headSha}/checks` + const base = `https://${githubRepositoryWebHost(ownerRepo)}/${ownerRepo.owner}/${ownerRepo.repo}/commits/${headSha}/checks` return typeof suiteId === 'number' && Number.isFinite(suiteId) ? `${base}#check-suite-${suiteId}` : base @@ -3695,7 +3829,7 @@ function getCheckJobLogTailCacheKey(job: PRCheckRunDetails['jobs'][number]): str async function attachFailedJobLogTails( jobs: PRCheckRunDetails['jobs'], - ownerRepo: OwnerRepo, + ownerRepo: GitHubApiRepository, ghOptions: GhExecOptions ): Promise { const failedJobs = jobs @@ -3707,7 +3841,8 @@ async function attachFailedJobLogTails( // 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) + const jobCacheKey = getCheckJobLogTailCacheKey(job) + const cacheKey = jobCacheKey ? `${githubRepoIdentityKey(ownerRepo)}:${jobCacheKey}` : null if (!cacheKey) { continue } @@ -3751,13 +3886,17 @@ export async function getPRCheckDetails( workflowRunId?: number checkName?: string url?: string | null - prRepo?: OwnerRepo | null + prRepo?: GitHubApiRepository | null }, connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) - const ownerRepo = args.prRepo ?? (await getOwnerRepo(repoPath, connectionId, localGitOptions)) + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( + repoPath, + args.prRepo, + connectionId, + localGitOptions + ) if (!ownerRepo) { return null } @@ -3845,12 +3984,20 @@ function parseActionsRunId(url: string | null | undefined): number | undefined { export async function rerunPRChecks( repoPath: string, prNumber: number, - options: { headSha?: string; failedOnly?: boolean } = {}, + options: { + headSha?: string + failedOnly?: boolean + prRepo?: GitHubApiRepository | null + } = {}, connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) - const ownerRepo = await getOwnerRepo(repoPath, connectionId, localGitOptions) + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( + repoPath, + options.prRepo, + connectionId, + localGitOptions + ) if (!ownerRepo) { return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' } } @@ -3983,14 +4130,21 @@ query($owner: String!, $repo: String!, $pr: Int!) { export async function getPRComments( repoPath: string, prNumber: number, - options?: { noCache?: boolean; prRepo?: OwnerRepo | null }, + options?: { noCache?: boolean; prRepo?: GitHubApiRepository | null }, connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) - const ownerRepo = options?.prRepo ?? (await getOwnerRepo(repoPath, connectionId, localGitOptions)) + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( + repoPath, + options?.prRepo, + connectionId, + localGitOptions + ) + if (connectionId && !ownerRepo) { + throw new Error(GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE) + } if (ownerRepo) { - await assertRateLimitBudget('core') + await assertRateLimitBudget('core', ownerRepo, ghOptions) } await acquire() try { @@ -4000,12 +4154,12 @@ export async function getPRComments( const base = `repos/${ownerRepo.owner}/${ownerRepo.repo}` // Why: allSettled so one failing endpoint doesn't blank out all comments; failed sources contribute zero. - const reviewThreadsGuard = rateLimitGuard('graphql') + const reviewThreadsGuard = repositoryRateLimitGuard(ownerRepo, 'graphql', ghOptions) let reviewThreadsFetch: Promise<{ stdout: string; stderr: string } | null> if (reviewThreadsGuard.blocked) { reviewThreadsFetch = Promise.resolve(null) } else { - noteRateLimitSpend('graphql') + noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions) reviewThreadsFetch = ghExecFileAsync( [ 'api', @@ -4034,7 +4188,7 @@ export async function getPRComments( ghOptions ) ]) - noteRateLimitSpend('core', 2) + noteRepositoryRateLimitSpend(ownerRepo, 'core', 2, ghOptions) // Parse issue comments (REST) type RESTComment = { @@ -4184,7 +4338,7 @@ export async function getPRComments( ['pr', 'view', String(prNumber), '--json', 'comments'], ghOptions ) - noteRateLimitSpend('graphql') + noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions) const data = JSON.parse(stdout) as { comments: { author: { login: string } @@ -4216,13 +4370,20 @@ export async function setPRFileViewed(args: { repoPath: string connectionId?: string | null localGitOptions?: LocalGitExecOptions + prRepo?: GitHubApiRepository | null pullRequestId: string path: string viewed: boolean }): Promise { - const ghOptions = ghRepoExecOptions( - githubRepoContext(args.repoPath, args.connectionId, args.localGitOptions) + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( + args.repoPath, + args.prRepo, + args.connectionId, + args.localGitOptions ) + if (!ownerRepo) { + return false + } const mutation = args.viewed ? 'markFileAsViewed' : 'unmarkFileAsViewed' const query = `mutation($pullRequestId: ID!, $path: String!) { ${mutation}(input: { pullRequestId: $pullRequestId, path: $path }) { @@ -4261,12 +4422,21 @@ export async function resolveReviewThread( threadId: string, resolve: boolean, connectionId?: string | null, + prRepo?: GitHubApiRepository | null, localGitOptions: LocalGitExecOptions = {} ): Promise { const mutation = resolve ? 'resolveReviewThread' : 'unresolveReviewThread' const query = `mutation($threadId: ID!) { ${mutation}(input: { threadId: $threadId }) { thread { isResolved } } }` - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) - const guard = rateLimitGuard('graphql') + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( + repoPath, + prRepo, + connectionId, + localGitOptions + ) + if (!ownerRepo) { + return false + } + const guard = repositoryRateLimitGuard(ownerRepo, 'graphql', ghOptions) if (guard.blocked) { console.warn( `${mutation} skipped: GitHub GraphQL rate limit nearly exhausted (${guard.remaining}/${guard.limit})` @@ -4275,7 +4445,7 @@ export async function resolveReviewThread( } await acquire() try { - noteRateLimitSpend('graphql') + noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions) await ghExecFileAsync( ['api', 'graphql', '-f', `query=${query}`, '-f', `threadId=${threadId}`], ghOptions @@ -4329,11 +4499,15 @@ export async function addPRReviewCommentReply( path?: string, line?: number, connectionId?: string | null, - prRepo?: OwnerRepo | null, + prRepo?: GitHubApiRepository | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) - const ownerRepo = prRepo ?? (await getOwnerRepo(repoPath, connectionId, localGitOptions)) + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( + repoPath, + prRepo, + connectionId, + localGitOptions + ) if (!ownerRepo) { return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' } } @@ -4372,10 +4546,12 @@ export async function addPRReviewComment( localGitOptions?: LocalGitExecOptions } ): Promise { - const ghOptions = ghRepoExecOptions( - githubRepoContext(args.repoPath, args.connectionId, args.localGitOptions) + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( + args.repoPath, + args.prRepo, + args.connectionId, + args.localGitOptions ) - const ownerRepo = await getOwnerRepo(args.repoPath, args.connectionId, args.localGitOptions) if (!ownerRepo) { return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' } } @@ -4433,11 +4609,18 @@ export async function mergePR( prNumber: number, method: 'merge' | 'squash' | 'rebase' = 'squash', connectionId?: string | null, - prRepo?: OwnerRepo | null, + prRepo?: GitHubApiRepository | null, localGitOptions: LocalGitExecOptions = {} ): Promise<{ ok: true } | { ok: false; error: string }> { - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) - const ownerRepo = prRepo ?? (await getOwnerRepo(repoPath, connectionId, localGitOptions)) + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( + repoPath, + prRepo, + connectionId, + localGitOptions + ) + if (!ownerRepo) { + return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' } + } await acquire() try { const mergeBlocker = await getPRMergeBlocker( @@ -4477,11 +4660,18 @@ export async function setPRAutoMerge( enabled: boolean, method: GitHubPRMergeMethod = 'squash', connectionId?: string | null, - prRepo?: OwnerRepo | null, + prRepo?: GitHubApiRepository | null, localGitOptions: LocalGitExecOptions = {} ): Promise<{ ok: true } | { ok: false; error: string }> { - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) - const ownerRepo = prRepo ?? (await getOwnerRepo(repoPath, connectionId, localGitOptions)) + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( + repoPath, + prRepo, + connectionId, + localGitOptions + ) + if (!ownerRepo) { + return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' } + } await acquire() try { if (enabled) { @@ -4521,7 +4711,7 @@ type PRAutoMergeIdentity = { async function getPRAutoMergeIdentity( prNumber: number, - ownerRepo: OwnerRepo | null, + ownerRepo: GitHubApiRepository | null, ghOptions: GhExecOptions ): Promise { const args = ['pr', 'view', String(prNumber), '--json', PR_AUTO_MERGE_IDENTITY_JSON_FIELDS] @@ -4540,7 +4730,7 @@ async function getPRAutoMergeIdentity( async function runPRAutoMergeCommand( prNumber: number, method: GitHubPRMergeMethod, - ownerRepo: OwnerRepo | null, + ownerRepo: GitHubApiRepository | null, ghOptions: GhExecOptions ): Promise { const args = ['pr', 'merge', String(prNumber), '--auto', `--${method}`] @@ -4555,7 +4745,7 @@ async function runPRAutoMergeCommand( async function shouldUseMergeQueueAutoMerge( pr: PRAutoMergeIdentity, - ownerRepo: OwnerRepo | null, + ownerRepo: GitHubApiRepository | null, ghOptions: GhExecOptions ): Promise { if (!ownerRepo || !pr.baseRefName) { @@ -4568,7 +4758,7 @@ async function shouldUseMergeQueueAutoMerge( async function enablePRAutoMerge( prNumber: number, method: GitHubPRMergeMethod, - ownerRepo: OwnerRepo | null, + ownerRepo: GitHubApiRepository | null, ghOptions: GhExecOptions ): Promise<{ ok: true } | { ok: false; error: string }> { const pr = await getPRAutoMergeIdentity(prNumber, ownerRepo, ghOptions) @@ -4612,7 +4802,7 @@ async function enablePRAutoMerge( async function getPRMergeBlocker( repoPath: string, prNumber: number, - ownerRepo: OwnerRepo | null, + ownerRepo: GitHubApiRepository | null, ghOptions: GhExecOptions, connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} @@ -4679,11 +4869,15 @@ export async function updatePRState( prNumber: number, updates: GitHubPullRequestStateUpdate, connectionId?: string | null, + prRepo?: GitHubApiRepository | null, localGitOptions: LocalGitExecOptions = {} ): Promise<{ ok: true } | { ok: false; error: string }> { - const context = githubRepoContext(repoPath, connectionId, localGitOptions) - const ghOptions = ghRepoExecOptions(context) - const ownerRepo = await getOwnerRepo(repoPath, connectionId, localGitOptions) + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( + repoPath, + prRepo, + connectionId, + localGitOptions + ) if (!ownerRepo) { return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' } } @@ -4713,14 +4907,22 @@ export async function requestPRReviewers( prNumber: number, reviewers: string[], connectionId?: string | null, + prRepo?: GitHubApiRepository | null, localGitOptions: LocalGitExecOptions = {} ): Promise<{ ok: true } | { ok: false; error: string }> { const logins = reviewers.map((reviewer) => reviewer.trim()).filter(Boolean) if (logins.length === 0) { return { ok: false, error: 'Enter at least one reviewer' } } - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) - const ownerRepo = await getOwnerRepo(repoPath, connectionId, localGitOptions) + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( + repoPath, + prRepo, + connectionId, + localGitOptions + ) + if (!ownerRepo) { + return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' } + } await acquire() try { const args = ['pr', 'edit', String(prNumber), '--add-reviewer', logins.join(',')] @@ -4746,14 +4948,22 @@ export async function removePRReviewers( prNumber: number, reviewers: string[], connectionId?: string | null, + prRepo?: GitHubApiRepository | null, localGitOptions: LocalGitExecOptions = {} ): Promise<{ ok: true } | { ok: false; error: string }> { const logins = reviewers.map((reviewer) => reviewer.trim()).filter(Boolean) if (logins.length === 0) { return { ok: false, error: 'Enter at least one reviewer' } } - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) - const ownerRepo = await getOwnerRepo(repoPath, connectionId, localGitOptions) + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( + repoPath, + prRepo, + connectionId, + localGitOptions + ) + if (!ownerRepo) { + return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' } + } await acquire() try { const args = ['pr', 'edit', String(prNumber), '--remove-reviewer', logins.join(',')] @@ -4782,11 +4992,18 @@ export async function updatePRTitle( prNumber: number, title: string, connectionId?: string | null, - prRepo?: OwnerRepo | null, + prRepo?: GitHubApiRepository | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) - const ownerRepo = prRepo ?? (await getOwnerRepo(repoPath, connectionId, localGitOptions)) + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( + repoPath, + prRepo, + connectionId, + localGitOptions + ) + if (!ownerRepo) { + return false + } await acquire() try { const args = ['pr', 'edit', String(prNumber), '--title', title] @@ -4810,11 +5027,15 @@ export async function updatePRDetails( prNumber: number, updates: { title?: string; body?: string }, connectionId?: string | null, - prRepo?: OwnerRepo | null, + prRepo?: GitHubApiRepository | null, localGitOptions: LocalGitExecOptions = {} ): Promise<{ ok: true } | { ok: false; error: string }> { - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) - const ownerRepo = prRepo ?? (await getOwnerRepo(repoPath, connectionId, localGitOptions)) + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( + repoPath, + prRepo, + connectionId, + localGitOptions + ) if (!ownerRepo) { return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' } } diff --git a/src/main/github/default-branch-stale-pr.test.ts b/src/main/github/default-branch-stale-pr.test.ts index e9a783b75ad..23f3eb8b96e 100644 --- a/src/main/github/default-branch-stale-pr.test.ts +++ b/src/main/github/default-branch-stale-pr.test.ts @@ -209,6 +209,11 @@ describe('issue #9171: default-branch checkout must not attach a stale non-open getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) getIssueOwnerRepoMock.mockReset() getOwnerRepoForRemoteMock.mockReset() + // Why: getPRForBranch resolves origin through getOwnerRepoForRemote. + getOwnerRepoForRemoteMock.mockImplementation( + async (repoPath: string, remoteName: string, connectionId?: string | null, opts = {}) => + remoteName === 'origin' ? getOwnerRepoMock(repoPath, connectionId, opts) : null + ) resolvePRRepositoryCandidatesMock.mockReset() resolvePRRepositoryCandidatesMock.mockImplementation(async (repoPath, connectionId) => { const origin = await getOwnerRepoMock(repoPath, connectionId) @@ -246,7 +251,7 @@ describe('issue #9171: default-branch checkout must not attach a stale non-open // The head-branch lookup still runs (state=all) and sees the closed PR… expect(ghExecFileAsyncMock).toHaveBeenCalledWith( ['api', 'repos/acme/widgets/pulls?head=acme%3Amaster&state=all&per_page=1'], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) // …but the default-branch guard discards it: no PR on the trunk (#9171). expect(pr).toBeNull() diff --git a/src/main/github/github-api-repository.test.ts b/src/main/github/github-api-repository.test.ts new file mode 100644 index 00000000000..fdf921a92ad --- /dev/null +++ b/src/main/github/github-api-repository.test.ts @@ -0,0 +1,205 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as GitHubEnterpriseRepository from './github-enterprise-repository' +import type * as GhUtils from './gh-utils' + +const { + getEnterpriseGitHubRepoSlugMock, + getOwnerRepoMock, + getOwnerRepoForRemoteMock, + isGitHubHostAuthenticatedMock +} = vi.hoisted(() => ({ + getEnterpriseGitHubRepoSlugMock: vi.fn(), + getOwnerRepoMock: vi.fn(), + getOwnerRepoForRemoteMock: vi.fn(), + isGitHubHostAuthenticatedMock: vi.fn() +})) + +vi.mock('./gh-utils', async (importOriginal) => ({ + ...(await importOriginal()), + getOwnerRepo: getOwnerRepoMock, + // Why: origin resolution uses getOwnerRepoForRemote, not getOwnerRepo. + getOwnerRepoForRemote: getOwnerRepoForRemoteMock +})) + +vi.mock('./github-enterprise-repository', async (importOriginal) => ({ + ...(await importOriginal()), + getEnterpriseGitHubRepoSlug: getEnterpriseGitHubRepoSlugMock, + isGitHubHostAuthenticated: isGitHubHostAuthenticatedMock +})) + +import { + _resetOriginGitHubApiRepositoryCache, + getOriginGitHubApiRepository, + githubHostExecOptions, + resolveGitHubApiRepository, + resolveGitHubRepoExecution +} from './github-api-repository' + +beforeEach(() => { + _resetOriginGitHubApiRepositoryCache() + getEnterpriseGitHubRepoSlugMock.mockReset().mockResolvedValue(null) + getOwnerRepoMock.mockReset().mockResolvedValue(null) + getOwnerRepoForRemoteMock.mockReset().mockResolvedValue(null) + isGitHubHostAuthenticatedMock.mockReset().mockResolvedValue(false) +}) + +describe('githubHostExecOptions', () => { + it('pins every known repository host', () => { + expect(githubHostExecOptions({ owner: 'acme', repo: 'widgets', host: 'github.com' })).toEqual({ + host: 'github.com' + }) + expect( + githubHostExecOptions({ owner: 'acme', repo: 'widgets', host: 'github.acme-corp.com' }) + ).toEqual({ host: 'github.acme-corp.com' }) + }) + + it('does not invent a host when repository identity is unavailable or legacy', () => { + expect(githubHostExecOptions({ owner: 'acme', repo: 'widgets' })).toEqual({}) + expect(githubHostExecOptions(null)).toEqual({}) + }) +}) + +describe('resolveGitHubRepoExecution', () => { + it('combines local repository and GitHub host execution options', async () => { + const ownerRepo = { owner: 'acme', repo: 'widgets', host: 'github.acme-corp.com' } + isGitHubHostAuthenticatedMock.mockResolvedValue(true) + + await expect( + resolveGitHubRepoExecution('/repo', ownerRepo, null, { wslDistro: 'Ubuntu' }) + ).resolves.toEqual({ + ownerRepo, + ghOptions: { + cwd: '/repo', + wslDistro: 'Ubuntu', + host: 'github.acme-corp.com' + } + }) + expect(isGitHubHostAuthenticatedMock).toHaveBeenCalledWith( + 'github.acme-corp.com', + '/repo', + null, + { wslDistro: 'Ubuntu' } + ) + }) + + it('rejects an explicit Enterprise host absent from the local gh auth inventory', async () => { + await expect( + resolveGitHubApiRepository( + '/remote/repo', + { + owner: 'acme', + repo: 'widgets', + host: 'evil.example.test' + }, + 'ssh-1' + ) + ).resolves.toBeNull() + + expect(isGitHubHostAuthenticatedMock).toHaveBeenCalledWith( + 'evil.example.test', + '/remote/repo', + 'ssh-1', + {} + ) + }) + + it.each([ + { owner: 'acme%2Fadmin', repo: 'widgets' }, + { owner: 'acme', repo: '..' } + ])('rejects repository overrides that could alter a gh REST path: %o', async (repository) => { + await expect( + resolveGitHubApiRepository('/repo', { + ...repository, + host: 'github.com' + }) + ).resolves.toBeNull() + + expect(isGitHubHostAuthenticatedMock).not.toHaveBeenCalled() + }) + + it('normalizes github.com without spending an auth inventory probe', async () => { + await expect( + resolveGitHubApiRepository('/repo', { + owner: 'acme', + repo: 'widgets', + host: ' GitHub.COM ' + }) + ).resolves.toEqual({ owner: 'acme', repo: 'widgets', host: 'github.com' }) + + expect(isGitHubHostAuthenticatedMock).not.toHaveBeenCalled() + }) + + it('backfills the origin host for a host-less caller-specific resolver', async () => { + const ownerRepo = { owner: 'upstream', repo: 'widgets' } + getOwnerRepoForRemoteMock.mockResolvedValue({ owner: 'fork', repo: 'widgets' }) + + await expect(resolveGitHubRepoExecution('/repo', async () => ownerRepo)).resolves.toEqual({ + ownerRepo: { ...ownerRepo, host: 'github.com' }, + ghOptions: { cwd: '/repo', host: 'github.com' } + }) + }) + + it('backfills an Enterprise origin host for a host-less caller-specific resolver', async () => { + const ownerRepo = { owner: 'upstream', repo: 'widgets' } + getEnterpriseGitHubRepoSlugMock.mockResolvedValue({ + owner: 'fork', + repo: 'widgets', + host: 'github.acme-corp.com' + }) + + await expect(resolveGitHubRepoExecution('/repo', async () => ownerRepo)).resolves.toEqual({ + ownerRepo: { ...ownerRepo, host: 'github.acme-corp.com' }, + ghOptions: { cwd: '/repo', host: 'github.acme-corp.com' } + }) + }) + + it('rejects a host-less caller-specific resolver for a connection-backed repository', async () => { + await expect( + resolveGitHubRepoExecution( + '/remote/repo', + async () => ({ owner: 'upstream', repo: 'widgets' }), + 'ssh-1' + ) + ).resolves.toEqual({ ownerRepo: null, ghOptions: {} }) + }) + + it('rejects a host-less caller-specific resolver for an unresolved local repository', async () => { + await expect( + resolveGitHubRepoExecution('/repo', async () => ({ owner: 'upstream', repo: 'widgets' })) + ).resolves.toEqual({ ownerRepo: null, ghOptions: { cwd: '/repo' } }) + }) + + it('preserves an authoritative null from a caller-specific resolver', async () => { + getOwnerRepoMock.mockResolvedValue({ owner: 'origin', repo: 'widgets' }) + + await expect(resolveGitHubRepoExecution('/repo', async () => null)).resolves.toEqual({ + ownerRepo: null, + ghOptions: { cwd: '/repo' } + }) + + expect(getOwnerRepoMock).not.toHaveBeenCalled() + }) +}) + +describe('origin repository cache', () => { + it('does not cache an indeterminate Enterprise auth probe', async () => { + const enterprise = { + owner: 'acme', + repo: 'widgets', + host: 'github.acme-corp.com' + } + getEnterpriseGitHubRepoSlugMock + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(enterprise) + + await expect(getOriginGitHubApiRepository('/repo')).resolves.toBeNull() + await expect(getOriginGitHubApiRepository('/repo')).resolves.toEqual(enterprise) + expect(getEnterpriseGitHubRepoSlugMock).toHaveBeenCalledTimes(2) + }) + + it('still caches a definitive negative Enterprise probe', async () => { + await expect(getOriginGitHubApiRepository('/repo')).resolves.toBeNull() + await expect(getOriginGitHubApiRepository('/repo')).resolves.toBeNull() + expect(getEnterpriseGitHubRepoSlugMock).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/github/github-api-repository.ts b/src/main/github/github-api-repository.ts new file mode 100644 index 00000000000..7ef11c9f8fb --- /dev/null +++ b/src/main/github/github-api-repository.ts @@ -0,0 +1,360 @@ +import type { GitHubOwnerRepo, IssueSourcePreference } from '../../shared/types' +import { + githubRepoIdentityKey, + isDefaultGitHubHost +} from '../../shared/github-repository-identity-key' +import { + getOwnerRepoForRemote, + ghRepoExecOptions, + githubRepoContext, + type LocalGitExecOptions +} from './gh-utils' +import { + getEnterpriseGitHubRepoSlug, + getEnterpriseGitHubRepoSlugForRemote, + isGitHubHostAuthenticated +} from './github-enterprise-repository' + +export type GitHubApiRepository = GitHubOwnerRepo +export type GitHubRepoExecOptions = ReturnType & { host?: string } +export type GitHubRepoExecution = { + ownerRepo: GitHubApiRepository | null + ghOptions: GitHubRepoExecOptions +} + +type GitHubApiRepositoryResolution = + | GitHubApiRepository + | null + | undefined + | (() => Promise) + +// Why: renderer/RPC repository overrides are interpolated into REST paths. +// Reject path syntax before an authenticated gh process can target it. +const GITHUB_OWNER_SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9-]*$/ +const GITHUB_REPO_SLUG_RE = /^[A-Za-z0-9._-]+$/ + +function isValidGitHubApiRepository(repository: GitHubApiRepository): boolean { + return ( + GITHUB_OWNER_SLUG_RE.test(repository.owner) && + GITHUB_REPO_SLUG_RE.test(repository.repo) && + repository.repo !== '.' && + repository.repo !== '..' + ) +} + +// Why: the enterprise branch spawns an uncached `git remote get-url` (an SSH +// round trip on connection-backed repos) — hot paths like per-file contents +// and viewed-state toggles resolve per call, so cache like ownerRepoCache does. +const ORIGIN_REPO_CACHE_TTL_MS = 30_000 +const ORIGIN_REPO_CACHE_MAX_ENTRIES = 512 +const originRepoCache = new Map() +const originRepoInFlight = new Map>() + +function originRepoCacheKey( + repoPath: string, + remoteName: string, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): string { + return `${connectionId ?? 'local'}\0${localGitOptions.wslDistro ?? ''}\0${repoPath}\0${remoteName}` +} + +/** @internal - exposed for tests only */ +export function _resetOriginGitHubApiRepositoryCache(): void { + originRepoCache.clear() + originRepoInFlight.clear() +} + +/** @internal - exposed for cache-bound tests only */ +export function _getOriginGitHubApiRepositoryCacheSize(): number { + return originRepoCache.size +} + +function pruneOriginRepoCache(now: number): void { + for (const [key, entry] of originRepoCache) { + if (entry.expiresAt <= now) { + originRepoCache.delete(key) + } + } + while (originRepoCache.size > ORIGIN_REPO_CACHE_MAX_ENTRIES) { + const oldestKey = originRepoCache.keys().next().value + if (oldestKey === undefined) { + return + } + originRepoCache.delete(oldestKey) + } +} + +/** + * Host-qualified repository identity for one remote: github.com remotes come + * from the cached slug parser; any other GitHub-shaped host is auth-gated so a + * non-GitHub forge never routes to the GitHub provider. + */ +export async function getGitHubApiRepositoryForRemote( + repoPath: string, + remoteName: string, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + // Why: generic PR resolution prefers upstream, but this API represents the + // caller-selected remote exactly (#7331). + const ownerRepo = await getOwnerRepoForRemote( + repoPath, + remoteName, + connectionId, + localGitOptions + ) + if (ownerRepo) { + return { ...ownerRepo, host: 'github.com' } + } + const cacheKey = originRepoCacheKey(repoPath, remoteName, connectionId, localGitOptions) + const now = Date.now() + pruneOriginRepoCache(now) + const cached = originRepoCache.get(cacheKey) + if (cached && cached.expiresAt > now) { + return cached.value + } + const inFlight = originRepoInFlight.get(cacheKey) + if (inFlight) { + return inFlight + } + const probe = (async () => { + const enterpriseOptions = + Object.keys(localGitOptions).length > 0 ? { localGitExecOptions: localGitOptions } : {} + const slug = + remoteName === 'origin' + ? await getEnterpriseGitHubRepoSlug(repoPath, connectionId, enterpriseOptions) + : await getEnterpriseGitHubRepoSlugForRemote( + repoPath, + remoteName, + connectionId, + enterpriseOptions + ) + // Why: undefined means the gh auth inventory could not be read. Caching it + // as a negative would turn a transient spawn failure into a 30-second miss. + if (slug !== undefined) { + originRepoCache.set(cacheKey, { + value: slug, + expiresAt: Date.now() + ORIGIN_REPO_CACHE_TTL_MS + }) + pruneOriginRepoCache(Date.now()) + } + return slug ?? null + })() + originRepoInFlight.set(cacheKey, probe) + try { + return await probe + } finally { + if (originRepoInFlight.get(cacheKey) === probe) { + originRepoInFlight.delete(cacheKey) + } + } +} + +export async function getOriginGitHubApiRepository( + repoPath: string, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + return getGitHubApiRepositoryForRemote(repoPath, 'origin', connectionId, localGitOptions) +} + +/** Hosted mirror of getIssueOwnerRepo: issues prefer `upstream` over `origin`. */ +export async function getIssueGitHubApiRepository( + repoPath: string, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + const upstream = await getGitHubApiRepositoryForRemote( + repoPath, + 'upstream', + connectionId, + localGitOptions + ) + if (upstream) { + return upstream + } + return getGitHubApiRepositoryForRemote(repoPath, 'origin', connectionId, localGitOptions) +} + +export type GitHubApiRepositoryCandidates = { + candidates: GitHubApiRepository[] + headRepo: GitHubApiRepository | null +} + +/** Hosted mirror of resolvePRRepositoryCandidates: upstream first, then origin. */ +export async function resolveGitHubApiRepositoryCandidates( + repoPath: string, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + const [upstream, origin] = await Promise.all([ + getGitHubApiRepositoryForRemote(repoPath, 'upstream', connectionId, localGitOptions), + getGitHubApiRepositoryForRemote(repoPath, 'origin', connectionId, localGitOptions) + ]) + const seen = new Set() + const candidates: GitHubApiRepository[] = [] + for (const candidate of [upstream, origin]) { + if (!candidate) { + continue + } + const key = githubRepoIdentityKey(candidate) + if (seen.has(key)) { + continue + } + seen.add(key) + candidates.push(candidate) + } + return { candidates, headRepo: origin } +} + +export type ResolvedGitHubApiRepositorySource = { + source: GitHubApiRepository | null + /** True when explicit upstream is gone and resolver fell back to origin. */ + fellBack: boolean +} + +/** Hosted mirror of resolveIssueSource — same preference semantics. */ +export async function resolveIssueGitHubApiRepositorySource( + repoPath: string, + preference: IssueSourcePreference | undefined, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + if (preference === 'upstream') { + const upstream = await getGitHubApiRepositoryForRemote( + repoPath, + 'upstream', + connectionId, + localGitOptions + ) + if (upstream) { + return { source: upstream, fellBack: false } + } + const origin = await getGitHubApiRepositoryForRemote( + repoPath, + 'origin', + connectionId, + localGitOptions + ) + return { source: origin, fellBack: origin !== null } + } + if (preference === 'origin') { + return { + source: await getGitHubApiRepositoryForRemote( + repoPath, + 'origin', + connectionId, + localGitOptions + ), + fellBack: false + } + } + return { + source: await getIssueGitHubApiRepository(repoPath, connectionId, localGitOptions), + fellBack: false + } +} + +export async function resolveGitHubApiRepository( + repoPath: string, + repository?: GitHubApiRepository | null, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + if (repository && !isValidGitHubApiRepository(repository)) { + return null + } + if (repository?.host) { + const host = repository.host.trim().toLowerCase() + if (!host) { + return null + } + if (isDefaultGitHubHost(host)) { + return { ...repository, host } + } + // Why: client-supplied hosts must match gh's local auth inventory before + // they can receive ambient Enterprise credentials from a host-pinned call. + const authenticated = await isGitHubHostAuthenticated( + host, + repoPath, + connectionId, + localGitOptions + ) + return authenticated ? { ...repository, host } : null + } + const originRepository = await getOriginGitHubApiRepository( + repoPath, + connectionId, + localGitOptions + ) + if (!repository) { + return originRepository + } + // Why: older clients only send owner/repo. The origin still supplies the + // execution host for fork-base slugs on the same GitHub Enterprise server. + if (originRepository?.host) { + return { ...repository, host: originRepository.host } + } + // Why: a host-less identity can honor ambient GH_HOST even with a local cwd. + // Only a resolved origin may supply the execution host for legacy clients. + return null +} + +export function isGitHubDotComRepository(repository: GitHubApiRepository): boolean { + return isDefaultGitHubHost(repository.host) +} + +// Why: the gh runner host-qualifies argv from `options.host`, so every known +// host must be carried through. Pinning github.com prevents a process-level +// GH_HOST from silently redirecting an otherwise unambiguous API request. +export function githubHostExecOptions(repository: GitHubApiRepository | null | undefined): { + host?: string +} { + return repository?.host ? { host: repository.host } : {} +} + +export async function resolveGitHubRepoExecution( + repoPath: string, + repository?: GitHubApiRepositoryResolution, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + // Why: issue-scoped paths retain their upstream-first resolver while sharing + // the same repo-scoped and host-scoped gh execution option construction. + const requestedRepository = typeof repository === 'function' ? await repository() : repository + // Why: normalize host-less resolver results without replacing an + // authoritative null with the generic origin fallback. + const ownerRepo = + typeof repository === 'function' && !requestedRepository + ? null + : await resolveGitHubApiRepository( + repoPath, + requestedRepository, + connectionId, + localGitOptions + ) + return { + ownerRepo, + ghOptions: { + ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), + ...githubHostExecOptions(ownerRepo) + } + } +} + +export function githubRepositoryWebHost(repository: GitHubApiRepository): string { + return repository.host ?? 'github.com' +} + +/** + * Positional `HOST/OWNER/REPO` argv value (e.g. `gh repo view `). + * Positional slugs bypass the runner's `--repo` qualifier, so they must be + * qualified here whenever the host is known. + */ +export function githubRepositorySlugArg(repository: GitHubApiRepository): string { + const slug = `${repository.owner}/${repository.repo}` + // Why: github.com must be explicit too; otherwise process-level GH_HOST can + // redirect positional `gh repo view OWNER/REPO` calls to an Enterprise host. + return repository.host ? `${repository.host}/${slug}` : slug +} diff --git a/src/main/github/github-enterprise-repository.test.ts b/src/main/github/github-enterprise-repository.test.ts index 73de02ddae9..0a361ea1323 100644 --- a/src/main/github/github-enterprise-repository.test.ts +++ b/src/main/github/github-enterprise-repository.test.ts @@ -15,7 +15,8 @@ vi.mock('../git/runner', () => ({ import { _resetGitHubHostAuthCache, getEnterpriseGitHubRepoSlug, - isGitHubHostAuthenticated + isGitHubHostAuthenticated, + isGitHubHostAuthenticatedForGlobalCli } from './github-enterprise-repository' function mockOriginRemote(url: string): void { @@ -61,11 +62,9 @@ describe('getEnterpriseGitHubRepoSlug', () => { repo: 'orca', host: 'github.acme-corp.com' }) - // The auth probe targets the remote's host, not a hardcoded github.com. - expect(ghExecFileAsyncMock).toHaveBeenCalledWith( - ['auth', 'status', '--hostname', 'github.acme-corp.com'], - { cwd: '/repo' } - ) + // Why: inventory configured gh hosts without targeting the untrusted remote; + // passing it as --hostname could expose an ambient enterprise token. + expect(ghExecFileAsyncMock).toHaveBeenCalledWith(['auth', 'status'], { cwd: '/repo' }) }) it('resolves a GHES SCP-style SSH remote', async () => { @@ -79,6 +78,28 @@ describe('getEnterpriseGitHubRepoSlug', () => { }) }) + it('uses the unique ported auth host for a hostname-only SSH remote', async () => { + mockOriginRemote('git@ghe.acme.com:team/orca.git') + mockHostAuthenticated('ghe.acme.com:8443') + + await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toEqual({ + owner: 'team', + repo: 'orca', + host: 'ghe.acme.com:8443' + }) + }) + + it('uses the auth inventory host when an HTTPS remote includes a non-default port', async () => { + mockOriginRemote('https://ghe.acme.com:8443/team/orca.git') + mockHostAuthenticated('ghe.acme.com') + + await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toEqual({ + owner: 'team', + repo: 'orca', + host: 'ghe.acme.com' + }) + }) + it('probes gh in the repository WSL runtime, not the host/default distro', async () => { mockOriginRemote('https://github.acme-corp.com/team/orca.git') mockHostAuthenticated() @@ -87,10 +108,10 @@ describe('getEnterpriseGitHubRepoSlug', () => { localGitExecOptions: { wslDistro: 'Ubuntu' } }) - expect(ghExecFileAsyncMock).toHaveBeenCalledWith( - ['auth', 'status', '--hostname', 'github.acme-corp.com'], - { cwd: '/repo', wslDistro: 'Ubuntu' } - ) + expect(ghExecFileAsyncMock).toHaveBeenCalledWith(['auth', 'status'], { + cwd: '/repo', + wslDistro: 'Ubuntu' + }) }) it('leaves github.com to getOwnerRepo without probing gh auth', async () => { @@ -120,6 +141,22 @@ describe('getEnterpriseGitHubRepoSlug', () => { await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toBeNull() }) + + it('preserves an indeterminate auth inventory failure so a later probe can recover', async () => { + mockOriginRemote('git@ghe.acme.com:team/orca.git') + ghExecFileAsyncMock.mockRejectedValueOnce( + Object.assign(new Error('not installed'), { stdout: '', stderr: '' }) + ) + + await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toBeUndefined() + + mockHostAuthenticated('ghe.acme.com:8443') + await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toEqual({ + owner: 'team', + repo: 'orca', + host: 'ghe.acme.com:8443' + }) + }) }) describe('isGitHubHostAuthenticated', () => { @@ -135,10 +172,7 @@ describe('isGitHubHostAuthenticated', () => { await expect( isGitHubHostAuthenticated('github.acme-corp.com', '/remote/repo', 'ssh-1') ).resolves.toBe(true) - expect(ghExecFileAsyncMock).toHaveBeenCalledWith( - ['auth', 'status', '--hostname', 'github.acme-corp.com'], - {} - ) + expect(ghExecFileAsyncMock).toHaveBeenCalledWith(['auth', 'status'], {}) }) it('caches per runtime+host so detection polling does not re-spawn gh', async () => { @@ -149,6 +183,28 @@ describe('isGitHubHostAuthenticated', () => { expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) }) + it('coalesces concurrent probes for the same runtime and host', async () => { + let finishProbe: (() => void) | undefined + ghExecFileAsyncMock.mockImplementation( + () => + new Promise((resolve) => { + finishProbe = () => + resolve({ + stdout: + 'github.acme-corp.com\n ✓ Logged in to github.acme-corp.com account kelora (keyring)', + stderr: '' + }) + }) + ) + + const first = isGitHubHostAuthenticated('github.acme-corp.com', '/repo') + const second = isGitHubHostAuthenticated('github.acme-corp.com', '/repo') + + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) + finishProbe?.() + await expect(Promise.all([first, second])).resolves.toEqual([true, true]) + }) + it('does not share cache state across WSL distros', async () => { mockHostAuthenticated() @@ -157,6 +213,84 @@ describe('isGitHubHostAuthenticated', () => { expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) }) + it('derives cache scope from an implicit WSL UNC repository path', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + ghExecFileAsyncMock.mockImplementation(async (_args: string[], options: { cwd?: string }) => ({ + stdout: options.cwd?.includes('Ubuntu') + ? 'github.com\n ✓ Logged in to github.com account other (keyring)' + : 'github.acme-corp.com\n ✓ Logged in to github.acme-corp.com account kelora (keyring)', + stderr: '' + })) + + try { + await expect(isGitHubHostAuthenticated('github.acme-corp.com', '/native/repo')).resolves.toBe( + true + ) + await expect( + isGitHubHostAuthenticated( + 'github.acme-corp.com', + String.raw`\\wsl.localhost\Ubuntu\home\me\repo` + ) + ).resolves.toBe(false) + await expect( + isGitHubHostAuthenticated( + 'github.acme-corp.com', + String.raw`\\wsl.localhost\Debian\home\me\repo` + ) + ).resolves.toBe(true) + + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(3) + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + + it('shares the native gh auth probe across SSH connections', async () => { + mockHostAuthenticated() + + await isGitHubHostAuthenticated('github.acme-corp.com', '/remote/a', 'ssh-1') + await isGitHubHostAuthenticated('github.acme-corp.com', '/remote/b', 'ssh-2') + + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) + }) + + it('does not target an unconfigured remote host with ambient credentials', async () => { + mockHostAuthenticated('github.acme-corp.com') + + await expect(isGitHubHostAuthenticated('evil.example.test', '/repo')).resolves.toBe(false) + + expect(ghExecFileAsyncMock).toHaveBeenCalledWith(['auth', 'status'], { cwd: '/repo' }) + expect(ghExecFileAsyncMock.mock.calls.flat()).not.toContain('evil.example.test') + }) + + it('does not guess between multiple ported auth hosts for one SSH hostname', async () => { + ghExecFileAsyncMock.mockResolvedValue({ + stdout: `ghe.acme.com:8443 + ✓ Logged in to ghe.acme.com:8443 account kelora (keyring) +ghe.acme.com:9443 + ✓ Logged in to ghe.acme.com:9443 account other (keyring)`, + stderr: '' + }) + + await expect(isGitHubHostAuthenticated('ghe.acme.com', '/repo')).resolves.toBe(false) + }) + + it('treats default web ports as the same auth host', async () => { + mockHostAuthenticated('ghe.acme.com:443') + + await expect(isGitHubHostAuthenticated('ghe.acme.com', '/repo')).resolves.toBe(true) + }) + + it('validates a global project host by inventory without targeting it', async () => { + mockHostAuthenticated('github.acme-corp.com') + + await expect(isGitHubHostAuthenticatedForGlobalCli('evil.example.test')).resolves.toBe(false) + + expect(ghExecFileAsyncMock).toHaveBeenCalledWith(['auth', 'status'], {}) + expect(ghExecFileAsyncMock.mock.calls.flat()).not.toContain('evil.example.test') + }) + it('treats a listed host as authenticated even when gh exits non-zero', async () => { ghExecFileAsyncMock.mockRejectedValue( Object.assign(new Error('exit 1'), { diff --git a/src/main/github/github-enterprise-repository.ts b/src/main/github/github-enterprise-repository.ts index 94cffe725fa..b6b28e62f4e 100644 --- a/src/main/github/github-enterprise-repository.ts +++ b/src/main/github/github-enterprise-repository.ts @@ -12,6 +12,7 @@ import { parseGitHubRemoteIdentity, type LocalGitExecOptions } from './github-repository-identity' +import { parseWslPath } from '../wsl' export type GitHubEnterpriseRepoSlug = GitHubOwnerRepo & { host: string } @@ -20,25 +21,47 @@ export type GitHubEnterpriseRepoSlug = GitHubOwnerRepo & { host: string } // mirrors the `glab auth status` signal GitLab self-hosted detection uses, so a // GHES remote is not left to fall through to Gitea (#8312). const HOST_AUTH_TTL_MS = 60_000 +const HOST_AUTH_CACHE_MAX_ENTRIES = 512 type HostAuthCacheEntry = { - authenticated: boolean + authenticatedHost: string | null expiresAt: number } const hostAuthCache = new Map() +const hostAuthInFlight = new Map>() -// Why: gh's authenticated hosts live in per-runtime config — a WSL distro and an -// SSH host each carry their own `hosts.yml` — so cache state must be keyed by the -// runtime that executes gh, not shared under one "local" bucket. Mirrors the -// runtime scoping used by owner/repo resolution. -function runtimeCacheKey(connectionId?: string | null, wslDistro?: string): string { - return connectionId ?? `local:${wslDistro ?? 'host'}` +// Why: connection-backed Git operations execute remotely, but gh intentionally +// executes on the native host. Only WSL selects a distinct gh config/runtime. +function runtimeCacheKey(repoPath: string, wslDistro?: string): string { + const resolvedDistro = wslDistro ?? parseWslPath(repoPath)?.distro + return `local:${resolvedDistro?.toLowerCase() ?? 'host'}` } /** @internal - exposed for tests only */ export function _resetGitHubHostAuthCache(): void { hostAuthCache.clear() + hostAuthInFlight.clear() +} + +/** @internal - exposed for cache-bound tests only */ +export function _getGitHubHostAuthCacheSize(): number { + return hostAuthCache.size +} + +function pruneHostAuthCache(now: number): void { + for (const [key, entry] of hostAuthCache) { + if (entry.expiresAt <= now) { + hostAuthCache.delete(key) + } + } + while (hostAuthCache.size > HOST_AUTH_CACHE_MAX_ENTRIES) { + const oldestKey = hostAuthCache.keys().next().value + if (oldestKey === undefined) { + return + } + hostAuthCache.delete(oldestKey) + } } // Only gh's own stdout/stderr — not the Error.message — counts as an @@ -52,14 +75,117 @@ function ghCommandOutput(error: unknown): string { .join('\n') } +type NormalizedGitHubHost = { + hostname: string + port: string | null + authority: string +} + +function normalizeGitHubHost(host: string): NormalizedGitHubHost | null { + const match = host + .trim() + .toLowerCase() + .match(/^([a-z0-9][a-z0-9.-]*)(?::(\d+))?$/i) + if (!match) { + return null + } + const hostname = match[1] + const rawPort = match[2] ?? null + // Why: default web ports do not distinguish a gh auth host from the same + // hostname without an explicit port. + const port = rawPort === '80' || rawPort === '443' ? null : rawPort + return { hostname, port, authority: port ? `${hostname}:${port}` : hostname } +} + +function authenticatedHostFromInventory(host: string, output: string): string | null { + const requested = normalizeGitHubHost(host) + if (!requested) { + return null + } + const inventory = Array.from( + new Map( + parseAuthStatus(output) + .map((account) => normalizeGitHubHost(account.host)) + .filter((candidate): candidate is NormalizedGitHubHost => candidate !== null) + .map((candidate) => [candidate.authority, candidate]) + ).values() + ) + const exact = inventory.find((candidate) => candidate.authority === requested.authority) + if (exact) { + return exact.authority + } + const compatible = inventory.filter( + (candidate) => + candidate.hostname === requested.hostname && (!requested.port || candidate.port === null) + ) + // Why: an SSH remote has no API port. Only a unique auth-inventory host can + // safely supply it; multiple ported endpoints on one hostname are ambiguous. + return compatible.length === 1 ? compatible[0].authority : null +} + +async function resolveAuthenticatedGitHubHost( + host: string, + repoPath: string, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + const normalizedHost = normalizeGitHubHost(host)?.authority ?? host.trim().toLowerCase() + const cacheKey = `${runtimeCacheKey(repoPath, localGitOptions.wslDistro)}\0${normalizedHost}` + const now = Date.now() + pruneHostAuthCache(now) + const cached = hostAuthCache.get(cacheKey) + if (cached && cached.expiresAt > now) { + return cached.authenticatedHost + } + const inFlight = hostAuthInFlight.get(cacheKey) + if (inFlight) { + return inFlight + } + // Why: provider detection and review loading can probe the same runtime at + // once; coalesce them so one host never spawns duplicate auth subprocesses. + const probe = (async () => { + const execOptions = { + ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) + } + let authenticatedHost: string | null + try { + const { stdout, stderr } = await ghExecFileAsync(['auth', 'status'], execOptions) + authenticatedHost = authenticatedHostFromInventory(host, `${stdout}\n${stderr}`) + } catch (error) { + const output = ghCommandOutput(error) + if (!output) { + // Indeterminate (gh missing / spawn failure) — do not cache so a later + // probe (gh installed, tunnel ready, token added) can recover. + return undefined + } + // gh exits non-zero when a host has a token problem but still prints the + // per-host status; trust only hosts that are actually listed. + authenticatedHost = authenticatedHostFromInventory(host, output) + } + hostAuthCache.set(cacheKey, { + authenticatedHost, + expiresAt: Date.now() + HOST_AUTH_TTL_MS + }) + pruneHostAuthCache(Date.now()) + return authenticatedHost + })() + hostAuthInFlight.set(cacheKey, probe) + try { + return await probe + } finally { + if (hostAuthInFlight.get(cacheKey) === probe) { + hostAuthInFlight.delete(cacheKey) + } + } +} + /** * Whether `gh` is authenticated to `host` from the repository's own runtime. * - * The probe runs `gh auth status --hostname ` with the repo's execution - * options (cwd / WSL distro, or SSH-local like the create path), so a GHES login - * stored only in that runtime's gh config — or a `GH_ENTERPRISE_TOKEN` inferred - * from it — is honored instead of the host/default-distro gh. Cached briefly per - * runtime+host so provider-detection polling does not re-spawn gh each time. + * The probe inventories gh's configured hosts and matches `host` locally. It + * deliberately does not pass an untrusted remote host to gh because ambient + * enterprise tokens could otherwise be sent to that host during validation. + * Cached briefly per runtime+host so provider-detection polling stays cheap. */ export async function isGitHubHostAuthenticated( host: string, @@ -67,52 +193,35 @@ export async function isGitHubHostAuthenticated( connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - const normalizedHost = host.toLowerCase() - const cacheKey = `${runtimeCacheKey(connectionId, localGitOptions.wslDistro)}\0${normalizedHost}` - const now = Date.now() - const cached = hostAuthCache.get(cacheKey) - if (cached && cached.expiresAt > now) { - return cached.authenticated - } - const execOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) - let authenticated: boolean - try { - await ghExecFileAsync(['auth', 'status', '--hostname', normalizedHost], execOptions) - authenticated = true - } catch (error) { - const output = ghCommandOutput(error) - if (!output) { - // Indeterminate (gh missing / spawn failure) — do not cache so a later - // probe (gh installed, tunnel ready, token added) can recover. - return false - } - // gh exits non-zero when a host has a token problem but still prints the - // per-host status; treat the host as GitHub only when it is actually listed. - authenticated = parseAuthStatus(output).some( - (account) => account.host.toLowerCase() === normalizedHost - ) - } - hostAuthCache.set(cacheKey, { authenticated, expiresAt: now + HOST_AUTH_TTL_MS }) - return authenticated + return Boolean( + await resolveAuthenticatedGitHubHost(host, repoPath, connectionId, localGitOptions) + ) +} + +/** Safely validate a project-selected host without giving the untrusted host + * to gh. Global project calls have no repository cwd, so they use native gh. */ +export function isGitHubHostAuthenticatedForGlobalCli(host: string): Promise { + return isGitHubHostAuthenticated(host, '', 'project-host-validation') } /** - * Resolve owner/repo for a GitHub Enterprise Server `origin` remote — a custom - * host the user is gh-authenticated to. Returns null for github.com (already - * handled by {@link getOwnerRepo}) and for hosts gh is not logged in to + * Resolve owner/repo for a GitHub Enterprise Server remote — a custom host the + * user is gh-authenticated to. Returns null for github.com (already handled by + * {@link getOwnerRepo}) and for hosts gh is not logged in to * (Gitea/Forgejo/self-hosted GitLab/etc.), so GHES routes to the GitHub provider * without a GitHub provider stealing another forge's remote. */ -export async function getEnterpriseGitHubRepoSlug( +export async function getEnterpriseGitHubRepoSlugForRemote( repoPath: string, + remoteName: string, connectionId?: string | null, options: HostedReviewExecutionOptions = {} -): Promise { +): Promise { const localGitOptions = getHostedReviewLocalGitOptions(options) const context = githubRepoContext(repoPath, connectionId, localGitOptions) let remoteUrl: string | null try { - remoteUrl = await getRemoteUrlForRepo(context, 'origin') + remoteUrl = await getRemoteUrlForRepo(context, remoteName) } catch { return null } @@ -120,11 +229,24 @@ export async function getEnterpriseGitHubRepoSlug( if (!identity || identity.host === 'github.com') { return null } - const authenticated = await isGitHubHostAuthenticated( + const authenticatedHost = await resolveAuthenticatedGitHubHost( identity.host, repoPath, connectionId, localGitOptions ) - return authenticated ? { owner: identity.owner, repo: identity.repo, host: identity.host } : null + if (authenticatedHost === undefined) { + return undefined + } + return authenticatedHost + ? { owner: identity.owner, repo: identity.repo, host: authenticatedHost } + : null +} + +export async function getEnterpriseGitHubRepoSlug( + repoPath: string, + connectionId?: string | null, + options: HostedReviewExecutionOptions = {} +): Promise { + return getEnterpriseGitHubRepoSlugForRemote(repoPath, 'origin', connectionId, options) } diff --git a/src/main/github/github-remote-identity-parsing.test.ts b/src/main/github/github-remote-identity-parsing.test.ts new file mode 100644 index 00000000000..c0b8b599520 --- /dev/null +++ b/src/main/github/github-remote-identity-parsing.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest' + +import { parseGitHubOwnerRepo, parseGitHubRemoteIdentity } from './github-remote-identity-parsing' + +describe('parseGitHubRemoteIdentity', () => { + it('parses a plain github.com https remote', () => { + expect(parseGitHubRemoteIdentity('https://github.com/team/orca.git')).toEqual({ + host: 'github.com', + owner: 'team', + repo: 'orca' + }) + }) + + it('parses an SCP-style github.com remote', () => { + expect(parseGitHubRemoteIdentity('git@github.com:team/orca.git')).toEqual({ + host: 'github.com', + owner: 'team', + repo: 'orca' + }) + }) + + it('preserves a custom port on a GHES https remote', () => { + // The port IS the Enterprise web/API endpoint, so gh must target + // ghe.acme.com:8443, not the portless hostname. + expect(parseGitHubRemoteIdentity('https://ghe.acme.com:8443/team/orca.git')).toEqual({ + host: 'ghe.acme.com:8443', + owner: 'team', + repo: 'orca' + }) + }) + + it('preserves a custom port on a GHES http remote', () => { + expect(parseGitHubRemoteIdentity('http://ghe.acme.com:8080/team/orca.git')).toEqual({ + host: 'ghe.acme.com:8080', + owner: 'team', + repo: 'orca' + }) + }) + + it('drops the default https port so github.com stays bare', () => { + // WHATWG URL omits default ports, so :443 never leaks into the host. + expect(parseGitHubRemoteIdentity('https://github.com:443/team/orca.git')?.host).toBe( + 'github.com' + ) + }) + + it('drops the default https port on a GHES host', () => { + expect(parseGitHubRemoteIdentity('https://ghe.acme.com:443/team/orca.git')?.host).toBe( + 'ghe.acme.com' + ) + }) + + it('normalizes ssh.github.com:443 (SSH-over-HTTPS) to github.com without a port', () => { + // :443 here is the ssh transport port, not an endpoint — it must not survive. + expect(parseGitHubRemoteIdentity('ssh://git@ssh.github.com:443/team/orca.git')).toEqual({ + host: 'github.com', + owner: 'team', + repo: 'orca' + }) + }) + + it('drops a custom ssh transport port on a GHES ssh remote', () => { + // ssh://…:2222 is a transport port; gh routes by hostname, so keep only the host. + expect(parseGitHubRemoteIdentity('ssh://git@ghe.acme.com:2222/team/orca.git')?.host).toBe( + 'ghe.acme.com' + ) + }) + + it('drops the transport port for git+ssh and git remotes', () => { + expect(parseGitHubRemoteIdentity('git+ssh://git@ghe.acme.com:2222/team/orca.git')?.host).toBe( + 'ghe.acme.com' + ) + expect(parseGitHubRemoteIdentity('git://ghe.acme.com:9418/team/orca.git')?.host).toBe( + 'ghe.acme.com' + ) + }) + + it('lowercases the host while keeping the port', () => { + expect(parseGitHubRemoteIdentity('https://GHE.Acme.COM:8443/team/orca.git')?.host).toBe( + 'ghe.acme.com:8443' + ) + }) + + it('returns null for an unparseable remote', () => { + expect(parseGitHubRemoteIdentity('not-a-remote')).toBeNull() + }) +}) + +describe('parseGitHubOwnerRepo', () => { + it('returns owner/repo for github.com', () => { + expect(parseGitHubOwnerRepo('https://github.com/team/orca.git')).toEqual({ + owner: 'team', + repo: 'orca' + }) + }) + + it('returns null for a custom-port GHES remote (not github.com)', () => { + // GHES is handled by the enterprise resolver, not the github.com fast path, + // and the port must not make a github.com remote look like GHES either. + expect(parseGitHubOwnerRepo('https://ghe.acme.com:8443/team/orca.git')).toBeNull() + }) + + it('still recognizes github.com even with an explicit default port', () => { + expect(parseGitHubOwnerRepo('https://github.com:443/team/orca.git')).toEqual({ + owner: 'team', + repo: 'orca' + }) + }) +}) diff --git a/src/main/github/github-remote-identity-parsing.ts b/src/main/github/github-remote-identity-parsing.ts index e7bd21242f1..6f13e62014d 100644 --- a/src/main/github/github-remote-identity-parsing.ts +++ b/src/main/github/github-remote-identity-parsing.ts @@ -4,10 +4,17 @@ export type GitHubRemoteIdentity = GitHubOwnerRepo & { host: string } function normalizeGitHubRemoteHost(host: string): string { const normalizedHost = host.toLowerCase() - // Why: GitHub documents ssh.github.com:443 as SSH-over-HTTPS for github.com repos. + // Why: GitHub documents ssh.github.com as SSH-over-HTTPS for github.com repos. return normalizedHost === 'ssh.github.com' ? 'github.com' : normalizedHost } +// Why: HTTP ports identify the GHES web/API endpoint; SSH and git ports are +// transport-only and must not leak into gh's host identity. +function hostFromRemoteUrl(url: URL): string { + const protocol = url.protocol.toLowerCase() + return protocol === 'http:' || protocol === 'https:' ? url.host : url.hostname +} + function parseGitHubRemotePath(path: string): Pick | null { const parts = path.replace(/^\/+/, '').replace(/\/+$/, '').split('/') if (parts.length !== 2) { @@ -34,7 +41,7 @@ export function parseGitHubRemoteIdentity(remoteUrl: string): GitHubRemoteIdenti return null } const path = parseGitHubRemotePath(url.pathname) - return path ? { host: normalizeGitHubRemoteHost(url.hostname), ...path } : null + return path ? { host: normalizeGitHubRemoteHost(hostFromRemoteUrl(url)), ...path } : null } catch { return null } diff --git a/src/main/github/github-repository-identity.fork-owner-repo.test.ts b/src/main/github/github-repository-identity.fork-owner-repo.test.ts index 62750e02458..7613baaf8fd 100644 --- a/src/main/github/github-repository-identity.fork-owner-repo.test.ts +++ b/src/main/github/github-repository-identity.fork-owner-repo.test.ts @@ -128,7 +128,8 @@ describe('issue #7331: fork PR owner/repo resolution', () => { // fast-path would be skipped. const upstream = await getRepoUpstream(FORK_PATH) - expect(upstream).toEqual({ owner: 'stablyai', repo: 'orca' }) + // Why: origin resolution pins github.com so host-scoped execution is explicit. + expect(upstream).toEqual({ owner: 'stablyai', repo: 'orca', host: 'github.com' }) expect(ghExecFileAsyncMock).not.toHaveBeenCalled() }) }) diff --git a/src/main/github/github-repository-identity.ts b/src/main/github/github-repository-identity.ts index 28b7b0676c7..1996d7b2b54 100644 --- a/src/main/github/github-repository-identity.ts +++ b/src/main/github/github-repository-identity.ts @@ -8,6 +8,7 @@ import { type GitHubRemoteIdentity } from './github-remote-identity-parsing' import { isStableMissingGitRemoteError } from './stable-missing-git-remote-error' +import { githubRepoIdentityKey } from '../../shared/github-repository-identity-key' export type OwnerRepo = GitHubOwnerRepo @@ -234,7 +235,7 @@ export type PRRepositoryCandidates = { } function ownerRepoKey(ownerRepo: OwnerRepo): string { - return `${ownerRepo.owner.toLowerCase()}/${ownerRepo.repo.toLowerCase()}` + return githubRepoIdentityKey(ownerRepo) } export async function resolvePRRepositoryCandidates( diff --git a/src/main/github/issues.test.ts b/src/main/github/issues.test.ts index 0f65fc1572c..88d61c1bcc9 100644 --- a/src/main/github/issues.test.ts +++ b/src/main/github/issues.test.ts @@ -1,4 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as GithubApiRepositoryModule from './github-api-repository' +import type * as GithubEnterpriseRepositoryModule from './github-enterprise-repository' import type * as GhUtils from './gh-utils' const { @@ -27,6 +29,45 @@ vi.mock('./gh-utils', async () => { } }) +vi.mock('./github-enterprise-repository', async (importOriginal) => ({ + ...(await importOriginal()), + isGitHubHostAuthenticated: vi.fn().mockResolvedValue(true) +})) + +vi.mock('./github-api-repository', async (importOriginal) => { + const actual = await importOriginal() + const withDotComHost = (repo: T) => + repo ? { host: 'github.com' as const, ...repo } : repo + return { + ...actual, + // Why: these suites drive source resolution through the legacy gh-utils + // mocks; bridge the hosted seams onto the same mocks and pin github.com so + // host-less fixtures still pass resolveGitHubApiRepository's host gate. + getIssueGitHubApiRepository: async ( + repoPath: string, + connectionId?: string | null, + localGitOptions?: unknown + ) => withDotComHost(await getIssueOwnerRepoMock(repoPath, connectionId, localGitOptions)), + resolveIssueGitHubApiRepositorySource: async ( + repoPath: string, + preference: unknown, + connectionId?: string | null, + localGitOptions?: unknown + ) => { + const result = await resolveIssueSourceMock( + repoPath, + preference, + connectionId, + localGitOptions + ) + return { + ...result, + source: withDotComHost(result?.source) + } + } + } +}) + import { addIssueComment, createIssue, @@ -37,6 +78,14 @@ import { updateIssue } from './issues' +import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository' + +// The origin-repository cache is module-level state; reset it so slugs +// resolved by one test cannot leak into the next. +beforeEach(() => { + _resetOriginGitHubApiRepositoryCache() +}) + describe('issue source operations', () => { beforeEach(() => { ghExecFileAsyncMock.mockReset() @@ -69,7 +118,7 @@ describe('issue source operations', () => { await expect(getIssue('/repo-root', 923)).resolves.toMatchObject({ number: 923 }) expect(ghExecFileAsyncMock).toHaveBeenCalledWith( ['api', '--cache', '300s', 'repos/stablyai/orca/issues/923'], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) }) @@ -138,6 +187,38 @@ describe('issue source operations', () => { ) }) + it('routes PR conversation comments to the supplied Enterprise host', async () => { + ghExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ + id: 9, + user: { login: 'octo', avatar_url: '', type: 'User' }, + body: 'Enterprise comment', + created_at: '2026-07-16T00:00:00.000Z', + html_url: 'https://github.acme-corp.com/team/orca/pull/7#issuecomment-9' + }) + }) + + await expect( + addIssueComment('/remote/repo', 7, 'Enterprise comment', 'ssh-1', { + owner: 'team', + repo: 'orca', + host: 'github.acme-corp.com' + }) + ).resolves.toMatchObject({ ok: true }) + + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + [ + 'api', + '-X', + 'POST', + 'repos/team/orca/issues/7/comments', + '--raw-field', + 'body=Enterprise comment' + ], + expect.objectContaining({ host: 'github.acme-corp.com' }) + ) + }) + it('lists issues from the issue owner/repo', async () => { getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }) @@ -151,7 +232,7 @@ describe('issue source operations', () => { '120s', 'repos/stablyai/orca/issues?per_page=5&state=open&sort=updated&direction=desc' ], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) }) @@ -195,7 +276,7 @@ describe('issue source operations', () => { '--raw-field', 'body=Body' ], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) }) @@ -235,7 +316,7 @@ describe('issue source operations', () => { '--raw-field', 'assignees[]=octo' ], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) }) @@ -274,17 +355,17 @@ describe('issue source operations', () => { 'labels[]=bug', 'assignees[]=octo' ]), - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 2, expect.arrayContaining(['body=', 'title=Image issue', 'labels[]=bug', 'assignees[]=octo']), - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 3, ['api', '-X', 'PATCH', 'repos/stablyai/orca/issues/926', '--raw-field', `body=${body}`], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) }) @@ -405,7 +486,7 @@ describe('issue source operations', () => { }) expect(ghExecFileAsyncMock).toHaveBeenCalledWith( ['api', '-X', 'PATCH', 'repos/stablyai/orca/issues/924', '--raw-field', 'body=Updated body'], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) }) @@ -430,17 +511,17 @@ describe('issue source operations', () => { expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 1, ['issue', 'close', '924', '--repo', 'stablyai/orca', '--reason', 'completed'], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 2, ['issue', 'close', '925', '--repo', 'stablyai/orca', '--reason', 'not planned'], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 3, ['issue', 'close', '926', '--repo', 'stablyai/orca', '--duplicate-of', '99'], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) }) @@ -453,7 +534,7 @@ describe('issue source operations', () => { }) expect(ghExecFileAsyncMock).toHaveBeenCalledWith( ['issue', 'reopen', '924', '--repo', 'stablyai/orca'], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) }) }) diff --git a/src/main/github/issues.ts b/src/main/github/issues.ts index b0f3fa6cb89..45ed0b1a99e 100644 --- a/src/main/github/issues.ts +++ b/src/main/github/issues.ts @@ -15,8 +15,13 @@ import type { } from '../../shared/types' import { mapIssueInfo } from './mappers' import type { LocalGitExecOptions, OwnerRepo } from './gh-utils' +import { + getIssueGitHubApiRepository, + resolveGitHubRepoExecution, + resolveIssueGitHubApiRepositorySource +} from './github-api-repository' // prettier-ignore -import { ghExecFileAsync, acquire, release, getIssueOwnerRepo, resolveIssueSource, classifyGhError, classifyListIssuesError, ghRepoExecOptions, githubRepoContext, extractExecError } from './gh-utils' +import { ghExecFileAsync, acquire, release, classifyGhError, classifyListIssuesError, extractExecError } from './gh-utils' // Why: distinguishes a successful-empty listing from a failed fetch. The // previous `catch { return [] }` conflated a 403 on a private upstream with an @@ -54,9 +59,17 @@ export async function getIssue( connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - const context = githubRepoContext(repoPath, connectionId, localGitOptions) - const ghOptions = ghRepoExecOptions(context) - const ownerRepo = await getIssueOwnerRepo(repoPath, connectionId, localGitOptions) + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( + repoPath, + () => getIssueGitHubApiRepository(repoPath, connectionId, localGitOptions), + connectionId, + localGitOptions + ) + // Why: a connection-backed request has no local cwd, so the non-GitHub + // fallback below would let gh target its default repository. Refuse instead. + if (connectionId && !ownerRepo) { + return null + } await acquire() try { if (ownerRepo) { @@ -104,14 +117,31 @@ export async function listIssues( connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - const context = githubRepoContext(repoPath, connectionId, localGitOptions) - const ghOptions = ghRepoExecOptions(context) - const { source: ownerRepo } = await resolveIssueSource( + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( repoPath, - preference, + async () => + ( + await resolveIssueGitHubApiRepositorySource( + repoPath, + preference, + connectionId, + localGitOptions + ) + ).source, connectionId, localGitOptions ) + // Why: a connection-backed request has no local cwd, so the non-GitHub + // fallback below would let gh list its default repository. Refuse instead. + if (connectionId && !ownerRepo) { + return { + items: [], + error: { + type: 'not_found', + message: 'Could not resolve GitHub owner/repo for this repository' + } + } + } await acquire() try { if (ownerRepo) { @@ -173,11 +203,17 @@ export async function createIssue( if (!trimmedTitle) { return { ok: false, error: 'Title is required' } } - const context = githubRepoContext(repoPath, connectionId, localGitOptions) - const ghOptions = ghRepoExecOptions(context) - const { source: ownerRepo } = await resolveIssueSource( + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( repoPath, - preference, + async () => + ( + await resolveIssueGitHubApiRepositorySource( + repoPath, + preference, + connectionId, + localGitOptions + ) + ).source, connectionId, localGitOptions ) @@ -286,9 +322,12 @@ export async function updateIssue( connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise<{ ok: true } | { ok: false; error: string }> { - const context = githubRepoContext(repoPath, connectionId, localGitOptions) - const ghOptions = ghRepoExecOptions(context) - const ownerRepo = await getIssueOwnerRepo(repoPath, connectionId, localGitOptions) + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( + repoPath, + () => getIssueGitHubApiRepository(repoPath, connectionId, localGitOptions), + connectionId, + localGitOptions + ) if (!ownerRepo) { return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' } } @@ -409,10 +448,13 @@ export async function addIssueComment( ownerRepoOverride?: OwnerRepo | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - const context = githubRepoContext(repoPath, connectionId, localGitOptions) - const ghOptions = ghRepoExecOptions(context) - const ownerRepo = - ownerRepoOverride ?? (await getIssueOwnerRepo(repoPath, connectionId, localGitOptions)) + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( + repoPath, + ownerRepoOverride ?? + (() => getIssueGitHubApiRepository(repoPath, connectionId, localGitOptions)), + connectionId, + localGitOptions + ) if (!ownerRepo) { return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' } } @@ -463,11 +505,17 @@ export async function listLabels( connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - const context = githubRepoContext(repoPath, connectionId, localGitOptions) - const ghOptions = ghRepoExecOptions(context) - const { source: ownerRepo } = await resolveIssueSource( + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( repoPath, - preference, + async () => + ( + await resolveIssueGitHubApiRepositorySource( + repoPath, + preference, + connectionId, + localGitOptions + ) + ).source, connectionId, localGitOptions ) @@ -503,11 +551,17 @@ export async function listAssignableUsers( connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - const context = githubRepoContext(repoPath, connectionId, localGitOptions) - const ghOptions = ghRepoExecOptions(context) - const { source: ownerRepo } = await resolveIssueSource( + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( repoPath, - preference, + async () => + ( + await resolveIssueGitHubApiRepositorySource( + repoPath, + preference, + connectionId, + localGitOptions + ) + ).source, connectionId, localGitOptions ) diff --git a/src/main/github/merged-pr-commit-membership.ts b/src/main/github/merged-pr-commit-membership.ts index 0bb390f6495..7350416ca6f 100644 --- a/src/main/github/merged-pr-commit-membership.ts +++ b/src/main/github/merged-pr-commit-membership.ts @@ -1,5 +1,7 @@ import { ghExecFileAsync } from './gh-utils' -import { noteRateLimitSpend, rateLimitGuard } from './rate-limit' +import { noteRepositoryRateLimitSpend, repositoryRateLimitGuard } from './rate-limit' +import { githubHostExecOptions } from './github-api-repository' +import { githubRepoIdentityKey } from '../../shared/github-repository-identity-key' import type { OwnerRepo } from './github-repository-identity' type GhExecOptions = Parameters[1] @@ -59,7 +61,8 @@ export async function isCommitPartOfMergedPR(args: { } const owner = args.ownerRepo.owner const repo = args.ownerRepo.repo - const cacheKey = `${owner.toLowerCase()}/${repo.toLowerCase()}#${args.prNumber}@${oid}` + const cacheKey = `${githubRepoIdentityKey(args.ownerRepo)}#${args.prNumber}@${oid}` + const ghOptions = { ...args.ghOptions, ...githubHostExecOptions(args.ownerRepo) } const now = Date.now() pruneMergedPRCommitMembershipCache(now) const cached = membershipCache.get(cacheKey) @@ -68,7 +71,7 @@ export async function isCommitPartOfMergedPR(args: { } // Why blocked stays unknown: hiding a transient branch match is safe, but // callers must not clear a durable linked PR when the probe never ran. - if (rateLimitGuard('core').blocked) { + if (repositoryRateLimitGuard(args.ownerRepo, 'core', ghOptions).blocked) { return 'unknown' } try { @@ -79,20 +82,20 @@ export async function isCommitPartOfMergedPR(args: { // found (contained) or a short page proves absence (not-contained); only the // pathological all-full case up to the cap stays 'unknown'. for (let page = 1; page <= COMMIT_PULLS_MAX_PAGES; page += 1) { - if (page > 1 && rateLimitGuard('core').blocked) { + if (page > 1 && repositoryRateLimitGuard(args.ownerRepo, 'core', ghOptions).blocked) { membershipCache.set(cacheKey, { value: 'unknown', expiresAt: now + MEMBERSHIP_ERROR_TTL_MS }) return 'unknown' } - noteRateLimitSpend('core') + noteRepositoryRateLimitSpend(args.ownerRepo, 'core', 1, ghOptions) const { stdout } = await ghExecFileAsync( [ 'api', `repos/${owner}/${repo}/commits/${oid}/pulls?per_page=${COMMIT_PULLS_PAGE_SIZE}&page=${page}` ], - args.ghOptions + ghOptions ) const parsed = JSON.parse(stdout) as unknown // Why: a non-array success payload is a shape mismatch, not an empty page; diff --git a/src/main/github/pr-refresh-coordinator.test.ts b/src/main/github/pr-refresh-coordinator.test.ts index 5ee1a882a62..7298c36af6d 100644 --- a/src/main/github/pr-refresh-coordinator.test.ts +++ b/src/main/github/pr-refresh-coordinator.test.ts @@ -2,21 +2,28 @@ request timestamps, and follow-up scheduling against shared module state. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { GitHubPRRefreshCandidate, PRInfo } from '../../shared/types' +import { isWslUncPath } from '../../shared/wsl-paths' const { sendMock, sendToTrustedUIRendererMock, getAllWebContentsMock, getPRForBranchOutcomeMock, + getOriginGitHubApiRepositoryMock, getRateLimitMock, - rateLimitGuardMock + noteRepositoryRateLimitSpendMock, + repositoryRateLimitGuardMock, + spendsSharedGitHubComQuotaMock } = vi.hoisted(() => ({ sendMock: vi.fn(), sendToTrustedUIRendererMock: vi.fn(), getAllWebContentsMock: vi.fn(), getPRForBranchOutcomeMock: vi.fn(), + getOriginGitHubApiRepositoryMock: vi.fn(), getRateLimitMock: vi.fn(), - rateLimitGuardMock: vi.fn() + noteRepositoryRateLimitSpendMock: vi.fn(), + repositoryRateLimitGuardMock: vi.fn(), + spendsSharedGitHubComQuotaMock: vi.fn() })) vi.mock('electron', () => ({ @@ -29,10 +36,15 @@ vi.mock('./client', () => ({ getPRForBranchOutcome: getPRForBranchOutcomeMock })) +vi.mock('./github-api-repository', () => ({ + getOriginGitHubApiRepository: getOriginGitHubApiRepositoryMock +})) + vi.mock('./rate-limit', () => ({ getRateLimit: getRateLimitMock, - noteRateLimitSpend: vi.fn(), - rateLimitGuard: rateLimitGuardMock + noteRepositoryRateLimitSpend: noteRepositoryRateLimitSpendMock, + repositoryRateLimitGuard: repositoryRateLimitGuardMock, + spendsSharedGitHubComQuota: spendsSharedGitHubComQuotaMock })) vi.mock('../ipc/ui', () => ({ @@ -94,9 +106,23 @@ describe('pr-refresh-coordinator', () => { }) getAllWebContentsMock.mockReset() getPRForBranchOutcomeMock.mockReset() + getOriginGitHubApiRepositoryMock.mockReset() + getOriginGitHubApiRepositoryMock.mockResolvedValue({ + owner: 'acme', + repo: 'widgets', + host: 'github.com' + }) getRateLimitMock.mockReset() - rateLimitGuardMock.mockReset() - rateLimitGuardMock.mockReturnValue({ blocked: false }) + noteRepositoryRateLimitSpendMock.mockReset() + repositoryRateLimitGuardMock.mockReset() + repositoryRateLimitGuardMock.mockReturnValue({ blocked: false }) + spendsSharedGitHubComQuotaMock.mockReset() + spendsSharedGitHubComQuotaMock.mockImplementation( + (repository: { host?: string } | null, options?: { cwd?: string; wslDistro?: string }) => + (!repository?.host || repository.host.toLowerCase() === 'github.com') && + !options?.wslDistro && + !(options?.cwd && isWslUncPath(options.cwd)) + ) getAllWebContentsMock.mockReturnValue([ { id: 1, @@ -569,6 +595,75 @@ describe('pr-refresh-coordinator', () => { expect(pausedEvents).toHaveLength(0) }) + it.each([ + { + scope: 'GitHub Enterprise', + repository: { owner: 'acme', repo: 'widgets', host: 'github.acme-corp.com' }, + repoPath: '/repo', + localGitOptions: undefined + }, + { + scope: 'WSL', + repository: { owner: 'acme', repo: 'widgets', host: 'github.com' }, + repoPath: '/repo', + localGitOptions: { wslDistro: 'Ubuntu' } + }, + { + scope: 'implicit WSL UNC', + repository: { owner: 'acme', repo: 'widgets', host: 'github.com' }, + repoPath: String.raw`\\wsl.localhost\Ubuntu\home\me\widgets`, + localGitOptions: undefined + } + ])('bypasses the shared budget for $scope background refreshes', async (testCase) => { + const { enqueuePRRefresh } = await import('./pr-refresh-coordinator') + getOriginGitHubApiRepositoryMock.mockResolvedValue(testCase.repository) + getPRForBranchOutcomeMock.mockResolvedValue({ + kind: 'no-pr', + fetchedAt: Date.now() + }) + const executionOptions = { + cwd: testCase.repoPath, + ...testCase.localGitOptions + } + + enqueuePRRefresh( + makeCandidate({ + repoPath: testCase.repoPath, + localGitOptions: testCase.localGitOptions + }), + 'active', + 80, + 1 + ) + + await vi.advanceTimersByTimeAsync(0) + + expect(getRateLimitMock).not.toHaveBeenCalled() + expect(repositoryRateLimitGuardMock).toHaveBeenCalledWith( + testCase.repository, + 'core', + executionOptions + ) + expect(repositoryRateLimitGuardMock).toHaveBeenCalledWith( + testCase.repository, + 'graphql', + executionOptions + ) + expect(noteRepositoryRateLimitSpendMock).toHaveBeenCalledWith( + testCase.repository, + 'core', + 1, + executionOptions + ) + expect(noteRepositoryRateLimitSpendMock).toHaveBeenCalledWith( + testCase.repository, + 'graphql', + 1, + executionOptions + ) + expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(1) + }) + it('does not consume active burst slots for rate-limit pauses', async () => { const { enqueuePRRefresh } = await import('./pr-refresh-coordinator') // Why: keyed on drained items (one getRateLimit call each) rather than @@ -579,7 +674,7 @@ describe('pr-refresh-coordinator', () => { drainedItems += 1 return { ok: true } }) - rateLimitGuardMock.mockImplementation(() => + repositoryRateLimitGuardMock.mockImplementation(() => drainedItems <= 3 ? { blocked: true, remaining: 0, limit: 5000, resetAt: 61 } : { blocked: false } diff --git a/src/main/github/pr-refresh-coordinator.ts b/src/main/github/pr-refresh-coordinator.ts index 592bea4870b..70b13ec2e69 100644 --- a/src/main/github/pr-refresh-coordinator.ts +++ b/src/main/github/pr-refresh-coordinator.ts @@ -9,7 +9,14 @@ import type { PRRefreshOutcome } from '../../shared/types' import { getPRForBranchOutcome, type GitHubPRBranchLookupOptions } from './client' -import { getRateLimit, noteRateLimitSpend, rateLimitGuard } from './rate-limit' +import { getOriginGitHubApiRepository } from './github-api-repository' +import { ghRepoExecOptions, githubRepoContext } from './gh-utils' +import { + getRateLimit, + noteRepositoryRateLimitSpend, + repositoryRateLimitGuard, + spendsSharedGitHubComQuota +} from './rate-limit' import { recordCoalescedCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store' import { sendToTrustedUIRenderer } from '../ipc/ui' @@ -728,11 +735,26 @@ async function drainQueue(): Promise { ) if (isBackground(next.reason)) { - // 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 executionOptions = ghRepoExecOptions( + githubRepoContext( + next.candidate.repoPath, + next.candidate.connectionId, + next.candidate.localGitOptions + ) + ) + const repository = await getOriginGitHubApiRepository( + next.candidate.repoPath, + next.candidate.connectionId, + executionOptions + ) + // Why: only native github.com uses the singleton snapshot; scoped breakers protect GHES and WSL. + if (spendsSharedGitHubComQuota(repository, executionOptions)) { + // Why: the probe only warms the cache, so failures must fail open (#7553). + await getRateLimit() + } const buckets = backgroundRefreshBuckets() const blockedGuard = buckets - .map((bucket) => rateLimitGuard(bucket)) + .map((bucket) => repositoryRateLimitGuard(repository, bucket, executionOptions)) .find((guard) => guard.blocked) if (blockedGuard?.blocked) { const retryAt = blockedGuard.resetAt * 1000 @@ -755,7 +777,7 @@ async function drainQueue(): Promise { noteActiveStart(next) } for (const bucket of buckets) { - noteRateLimitSpend(bucket) + noteRepositoryRateLimitSpend(repository, bucket, 1, executionOptions) } } @@ -922,8 +944,16 @@ export async function refreshPRNow(candidate: GitHubPRRefreshCandidate): Promise } // 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 manualExecutionOptions = ghRepoExecOptions( + githubRepoContext(candidate.repoPath, candidate.connectionId, candidate.localGitOptions) + ) + const manualRepository = await getOriginGitHubApiRepository( + candidate.repoPath, + candidate.connectionId, + manualExecutionOptions + ) const manualBlockedGuard = backgroundRefreshBuckets() - .map((bucket) => rateLimitGuard(bucket)) + .map((bucket) => repositoryRateLimitGuard(manualRepository, bucket, manualExecutionOptions)) .find((guard) => guard.blocked) const secondaryGateUntil = manualRetryGates.get(key) const gateUntil = Math.max( diff --git a/src/main/github/project-view-host-auth.test.ts b/src/main/github/project-view-host-auth.test.ts new file mode 100644 index 00000000000..f243c06ed45 --- /dev/null +++ b/src/main/github/project-view-host-auth.test.ts @@ -0,0 +1,125 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + acquireMock, + releaseMock, + ghExecFileAsyncMock, + hostAuthenticatedMock, + noteRepositoryRateLimitSpendMock +} = vi.hoisted(() => ({ + acquireMock: vi.fn(), + releaseMock: vi.fn(), + ghExecFileAsyncMock: vi.fn(), + hostAuthenticatedMock: vi.fn(), + noteRepositoryRateLimitSpendMock: vi.fn() +})) + +vi.mock('./gh-utils', () => ({ + acquire: acquireMock, + release: releaseMock +})) + +vi.mock('../git/runner', () => ({ + extractExecError: vi.fn(() => ({ stdout: '', stderr: '' })), + ghExecFileAsync: ghExecFileAsyncMock +})) + +vi.mock('./rate-limit', () => ({ + rateLimitGuard: vi.fn(() => ({ blocked: false })), + noteRateLimitSpend: vi.fn(), + repositoryRateLimitGuard: vi.fn(() => ({ blocked: false })), + noteRepositoryRateLimitSpend: noteRepositoryRateLimitSpendMock +})) + +vi.mock('./github-enterprise-repository', () => ({ + isGitHubHostAuthenticatedForGlobalCli: hostAuthenticatedMock +})) + +import { runGraphql, runRest } from './project-view/internals' +import { _resetProjectViewCachesForTests, resolveProjectRef } from './project-view' + +describe('project view host authentication boundary', () => { + beforeEach(() => { + acquireMock.mockReset().mockResolvedValue(undefined) + releaseMock.mockReset() + ghExecFileAsyncMock.mockReset() + hostAuthenticatedMock.mockReset() + noteRepositoryRateLimitSpendMock.mockReset() + _resetProjectViewCachesForTests() + }) + + it('does not send GraphQL or REST requests to an unconfigured host', async () => { + hostAuthenticatedMock.mockResolvedValue(false) + + const [graphql, rest] = await Promise.all([ + runGraphql('query { viewer { login } }', {}, { host: 'evil.example.test' }), + runRest(['user'], undefined, 'core', { host: 'evil.example.test' }) + ]) + + expect(graphql).toMatchObject({ ok: false, error: { type: 'auth_required' } }) + expect(rest).toMatchObject({ ok: false, error: { type: 'auth_required' } }) + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + expect(acquireMock).not.toHaveBeenCalled() + expect(noteRepositoryRateLimitSpendMock).not.toHaveBeenCalled() + }) + + it('routes a configured Enterprise request to its selected host', async () => { + hostAuthenticatedMock.mockResolvedValue(true) + ghExecFileAsyncMock.mockResolvedValue({ + stdout: '{"data":{"viewer":{"login":"me"}}}', + stderr: '' + }) + + await expect( + runGraphql('query { viewer { login } }', {}, { host: 'github.corp.example' }) + ).resolves.toMatchObject({ ok: true }) + + expect(ghExecFileAsyncMock).toHaveBeenCalledWith(expect.any(Array), { + encoding: 'utf-8', + host: 'github.corp.example' + }) + }) + + it('keeps github.com fast and never probes it as Enterprise', async () => { + ghExecFileAsyncMock.mockResolvedValue({ + stdout: '{"data":{"viewer":{"login":"me"}}}', + stderr: '' + }) + + await expect( + runGraphql('query { viewer { login } }', {}, { host: 'github.com' }) + ).resolves.toMatchObject({ ok: true }) + + expect(hostAuthenticatedMock).not.toHaveBeenCalled() + }) + + it('uses a pasted github.com URL instead of the ambient Enterprise host', async () => { + ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { + const query = args.find((arg) => arg.startsWith('query=')) ?? '' + return query.includes('projectV2') + ? { + stdout: JSON.stringify({ + data: { organization: { projectV2: { id: 'PVT_7', title: 'Roadmap' } } } + }), + stderr: '' + } + : { + stdout: JSON.stringify({ data: { organization: { login: 'acme' } } }), + stderr: '' + } + }) + + await expect( + resolveProjectRef({ + input: 'https://github.com/orgs/acme/projects/7', + host: 'github.corp.example' + }) + ).resolves.toMatchObject({ ok: true, host: 'github.com' }) + + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) + expect( + ghExecFileAsyncMock.mock.calls.every(([, options]) => options.host === 'github.com') + ).toBe(true) + expect(hostAuthenticatedMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/github/project-view.test.ts b/src/main/github/project-view.test.ts index 00a8844bb34..1339c5c0fdc 100644 --- a/src/main/github/project-view.test.ts +++ b/src/main/github/project-view.test.ts @@ -68,6 +68,19 @@ describe('classifyProjectError', () => { it('classifies auth-required when gh is not signed in', () => { expect(classifyProjectError('gh auth login required', '').type).toBe('auth_required') }) + + it('pins Enterprise auth and scope remediation to the selected host', () => { + expect( + classifyProjectError('gh auth login required', '', 'github.acme.test').message + ).toContain('gh auth login --hostname github.acme.test') + expect( + classifyProjectError( + 'your token has not been granted the required scopes', + '', + 'github.acme.test' + ).message + ).toContain('gh auth refresh --hostname github.acme.test') + }) }) describe('isValidOwnerSlug', () => { @@ -136,6 +149,7 @@ describe('parseProjectPaste', () => { kind: 'org', owner: 'acme', number: 42, + host: 'github.com', viewNumber: 3 }) }) @@ -144,7 +158,8 @@ describe('parseProjectPaste', () => { expect(parseProjectPaste('https://github.com/users/octocat/projects/1')).toEqual({ kind: 'user', owner: 'octocat', - number: 1 + number: 1, + host: 'github.com' }) }) @@ -152,6 +167,43 @@ describe('parseProjectPaste', () => { expect(parseProjectPaste('https://github.com/orgs/co_op/projects/1')).toBeNull() }) + it('accepts enterprise-host URLs only when that host is provided (GHES)', () => { + const url = 'https://github.corp.example/orgs/acme/projects/7/views/2' + expect(parseProjectPaste(url, 'github.corp.example')).toEqual({ + kind: 'org', + owner: 'acme', + number: 7, + host: 'github.corp.example', + viewNumber: 2 + }) + expect(parseProjectPaste(url)).toBeNull() + // github.com URLs still parse when a GHES host is supplied. + expect( + parseProjectPaste('https://github.com/orgs/acme/projects/7', 'github.corp.example') + ).toEqual({ kind: 'org', owner: 'acme', number: 7, host: 'github.com' }) + }) + + it('preserves a GHES custom port while parsing project URLs', () => { + expect( + parseProjectPaste( + 'https://github.corp.example:8443/orgs/acme/projects/7', + 'github.corp.example:8443' + ) + ).toEqual({ + kind: 'org', + owner: 'acme', + number: 7, + host: 'github.corp.example:8443' + }) + }) + + it('rejects credentials and paths that only begin like a Project URL', () => { + expect(parseProjectPaste('https://user:token@github.com/orgs/acme/projects/1')).toBeNull() + expect(parseProjectPaste('https://github.com/orgs/acme/projects/1evil')).toBeNull() + expect(parseProjectPaste('https://github.com/orgs/acme/projects/1/views/2evil')).toBeNull() + expect(parseProjectPaste('https://github.com/orgs/acme/projects/1/files')).toBeNull() + }) + it('returns null for empty input', () => { expect(parseProjectPaste('')).toBeNull() expect(parseProjectPaste(' ')).toBeNull() @@ -208,6 +260,13 @@ describe('project view owner caches', () => { expect(_getProjectViewOwnerTypeForTests('owner-1')).toBe('user') }) + it('shares owner type probes between implicit and explicit github.com hosts', () => { + _rememberProjectViewOwnerTypeForTests('acme', 'organization') + + expect(_getProjectViewOwnerTypeForTests('acme', 'github.com')).toBe('organization') + expect(_getProjectViewCacheSizesForTests().ownerTypes).toBe(1) + }) + it('LRU-evicts old parent-field retry and warning probes', () => { for (let i = 0; i <= PROJECT_VIEW_OWNER_CACHE_MAX_ENTRIES; i++) { const scopeKey = `owner-${i}\u0000organization` diff --git a/src/main/github/project-view.ts b/src/main/github/project-view.ts index 8e2be18044e..2e53eff357c 100644 --- a/src/main/github/project-view.ts +++ b/src/main/github/project-view.ts @@ -4,19 +4,23 @@ import { release, extractExecError, ghExecFileAsync, - rateLimitGuard, - noteRateLimitSpend, - classifyProjectError, - driftError, - errorsIndicateParentField, - rateLimitedError, + repositoryRateLimitGuard, + noteRepositoryRateLimitSpend, runGraphql, isValidOwnerSlug, assertSlug, assertPositiveInt, - type GhGraphqlErrorShape, + projectHostAuthenticationError, + projectGhExecOptions, type GraphqlVars } from './project-view/internals' +import { + classifyProjectError, + driftError, + errorsIndicateParentField, + rateLimitedError, + type GhGraphqlErrorShape +} from './project-view/project-error-classification' import type { GetProjectViewTableArgs, GetProjectViewTableResult, @@ -36,6 +40,7 @@ import type { GitHubProjectViewError, GitHubProjectViewLayout, GitHubProjectViewSummary, + ListAccessibleProjectsArgs, ListAccessibleProjectsResult, ListProjectViewsArgs, ListProjectViewsResult, @@ -46,14 +51,11 @@ import { GITHUB_PROJECT_REF_INPUT_TOO_LARGE_ERROR, isGitHubProjectRefInputTooLarge } from '../../shared/github-project-ref-input' +import { githubProjectHost } from '../../shared/github-project-identity' // Re-export the public API so existing `./project-view` call sites keep working; the split is internal-only. -export { - isValidOwnerSlug, - isValidRepoSlug, - isValidSlug, - classifyProjectError -} from './project-view/internals' +export { isValidOwnerSlug, isValidRepoSlug, isValidSlug } from './project-view/internals' +export { classifyProjectError } from './project-view/project-error-classification' export { updateProjectItemFieldValue, clearProjectItemFieldValue, @@ -122,16 +124,32 @@ const parentFieldWarningLoggedByOwner = new Map() // 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 { - return `${owner}\u0000${ownerType}` +// Why: GHES owners are a separate namespace and capability surface from +// github.com owners with the same login — scope cache keys by host so one +// host's probe result can't leak into another. Normalize github.com so +// host-less callers share the same probe state as explicitly pinned calls. +function ownerScopeKey(owner: string, ownerType: GitHubProjectOwnerType, host?: string): string { + const base = `${owner}\u0000${ownerType}` + return `${base}\u0000${githubProjectHost(host)}` } -function rememberOwnerType(owner: string, ownerType: GitHubProjectOwnerType | null): void { - rememberProjectViewCacheEntry(ownerTypeCache, owner, ownerType) +function ownerTypeCacheKey(owner: string, host?: string): string { + return `${owner}\u0000${githubProjectHost(host)}` } -function getCachedOwnerType(owner: string): GitHubProjectOwnerType | null | undefined { - return getProjectViewCacheEntry(ownerTypeCache, owner) +function rememberOwnerType( + owner: string, + ownerType: GitHubProjectOwnerType | null, + host?: string +): void { + rememberProjectViewCacheEntry(ownerTypeCache, ownerTypeCacheKey(owner, host), ownerType) +} + +function getCachedOwnerType( + owner: string, + host?: string +): GitHubProjectOwnerType | null | undefined { + return getProjectViewCacheEntry(ownerTypeCache, ownerTypeCacheKey(owner, host)) } function markParentFieldRetried(scopeKey: string): void { @@ -174,16 +192,18 @@ export function _getProjectViewCacheSizesForTests(): { /** @internal - exposed for cache-bound tests only. */ export function _rememberProjectViewOwnerTypeForTests( owner: string, - ownerType: GitHubProjectOwnerType | null + ownerType: GitHubProjectOwnerType | null, + host?: string ): void { - rememberOwnerType(owner, ownerType) + rememberOwnerType(owner, ownerType, host) } /** @internal - exposed for cache-bound tests only. */ export function _getProjectViewOwnerTypeForTests( - owner: string + owner: string, + host?: string ): GitHubProjectOwnerType | null | undefined { - return getCachedOwnerType(owner) + return getCachedOwnerType(owner, host) } /** @internal - exposed for cache-bound tests only. */ @@ -618,6 +638,7 @@ async function fetchProjectViewsPage(args: { owner: string ownerType: GitHubProjectOwnerType projectNumber: number + host?: string after: string | null }): Promise< | { @@ -662,7 +683,8 @@ async function fetchProjectViewsPage(args: { } const res = await runGraphql>( query, - vars + vars, + projectGhExecOptions(args.host) ) if (!res.ok) { return res @@ -685,7 +707,8 @@ async function fetchProjectViewsPage(args: { async function fetchViewFieldsContinuation( viewId: string, - after: string + after: string, + host?: string ): Promise< { ok: true; fields: RawProjectV2Field[] } | { ok: false; error: GitHubProjectViewError } > { @@ -715,7 +738,7 @@ async function fetchViewFieldsContinuation( nodes?: (RawProjectV2Field | null)[] } } | null - }>(query, { viewId, after: cursor }) + }>(query, { viewId, after: cursor }, projectGhExecOptions(host)) if (!res.ok) { return res } @@ -823,6 +846,7 @@ async function fetchItemsPageWithRaw(args: { first: number after: string | null includeParent: boolean + host?: string }): Promise< | { ok: true; page: RawItemsPage } | { @@ -832,6 +856,10 @@ async function fetchItemsPageWithRaw(args: { stderr: string } > { + const authError = await projectHostAuthenticationError(args.host) + if (authError) { + return { ok: false, error: authError, rawErrors: [], stderr: '' } + } const root = ownerQueryRoot(args.ownerType) const afterArg = args.after ? `, after: $after` : '' const afterVar = args.after ? `$after:String!, ` : '' @@ -864,7 +892,9 @@ async function fetchItemsPageWithRaw(args: { argsArr.push('-f', `after=${args.after}`) } - const guard = rateLimitGuard('graphql') + // Why: GHES traffic runs against its own quota — only github.com requests + // consult/debit the shared snapshot. + const guard = repositoryRateLimitGuard(args, 'graphql') if (guard.blocked) { return { ok: false, @@ -874,13 +904,16 @@ async function fetchItemsPageWithRaw(args: { } } await acquire() - noteRateLimitSpend('graphql') + noteRepositoryRateLimitSpend(args, 'graphql') try { let stdout = '' let stderr = '' let execFailed = false try { - const r = await ghExecFileAsync(argsArr, { encoding: 'utf-8' }) + const r = await ghExecFileAsync(argsArr, { + encoding: 'utf-8', + ...projectGhExecOptions(args.host) + }) stdout = r.stdout stderr = r.stderr } catch (err) { @@ -897,7 +930,7 @@ async function fetchItemsPageWithRaw(args: { if (execFailed) { return { ok: false, - error: classifyProjectError(stderr, stdout), + error: classifyProjectError(stderr, stdout, args.host), rawErrors: [], stderr } @@ -913,7 +946,7 @@ async function fetchItemsPageWithRaw(args: { if (execFailed && (!parsed.errors || parsed.errors.length === 0) && !parsed.data) { return { ok: false, - error: classifyProjectError(stderr, stdout), + error: classifyProjectError(stderr, stdout, args.host), rawErrors: [], stderr } @@ -921,7 +954,7 @@ async function fetchItemsPageWithRaw(args: { if (parsed.errors && parsed.errors.length > 0) { return { ok: false, - error: classifyProjectError(stderr, stdout), + error: classifyProjectError(stderr, stdout, args.host), rawErrors: parsed.errors, stderr } @@ -947,13 +980,14 @@ async function fetchAllItems(args: { ownerType: GitHubProjectOwnerType projectNumber: number query: string + host?: string }): Promise< | { ok: true; rows: GitHubProjectRow[]; totalCount: number; parentFieldDropped: boolean } | { ok: false; error: GitHubProjectViewError; totalCount?: number } > { - // 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: 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). + // Why: isolate missing Issue.parent capability by owner, type, and host. + const scopeKey = ownerScopeKey(args.owner, args.ownerType, args.host) + // Why: await the same-scope probe, then re-read state because it may have changed. const inFlight = parentFieldProbeInFlight.get(scopeKey) if (inFlight) { await inFlight.catch(() => {}) @@ -978,7 +1012,8 @@ async function fetchAllItems(args: { query: args.query, first: ITEM_PAGE_SIZE, after: null, - includeParent: true + includeParent: true, + host: args.host }) // 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)) { @@ -999,7 +1034,8 @@ async function fetchAllItems(args: { query: args.query, first: ITEM_PAGE_SIZE, after: null, - includeParent + includeParent, + host: args.host }) } if (!first.ok && includeParent && errorsIndicateParentField(first.rawErrors, first.stderr)) { @@ -1020,7 +1056,8 @@ async function fetchAllItems(args: { query: args.query, first: ITEM_PAGE_SIZE, after: null, - includeParent: false + includeParent: false, + host: args.host }) } if (!first.ok) { @@ -1087,7 +1124,8 @@ async function fetchAllItems(args: { query: args.query, first: ITEM_PAGE_SIZE, after: cursor as string, - includeParent + includeParent, + host: args.host }) if (!next.ok) { return { ok: false, error: next.error, totalCount } @@ -1126,6 +1164,7 @@ async function fetchItemsCountOnly(args: { ownerType: GitHubProjectOwnerType projectNumber: number query: string + host?: string }): Promise { const root = ownerQueryRoot(args.ownerType) const query = ` @@ -1139,7 +1178,11 @@ async function fetchItemsCountOnly(args: { ` const res = await runGraphql< Record - >(query, { owner: args.owner, num: args.projectNumber, q: args.query }) + >( + query, + { owner: args.owner, num: args.projectNumber, q: args.query }, + projectGhExecOptions(args.host) + ) if (!res.ok) { return null } @@ -1178,6 +1221,7 @@ export async function getProjectViewTable( owner: args.owner, ownerType: args.ownerType, projectNumber: args.projectNumber, + host: args.host, after: cursor }) if (!page.ok) { @@ -1225,7 +1269,7 @@ export async function getProjectViewTable( let extraFields: RawProjectV2Field[] = [] const fieldsPi = selectedRaw.fields?.pageInfo if (fieldsPi?.hasNextPage === true && typeof fieldsPi.endCursor === 'string' && selectedRaw.id) { - const cont = await fetchViewFieldsContinuation(selectedRaw.id, fieldsPi.endCursor) + const cont = await fetchViewFieldsContinuation(selectedRaw.id, fieldsPi.endCursor, args.host) if (!cont.ok) { return { ok: false, error: cont.error } } @@ -1248,7 +1292,8 @@ export async function getProjectViewTable( owner: args.owner, ownerType: args.ownerType, projectNumber: args.projectNumber, - query: effectiveQuery + query: effectiveQuery, + host: args.host }) return { ok: false, @@ -1265,7 +1310,8 @@ export async function getProjectViewTable( owner: args.owner, ownerType: args.ownerType, projectNumber: args.projectNumber, - query: effectiveQuery + query: effectiveQuery, + host: args.host }) if (!items.ok) { return { @@ -1278,6 +1324,7 @@ export async function getProjectViewTable( const table: GitHubProjectTable = { project: { id: project.id, + host: githubProjectHost(args.host), owner: args.owner, ownerType: args.ownerType, number: args.projectNumber, @@ -1320,7 +1367,10 @@ type RawViewerDiscovery = { } } -export async function listAccessibleProjects(): Promise { +export async function listAccessibleProjects( + args?: ListAccessibleProjectsArgs +): Promise { + const host = githubProjectHost(args?.host) const viewerProjects: GitHubProjectSummary[] = [] const orgProjects: GitHubProjectSummary[] = [] // Why: collect per-org failures so the picker shows a "some orgs didn't load" banner instead of aborting discovery on the first 504. @@ -1352,7 +1402,7 @@ export async function listAccessibleProjects(): Promise(query, vars) + const res = await runGraphql(query, vars, projectGhExecOptions(host)) if (!res.ok) { // 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 } @@ -1373,6 +1423,7 @@ export async function listAccessibleProjects(): Promise(query, vars) + const res = await runGraphql(query, vars, projectGhExecOptions(host)) if (!res.ok) { // 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 }) @@ -1434,8 +1485,8 @@ export async function listAccessibleProjects(): Promise= 1 - ? { viewNumber } - : {}) + host: url.host.toLowerCase(), + ...(viewNumber !== undefined ? { viewNumber } : {}) } + } catch { + // Shorthand parsing below remains available for non-URL input. } // owner/number shorthand — owner alphabet matches OWNER_SLUG_RE. const shortRe = /^([A-Za-z0-9][A-Za-z0-9-]*)\/(\d+)$/ @@ -1526,7 +1593,8 @@ export function parseProjectPaste(input: string): ParsedPaste | null { async function resolveOwnerType( owner: string, - preferred: GitHubProjectOwnerType | null + preferred: GitHubProjectOwnerType | null, + host?: string ): Promise< | { ok: true; ownerType: GitHubProjectOwnerType; title: string } | { ok: false; error: GitHubProjectViewError } @@ -1554,7 +1622,7 @@ async function resolveOwnerType( } const res = await runGraphql< Record - >(query, vars) + >(query, vars, projectGhExecOptions(host)) if (!res.ok) { return { ok: false, error: res.error } } @@ -1572,7 +1640,7 @@ async function resolveOwnerType( return { ok: true, title: '' } } - const cached = getCachedOwnerType(owner) + const cached = getCachedOwnerType(owner, host) const candidates: GitHubProjectOwnerType[] = preferred ? [preferred] : cached @@ -1590,7 +1658,7 @@ async function resolveOwnerType( for (const ot of ordered) { const r = await tryOne(ot, null) if (r.ok) { - rememberOwnerType(owner, ot) + rememberOwnerType(owner, ot, host) return { ok: true, ownerType: ot, title: r.title } } lastError = r.error @@ -1599,7 +1667,7 @@ async function resolveOwnerType( return { ok: false, error: r.error } } } - rememberOwnerType(owner, null) + rememberOwnerType(owner, null, host) return { ok: false, error: lastError ?? { type: 'not_found', message: 'Owner not found.' } @@ -1622,7 +1690,7 @@ export async function resolveProjectRef( error: { type: 'validation_error', message: GITHUB_PROJECT_REF_INPUT_TOO_LARGE_ERROR } } } - const parsed = parseProjectPaste(input) + const parsed = parseProjectPaste(input, args.host) if (!parsed) { return { ok: false, @@ -1634,8 +1702,11 @@ export async function resolveProjectRef( } const preferred: GitHubProjectOwnerType | null = parsed.kind === 'org' ? 'organization' : parsed.kind === 'user' ? 'user' : null + // Why: a pasted URL is authoritative. The ambient host only applies to + // owner/number shorthand; otherwise same-number Projects can cross hosts. + const executionHost = parsed.kind === 'bare' ? githubProjectHost(args.host) : parsed.host // Verify by fetching project title. - const ownerRes = await resolveOwnerType(parsed.owner, preferred) + const ownerRes = await resolveOwnerType(parsed.owner, preferred, executionHost) if (!ownerRes.ok) { return { ok: false, error: ownerRes.error } } @@ -1648,7 +1719,7 @@ export async function resolveProjectRef( ` const res = await runGraphql< Record - >(query, { owner: parsed.owner, num: parsed.number }) + >(query, { owner: parsed.owner, num: parsed.number }, projectGhExecOptions(executionHost)) if (!res.ok) { return { ok: false, error: res.error } } @@ -1662,7 +1733,8 @@ export async function resolveProjectRef( ownerType, number: parsed.number, title: p.title ?? '', - // Why: forward the view number from /views/{n} URLs so the renderer can skip the view-pick step (bare owner/number shorthand carries none). + host: executionHost, + // Why: forward URL view numbers so the renderer can skip view selection; bare shorthand has none. ...(parsed.kind !== 'bare' && parsed.viewNumber !== undefined ? { viewNumber: parsed.viewNumber } : {}) @@ -1692,6 +1764,7 @@ export async function listProjectViews( owner: args.owner, ownerType: args.ownerType, projectNumber: args.projectNumber, + host: args.host, after: cursor }) if (!page.ok) { diff --git a/src/main/github/project-view/internals.ts b/src/main/github/project-view/internals.ts index 586bbaa6e95..5952118ee4c 100644 --- a/src/main/github/project-view/internals.ts +++ b/src/main/github/project-view/internals.ts @@ -4,12 +4,60 @@ // translation, and a single hook point for future quota tracking. import { acquire, release } from '../gh-utils' import { extractExecError, ghExecFileAsync } from '../../git/runner' -import { rateLimitGuard, noteRateLimitSpend, type RateLimitBucketKind } from '../rate-limit' +import { + repositoryRateLimitGuard, + noteRepositoryRateLimitSpend, + type RateLimitBucketKind +} from '../rate-limit' import type { GitHubProjectViewError } from '../../../shared/github-project-types' +import { githubProjectHost } from '../../../shared/github-project-identity' +import { isDefaultGitHubHost } from '../../../shared/github-repository-identity-key' +import { isGitHubHostAuthenticatedForGlobalCli } from '../github-enterprise-repository' +import { + classifyProjectError, + driftError, + rateLimitedError, + type GhGraphqlErrorShape +} from './project-error-classification' -export { acquire, release, extractExecError, ghExecFileAsync, rateLimitGuard, noteRateLimitSpend } +export { + acquire, + release, + extractExecError, + ghExecFileAsync, + repositoryRateLimitGuard, + noteRepositoryRateLimitSpend +} export type { RateLimitBucketKind } +/** gh exec routing shared by the project-view read/write paths. `host` pins + * GHES requests to the enterprise server via the runner's host qualifier. */ +export type ProjectGhExecOptions = { cwd?: string; host?: string } + +// Why: owner/project-addressed calls carry only a host (no repo slug), so they +// can't reuse githubHostExecOptions — mirror its host pinning for that shape. +export function projectGhExecOptions(host: string | undefined): ProjectGhExecOptions { + return { host: githubProjectHost(host) } +} + +export async function projectHostAuthenticationError( + host: string | undefined +): Promise { + const selectedHost = githubProjectHost(host) + if ( + isDefaultGitHubHost(selectedHost) || + (await isGitHubHostAuthenticatedForGlobalCli(selectedHost)) + ) { + return null + } + // Why: never target an unconfigured pasted host with gh; ambient + // GH_ENTERPRISE_TOKEN credentials could otherwise be sent to that server. + return { + type: 'auth_required', + message: `GitHub CLI is not authenticated to ${selectedHost}. Run gh auth login --hostname ${selectedHost}.` + } +} + // ─── Slug validation ────────────────────────────────────────────────── // Why: GitHub usernames/org logins disallow `_`, `.`, leading `-`. Repo names @@ -88,176 +136,6 @@ export function validateSlugArgs( return { ok: true } } -// ─── Error classification ────────────────────────────────────────────── - -export type GhGraphqlErrorShape = { - type?: string - message?: string - path?: (string | number)[] - extensions?: { code?: string } -} - -export function extractGraphqlErrors(stderr: string, stdout: string): GhGraphqlErrorShape[] { - // `gh api graphql` prints the response JSON to stdout even on GraphQL - // errors, and the stderr carries a summary. Try stdout first; if parsing - // fails, fall back to stderr. - const sources = [stdout, stderr] - for (const src of sources) { - if (!src) { - continue - } - try { - const parsed = JSON.parse(src) as { errors?: GhGraphqlErrorShape[] } - if (parsed.errors && parsed.errors.length > 0) { - return parsed.errors - } - } catch { - // not JSON — continue - } - } - return [] -} - -export function errorsIndicateParentField(errors: GhGraphqlErrorShape[], stderr: string): boolean { - const lower = stderr.toLowerCase() - // Preview-header shape: gh returns a 4xx with "preview" in the message. - if (lower.includes('preview') && lower.includes('parent')) { - return true - } - return errors.some((e) => { - const type = (e.type ?? '').toUpperCase() - if (type === 'FIELD_NOT_FOUND' || type === 'UNDEFINED_FIELD' || type === 'FIELD_ERRORS') { - const tail = e.path?.at(-1) - if (tail === 'parent') { - return true - } - // FIELD_ERRORS often omits `path`; match on message for the parent field. - if ((e.message ?? '').toLowerCase().includes('parent')) { - return true - } - } - return false - }) -} - -export function classifyProjectError(stderr: string, stdout: string): GitHubProjectViewError { - const errors = extractGraphqlErrors(stderr, stdout) - const s = stderr.toLowerCase() - - // Auth - if ( - s.includes('authentication required') || - s.includes('not logged in') || - s.includes('gh auth login') - ) { - return { - type: 'auth_required', - message: 'Sign in to GitHub to load project tasks. Run `gh auth login`.' - } - } - // Scope - if ( - s.includes('missing required scope') || - s.includes('your token has not been granted') || - (s.includes('resource not accessible') && (s.includes('project') || s.includes('scope'))) - ) { - return { - type: 'scope_missing', - message: - 'GitHub project access needs additional scopes. Run `gh auth refresh -s project -s read:org -s repo`.' - } - } - // Rate limit - if (s.includes('rate limit') || s.includes('api rate limit exceeded')) { - return { type: 'rate_limited', message: 'GitHub rate limit hit. Try again in a few minutes.' } - } - // Network — checked BEFORE not_found because DNS failures surface as - // "could not resolve host", which would otherwise be partially matched by - // the not_found branch's "could not resolve" check. Substring matching here - // is a one-way trapdoor: a real GraphQL "Could not resolve to a User…" - // error always contains "to a", so we tighten the not_found check below to - // require that token. - if ( - s.includes('timeout') || - s.includes('no such host') || - s.includes('network') || - s.includes('could not resolve host') || - s.includes('dial tcp') - ) { - return { type: 'network_error', message: 'Network error — check your connection.' } - } - // Not found - if ( - s.includes('http 404') || - errors.some((e) => (e.type ?? '').toUpperCase() === 'NOT_FOUND') || - // Why: GitHub uses "to an" for vowel-leading types ("to an Issue", "to - // an Organization") and "to a" otherwise. The previous singular-only - // check missed the "an" variants when gh emits only the stderr summary - // without a structured GraphQL error array. See bug-scan finding 3. - /could not resolve to an? /.test(s) - ) { - const firstNotFound = errors.find((e) => (e.type ?? '').toUpperCase() === 'NOT_FOUND') - return { - type: 'not_found', - message: 'Project or view not found.', - details: firstNotFound - ? { path: firstNotFound.path, code: firstNotFound.extensions?.code } - : undefined - } - } - // Validation - if (s.includes('http 422') || s.includes('validation failed')) { - return { type: 'validation_error', message: `Invalid request — ${stderr.trim()}` } - } - // GraphQL error with structured info - if (errors.length > 0) { - const first = errors[0] - return { - type: 'unknown', - message: first.message ?? 'Unknown GraphQL error.', - details: { path: first.path, code: first.extensions?.code } - } - } - // Why: don't leak full stderr to the UI — it can include verbose request - // dumps with header diagnostics. Truncate to the first non-empty line and - // cap length so unexpected diagnostics stay readable but bounded. - const firstLine = - stderr - .split('\n') - .map((l) => l.trim()) - .find((l) => l.length > 0) ?? '' - const safe = firstLine.length > 200 ? `${firstLine.slice(0, 200)}…` : firstLine - return { - type: 'unknown', - message: safe ? `GitHub request failed: ${safe}` : 'GitHub request failed.' - } -} - -export function driftError( - reason: string, - details?: { path?: (string | number)[]; code?: string } -): GitHubProjectViewError { - return { type: 'schema_drift', message: `Could not read this project view: ${reason}.`, details } -} - -// Why: the rate-limit circuit breaker short-circuits before we spawn `gh` -// when the cached snapshot says we're below the safety floor. Synthesize the -// same `rate_limited` error shape as the post-hoc classifier so the UI path -// is unchanged. We DO NOT fail open here when there's no cached snapshot — -// rateLimitGuard already handles that case (returns `blocked:false`). -export function rateLimitedError(blocked: { - remaining: number - limit: number - resetAt: number -}): GitHubProjectViewError { - const resetIn = Math.max(0, blocked.resetAt - Math.floor(Date.now() / 1000)) - const mins = Math.ceil(resetIn / 60) - return { - type: 'rate_limited', - message: `GitHub rate limit nearly exhausted (${blocked.remaining}/${blocked.limit} left). Resets in ~${mins}m.` - } -} - // ─── Low-level gh api graphql invocation ─────────────────────────────── export type GraphqlVars = Record @@ -265,12 +143,18 @@ export type GraphqlVars = Record export async function runGraphql( query: string, vars: GraphqlVars, - cwd?: string + exec?: ProjectGhExecOptions ): Promise< | { ok: true; data: T } | { ok: false; error: GitHubProjectViewError; raw: { stderr: string; stdout: string } } > { - const guard = rateLimitGuard('graphql') + const authError = await projectHostAuthenticationError(exec?.host) + if (authError) { + return { ok: false, error: authError, raw: { stderr: '', stdout: '' } } + } + // Why: GHES traffic runs against its own quota — only github.com requests + // consult/debit the shared snapshot. + const guard = repositoryRateLimitGuard({ host: exec?.host }, 'graphql') if (guard.blocked) { return { ok: false, error: rateLimitedError(guard), raw: { stderr: '', stdout: '' } } } @@ -286,18 +170,19 @@ export async function runGraphql( } } await acquire() - noteRateLimitSpend('graphql') + noteRepositoryRateLimitSpend({ host: exec?.host }, 'graphql') try { const { stdout, stderr } = await ghExecFileAsync(args, { encoding: 'utf-8', - ...(cwd ? { cwd } : {}) + ...(exec?.cwd ? { cwd: exec.cwd } : {}), + ...(exec?.host ? { host: exec.host } : {}) }) try { const parsed = JSON.parse(stdout) as { data?: T; errors?: GhGraphqlErrorShape[] } if (parsed.errors && parsed.errors.length > 0) { return { ok: false, - error: classifyProjectError(stderr, stdout), + error: classifyProjectError(stderr, stdout, exec?.host), raw: { stderr, stdout } } } @@ -324,7 +209,7 @@ export async function runGraphql( const { stderr, stdout: maybeStdout } = extractExecError(err) return { ok: false, - error: classifyProjectError(stderr, maybeStdout), + error: classifyProjectError(stderr, maybeStdout, exec?.host), raw: { stderr, stdout: maybeStdout } } } finally { @@ -336,18 +221,25 @@ export async function runRest( args: string[], cwd?: string, bucket: RateLimitBucketKind = 'core', - options?: { expectEmpty?: boolean } + options?: { expectEmpty?: boolean; host?: string } ): Promise<{ ok: true; data: T } | { ok: false; error: GitHubProjectViewError }> { - const guard = rateLimitGuard(bucket) + const authError = await projectHostAuthenticationError(options?.host) + if (authError) { + return { ok: false, error: authError } + } + // Why: GHES traffic runs against its own quota — only github.com requests + // consult/debit the shared snapshot. + const guard = repositoryRateLimitGuard({ host: options?.host }, bucket) if (guard.blocked) { return { ok: false, error: rateLimitedError(guard) } } await acquire() - noteRateLimitSpend(bucket) + noteRepositoryRateLimitSpend({ host: options?.host }, bucket) try { const { stdout, stderr } = await ghExecFileAsync(['api', ...args], { encoding: 'utf-8', - ...(cwd ? { cwd } : {}) + ...(cwd ? { cwd } : {}), + ...(options?.host ? { host: options.host } : {}) }) // Why: 204/empty-body endpoints (DELETE label, DELETE comment) return no // body. Treat empty stdout as success rather than misclassifying the @@ -367,7 +259,7 @@ export async function runRest( } } catch (err) { const { stderr, stdout: maybeStdout } = extractExecError(err) - return { ok: false, error: classifyProjectError(stderr, maybeStdout) } + return { ok: false, error: classifyProjectError(stderr, maybeStdout, options?.host) } } finally { release() } diff --git a/src/main/github/project-view/mutations.test.ts b/src/main/github/project-view/mutations.test.ts index 9d8cabca7f8..30a00a0f110 100644 --- a/src/main/github/project-view/mutations.test.ts +++ b/src/main/github/project-view/mutations.test.ts @@ -1,9 +1,22 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { acquireMock, ghExecFileAsyncMock, releaseMock, runRestMock } = vi.hoisted(() => ({ +const { + acquireMock, + ghExecFileAsyncMock, + noteRepositoryRateLimitSpendMock, + projectHostAuthenticationErrorMock, + repositoryRateLimitGuardMock, + releaseMock, + runGraphqlMock, + runRestMock +} = vi.hoisted(() => ({ acquireMock: vi.fn(), ghExecFileAsyncMock: vi.fn(), + noteRepositoryRateLimitSpendMock: vi.fn(), + projectHostAuthenticationErrorMock: vi.fn(), + repositoryRateLimitGuardMock: vi.fn(), releaseMock: vi.fn(), + runGraphqlMock: vi.fn(), runRestMock: vi.fn() })) @@ -17,9 +30,13 @@ vi.mock('./internals', () => ({ ghExecFileAsync: ghExecFileAsyncMock, rateLimitGuard: () => ({ blocked: false }), noteRateLimitSpend: vi.fn(), + repositoryRateLimitGuard: repositoryRateLimitGuardMock, + noteRepositoryRateLimitSpend: noteRepositoryRateLimitSpendMock, + projectHostAuthenticationError: projectHostAuthenticationErrorMock, + projectGhExecOptions: (host?: string) => (host ? { host } : {}), classifyProjectError: (stderr: string) => ({ type: 'unknown', message: stderr }), rateLimitedError: () => ({ type: 'rate_limited', message: 'rate limited' }), - runGraphql: vi.fn(), + runGraphql: runGraphqlMock, runRest: runRestMock, validateSlugArgs: (owner: string, repo: string) => owner && repo ? { ok: true } : { ok: false, error: { type: 'validation_error' } }, @@ -29,12 +46,15 @@ vi.mock('./internals', () => ({ : { ok: false, error: { type: 'validation_error', message: `${name} invalid` } } })) -import { updateIssueBySlug } from './mutations' +import { getWorkItemDetailsBySlug, updateIssueBySlug } from './mutations' describe('updateIssueBySlug', () => { beforeEach(() => { acquireMock.mockReset().mockResolvedValue(undefined) ghExecFileAsyncMock.mockReset().mockResolvedValue({ stdout: '', stderr: '' }) + noteRepositoryRateLimitSpendMock.mockReset() + projectHostAuthenticationErrorMock.mockReset().mockResolvedValue(null) + repositoryRateLimitGuardMock.mockReset().mockReturnValue({ blocked: false }) releaseMock.mockReset() runRestMock.mockReset().mockResolvedValue({ ok: true, data: {} }) }) @@ -60,16 +80,68 @@ describe('updateIssueBySlug', () => { expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 1, ['issue', 'close', '12', '--repo', 'acme/widgets', '--reason', 'completed'], - { encoding: 'utf-8' } + { encoding: 'utf-8', host: 'github.com' } ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 2, ['issue', 'close', '13', '--repo', 'acme/widgets', '--reason', 'not planned'], - { encoding: 'utf-8' } + { encoding: 'utf-8', host: 'github.com' } ) expect(runRestMock).not.toHaveBeenCalled() }) + it('rejects an unconfigured Enterprise host before spawning gh', async () => { + projectHostAuthenticationErrorMock.mockResolvedValue({ + type: 'auth_required', + message: 'GitHub host is not authenticated' + }) + + await expect( + updateIssueBySlug({ + owner: 'acme', + repo: 'widgets', + host: 'evil.example.test', + number: 12, + updates: { state: 'closed' } + }) + ).resolves.toMatchObject({ ok: false, error: { type: 'auth_required' } }) + + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + expect(runRestMock).not.toHaveBeenCalled() + }) + + it('guards and accounts state mutations in the selected host quota scope', async () => { + const args = { + owner: 'acme', + repo: 'widgets', + host: 'github.corp.example', + number: 12, + updates: { state: 'closed' as const } + } + + await expect(updateIssueBySlug(args)).resolves.toEqual({ ok: true }) + + expect(repositoryRateLimitGuardMock).toHaveBeenCalledWith(args, 'core') + expect(noteRepositoryRateLimitSpendMock).toHaveBeenCalledWith(args, 'core') + }) + + it('does not spawn a state mutation while that host quota is blocked', async () => { + repositoryRateLimitGuardMock.mockReturnValue({ blocked: true }) + + await expect( + updateIssueBySlug({ + owner: 'acme', + repo: 'widgets', + host: 'github.corp.example', + number: 12, + updates: { state: 'closed' } + }) + ).resolves.toMatchObject({ ok: false, error: { type: 'rate_limited' } }) + + expect(noteRepositoryRateLimitSpendMock).not.toHaveBeenCalled() + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + }) + it('closes slug-addressed duplicate issues with --duplicate-of', async () => { await expect( updateIssueBySlug({ @@ -82,7 +154,7 @@ describe('updateIssueBySlug', () => { expect(ghExecFileAsyncMock).toHaveBeenCalledWith( ['issue', 'close', '12', '--repo', 'acme/widgets', '--duplicate-of', '99'], - { encoding: 'utf-8' } + { encoding: 'utf-8', host: 'github.com' } ) expect(runRestMock).not.toHaveBeenCalled() }) @@ -115,7 +187,68 @@ describe('updateIssueBySlug', () => { expect(ghExecFileAsyncMock).toHaveBeenCalledWith( ['issue', 'reopen', '12', '--repo', 'acme/widgets'], - { encoding: 'utf-8' } + { encoding: 'utf-8', host: 'github.com' } + ) + }) + + it('threads the GHES host into gh exec and REST options', async () => { + await expect( + updateIssueBySlug({ + owner: 'acme', + repo: 'widgets', + number: 12, + host: 'github.corp.example', + updates: { state: 'closed', stateReason: 'completed', title: 'New title' } + }) + ).resolves.toEqual({ ok: true }) + + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + ['issue', 'close', '12', '--repo', 'acme/widgets', '--reason', 'completed'], + { encoding: 'utf-8', host: 'github.corp.example' } + ) + expect(runRestMock).toHaveBeenCalledWith( + ['-X', 'PATCH', 'repos/acme/widgets/issues/12', '--raw-field', 'title=New title'], + undefined, + 'core', + { host: 'github.corp.example' } + ) + }) +}) + +describe('getWorkItemDetailsBySlug', () => { + beforeEach(() => { + runGraphqlMock.mockReset() + }) + + it('threads the GHES host into the GraphQL exec options', async () => { + runGraphqlMock.mockResolvedValue({ + ok: true, + data: { + repository: { + issue: { + id: 'I_1', + number: 7, + title: 'Enterprise issue', + url: 'https://github.corp.example/acme/widgets/issues/7', + state: 'OPEN' + } + } + } + }) + + const result = await getWorkItemDetailsBySlug({ + owner: 'acme', + repo: 'widgets', + number: 7, + type: 'issue', + host: 'github.corp.example' + }) + + expect(result.ok).toBe(true) + expect(runGraphqlMock).toHaveBeenCalledWith( + expect.any(String), + { owner: 'acme', repo: 'widgets', num: 7 }, + { host: 'github.corp.example' } ) }) }) diff --git a/src/main/github/project-view/mutations.ts b/src/main/github/project-view/mutations.ts index e2da3dae068..453a695baec 100644 --- a/src/main/github/project-view/mutations.ts +++ b/src/main/github/project-view/mutations.ts @@ -6,16 +6,18 @@ import { release, extractExecError, ghExecFileAsync, - rateLimitGuard, - noteRateLimitSpend, - classifyProjectError, - rateLimitedError, + repositoryRateLimitGuard, + noteRepositoryRateLimitSpend, runGraphql, runRest, validateSlugArgs, assertPositiveInt, + projectHostAuthenticationError, + projectGhExecOptions, type GraphqlVars } from './internals' +import { classifyProjectError, rateLimitedError } from './project-error-classification' +import { githubProjectHost } from '../../../shared/github-project-identity' import type { GitHubAssignableUser, GitHubWorkItemDetails, PRComment } from '../../../shared/types' import type { AddIssueCommentBySlugArgs, @@ -39,6 +41,10 @@ import type { UpdateProjectItemFieldArgs } from '../../../shared/github-project-types' +function githubHostExecOptions(args: { host?: string }): { host: string } { + return { host: githubProjectHost(args.host) } +} + // ─── Project field mutations ────────────────────────────────────────── class UnknownFieldMutationKindError extends Error { @@ -122,7 +128,7 @@ export async function updateProjectItemFieldValue( fieldId: args.fieldId, value: valVar.val } - const res = await runGraphql(query, vars) + const res = await runGraphql(query, vars, projectGhExecOptions(args.host)) if (!res.ok) { return { ok: false, error: res.error } } @@ -144,11 +150,15 @@ export async function clearProjectItemFieldValue( }) { projectV2Item { id } } } ` - const res = await runGraphql(query, { - projectId: args.projectId, - itemId: args.itemId, - fieldId: args.fieldId - }) + const res = await runGraphql( + query, + { + projectId: args.projectId, + itemId: args.itemId, + fieldId: args.fieldId + }, + projectGhExecOptions(args.host) + ) if (!res.ok) { return { ok: false, error: res.error } } @@ -207,6 +217,10 @@ export async function updateIssueBySlug( return { ok: false, error: duplicate.error } } } + const authError = await projectHostAuthenticationError(args.host) + if (authError) { + return { ok: false, error: authError } + } // Title/body go through PATCH /repos/{owner}/{repo}/issues/{n}. // State uses gh issue close/reopen so duplicate closes can record a target. @@ -214,6 +228,10 @@ export async function updateIssueBySlug( const base = `repos/${args.owner}/${args.repo}/issues/${args.number}` if (state !== undefined) { + const guard = repositoryRateLimitGuard(args, 'core') + if (guard.blocked) { + return { ok: false, error: rateLimitedError(guard) } + } const stateArgs = state === 'closed' ? ['issue', 'close', String(args.number), '--repo', `${args.owner}/${args.repo}`] @@ -228,11 +246,12 @@ export async function updateIssueBySlug( } } await acquire() + noteRepositoryRateLimitSpend(args, 'core') try { - await ghExecFileAsync(stateArgs, { encoding: 'utf-8' }) + await ghExecFileAsync(stateArgs, { encoding: 'utf-8', ...githubHostExecOptions(args) }) } catch (err) { const { stderr, stdout } = extractExecError(err) - return { ok: false, error: classifyProjectError(stderr, stdout) } + return { ok: false, error: classifyProjectError(stderr, stdout, args.host) } } finally { release() } @@ -247,7 +266,7 @@ export async function updateIssueBySlug( if (body !== undefined) { patchArgs.push('--raw-field', `body=${body}`) } - const r = await runRest(patchArgs) + const r = await runRest(patchArgs, undefined, 'core', githubHostExecOptions(args)) if (!r.ok) { return { ok: false, error: r.error } } @@ -263,7 +282,12 @@ export async function updateIssueBySlug( const addCount = addLabels?.length ?? 0 if (removeCount > 1) { type RawLabelResp = { name?: string }[] - const fetched = await runRest(['-X', 'GET', `${base}/labels`]) + const fetched = await runRest( + ['-X', 'GET', `${base}/labels`], + undefined, + 'core', + githubHostExecOptions(args) + ) if (!fetched.ok) { return { ok: false, error: fetched.error } } @@ -282,7 +306,8 @@ export async function updateIssueBySlug( // dedicated DELETE endpoint is the documented way to remove all // labels in a single call. const r = await runRest(['-X', 'DELETE', `${base}/labels`], undefined, 'core', { - expectEmpty: true + expectEmpty: true, + ...githubHostExecOptions(args) }) if (!r.ok && r.error.type !== 'not_found') { return { ok: false, error: r.error } @@ -292,7 +317,7 @@ export async function updateIssueBySlug( for (const name of currentNames) { putArgs.push('--raw-field', `labels[]=${name}`) } - const r = await runRest(putArgs) + const r = await runRest(putArgs, undefined, 'core', githubHostExecOptions(args)) if (!r.ok) { return { ok: false, error: r.error } } @@ -303,7 +328,7 @@ export async function updateIssueBySlug( for (const l of addLabels ?? []) { restArgs.push('--raw-field', `labels[]=${l}`) } - const r = await runRest(restArgs) + const r = await runRest(restArgs, undefined, 'core', githubHostExecOptions(args)) if (!r.ok) { return { ok: false, error: r.error } } @@ -313,7 +338,7 @@ export async function updateIssueBySlug( ['-X', 'DELETE', `${base}/labels/${encodeURIComponent(removeLabels![0])}`], undefined, 'core', - { expectEmpty: true } + { expectEmpty: true, ...githubHostExecOptions(args) } ) if (!r.ok && r.error.type !== 'not_found') { return { ok: false, error: r.error } @@ -328,7 +353,7 @@ export async function updateIssueBySlug( for (const u of addAssignees) { restArgs.push('--raw-field', `assignees[]=${u}`) } - const r = await runRest(restArgs) + const r = await runRest(restArgs, undefined, 'core', githubHostExecOptions(args)) if (!r.ok) { return { ok: false, error: r.error } } @@ -338,7 +363,7 @@ export async function updateIssueBySlug( for (const u of removeAssignees) { restArgs.push('--raw-field', `assignees[]=${u}`) } - const r = await runRest(restArgs) + const r = await runRest(restArgs, undefined, 'core', githubHostExecOptions(args)) if (!r.ok) { return { ok: false, error: r.error } } @@ -384,7 +409,7 @@ export async function updatePullRequestBySlug( // No fields to update — nothing to do. return { ok: true } } - const r = await runRest(patchArgs) + const r = await runRest(patchArgs, undefined, 'core', githubHostExecOptions(args)) if (!r.ok) { return { ok: false, error: r.error } } @@ -425,13 +450,18 @@ export async function addIssueCommentBySlug( if (typeof args.body !== 'string' || !args.body.trim()) { return { ok: false, error: { type: 'validation_error', message: 'Comment body required.' } } } - const r = await runRest([ - '-X', - 'POST', - `repos/${args.owner}/${args.repo}/issues/${args.number}/comments`, - '--raw-field', - `body=${args.body}` - ]) + const r = await runRest( + [ + '-X', + 'POST', + `repos/${args.owner}/${args.repo}/issues/${args.number}/comments`, + '--raw-field', + `body=${args.body}` + ], + undefined, + 'core', + githubHostExecOptions(args) + ) if (!r.ok) { return { ok: false, error: r.error } } @@ -452,13 +482,18 @@ export async function updateIssueCommentBySlug( if (typeof args.body !== 'string' || !args.body.trim()) { return { ok: false, error: { type: 'validation_error', message: 'Comment body required.' } } } - const r = await runRest([ - '-X', - 'PATCH', - `repos/${args.owner}/${args.repo}/issues/comments/${args.commentId}`, - '--raw-field', - `body=${args.body}` - ]) + const r = await runRest( + [ + '-X', + 'PATCH', + `repos/${args.owner}/${args.repo}/issues/comments/${args.commentId}`, + '--raw-field', + `body=${args.body}` + ], + undefined, + 'core', + githubHostExecOptions(args) + ) if (!r.ok) { return { ok: false, error: r.error } } @@ -480,7 +515,7 @@ export async function deleteIssueCommentBySlug( ['-X', 'DELETE', `repos/${args.owner}/${args.repo}/issues/comments/${args.commentId}`], undefined, 'core', - { expectEmpty: true } + { expectEmpty: true, ...githubHostExecOptions(args) } ) if (!r.ok) { return { ok: false, error: r.error } @@ -497,18 +532,22 @@ export async function listLabelsBySlug( if (!v.ok) { return v } - const guard = rateLimitGuard('core') + const authError = await projectHostAuthenticationError(args.host) + if (authError) { + return { ok: false, error: authError } + } + const guard = repositoryRateLimitGuard(args, 'core') if (guard.blocked) { return { ok: false, error: rateLimitedError(guard) } } await acquire() // Why: `--paginate` may fan out to multiple pages; we can only reasonably // estimate a 1-call spend up front. The next probe will reconcile. - noteRateLimitSpend('core') + noteRepositoryRateLimitSpend(args, 'core') try { const { stdout } = await ghExecFileAsync( ['api', '--paginate', `repos/${args.owner}/${args.repo}/labels`, '--jq', '.[].name'], - { encoding: 'utf-8' } + { encoding: 'utf-8', ...githubHostExecOptions(args) } ) return { ok: true, @@ -519,7 +558,7 @@ export async function listLabelsBySlug( } } catch (err) { const { stderr, stdout: maybeStdout } = extractExecError(err) - return { ok: false, error: classifyProjectError(stderr, maybeStdout) } + return { ok: false, error: classifyProjectError(stderr, maybeStdout, args.host) } } finally { release() } @@ -532,15 +571,19 @@ export async function listAssignableUsersBySlug( if (!v.ok) { return v } + const authError = await projectHostAuthenticationError(args.host) + if (authError) { + return { ok: false, error: authError } + } // Seed logins merge after the fetch so callers can include currently-visible // assignees even if the repo participant search is sparse. const result: GitHubAssignableUser[] = [] - const guard = rateLimitGuard('core') + const guard = repositoryRateLimitGuard(args, 'core') if (guard.blocked) { return { ok: false, error: rateLimitedError(guard) } } await acquire() - noteRateLimitSpend('core') + noteRepositoryRateLimitSpend(args, 'core') try { const { stdout } = await ghExecFileAsync( [ @@ -550,7 +593,7 @@ export async function listAssignableUsersBySlug( '--jq', '.[] | {login: .login, name: null, avatarUrl: .avatar_url}' ], - { encoding: 'utf-8' } + { encoding: 'utf-8', ...githubHostExecOptions(args) } ) for (const line of stdout .trim() @@ -567,7 +610,7 @@ export async function listAssignableUsersBySlug( } } catch (err) { const { stderr } = extractExecError(err) - return { ok: false, error: classifyProjectError(stderr, '') } + return { ok: false, error: classifyProjectError(stderr, '', args.host) } } finally { release() } @@ -614,7 +657,7 @@ export async function listIssueTypesBySlug( } | null)[] } | null } | null - }>(query, { owner: args.owner, repo: args.repo }) + }>(query, { owner: args.owner, repo: args.repo }, githubHostExecOptions(args)) if (!res.ok) { // Why: repos without issue types respond with a GraphQL error claiming the // `issueTypes` field is unknown. Map that to an empty list so the UI shows @@ -659,7 +702,8 @@ export async function updateIssueTypeBySlug( `query($owner:String!, $repo:String!, $num:Int!) { repository(owner:$owner, name:$repo) { issue(number:$num) { id } } }`, - { owner: args.owner, repo: args.repo, num: args.number } + { owner: args.owner, repo: args.repo, num: args.number }, + githubHostExecOptions(args) ) if (!lookup.ok) { return { ok: false, error: lookup.error } @@ -689,7 +733,7 @@ export async function updateIssueTypeBySlug( const vars: GraphqlVars = args.issueTypeId ? { issueId, issueTypeId: args.issueTypeId } : { issueId } - const res = await runGraphql(query, vars) + const res = await runGraphql(query, vars, githubHostExecOptions(args)) if (!res.ok) { return { ok: false, error: res.error } } @@ -810,7 +854,7 @@ export async function getWorkItemDetailsBySlug( }) | null } | null - }>(query, { owner: args.owner, repo: args.repo, num: args.number }) + }>(query, { owner: args.owner, repo: args.repo, num: args.number }, githubHostExecOptions(args)) if (!res.ok) { return { ok: false, error: res.error } } diff --git a/src/main/github/project-view/project-error-classification.ts b/src/main/github/project-view/project-error-classification.ts new file mode 100644 index 00000000000..1468552b345 --- /dev/null +++ b/src/main/github/project-view/project-error-classification.ts @@ -0,0 +1,177 @@ +// Why: turns raw gh stderr/stdout into the typed GitHubProjectViewError the +// renderer can act on (auth vs scope vs rate limit vs drift), shared by the +// project-view read and mutation paths. +import type { GitHubProjectViewError } from '../../../shared/github-project-types' +import { githubProjectHost } from '../../../shared/github-project-identity' + +export type GhGraphqlErrorShape = { + type?: string + message?: string + path?: (string | number)[] + extensions?: { code?: string } +} + +export function extractGraphqlErrors(stderr: string, stdout: string): GhGraphqlErrorShape[] { + // `gh api graphql` prints the response JSON to stdout even on GraphQL + // errors, and the stderr carries a summary. Try stdout first; if parsing + // fails, fall back to stderr. + const sources = [stdout, stderr] + for (const src of sources) { + if (!src) { + continue + } + try { + const parsed = JSON.parse(src) as { errors?: GhGraphqlErrorShape[] } + if (parsed.errors && parsed.errors.length > 0) { + return parsed.errors + } + } catch { + // not JSON — continue + } + } + return [] +} + +export function errorsIndicateParentField(errors: GhGraphqlErrorShape[], stderr: string): boolean { + const lower = stderr.toLowerCase() + // Preview-header shape: gh returns a 4xx with "preview" in the message. + if (lower.includes('preview') && lower.includes('parent')) { + return true + } + return errors.some((e) => { + const type = (e.type ?? '').toUpperCase() + if (type === 'FIELD_NOT_FOUND' || type === 'UNDEFINED_FIELD' || type === 'FIELD_ERRORS') { + const tail = e.path?.at(-1) + if (tail === 'parent') { + return true + } + // FIELD_ERRORS often omits `path`; match on message for the parent field. + if ((e.message ?? '').toLowerCase().includes('parent')) { + return true + } + } + return false + }) +} + +export function classifyProjectError( + stderr: string, + stdout: string, + host?: string +): GitHubProjectViewError { + const errors = extractGraphqlErrors(stderr, stdout) + const s = stderr.toLowerCase() + const selectedHost = githubProjectHost(host) + + // Auth + if ( + s.includes('authentication required') || + s.includes('not logged in') || + s.includes('gh auth login') + ) { + return { + type: 'auth_required', + message: `Sign in to GitHub to load project tasks. Run \`gh auth login --hostname ${selectedHost}\`.` + } + } + // Scope + if ( + s.includes('missing required scope') || + s.includes('your token has not been granted') || + (s.includes('resource not accessible') && (s.includes('project') || s.includes('scope'))) + ) { + return { + type: 'scope_missing', + message: `GitHub project access needs additional scopes. Run \`gh auth refresh --hostname ${selectedHost} -s project -s read:org -s repo\`.` + } + } + // Rate limit + if (s.includes('rate limit') || s.includes('api rate limit exceeded')) { + return { type: 'rate_limited', message: 'GitHub rate limit hit. Try again in a few minutes.' } + } + // Network — checked BEFORE not_found because DNS failures surface as + // "could not resolve host", which would otherwise be partially matched by + // the not_found branch's "could not resolve" check. Substring matching here + // is a one-way trapdoor: a real GraphQL "Could not resolve to a User…" + // error always contains "to a", so we tighten the not_found check below to + // require that token. + if ( + s.includes('timeout') || + s.includes('no such host') || + s.includes('network') || + s.includes('could not resolve host') || + s.includes('dial tcp') + ) { + return { type: 'network_error', message: 'Network error — check your connection.' } + } + // Not found + if ( + s.includes('http 404') || + errors.some((e) => (e.type ?? '').toUpperCase() === 'NOT_FOUND') || + // Why: GitHub uses "to an" for vowel-leading types ("to an Issue", "to + // an Organization") and "to a" otherwise. The previous singular-only + // check missed the "an" variants when gh emits only the stderr summary + // without a structured GraphQL error array. See bug-scan finding 3. + /could not resolve to an? /.test(s) + ) { + const firstNotFound = errors.find((e) => (e.type ?? '').toUpperCase() === 'NOT_FOUND') + return { + type: 'not_found', + message: 'Project or view not found.', + details: firstNotFound + ? { path: firstNotFound.path, code: firstNotFound.extensions?.code } + : undefined + } + } + // Validation + if (s.includes('http 422') || s.includes('validation failed')) { + return { type: 'validation_error', message: `Invalid request — ${stderr.trim()}` } + } + // GraphQL error with structured info + if (errors.length > 0) { + const first = errors[0] + return { + type: 'unknown', + message: first.message ?? 'Unknown GraphQL error.', + details: { path: first.path, code: first.extensions?.code } + } + } + // Why: don't leak full stderr to the UI — it can include verbose request + // dumps with header diagnostics. Truncate to the first non-empty line and + // cap length so unexpected diagnostics stay readable but bounded. + const firstLine = + stderr + .split('\n') + .map((l) => l.trim()) + .find((l) => l.length > 0) ?? '' + const safe = firstLine.length > 200 ? `${firstLine.slice(0, 200)}…` : firstLine + return { + type: 'unknown', + message: safe ? `GitHub request failed: ${safe}` : 'GitHub request failed.' + } +} + +export function driftError( + reason: string, + details?: { path?: (string | number)[]; code?: string } +): GitHubProjectViewError { + return { type: 'schema_drift', message: `Could not read this project view: ${reason}.`, details } +} + +// Why: the rate-limit circuit breaker short-circuits before we spawn `gh` +// when the cached snapshot says we're below the safety floor. Synthesize the +// same `rate_limited` error shape as the post-hoc classifier so the UI path +// is unchanged. We DO NOT fail open here when there's no cached snapshot — +// rateLimitGuard already handles that case (returns `blocked:false`). +export function rateLimitedError(blocked: { + remaining: number + limit: number + resetAt: number +}): GitHubProjectViewError { + const resetIn = Math.max(0, blocked.resetAt - Math.floor(Date.now() / 1000)) + const mins = Math.ceil(resetIn / 60) + return { + type: 'rate_limited', + message: `GitHub rate limit nearly exhausted (${blocked.remaining}/${blocked.limit} left). Resets in ~${mins}m.` + } +} diff --git a/src/main/github/rate-limit.test.ts b/src/main/github/rate-limit.test.ts index ab1373a70f0..323f729ea26 100644 --- a/src/main/github/rate-limit.test.ts +++ b/src/main/github/rate-limit.test.ts @@ -13,7 +13,21 @@ vi.mock('./gh-utils', () => ({ release: vi.fn() })) -import { getRateLimit, rateLimitGuard, _resetRateLimitCache } from './rate-limit' +import { + getRateLimit, + rateLimitGuard, + noteRateLimitSpend, + spendsSharedGitHubComQuota, + repositoryRateLimitGuard, + noteRepositoryRateLimitSpend, + _resetRateLimitCache +} from './rate-limit' +import { + clearGhRateLimitBlock, + getGhRateLimitBlockedUntilMs, + notifyGhPrimaryRateLimit, + type GhRateLimitBucket +} from '../git/gh-rate-limit-breaker' const PAYLOAD = JSON.stringify({ resources: { @@ -49,12 +63,17 @@ describe('getRateLimit', () => { fetchedAt: 1_000 } }) + // The runner injects --hostname at spawn from the `host` option, so the + // probe passes bare argv and pins the host to github.com through options. + expect(ghExecFileAsyncMock).toHaveBeenCalledWith(['api', 'rate_limit'], { + encoding: 'utf-8', + host: 'github.com' + }) }) it('caches a failed probe for the TTL instead of re-spawning gh', async () => { - // Why: regression for #7553 — GHES with rate limiting disabled fails every - // probe. Refreshes fail open past the failure, so without a negative cache - // each queued refresh would spawn its own doomed gh subprocess. + // Why: refreshes fail open past a probe failure, so without a negative cache + // an auth or transport failure would spawn the same doomed gh call repeatedly. ghExecFileAsyncMock.mockRejectedValue(new Error('HTTP 404: Rate limiting is not enabled.')) const first = await getRateLimit() @@ -136,3 +155,212 @@ describe('rateLimitGuard', () => { expect(rateLimitGuard('core')).toEqual({ blocked: false }) }) }) + +// Why: only default github.com traffic on the native runtime shares this +// snapshot's budget. GHES hosts and WSL distros run against separate quotas, +// so the shared guard must bypass them entirely. +describe('shared-quota host scoping', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + ghExecFileAsyncMock.mockReset() + _resetRateLimitCache() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + async function cacheExhaustedSnapshot(resetEpochSeconds: number): Promise { + ghExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ + resources: { + core: { limit: 5000, remaining: 3, reset: resetEpochSeconds }, + search: { limit: 30, remaining: 28, reset: resetEpochSeconds }, + graphql: { limit: 5000, remaining: 4900, reset: resetEpochSeconds } + } + }) + }) + expect((await getRateLimit()).ok).toBe(true) + } + + it('treats github.com / no host on the native runtime as sharing the budget', () => { + expect(spendsSharedGitHubComQuota(undefined)).toBe(true) + expect(spendsSharedGitHubComQuota(null)).toBe(true) + expect(spendsSharedGitHubComQuota({ host: 'github.com' })).toBe(true) + expect(spendsSharedGitHubComQuota({ host: 'GitHub.com' })).toBe(true) + }) + + it('excludes GHES hosts and WSL runtimes from the shared budget', () => { + expect(spendsSharedGitHubComQuota({ host: 'github.acme-corp.com' })).toBe(false) + expect(spendsSharedGitHubComQuota({ host: 'github.com' }, { wslDistro: 'Ubuntu' })).toBe(false) + expect(spendsSharedGitHubComQuota(undefined, { wslDistro: 'Ubuntu' })).toBe(false) + }) + + it('treats a WSL UNC cwd as non-shared even without an explicit wslDistro', () => { + // Why: the runner derives the WSL distro from a \\wsl.localhost\... cwd, so + // gh runs inside WSL and spends that distro's quota — not the native one. + expect( + spendsSharedGitHubComQuota( + { host: 'github.com' }, + { cwd: '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo' } + ) + ).toBe(false) + expect( + spendsSharedGitHubComQuota( + { host: 'github.com' }, + { cwd: '\\\\wsl$\\Ubuntu\\home\\me\\repo' } + ) + ).toBe(false) + // Regular Windows/POSIX cwds keep sharing the native github.com budget. + expect(spendsSharedGitHubComQuota({ host: 'github.com' }, { cwd: 'C:\\repos\\a' })).toBe(true) + expect(spendsSharedGitHubComQuota({ host: 'github.com' }, { cwd: '/Users/me/repo' })).toBe(true) + }) + + it('bypasses the guard and spend for a WSL UNC cwd', async () => { + await cacheExhaustedSnapshot(61) + + const cwd = '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo' + expect(repositoryRateLimitGuard({ host: 'github.com' }, 'core', { cwd })).toEqual({ + blocked: false + }) + + noteRepositoryRateLimitSpend({ host: 'github.com' }, 'core', 1, { cwd }) + const guard = rateLimitGuard('core') + expect(guard.blocked && guard.remaining).toBe(3) + }) + + it('applies the shared guard for github.com but bypasses non-shared scopes', async () => { + await cacheExhaustedSnapshot(61) + + expect(repositoryRateLimitGuard({ host: 'github.com' }, 'core')).toEqual({ + blocked: true, + remaining: 3, + limit: 5000, + resetAt: 61 + }) + expect(repositoryRateLimitGuard({ host: 'github.acme-corp.com' }, 'core')).toEqual({ + blocked: false + }) + expect( + repositoryRateLimitGuard({ host: 'github.com' }, 'core', { wslDistro: 'Ubuntu' }) + ).toEqual({ blocked: false }) + }) + + it('debits shared-budget spend but no-ops for non-shared scopes', async () => { + await cacheExhaustedSnapshot(61) + + noteRepositoryRateLimitSpend({ host: 'github.acme-corp.com' }, 'core') + const afterEnterprise = rateLimitGuard('core') + expect(afterEnterprise.blocked && afterEnterprise.remaining).toBe(3) + + noteRepositoryRateLimitSpend({ host: 'github.com' }, 'core') + const afterShared = rateLimitGuard('core') + expect(afterShared.blocked && afterShared.remaining).toBe(2) + }) + + it('matches noteRateLimitSpend when the scope is shared github.com', async () => { + await cacheExhaustedSnapshot(61) + noteRateLimitSpend('core') + const guard = rateLimitGuard('core') + expect(guard.blocked && guard.remaining).toBe(2) + }) +}) + +// Why: the breaker's reset probe must query the tripping scope's runtime/host +// (WSL distro, GHES host), record into that scope only, and never touch the +// shared native-github.com snapshot. +describe('scope-aware breaker reset probe', () => { + const WSL_SCOPE = 'wsl:ubuntu:github.com' + const GHES_SCOPE = 'native:github.acme-corp.com' + const ALL_BUCKETS: GhRateLimitBucket[] = ['core', 'search', 'graphql'] + + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + ghExecFileAsyncMock.mockReset() + _resetRateLimitCache() + }) + + afterEach(() => { + // Clear per-scope breaker state without unregistering the module-load + // probe (which _resetGhRateLimitBreaker would drop for later tests). + for (const scope of [WSL_SCOPE, GHES_SCOPE]) { + for (const bucket of ALL_BUCKETS) { + clearGhRateLimitBlock(bucket, scope) + } + } + vi.useRealTimers() + }) + + async function flushProbe(): Promise { + for (let i = 0; i < 20; i++) { + await Promise.resolve() + } + } + + it('refines a WSL-scope trip via a probe matching that scope', async () => { + // resetAt 601s > the 5-minute fallback so the refinement is observable. + ghExecFileAsyncMock.mockResolvedValue({ + stdout: JSON.stringify({ + resources: { + core: { limit: 5000, remaining: 0, reset: 601 }, + search: { limit: 30, remaining: 20, reset: 601 }, + graphql: { limit: 5000, remaining: 100, reset: 601 } + } + }) + }) + + notifyGhPrimaryRateLimit('core', WSL_SCOPE) + // Single-flight: a concurrent second trip must not spawn a second probe. + notifyGhPrimaryRateLimit('core', WSL_SCOPE) + await flushProbe() + + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) + expect(ghExecFileAsyncMock).toHaveBeenCalledWith(['api', 'rate_limit'], { + encoding: 'utf-8', + host: 'github.com', + wslDistro: 'ubuntu' + }) + // The exhausted core bucket is refined to the real reset for THAT scope. + expect(getGhRateLimitBlockedUntilMs('core', 1_000, WSL_SCOPE)).toBe(601_000) + // Non-exhausted buckets in the same scope stay clear. + expect(getGhRateLimitBlockedUntilMs('search', 1_000, WSL_SCOPE)).toBeNull() + // The shared native-github.com snapshot was not written: the guard has no + // cached data and fails open. + expect(rateLimitGuard('core')).toEqual({ blocked: false }) + expect(getGhRateLimitBlockedUntilMs('core', 1_000)).toBeNull() + }) + + it('fails open on a GHES probe failure and negative-caches the scope', async () => { + ghExecFileAsyncMock.mockRejectedValue(new Error('HTTP 404: Rate limiting is not enabled.')) + + notifyGhPrimaryRateLimit('core', GHES_SCOPE) + await flushProbe() + // A second trip inside the failure TTL must not spawn another probe. + notifyGhPrimaryRateLimit('core', GHES_SCOPE) + await flushProbe() + + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) + // Fail open: the blunt fallback block stands (5 minutes from trip time). + expect(getGhRateLimitBlockedUntilMs('core', 1_000, GHES_SCOPE)).toBe(301_000) + + // Past the TTL the scope is probed again (and can recover on success). + vi.setSystemTime(1_000 + 30_000) + ghExecFileAsyncMock.mockResolvedValue({ + stdout: JSON.stringify({ + resources: { core: { limit: 5000, remaining: 4999, reset: 700 } } + }) + }) + notifyGhPrimaryRateLimit('core', GHES_SCOPE) + await flushProbe() + + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) + expect(ghExecFileAsyncMock).toHaveBeenLastCalledWith(['api', 'rate_limit'], { + encoding: 'utf-8', + host: 'github.acme-corp.com' + }) + // Budget is actually available in that scope — the block is cleared. + expect(getGhRateLimitBlockedUntilMs('core', 31_000, GHES_SCOPE)).toBeNull() + }) +}) diff --git a/src/main/github/rate-limit.ts b/src/main/github/rate-limit.ts index 1b77198db68..2ca3f5cad43 100644 --- a/src/main/github/rate-limit.ts +++ b/src/main/github/rate-limit.ts @@ -9,11 +9,15 @@ import type { GitHubRateLimitBucket, GitHubRateLimitSnapshot } from '../../shared/types' +import { isDefaultGitHubHost } from '../../shared/github-repository-identity-key' +import { isWslUncPath } from '../../shared/wsl-paths' import { acquire, release } from './gh-utils' import { ghExecFileAsync } from '../git/runner' import { clearGhRateLimitBlock, getGhRateLimitBlockedUntilMs, + ghRateLimitScopeKey, + parseGhRateLimitScopeKey, recordGhPrimaryRateLimit, registerGhRateLimitResetProbe, type GhRateLimitBucket @@ -54,6 +58,8 @@ function parseBucket( export function _resetRateLimitCache(): void { cached = null probeFailure = null + scopeProbeFailureAtMs.clear() + scopeRefinementInFlight.clear() } // Circuit-breaker floors: enough budget for one user flow; search paginates by 1, so 2 leaves a safety click under the 30/min cap. @@ -120,6 +126,41 @@ export function noteRateLimitSpend(bucket: RateLimitBucketKind, cost = 1): void } } +// Why: this snapshot covers native github.com only; GHES and WSL use quotas protected by the runner's scoped breaker. +export function spendsSharedGitHubComQuota( + repository: { host?: string } | null | undefined, + executionOptions?: { wslDistro?: string; cwd?: string } +): boolean { + if (!isDefaultGitHubHost(repository?.host) || executionOptions?.wslDistro) { + return false + } + // Why: a \\wsl.localhost\... (or \\wsl$\...) cwd makes the runner spawn gh + // inside WSL, so it spends that distro's quota. The UNC shape is + // platform-unambiguous, so no win32 gate is needed. + return !(executionOptions?.cwd && isWslUncPath(executionOptions.cwd)) +} + +export function repositoryRateLimitGuard( + repository: { host?: string } | null | undefined, + bucket: RateLimitBucketKind, + executionOptions?: { wslDistro?: string; cwd?: string } +): ReturnType { + return spendsSharedGitHubComQuota(repository, executionOptions) + ? rateLimitGuard(bucket) + : { blocked: false } +} + +export function noteRepositoryRateLimitSpend( + repository: { host?: string } | null | undefined, + bucket: RateLimitBucketKind, + cost = 1, + executionOptions?: { wslDistro?: string; cwd?: string } +): void { + if (spendsSharedGitHubComQuota(repository, executionOptions)) { + noteRateLimitSpend(bucket, cost) + } +} + // 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 @@ -147,7 +188,88 @@ function refineBreakerFromSnapshot(): void { })() } -registerGhRateLimitResetProbe(() => refineBreakerFromSnapshot()) +const DEFAULT_BREAKER_SCOPE = ghRateLimitScopeKey('native', 'github.com') +// Why: non-default scopes (GHES hosts, WSL runtimes) have their own quota and +// their own probe lifecycle — single-flight and failure negative-cache are +// keyed per scope so a GHES 403 storm can't starve a WSL refinement (or vice +// versa). +const scopeRefinementInFlight = new Map>() +const scopeProbeFailureAtMs = new Map() +const SCOPE_PROBE_FAILURE_MAX_ENTRIES = 512 + +function rememberScopeProbeFailure(scope: string, failedAt: number): void { + for (const [key, at] of scopeProbeFailureAtMs) { + if (failedAt - at >= RATE_LIMIT_CACHE_TTL_MS) { + scopeProbeFailureAtMs.delete(key) + } + } + scopeProbeFailureAtMs.delete(scope) + scopeProbeFailureAtMs.set(scope, failedAt) + while (scopeProbeFailureAtMs.size > SCOPE_PROBE_FAILURE_MAX_ENTRIES) { + const oldestKey = scopeProbeFailureAtMs.keys().next().value + if (oldestKey === undefined) { + break + } + scopeProbeFailureAtMs.delete(oldestKey) + } +} + +function refineBreakerForScope(scope: string): void { + // Default scope keeps the existing shared-snapshot refinement path. + if (scope === DEFAULT_BREAKER_SCOPE) { + refineBreakerFromSnapshot() + return + } + const parts = parseGhRateLimitScopeKey(scope) + if (!parts || scopeRefinementInFlight.has(scope)) { + return + } + // Why: GHES with rate limiting disabled 404s every probe. Fail open (the + // fallback block stands) and don't re-probe in a tight loop while the + // breaker keeps tripping. + const failedAt = scopeProbeFailureAtMs.get(scope) + if (failedAt !== undefined && Date.now() - failedAt < RATE_LIMIT_CACHE_TTL_MS) { + return + } + const probe = (async () => { + try { + await acquire() + try { + // Run the probe on the tripping scope's runtime/host so it describes + // that account's buckets, not the native github.com ones. + const { stdout } = await ghExecFileAsync(['api', 'rate_limit'], { + encoding: 'utf-8', + host: parts.host, + ...(parts.runtime === 'wsl' ? { wslDistro: parts.wslDistro } : {}) + }) + const parsed = JSON.parse(stdout) as GhRateLimitPayload + scopeProbeFailureAtMs.delete(scope) + // Why: mirrors the default-scope refinement, but records into the + // per-scope breaker only — the shared snapshot must keep describing + // native github.com exclusively. + for (const bucket of ['core', 'search', 'graphql'] as GhRateLimitBucket[]) { + const b = parseBucket(parsed.resources?.[bucket]) + if (b.limit > 0 && b.remaining <= 0) { + recordGhPrimaryRateLimit(bucket, b.resetAt * 1000, scope) + } else if (b.limit > 0) { + clearGhRateLimitBlock(bucket, scope) + } + } + } finally { + release() + } + } catch { + // Why: GHES hosts can be supplied through pasted project URLs. Bound the + // negative cache so repeated failing hosts cannot accumulate forever. + rememberScopeProbeFailure(scope, Date.now()) + } finally { + scopeRefinementInFlight.delete(scope) + } + })() + scopeRefinementInFlight.set(scope, probe) +} + +registerGhRateLimitResetProbe((_bucket, scope) => refineBreakerForScope(scope)) // 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 @@ -176,7 +298,12 @@ export async function getRateLimit(options?: { force?: boolean }): Promise { await acquire() try { - const { stdout } = await ghExecFileAsync(['api', 'rate_limit'], { encoding: 'utf-8' }) + // Why: this singleton snapshot guards native github.com traffic. Pin the + // host so a process-level GH_HOST cannot make it describe a GHES account. + const { stdout } = await ghExecFileAsync(['api', 'rate_limit'], { + encoding: 'utf-8', + host: 'github.com' + }) const parsed = JSON.parse(stdout) as GhRateLimitPayload const snapshot: GitHubRateLimitSnapshot = { core: parseBucket(parsed.resources?.core), diff --git a/src/main/github/work-item-details-concurrency.test.ts b/src/main/github/work-item-details-concurrency.test.ts new file mode 100644 index 00000000000..3fdbacb6491 --- /dev/null +++ b/src/main/github/work-item-details-concurrency.test.ts @@ -0,0 +1,159 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + acquireMock, + releaseMock, + ghExecFileAsyncMock, + getWorkItemMock, + getPRCommentsMock, + getPRChecksMock +} = vi.hoisted(() => ({ + acquireMock: vi.fn<() => Promise>(), + releaseMock: vi.fn(), + ghExecFileAsyncMock: vi.fn(), + getWorkItemMock: vi.fn(), + getPRCommentsMock: vi.fn(), + getPRChecksMock: vi.fn() +})) + +vi.mock('./gh-utils', () => ({ + acquire: acquireMock, + release: releaseMock, + ghExecFileAsync: ghExecFileAsyncMock, + ghRepoExecOptions: (context: { repoPath: string }) => ({ cwd: context.repoPath }), + githubRepoContext: (repoPath: string, connectionId?: string | null) => ({ + repoPath, + connectionId: connectionId ?? null + }) +})) + +vi.mock('./client', () => ({ + getWorkItem: getWorkItemMock, + getPRComments: getPRCommentsMock, + getPRChecks: getPRChecksMock +})) + +vi.mock('./github-api-repository', () => ({ + getIssueGitHubApiRepository: vi.fn(), + getOriginGitHubApiRepository: vi.fn(), + githubHostExecOptions: (repository?: { host?: string } | null) => + repository?.host ? { host: repository.host } : {}, + // Why: getWorkItemDetails awaits resolveGitHubRepoExecution and reads .ownerRepo. + resolveGitHubRepoExecution: vi.fn( + async ( + _repoPath: string, + repository?: { owner: string; repo: string; host?: string } | null + ) => ({ + ownerRepo: repository ?? { owner: 'acme', repo: 'widgets', host: 'github.com' }, + ghOptions: { + cwd: '/repo', + host: repository?.host ?? 'github.com' + } + }) + ) +})) + +vi.mock('./rate-limit', () => ({ + repositoryRateLimitGuard: vi.fn(() => ({ blocked: false })), + noteRepositoryRateLimitSpend: vi.fn() +})) + +import { getWorkItemDetails } from './work-item-details' + +describe('getWorkItemDetails concurrency', () => { + beforeEach(() => { + let running = 0 + const queue: (() => void)[] = [] + acquireMock.mockReset() + acquireMock.mockImplementation(() => { + if (running < 4) { + running += 1 + return Promise.resolve() + } + return new Promise((resolve) => + queue.push(() => { + running += 1 + resolve() + }) + ) + }) + releaseMock.mockReset() + releaseMock.mockImplementation(() => { + running -= 1 + queue.shift()?.() + }) + + getWorkItemMock.mockReset() + getWorkItemMock.mockImplementation(async (_repoPath: string, number: number) => ({ + id: `pr:${number}`, + type: 'pr', + number, + title: `PR ${number}`, + state: 'open', + url: `https://github.com/acme/widgets/pull/${number}`, + labels: [], + updatedAt: '2026-07-16T00:00:00Z', + author: null, + prRepo: { owner: 'acme', repo: 'widgets', host: 'github.com' } + })) + + const withNestedPermit = async (value: T): Promise => { + await acquireMock() + try { + return value + } finally { + releaseMock() + } + } + getPRCommentsMock.mockReset() + getPRCommentsMock.mockImplementation(() => withNestedPermit([])) + getPRChecksMock.mockReset() + getPRChecksMock.mockImplementation(() => withNestedPermit([])) + + ghExecFileAsyncMock.mockReset() + ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { + const query = args.find((arg) => arg.startsWith('query=')) ?? '' + if (query.includes('viewerViewedState')) { + return { + stdout: JSON.stringify({ + data: { + repository: { + pullRequest: { + id: 'PR_id', + files: { pageInfo: { hasNextPage: false }, nodes: [] } + } + } + } + }) + } + } + if (query.includes('participants')) { + return { + stdout: JSON.stringify({ + data: { repository: { pullRequest: { participants: { nodes: [] } } } } + }) + } + } + const endpoint = args.find((arg) => arg.startsWith('repos/')) ?? '' + if (endpoint.includes('/files?')) { + return { stdout: '[]' } + } + if (/\/pulls\/\d+$/.test(endpoint)) { + return { + stdout: JSON.stringify({ body: 'body', head: { sha: 'head' }, base: { sha: 'base' } }) + } + } + return { stdout: JSON.stringify({ data: {} }) } + }) + }) + + it('does not deadlock four concurrent PR loads on nested client permits', async () => { + const details = await Promise.all( + [1, 2, 3, 4].map((number) => getWorkItemDetails('/repo', number, 'pr')) + ) + + expect(details.map((detail) => detail?.item.number)).toEqual([1, 2, 3, 4]) + expect(acquireMock.mock.calls.length).toBeGreaterThan(4) + expect(releaseMock).toHaveBeenCalledTimes(acquireMock.mock.calls.length) + }) +}) diff --git a/src/main/github/work-item-details-enterprise-host.test.ts b/src/main/github/work-item-details-enterprise-host.test.ts new file mode 100644 index 00000000000..aca6e4a7c1c --- /dev/null +++ b/src/main/github/work-item-details-enterprise-host.test.ts @@ -0,0 +1,313 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +type RateLimitGuardResult = + | { blocked: false } + | { blocked: true; remaining: number; limit: number; resetAt: number } + +const { + ghExecFileAsyncMock, + getOwnerRepoMock, + getEnterpriseGitHubRepoSlugMock, + getWorkItemMock, + getWorkItemByOwnerRepoMock, + getPRChecksMock, + getPRCommentsMock, + repositoryRateLimitGuardMock, + noteRepositoryRateLimitSpendMock +} = vi.hoisted(() => ({ + ghExecFileAsyncMock: vi.fn(), + getOwnerRepoMock: vi.fn(), + getEnterpriseGitHubRepoSlugMock: vi.fn(), + getWorkItemMock: vi.fn(), + getWorkItemByOwnerRepoMock: vi.fn(), + getPRChecksMock: vi.fn(), + getPRCommentsMock: vi.fn(), + repositoryRateLimitGuardMock: vi.fn< + ( + repository: { host?: string } | null | undefined, + bucket: 'core' | 'graphql' | 'search', + options?: { cwd?: string; host?: string; wslDistro?: string } + ) => RateLimitGuardResult + >(() => ({ blocked: false })), + noteRepositoryRateLimitSpendMock: vi.fn() +})) + +vi.mock('./gh-utils', () => ({ + ghExecFileAsync: ghExecFileAsyncMock, + getOwnerRepo: getOwnerRepoMock, + getOwnerRepoForRemote: vi.fn(), + ghRepoExecOptions: vi.fn((context: { connectionId?: string | null; repoPath: string }) => + context.connectionId ? {} : { cwd: context.repoPath } + ), + githubRepoContext: vi.fn((repoPath: string, connectionId?: string | null) => ({ + repoPath, + connectionId: connectionId ?? null + })), + acquire: vi.fn().mockResolvedValue(undefined), + release: vi.fn() +})) + +vi.mock('./client', () => ({ + getWorkItem: getWorkItemMock, + getWorkItemByOwnerRepo: getWorkItemByOwnerRepoMock, + getPRChecks: getPRChecksMock, + getPRComments: getPRCommentsMock +})) + +vi.mock('./github-enterprise-repository', () => ({ + getEnterpriseGitHubRepoSlug: getEnterpriseGitHubRepoSlugMock, + getEnterpriseGitHubRepoSlugForRemote: vi.fn().mockResolvedValue(null), + isGitHubHostAuthenticated: vi.fn().mockResolvedValue(true) +})) + +vi.mock('./rate-limit', () => ({ + repositoryRateLimitGuard: repositoryRateLimitGuardMock, + noteRepositoryRateLimitSpend: noteRepositoryRateLimitSpendMock +})) + +import { getWorkItemDetails } from './work-item-details' + +import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository' + +// The origin-repository cache is module-level state; reset it so slugs +// resolved by one test cannot leak into the next. +beforeEach(() => { + _resetOriginGitHubApiRepositoryCache() +}) + +describe('getWorkItemDetails Enterprise host routing', () => { + beforeEach(() => { + ghExecFileAsyncMock.mockReset() + getOwnerRepoMock.mockReset() + getEnterpriseGitHubRepoSlugMock.mockReset() + getEnterpriseGitHubRepoSlugMock.mockResolvedValue(null) + getWorkItemMock.mockReset() + getWorkItemByOwnerRepoMock.mockReset() + getPRChecksMock.mockReset() + getPRCommentsMock.mockReset() + repositoryRateLimitGuardMock.mockReset() + repositoryRateLimitGuardMock.mockReturnValue({ blocked: false }) + noteRepositoryRateLimitSpendMock.mockReset() + }) + + it('uses the GitHub Enterprise host for SSH-backed issue work item details', async () => { + const enterpriseRepository = { + owner: 'team', + repo: 'orca', + host: 'github.acme-corp.com' + } + getWorkItemMock.mockResolvedValueOnce({ + id: 'issue:7', + type: 'issue', + number: 7, + title: 'Enterprise issue', + state: 'open', + url: 'https://github.acme-corp.com/team/orca/issues/7', + labels: [], + updatedAt: '2026-07-16T00:00:00Z', + author: 'issue-author' + }) + getOwnerRepoMock.mockResolvedValue(null) + getEnterpriseGitHubRepoSlugMock.mockResolvedValue(enterpriseRepository) + ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { + const query = args.find((arg) => arg.startsWith('query=')) ?? '' + if (query.includes('comments(first: 100)')) { + return { + stdout: JSON.stringify({ + data: { + repository: { + issue: { + body: 'Enterprise issue body', + assignees: { nodes: [] }, + participants: { nodes: [] }, + comments: { nodes: [] } + } + } + } + }) + } + } + const endpoint = args.find((arg) => arg.startsWith('repos/')) ?? '' + if (endpoint === 'repos/team/orca/issues/7/timeline?per_page=100&page=1') { + return { stdout: '' } + } + throw new Error(`unexpected gh call: ${args.join(' ')}`) + }) + + const details = await getWorkItemDetails('/remote/repo', 7, 'issue', 'ssh-1') + + expect(details?.body).toBe('Enterprise issue body') + expect(getWorkItemMock).toHaveBeenCalledWith('/remote/repo', 7, 'issue', 'ssh-1') + expect(getWorkItemByOwnerRepoMock).not.toHaveBeenCalled() + expect(getEnterpriseGitHubRepoSlugMock).toHaveBeenCalledTimes(1) + expect(repositoryRateLimitGuardMock).toHaveBeenCalledWith(enterpriseRepository, 'graphql', { + host: 'github.acme-corp.com' + }) + expect(noteRepositoryRateLimitSpendMock).toHaveBeenCalled() + expect( + ghExecFileAsyncMock.mock.calls.every( + ([, options]) => options?.host === 'github.acme-corp.com' + ) + ).toBe(true) + }) + + it('does not query the default host when an SSH issue work item is not found', async () => { + getWorkItemMock.mockResolvedValue(null) + + await expect(getWorkItemDetails('/remote/repo', 7, 'issue', 'ssh-1')).resolves.toBeNull() + + expect(getWorkItemMock).toHaveBeenCalledWith('/remote/repo', 7, 'issue', 'ssh-1') + expect(getWorkItemByOwnerRepoMock).not.toHaveBeenCalled() + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + }) + + it('uses the GitHub Enterprise host for SSH-backed PR work item details', async () => { + getWorkItemMock.mockResolvedValueOnce({ + id: 'pr:7', + type: 'pr', + number: 7, + title: 'Enterprise PR files', + state: 'open', + url: 'https://github.acme-corp.com/team/orca/pull/7', + labels: [], + updatedAt: '2026-07-16T00:00:00Z', + author: 'pr-author', + prRepo: { owner: 'team', repo: 'orca', host: 'github.acme-corp.com' } + }) + getPRCommentsMock.mockResolvedValue([]) + getPRChecksMock.mockResolvedValue([]) + ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { + const endpoint = args.find((arg) => arg.startsWith('repos/')) ?? '' + if (endpoint === 'repos/team/orca/pulls/7') { + return { + stdout: JSON.stringify({ + body: 'Enterprise PR body', + head: { sha: 'head-sha' }, + base: { sha: 'base-sha' } + }) + } + } + if (endpoint === 'repos/team/orca/pulls/7/files?per_page=100') { + return { + stdout: JSON.stringify([ + { + filename: 'src/enterprise.ts', + status: 'modified', + additions: 2, + deletions: 1, + changes: 3, + patch: '@@ -1 +1 @@' + } + ]) + } + } + const query = args.find((arg) => arg.startsWith('query=')) ?? '' + if (query.includes('viewerViewedState')) { + return { + stdout: JSON.stringify({ + data: { + repository: { + pullRequest: { + id: 'PR_enterprise', + files: { + pageInfo: { hasNextPage: false, endCursor: null }, + nodes: [{ path: 'src/enterprise.ts', viewerViewedState: 'VIEWED' }] + } + } + } + } + }) + } + } + if (query.includes('participants(first: 100)')) { + return { + stdout: JSON.stringify({ + data: { repository: { pullRequest: { participants: { nodes: [] } } } } + }) + } + } + throw new Error(`unexpected gh call: ${args.join(' ')}`) + }) + + const details = await getWorkItemDetails('/remote/repo', 7, 'pr', 'ssh-1') + + expect(details?.body).toBe('Enterprise PR body') + expect(details?.headSha).toBe('head-sha') + expect(details?.baseSha).toBe('base-sha') + expect(details?.filesUnavailable).toBe(false) + expect(details?.files).toEqual([ + { + path: 'src/enterprise.ts', + oldPath: undefined, + status: 'modified', + additions: 2, + deletions: 1, + isBinary: false, + reviewCommentLineNumbers: [], + viewerViewedState: 'VIEWED' + } + ]) + expect(getWorkItemMock).toHaveBeenCalledWith('/remote/repo', 7, 'pr', 'ssh-1') + expect(getWorkItemByOwnerRepoMock).not.toHaveBeenCalled() + expect(getPRCommentsMock).toHaveBeenCalledWith( + '/remote/repo', + 7, + { prRepo: { owner: 'team', repo: 'orca', host: 'github.acme-corp.com' } }, + 'ssh-1' + ) + expect(getPRChecksMock).toHaveBeenCalledWith( + '/remote/repo', + 7, + 'head-sha', + { owner: 'team', repo: 'orca', host: 'github.acme-corp.com' }, + undefined, + 'ssh-1' + ) + const apiCalls = ghExecFileAsyncMock.mock.calls + .map(([args]) => args as string[]) + .filter((args) => args[0] === 'api') + expect(apiCalls.length).toBeGreaterThan(0) + expect(apiCalls.every((args) => !args.includes('--hostname'))).toBe(true) + expect( + ghExecFileAsyncMock.mock.calls.every( + ([, options]) => options?.host === 'github.acme-corp.com' + ) + ).toBe(true) + }) + + it('does not query the default host when an SSH PR work item is not found', async () => { + getWorkItemMock.mockResolvedValue(null) + + await expect(getWorkItemDetails('/remote/repo', 7, 'pr', 'ssh-1')).resolves.toBeNull() + + expect(getWorkItemMock).toHaveBeenCalledWith('/remote/repo', 7, 'pr', 'ssh-1') + expect(getWorkItemByOwnerRepoMock).not.toHaveBeenCalled() + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + }) + + it('surfaces a found SSH PR without any default-host gh call when the repository is unresolved', async () => { + // Why: this is the regression under test — a *found* PR whose owner/repo + // cannot be resolved to an Enterprise host must not silently fall back to + // github.com. With no prRepo, origin resolution runs and returns null. + getWorkItemMock.mockResolvedValueOnce({ + id: 'pr:7', + type: 'pr', + number: 7, + title: 'Unresolved Enterprise PR', + state: 'open', + url: 'https://github.acme-corp.com/team/orca/pull/7', + labels: [], + updatedAt: '2026-07-16T00:00:00Z', + author: 'pr-author' + }) + getEnterpriseGitHubRepoSlugMock.mockResolvedValue(null) + + const details = await getWorkItemDetails('/remote/repo', 7, 'pr', 'ssh-1') + + // The item is still surfaced, but no host-qualified (or default-host) gh + // request is made because the execution repository never resolved. + expect(details?.item.id).toBe('pr:7') + expect(getWorkItemByOwnerRepoMock).not.toHaveBeenCalled() + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/github/work-item-details-file-viewed.test.ts b/src/main/github/work-item-details-file-viewed.test.ts index a1ec6824aaa..6075803493c 100644 --- a/src/main/github/work-item-details-file-viewed.test.ts +++ b/src/main/github/work-item-details-file-viewed.test.ts @@ -13,6 +13,8 @@ const { getPRCommentsMock, rateLimitGuardMock, noteRateLimitSpendMock, + repositoryRateLimitGuardMock, + noteRepositoryRateLimitSpendMock, acquireMock, releaseMock } = vi.hoisted(() => ({ @@ -24,13 +26,29 @@ const { getPRCommentsMock: vi.fn(), rateLimitGuardMock: vi.fn<() => RateLimitGuardResult>(() => ({ blocked: false })), noteRateLimitSpendMock: vi.fn(), + repositoryRateLimitGuardMock: vi.fn< + ( + repository: { host?: string } | null | undefined, + bucket: string, + options?: { cwd?: string; host?: string } + ) => RateLimitGuardResult + >(() => ({ blocked: false })), + noteRepositoryRateLimitSpendMock: + vi.fn< + ( + repository: { host?: string } | null | undefined, + bucket: string, + cost?: number, + options?: { cwd?: string; host?: string } + ) => void + >(), acquireMock: vi.fn(), releaseMock: vi.fn() })) vi.mock('./gh-utils', () => ({ ghExecFileAsync: ghExecFileAsyncMock, - getOwnerRepo: getOwnerRepoMock, + getOwnerRepoForRemote: getOwnerRepoMock, getIssueOwnerRepo: getIssueOwnerRepoMock, ghRepoExecOptions: vi.fn((context) => (context.connectionId ? {} : { cwd: context.repoPath })), githubRepoContext: vi.fn((repoPath, connectionId) => ({ @@ -43,17 +61,28 @@ vi.mock('./gh-utils', () => ({ vi.mock('./client', () => ({ getWorkItem: getWorkItemMock, + getWorkItemByOwnerRepo: vi.fn(), getPRChecks: getPRChecksMock, getPRComments: getPRCommentsMock })) vi.mock('./rate-limit', () => ({ rateLimitGuard: rateLimitGuardMock, - noteRateLimitSpend: noteRateLimitSpendMock + noteRateLimitSpend: noteRateLimitSpendMock, + repositoryRateLimitGuard: repositoryRateLimitGuardMock, + noteRepositoryRateLimitSpend: noteRepositoryRateLimitSpendMock })) import { getWorkItemDetails } from './work-item-details' +import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository' + +// The origin-repository cache is module-level state; reset it so slugs +// resolved by one test cannot leak into the next. +beforeEach(() => { + _resetOriginGitHubApiRepositoryCache() +}) + describe('getWorkItemDetails PR file viewed state', () => { beforeEach(() => { ghExecFileAsyncMock.mockReset() @@ -65,6 +94,9 @@ describe('getWorkItemDetails PR file viewed state', () => { rateLimitGuardMock.mockReset() rateLimitGuardMock.mockReturnValue({ blocked: false }) noteRateLimitSpendMock.mockReset() + repositoryRateLimitGuardMock.mockReset() + repositoryRateLimitGuardMock.mockReturnValue({ blocked: false }) + noteRepositoryRateLimitSpendMock.mockReset() acquireMock.mockReset() releaseMock.mockReset() acquireMock.mockResolvedValue(undefined) @@ -162,10 +194,26 @@ describe('getWorkItemDetails PR file viewed state', () => { '/repo-root', 42, 'head-sha', - null, + { owner: 'stablyai', repo: 'orca', host: 'github.com' }, undefined, undefined ) + expect(repositoryRateLimitGuardMock.mock.calls.length).toBeGreaterThan(0) + expect( + repositoryRateLimitGuardMock.mock.calls.every(([, bucket, options]) => + bucket === 'graphql' + ? options?.cwd === '/repo-root' && options?.host === 'github.com' + : true + ) + ).toBe(true) + expect(noteRepositoryRateLimitSpendMock.mock.calls.length).toBeGreaterThan(0) + expect( + noteRepositoryRateLimitSpendMock.mock.calls.every(([, bucket, , options]) => + bucket === 'graphql' + ? options?.cwd === '/repo-root' && options?.host === 'github.com' + : true + ) + ).toBe(true) }) it('keeps PR details loadable when checks lookup fails', async () => { diff --git a/src/main/github/work-item-details-pr-files.test.ts b/src/main/github/work-item-details-pr-files.test.ts new file mode 100644 index 00000000000..cac3c33eaf4 --- /dev/null +++ b/src/main/github/work-item-details-pr-files.test.ts @@ -0,0 +1,359 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +type RateLimitGuardResult = + | { blocked: false } + | { blocked: true; remaining: number; limit: number; resetAt: number } + +const { + ghExecFileAsyncMock, + getEnterpriseGitHubRepoSlugMock, + getOwnerRepoMock, + getWorkItemMock, + getPRChecksMock, + getPRCommentsMock, + noteRepositoryRateLimitSpendMock, + repositoryRateLimitGuardMock +} = vi.hoisted(() => ({ + ghExecFileAsyncMock: vi.fn(), + getEnterpriseGitHubRepoSlugMock: vi.fn(), + getOwnerRepoMock: vi.fn(), + getWorkItemMock: vi.fn(), + getPRChecksMock: vi.fn(), + getPRCommentsMock: vi.fn(), + noteRepositoryRateLimitSpendMock: + vi.fn< + ( + repository: { host?: string } | null | undefined, + bucket: string, + cost?: number, + options?: { cwd?: string; host?: string } + ) => void + >(), + repositoryRateLimitGuardMock: vi.fn< + ( + repository: { host?: string } | null | undefined, + bucket: string, + options?: { cwd?: string; host?: string } + ) => RateLimitGuardResult + >(() => ({ blocked: false })) +})) + +vi.mock('./gh-utils', () => ({ + ghExecFileAsync: ghExecFileAsyncMock, + getOwnerRepoForRemote: getOwnerRepoMock, + getIssueOwnerRepo: vi.fn(), + ghRepoExecOptions: vi.fn((context) => ({ cwd: context.repoPath })), + githubRepoContext: vi.fn((repoPath, connectionId, localGitOptions) => ({ + repoPath, + connectionId: connectionId ?? null, + ...localGitOptions + })), + acquire: vi.fn(), + release: vi.fn() +})) + +vi.mock('./client', () => ({ + getWorkItem: getWorkItemMock, + getWorkItemByOwnerRepo: vi.fn(), + getPRChecks: getPRChecksMock, + getPRComments: getPRCommentsMock +})) + +vi.mock('./github-enterprise-repository', () => ({ + getEnterpriseGitHubRepoSlug: getEnterpriseGitHubRepoSlugMock, + isGitHubHostAuthenticated: vi.fn().mockResolvedValue(true) +})) + +vi.mock('./rate-limit', () => ({ + rateLimitGuard: vi.fn(() => ({ blocked: false })), + noteRateLimitSpend: vi.fn(), + repositoryRateLimitGuard: repositoryRateLimitGuardMock, + noteRepositoryRateLimitSpend: noteRepositoryRateLimitSpendMock +})) + +import { getPRFileContents, getWorkItemDetails } from './work-item-details' + +import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository' + +// The origin-repository cache is module-level state; reset it so slugs +// resolved by one test cannot leak into the next. +beforeEach(() => { + _resetOriginGitHubApiRepositoryCache() +}) + +function pullRequestItem(number: number, title: string): Record { + return { + id: `pr:${number}`, + type: 'pr', + number, + title, + state: 'open', + url: `https://github.com/acme/widgets/pull/${number}`, + labels: [], + updatedAt: '2026-07-16T00:00:00Z', + author: 'pr-author' + } +} + +function auxiliaryPRResponse(args: string[]): { stdout: string } { + const query = args.find((arg) => arg.startsWith('query=')) ?? '' + if (query.includes('viewerViewedState')) { + return { + stdout: JSON.stringify({ + data: { + repository: { + pullRequest: { + id: 'PR_file_list', + files: { pageInfo: { hasNextPage: false }, nodes: [] } + } + } + } + }) + } + } + if (query.includes('participants(first: 100)')) { + return { + stdout: JSON.stringify({ + data: { repository: { pullRequest: { participants: { nodes: [] } } } } + }) + } + } + return { stdout: JSON.stringify({ data: {} }) } +} + +describe('getWorkItemDetails PR file listing', () => { + beforeEach(() => { + ghExecFileAsyncMock.mockReset() + getOwnerRepoMock.mockReset() + getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + getEnterpriseGitHubRepoSlugMock.mockReset() + getEnterpriseGitHubRepoSlugMock.mockResolvedValue(null) + getWorkItemMock.mockReset() + getPRChecksMock.mockReset() + getPRChecksMock.mockResolvedValue([]) + getPRCommentsMock.mockReset() + getPRCommentsMock.mockResolvedValue([]) + noteRepositoryRateLimitSpendMock.mockReset() + repositoryRateLimitGuardMock.mockReset() + repositoryRateLimitGuardMock.mockReturnValue({ blocked: false }) + }) + + it('loads files beyond the first 100-result REST page', async () => { + getWorkItemMock.mockResolvedValueOnce(pullRequestItem(108, 'Large PR')) + const restFile = (index: number) => ({ + filename: `src/file-${index}.ts`, + status: 'modified', + additions: 1, + deletions: 0, + changes: 1, + patch: '@@ -1 +1 @@' + }) + ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { + const endpoint = args.find((arg) => arg.startsWith('repos/')) ?? '' + if (endpoint === 'repos/acme/widgets/pulls/108') { + return { stdout: JSON.stringify({ head: { sha: 'head' }, base: { sha: 'base' } }) } + } + if (endpoint === 'repos/acme/widgets/pulls/108/files?per_page=100') { + return { + stdout: JSON.stringify(Array.from({ length: 100 }, (_, index) => restFile(index))) + } + } + if (endpoint === 'repos/acme/widgets/pulls/108/files?per_page=100&page=2') { + return { stdout: JSON.stringify([restFile(100)]) } + } + return auxiliaryPRResponse(args) + }) + + const details = await getWorkItemDetails('/repo-root', 108, 'pr') + + expect(details?.files).toHaveLength(101) + expect(details?.files?.at(-1)?.path).toBe('src/file-100.ts') + const fileEndpoints = ghExecFileAsyncMock.mock.calls + .map(([args]) => (args as string[]).find((arg) => arg.includes('/files?'))) + .filter(Boolean) + expect(fileEndpoints).toEqual([ + 'repos/acme/widgets/pulls/108/files?per_page=100', + 'repos/acme/widgets/pulls/108/files?per_page=100&page=2' + ]) + expect( + noteRepositoryRateLimitSpendMock.mock.calls.filter(([, bucket]) => bucket === 'core') + ).toHaveLength(3) + expect( + repositoryRateLimitGuardMock.mock.calls.filter(([, bucket]) => bucket === 'core') + ).toHaveLength(3) + }) + + // Why: a rate-limited/auth-failed file fetch must not render as an empty PR; + // the Files tab keys its retry state off details.filesUnavailable. + it('flags filesUnavailable when the file fetch fails', async () => { + getWorkItemMock.mockResolvedValueOnce(pullRequestItem(8305, 'Files fetch fails')) + ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { + const target = args.at(-1) + if (target === 'repos/acme/widgets/pulls/8305') { + return { + stdout: JSON.stringify({ head: { sha: 'head-sha' }, base: { sha: 'base-sha' } }) + } + } + if (target === 'repos/acme/widgets/pulls/8305/files?per_page=100') { + throw new Error('gh: API rate limit exceeded (403)') + } + return auxiliaryPRResponse(args) + }) + + const details = await getWorkItemDetails('/repo-root', 8305, 'pr') + + expect(details?.filesUnavailable).toBe(true) + expect(details?.files).toBeUndefined() + }) + + it('preserves an empty file list as an available result', async () => { + getWorkItemMock.mockResolvedValueOnce(pullRequestItem(8306, 'Empty PR')) + ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { + const target = args.at(-1) + if (target === 'repos/acme/widgets/pulls/8306') { + return { + stdout: JSON.stringify({ head: { sha: 'head-sha' }, base: { sha: 'base-sha' } }) + } + } + if (target === 'repos/acme/widgets/pulls/8306/files?per_page=100') { + return { stdout: '[]' } + } + return auxiliaryPRResponse(args) + }) + + const details = await getWorkItemDetails('/repo-root', 8306, 'pr') + + expect(details?.filesUnavailable).toBe(false) + expect(details?.files).toEqual([]) + }) + + it('backfills the Enterprise origin host before a host-less PR detail fan-out', async () => { + const enterprise = { owner: 'team', repo: 'orca', host: 'github.acme-corp.com' } + getWorkItemMock.mockResolvedValueOnce({ + ...pullRequestItem(8, 'Host-less Enterprise PR'), + author: '', + prRepo: { owner: 'team', repo: 'orca' } + }) + getOwnerRepoMock.mockResolvedValue(null) + getEnterpriseGitHubRepoSlugMock.mockResolvedValue(enterprise) + ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { + const endpoint = args.find((arg) => arg.startsWith('repos/')) ?? '' + if (endpoint === 'repos/team/orca/pulls/8') { + return { stdout: JSON.stringify({ body: 'Enterprise body' }) } + } + if (endpoint === 'repos/team/orca/pulls/8/files?per_page=100') { + return { stdout: '[]' } + } + return auxiliaryPRResponse(args) + }) + + const details = await getWorkItemDetails('/remote/repo', 8, 'pr', 'ssh-1') + + expect(details?.item.prRepo).toEqual(enterprise) + expect(getPRCommentsMock).toHaveBeenCalledWith( + '/remote/repo', + 8, + { prRepo: enterprise }, + 'ssh-1' + ) + expect(getPRChecksMock).toHaveBeenCalledWith( + '/remote/repo', + 8, + undefined, + enterprise, + undefined, + 'ssh-1' + ) + expect( + ghExecFileAsyncMock.mock.calls.every(([, options]) => options?.host === enterprise.host) + ).toBe(true) + }) + + it('does not run bare PR detail commands when local host resolution fails', async () => { + getWorkItemMock.mockResolvedValueOnce({ + ...pullRequestItem(9, 'Unresolved PR'), + prRepo: { owner: 'team', repo: 'orca' } + }) + getOwnerRepoMock.mockResolvedValue(null) + + const details = await getWorkItemDetails('/repo-root', 9, 'pr') + + expect(details).toMatchObject({ body: '', comments: [], checks: [], filesUnavailable: true }) + expect(getPRCommentsMock).not.toHaveBeenCalled() + expect(getPRChecksMock).not.toHaveBeenCalled() + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + }) + + it('uses the selected upstream GitHub Enterprise repo for both PR file sides', async () => { + const prRepo = { + owner: 'team', + repo: 'orca', + host: 'github.acme-corp.com' + } + ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { + const endpoint = args.find((arg) => arg.startsWith('repos/')) ?? '' + if (endpoint === 'repos/team/orca/contents/src/path%23with%3Fchars.ts?ref=base-sha') { + return { stdout: 'base content' } + } + if (endpoint === 'repos/team/orca/contents/src/path%23with%3Fchars.ts?ref=head-sha') { + return { stdout: 'head content' } + } + throw new Error(`unexpected gh call: ${args.join(' ')}`) + }) + + const contents = await getPRFileContents({ + repoPath: '/repo-root', + prRepo, + prNumber: 7, + path: 'src/path#with?chars.ts', + status: 'modified', + headSha: 'head-sha', + baseSha: 'base-sha' + }) + + expect(contents).toMatchObject({ + original: 'base content', + modified: 'head content', + originalIsBinary: false, + modifiedIsBinary: false + }) + const apiCalls = ghExecFileAsyncMock.mock.calls.map(([args]) => args as string[]) + expect(apiCalls).toHaveLength(2) + expect(apiCalls.every((args) => !args.includes('--hostname'))).toBe(true) + expect( + ghExecFileAsyncMock.mock.calls.every( + ([, options]) => options?.host === 'github.acme-corp.com' + ) + ).toBe(true) + expect( + repositoryRateLimitGuardMock.mock.calls.filter(([, bucket]) => bucket === 'core') + ).toHaveLength(2) + expect( + noteRepositoryRateLimitSpendMock.mock.calls.filter(([, bucket]) => bucket === 'core') + ).toHaveLength(2) + }) + + it('does not fetch raw PR file contents while the repository core budget is blocked', async () => { + repositoryRateLimitGuardMock.mockReturnValue({ + blocked: true, + remaining: 0, + limit: 5000, + resetAt: 1_800_000_000 + }) + + await expect( + getPRFileContents({ + repoPath: '/repo-root', + prRepo: { owner: 'team', repo: 'orca', host: 'github.acme-corp.com' }, + prNumber: 7, + path: 'src/file.ts', + status: 'modified', + headSha: 'head-sha', + baseSha: 'base-sha' + }) + ).resolves.toMatchObject({ original: '', modified: '' }) + + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + expect(noteRepositoryRateLimitSpendMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/github/work-item-details.test.ts b/src/main/github/work-item-details.test.ts index 4fd21131c23..bc2bef2a722 100644 --- a/src/main/github/work-item-details.test.ts +++ b/src/main/github/work-item-details.test.ts @@ -7,12 +7,15 @@ type RateLimitGuardResult = const { ghExecFileAsyncMock, getOwnerRepoMock, - getIssueOwnerRepoMock, + getOwnerRepoForRemoteMock, + getEnterpriseGitHubRepoSlugMock, + getEnterpriseGitHubRepoSlugForRemoteMock, getWorkItemMock, + getWorkItemByOwnerRepoMock, getPRChecksMock, getPRCommentsMock, - rateLimitGuardMock, - noteRateLimitSpendMock, + repositoryRateLimitGuardMock, + noteRepositoryRateLimitSpendMock, ghRepoExecOptionsMock, githubRepoContextMock, acquireMock, @@ -20,12 +23,21 @@ const { } = vi.hoisted(() => ({ ghExecFileAsyncMock: vi.fn(), getOwnerRepoMock: vi.fn(), - getIssueOwnerRepoMock: vi.fn(), + getOwnerRepoForRemoteMock: vi.fn(), + getEnterpriseGitHubRepoSlugMock: vi.fn(), + getEnterpriseGitHubRepoSlugForRemoteMock: vi.fn(), getWorkItemMock: vi.fn(), + getWorkItemByOwnerRepoMock: vi.fn(), getPRChecksMock: vi.fn(), getPRCommentsMock: vi.fn(), - rateLimitGuardMock: vi.fn<() => RateLimitGuardResult>(() => ({ blocked: false })), - noteRateLimitSpendMock: vi.fn(), + repositoryRateLimitGuardMock: vi.fn< + ( + repository: { host?: string } | null | undefined, + bucket: 'core' | 'graphql' | 'search', + options?: { cwd?: string; wslDistro?: string } + ) => RateLimitGuardResult + >(() => ({ blocked: false })), + noteRepositoryRateLimitSpendMock: vi.fn(), ghRepoExecOptionsMock: vi.fn((context) => context.connectionId ? {} @@ -43,7 +55,7 @@ const { vi.mock('./gh-utils', () => ({ ghExecFileAsync: ghExecFileAsyncMock, getOwnerRepo: getOwnerRepoMock, - getIssueOwnerRepo: getIssueOwnerRepoMock, + getOwnerRepoForRemote: getOwnerRepoForRemoteMock, ghRepoExecOptions: ghRepoExecOptionsMock, githubRepoContext: githubRepoContextMock, acquire: acquireMock, @@ -52,28 +64,48 @@ vi.mock('./gh-utils', () => ({ vi.mock('./client', () => ({ getWorkItem: getWorkItemMock, + getWorkItemByOwnerRepo: getWorkItemByOwnerRepoMock, getPRChecks: getPRChecksMock, getPRComments: getPRCommentsMock })) +vi.mock('./github-enterprise-repository', () => ({ + getEnterpriseGitHubRepoSlug: getEnterpriseGitHubRepoSlugMock, + getEnterpriseGitHubRepoSlugForRemote: getEnterpriseGitHubRepoSlugForRemoteMock, + isGitHubHostAuthenticated: vi.fn().mockResolvedValue(true) +})) + vi.mock('./rate-limit', () => ({ - rateLimitGuard: rateLimitGuardMock, - noteRateLimitSpend: noteRateLimitSpendMock + repositoryRateLimitGuard: repositoryRateLimitGuardMock, + noteRepositoryRateLimitSpend: noteRepositoryRateLimitSpendMock })) import { getWorkItemDetails } from './work-item-details' +import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository' + +// The origin-repository cache is module-level state; reset it so slugs +// resolved by one test cannot leak into the next. +beforeEach(() => { + _resetOriginGitHubApiRepositoryCache() +}) + describe('getWorkItemDetails', () => { beforeEach(() => { ghExecFileAsyncMock.mockReset() getOwnerRepoMock.mockReset() - getIssueOwnerRepoMock.mockReset() + getOwnerRepoForRemoteMock.mockReset() + getEnterpriseGitHubRepoSlugMock.mockReset() + getEnterpriseGitHubRepoSlugMock.mockResolvedValue(null) + getEnterpriseGitHubRepoSlugForRemoteMock.mockReset() + getEnterpriseGitHubRepoSlugForRemoteMock.mockResolvedValue(null) getWorkItemMock.mockReset() + getWorkItemByOwnerRepoMock.mockReset() getPRChecksMock.mockReset() getPRCommentsMock.mockReset() - rateLimitGuardMock.mockReset() - rateLimitGuardMock.mockReturnValue({ blocked: false }) - noteRateLimitSpendMock.mockReset() + repositoryRateLimitGuardMock.mockReset() + repositoryRateLimitGuardMock.mockReturnValue({ blocked: false }) + noteRepositoryRateLimitSpendMock.mockReset() ghRepoExecOptionsMock.mockClear() githubRepoContextMock.mockClear() acquireMock.mockReset() @@ -93,7 +125,7 @@ describe('getWorkItemDetails', () => { updatedAt: '2026-04-01T00:00:00Z', author: 'issue-author' }) - getIssueOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + getOwnerRepoForRemoteMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) const timelineEvents = [ { id: 101, @@ -214,6 +246,79 @@ describe('getWorkItemDetails', () => { } ]) expect(details?.participants?.[0]?.login).toBe('issue-author') + // Why: issue identities resolve hosted, pinned to github.com for dotcom. + expect(repositoryRateLimitGuardMock).toHaveBeenCalledWith( + { owner: 'acme', repo: 'widgets', host: 'github.com' }, + 'graphql', + { cwd: '/repo-root', host: 'github.com' } + ) + expect(noteRepositoryRateLimitSpendMock).toHaveBeenCalledWith( + { owner: 'acme', repo: 'widgets', host: 'github.com' }, + 'graphql', + 1, + { cwd: '/repo-root', host: 'github.com' } + ) + expect(repositoryRateLimitGuardMock).toHaveBeenCalledWith( + { owner: 'acme', repo: 'widgets', host: 'github.com' }, + 'core', + { cwd: '/repo-root', host: 'github.com' } + ) + expect(noteRepositoryRateLimitSpendMock).toHaveBeenCalledWith( + { owner: 'acme', repo: 'widgets', host: 'github.com' }, + 'core', + 1, + { cwd: '/repo-root', host: 'github.com' } + ) + }) + + it('scopes collapsed issue GraphQL accounting to an implicit WSL UNC runtime', async () => { + const repository = { owner: 'acme', repo: 'widgets' } + const repoPath = String.raw`\\wsl.localhost\Ubuntu\home\me\widgets` + getWorkItemMock.mockResolvedValueOnce({ + id: 'issue:923', + type: 'issue', + number: 923, + title: 'Use upstream issues', + state: 'open', + url: 'https://github.com/acme/widgets/issues/923', + labels: [], + updatedAt: '2026-04-01T00:00:00Z', + author: 'issue-author' + }) + getOwnerRepoForRemoteMock.mockResolvedValue(repository) + ghExecFileAsyncMock + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + data: { + repository: { + issue: { + body: 'WSL issue body', + assignees: { nodes: [] }, + participants: { nodes: [] }, + comments: { nodes: [] } + } + } + } + }) + }) + .mockResolvedValueOnce({ stdout: '' }) + + const details = await getWorkItemDetails(repoPath, 923, 'issue') + + expect(details?.body).toBe('WSL issue body') + // Why: issue identities resolve hosted, pinned to github.com for dotcom. + expect(repositoryRateLimitGuardMock).toHaveBeenCalledWith( + { ...repository, host: 'github.com' }, + 'graphql', + { cwd: repoPath, host: 'github.com' } + ) + expect(noteRepositoryRateLimitSpendMock).toHaveBeenCalledWith( + { ...repository, host: 'github.com' }, + 'graphql', + 1, + { cwd: repoPath, host: 'github.com' } + ) + expect(ghExecFileAsyncMock.mock.calls.every((call) => call[1]?.cwd === repoPath)).toBe(true) }) it('enriches a non-participating assignee avatar from the GraphQL assignees connection', async () => { @@ -232,7 +337,7 @@ describe('getWorkItemDetails', () => { author: 'issue-author', assignees: [{ login: 'ghe-assignee', name: 'GHE Assignee', avatarUrl: '' }] }) - getIssueOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + getOwnerRepoForRemoteMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock .mockResolvedValueOnce({ stdout: JSON.stringify({ @@ -278,7 +383,7 @@ describe('getWorkItemDetails', () => { updatedAt: '2026-04-01T00:00:00Z', author: 'issue-author' }) - getIssueOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + getOwnerRepoForRemoteMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) const makeTimelineEvent = (page: number, index: number, event: string): string => JSON.stringify({ id: `${page}:${index}`, @@ -354,7 +459,7 @@ describe('getWorkItemDetails', () => { updatedAt: '2026-04-01T00:00:00Z', author: 'issue-author' }) - getIssueOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + getOwnerRepoForRemoteMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) // Collapsed GraphQL throws → fallback path picks up. ghExecFileAsyncMock .mockRejectedValueOnce(new Error('GraphQL error')) @@ -375,12 +480,12 @@ describe('getWorkItemDetails', () => { expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 2, ['api', '--cache', '60s', 'repos/acme/widgets/issues/923'], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 3, ['api', '--cache', '60s', 'repos/acme/widgets/issues/923/comments?per_page=100'], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 4, @@ -392,18 +497,22 @@ describe('getWorkItemDetails', () => { '--jq', '.[] | @json' ], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) expect(details?.body).toBe('Issue body') }) it('skips optional GraphQL issue detail calls when the cached GraphQL budget is low', async () => { - rateLimitGuardMock.mockReturnValue({ - blocked: true, - remaining: 3, - limit: 5000, - resetAt: 1_800_000_000 - }) + repositoryRateLimitGuardMock.mockImplementation((_repository, bucket) => + bucket === 'graphql' + ? { + blocked: true, + remaining: 3, + limit: 5000, + resetAt: 1_800_000_000 + } + : { blocked: false } + ) getWorkItemMock.mockResolvedValueOnce({ id: 'issue:923', type: 'issue', @@ -415,7 +524,7 @@ describe('getWorkItemDetails', () => { updatedAt: '2026-04-01T00:00:00Z', author: 'issue-author' }) - getIssueOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + getOwnerRepoForRemoteMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock .mockResolvedValueOnce({ stdout: JSON.stringify({ body: 'Issue body', assignees: [] }) }) .mockResolvedValueOnce({ stdout: '[]' }) @@ -425,7 +534,15 @@ describe('getWorkItemDetails', () => { expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(3) expect(ghExecFileAsyncMock.mock.calls.some((call) => call[0][1] === 'graphql')).toBe(false) - expect(noteRateLimitSpendMock).not.toHaveBeenCalled() + expect( + noteRepositoryRateLimitSpendMock.mock.calls.some(([, bucket]) => bucket === 'graphql') + ).toBe(false) + expect(noteRepositoryRateLimitSpendMock).toHaveBeenCalledWith( + { owner: 'acme', repo: 'widgets', host: 'github.com' }, + 'core', + 2, + { cwd: '/repo-root', host: 'github.com' } + ) expect(details?.body).toBe('Issue body') expect(details?.participants).toEqual([]) }) @@ -442,7 +559,7 @@ describe('getWorkItemDetails', () => { updatedAt: '2026-04-01T00:00:00Z', author: 'issue-author' }) - getIssueOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + getOwnerRepoForRemoteMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock .mockResolvedValueOnce({ stdout: JSON.stringify({ @@ -463,8 +580,13 @@ describe('getWorkItemDetails', () => { const details = await getWorkItemDetails('/home/tester/widgets', 923, 'issue', 'ssh-test-1') expect(getWorkItemMock).toHaveBeenCalledWith('/home/tester/widgets', 923, 'issue', 'ssh-test-1') - expect(getIssueOwnerRepoMock).toHaveBeenCalledWith('/home/tester/widgets', 'ssh-test-1') - expect(ghExecFileAsyncMock.mock.calls[0][1]).toEqual({}) + expect(getOwnerRepoForRemoteMock).toHaveBeenCalledWith( + '/home/tester/widgets', + 'upstream', + 'ssh-test-1', + {} + ) + expect(ghExecFileAsyncMock.mock.calls[0][1]).toEqual({ host: 'github.com' }) expect(details?.body).toBe('Remote issue body') }) @@ -481,7 +603,8 @@ describe('getWorkItemDetails', () => { updatedAt: '2026-04-01T00:00:00Z', author: 'pr-author' }) - getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + // Why: PR detail resolution uses getOwnerRepoForRemote via origin repository lookup. + getOwnerRepoForRemoteMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) getPRCommentsMock.mockResolvedValue([]) getPRChecksMock.mockResolvedValue([]) ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { @@ -527,11 +650,16 @@ describe('getWorkItemDetails', () => { expect(details?.body).toBe('PR body') expect(getWorkItemMock).toHaveBeenCalledWith('/repo-root', 42, 'pr', null, localGitOptions) - expect(getOwnerRepoMock).toHaveBeenCalledWith('/repo-root', null, localGitOptions) + expect(getOwnerRepoForRemoteMock).toHaveBeenCalledWith( + '/repo-root', + 'origin', + null, + localGitOptions + ) expect(getPRCommentsMock).toHaveBeenCalledWith( '/repo-root', 42, - undefined, + { prRepo: { owner: 'acme', repo: 'widgets', host: 'github.com' } }, null, localGitOptions ) @@ -539,7 +667,7 @@ describe('getWorkItemDetails', () => { '/repo-root', 42, 'head-sha', - null, + { owner: 'acme', repo: 'widgets', host: 'github.com' }, undefined, null, localGitOptions @@ -563,7 +691,7 @@ describe('getWorkItemDetails', () => { updatedAt: '2026-07-11T00:00:00Z', author: 'pr-author' }) - getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + getOwnerRepoForRemoteMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) getPRCommentsMock.mockResolvedValue([]) getPRChecksMock.mockResolvedValue([]) ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { @@ -597,7 +725,7 @@ describe('getWorkItemDetails', () => { updatedAt: '2026-07-11T00:00:00Z', author: 'pr-author' }) - getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + getOwnerRepoForRemoteMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) getPRCommentsMock.mockResolvedValue([]) getPRChecksMock.mockResolvedValue([]) ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { @@ -638,7 +766,7 @@ describe('getWorkItemDetails', () => { // A default `u/0` placeholder must be replaced by the resolved avatar. assignees: [{ login: 'seah', name: 'Seah', avatarUrl: 'https://avatars.example.com/u/0?v=4' }] }) - getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + getOwnerRepoForRemoteMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) getPRCommentsMock.mockResolvedValue([]) getPRChecksMock.mockResolvedValue([]) const avatars: Record = { diff --git a/src/main/github/work-item-details.ts b/src/main/github/work-item-details.ts index 87cdbe52eaa..5e389be8581 100644 --- a/src/main/github/work-item-details.ts +++ b/src/main/github/work-item-details.ts @@ -15,14 +15,18 @@ import { ghExecFileAsync, acquire, release, - getOwnerRepo, - getIssueOwnerRepo, ghRepoExecOptions, githubRepoContext, type LocalGitExecOptions } from './gh-utils' import { getWorkItem, getPRChecks, getPRComments } from './client' -import { noteRateLimitSpend, rateLimitGuard } from './rate-limit' +import { + getIssueGitHubApiRepository, + githubHostExecOptions, + resolveGitHubRepoExecution, + type GitHubApiRepository +} from './github-api-repository' +import { noteRepositoryRateLimitSpend, repositoryRateLimitGuard } from './rate-limit' import { getPRReviewCommentLineNumbersFromPatch } from './pr-review-comment-lines' import { isMaxBufferOverflowError } from '../git/max-buffer-overflow' @@ -38,6 +42,10 @@ function localGitOptionArgs(options: LocalGitExecOptions = {}): [] | [LocalGitEx return Object.keys(options).length > 0 ? [options] : [] } +function encodeGitHubContentPath(path: string): string { + return path.split('/').map(encodeURIComponent).join('/') +} + const PR_FILE_VIEWED_STATES_QUERY = `query($owner: String!, $repo: String!, $number: Int!, $after: String) { repository(owner: $owner, name: $repo) { pullRequest(number: $number) { @@ -120,8 +128,6 @@ type GraphQLIssueDetailsResponse = { errors?: { message?: string }[] } -type GitHubOwnerRepoSlug = { owner: string; repo: string } - type RestTimelineUser = { login?: string | null avatar_url?: string | null @@ -270,13 +276,17 @@ function parseRestTimelineEventLines(stdout: string): RestTimelineEvent[] { } async function getIssueTimelineItems( - ownerRepo: GitHubOwnerRepoSlug, + ownerRepo: GitHubApiRepository, issueNumber: number, ghOptions: ReturnType ): Promise { try { const items: GitHubIssueTimelineItem[] = [] for (let page = 1; items.length < MAX_ISSUE_TIMELINE_ITEMS; page += 1) { + if (repositoryRateLimitGuard(ownerRepo, 'core', ghOptions).blocked) { + return items + } + noteRepositoryRateLimitSpend(ownerRepo, 'core', 1, ghOptions) const { stdout } = await ghExecFileAsync( [ 'api', @@ -286,7 +296,7 @@ async function getIssueTimelineItems( '--jq', '.[] | @json' ], - ghOptions + { ...ghOptions, ...githubHostExecOptions(ownerRepo) } ) // Why: --jq emits NDJSON and explicit paging lets us stop once we hit the drawer cap. const pageEvents = parseRestTimelineEventLines(stdout) @@ -318,6 +328,7 @@ async function getIssueTimelineItems( async function getIssueDetailsViaGraphQL( repoPath: string, issueNumber: number, + ownerRepo: GitHubApiRepository | null, connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise<{ @@ -329,20 +340,18 @@ async function getIssueDetailsViaGraphQL( participants: GitHubAssignableUser[] timelineItems: GitHubIssueTimelineItem[] } | null> { - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) - const ownerRepo = await getIssueOwnerRepo( - repoPath, - connectionId, - ...localGitOptionArgs(localGitOptions) - ) + const ghOptions = { + ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), + ...githubHostExecOptions(ownerRepo) + } if (!ownerRepo) { return null } - if (rateLimitGuard('graphql').blocked) { + if (repositoryRateLimitGuard(ownerRepo, 'graphql', ghOptions).blocked) { return null } try { - noteRateLimitSpend('graphql') + noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions) const { stdout } = await ghExecFileAsync( [ 'api', @@ -476,47 +485,43 @@ function isBinaryHint(file: RESTPRFile): boolean { return file.patch === undefined && file.changes > 0 } -async function getPRHeadBaseSha( +async function getPRMetadata( repoPath: string, prNumber: number, + ownerRepo: GitHubApiRepository | null, connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} -): Promise<{ headSha: string; baseSha: string } | null> { - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) - const ownerRepo = await getOwnerRepo( - repoPath, - connectionId, - ...localGitOptionArgs(localGitOptions) - ) +): Promise<{ body: string; headSha?: string; baseSha?: string }> { + if (!ownerRepo) { + // Why: a bare `gh pr view` can honor ambient GH_HOST/GH_REPO after hosted + // repository resolution fails, returning metadata for the wrong PR. + return { body: '' } + } + const ghOptions = { + ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), + ...githubHostExecOptions(ownerRepo) + } + if (repositoryRateLimitGuard(ownerRepo, 'core', ghOptions).blocked) { + return { body: '' } + } try { - if (ownerRepo) { - const { stdout } = await ghExecFileAsync( - ['api', '--cache', '60s', `repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${prNumber}`], - ghOptions - ) - const data = JSON.parse(stdout) as { - head?: { sha?: string } - base?: { sha?: string } - } - if (data.head?.sha && data.base?.sha) { - return { headSha: data.head.sha, baseSha: data.base.sha } - } - return null - } + noteRepositoryRateLimitSpend(ownerRepo, 'core', 1, ghOptions) const { stdout } = await ghExecFileAsync( - ['pr', 'view', String(prNumber), '--json', 'headRefOid,baseRefOid'], + ['api', '--cache', '60s', `repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${prNumber}`], ghOptions ) const data = JSON.parse(stdout) as { - headRefOid?: string - baseRefOid?: string + body?: string | null + head?: { sha?: string } + base?: { sha?: string } } - if (data.headRefOid && data.baseRefOid) { - return { headSha: data.headRefOid, baseSha: data.baseRefOid } + return { + body: data.body ?? '', + ...(data.head?.sha ? { headSha: data.head.sha } : {}), + ...(data.base?.sha ? { baseSha: data.base.sha } : {}) } - return null } catch { - return null + return { body: '' } } } @@ -524,30 +529,41 @@ async function getPRHeadBaseSha( async function getPRFiles( repoPath: string, prNumber: number, + ownerRepo: GitHubApiRepository | null, connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) - const ownerRepo = await getOwnerRepo( - repoPath, - connectionId, - ...localGitOptionArgs(localGitOptions) - ) if (!ownerRepo) { return null } + const ghOptions = { + ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), + ...githubHostExecOptions(ownerRepo) + } try { - const { stdout } = await ghExecFileAsync( - [ - 'api', - '--cache', - '60s', - `repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${prNumber}/files?per_page=100` - ], - ghOptions - ) - const data = JSON.parse(stdout) as RESTPRFile[] - return data.slice(0, MAX_PR_FILES).map((file) => ({ + const data: RESTPRFile[] = [] + for (let page = 1; data.length < MAX_PR_FILES; page += 1) { + if (repositoryRateLimitGuard(ownerRepo, 'core', ghOptions).blocked) { + return null + } + const pageSuffix = page === 1 ? '' : `&page=${page}` + noteRepositoryRateLimitSpend(ownerRepo, 'core', 1, ghOptions) + const { stdout } = await ghExecFileAsync( + [ + 'api', + '--cache', + '60s', + `repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${prNumber}/files?per_page=100${pageSuffix}` + ], + ghOptions + ) + const pageData = JSON.parse(stdout) as RESTPRFile[] + data.push(...pageData.slice(0, MAX_PR_FILES - data.length)) + if (pageData.length < 100) { + break + } + } + return data.map((file) => ({ path: file.filename, oldPath: file.previous_filename, status: mapFileStatus(file.status), @@ -569,19 +585,18 @@ type PRFileViewedStatesResult = { async function getPRFileViewedStates( repoPath: string, prNumber: number, + ownerRepo: GitHubApiRepository | null, connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) - const ownerRepo = await getOwnerRepo( - repoPath, - connectionId, - ...localGitOptionArgs(localGitOptions) - ) if (!ownerRepo) { return null } - if (rateLimitGuard('graphql').blocked) { + const ghOptions = { + ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), + ...githubHostExecOptions(ownerRepo) + } + if (repositoryRateLimitGuard(ownerRepo, 'graphql', ghOptions).blocked) { return null } const viewedStates = new Map() @@ -605,7 +620,7 @@ async function getPRFileViewedStates( if (after) { args.push('-f', `after=${after}`) } - noteRateLimitSpend('graphql') + noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions) const { stdout } = await ghExecFileAsync(args, ghOptions) const parsed = JSON.parse(stdout) as { data?: { @@ -665,6 +680,7 @@ function mergePRFileViewedStates( async function getIssueBodyAndComments( repoPath: string, issueNumber: number, + ownerRepo: GitHubApiRepository | null, connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise<{ @@ -673,14 +689,18 @@ async function getIssueBodyAndComments( assignees: string[] timelineItems: GitHubIssueTimelineItem[] }> { - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) - const ownerRepo = await getIssueOwnerRepo( - repoPath, - connectionId, - ...localGitOptionArgs(localGitOptions) - ) + const ghOptions = { + ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), + ...githubHostExecOptions(ownerRepo) + } try { if (ownerRepo) { + if (repositoryRateLimitGuard(ownerRepo, 'core', ghOptions).blocked) { + return { body: '', comments: [], assignees: [], timelineItems: [] } + } + // Why: the fallback starts the issue and comments reads together; debit + // both before spawning so a failed response cannot leave quota overstated. + noteRepositoryRateLimitSpend(ownerRepo, 'core', 2, ghOptions) const [issueResult, commentsResult, timelineItems] = await Promise.all([ ghExecFileAsync( [ @@ -727,6 +747,11 @@ async function getIssueBodyAndComments( const assignees = (issue.assignees ?? []).map((a) => a.login) return { body: issue.body ?? '', comments, assignees, timelineItems } } + if (connectionId) { + // Why: connection-backed gh has no cwd. A bare issue lookup could honor + // process GH_REPO/GH_HOST and return an unrelated repository's issue. + return { body: '', comments: [], assignees: [], timelineItems: [] } + } // Fallback: non-GitHub remote const { stdout } = await ghExecFileAsync( ['issue', 'view', String(issueNumber), '--json', 'body,comments,assignees'], @@ -759,57 +784,27 @@ async function getIssueBodyAndComments( } } -async function getPRBody( - repoPath: string, - prNumber: number, - connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} -): Promise { - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) - const ownerRepo = await getOwnerRepo( - repoPath, - connectionId, - ...localGitOptionArgs(localGitOptions) - ) - try { - if (ownerRepo) { - const { stdout } = await ghExecFileAsync( - ['api', '--cache', '60s', `repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${prNumber}`], - ghOptions - ) - const data = JSON.parse(stdout) as { body?: string | null } - return data.body ?? '' - } - const { stdout } = await ghExecFileAsync( - ['pr', 'view', String(prNumber), '--json', 'body'], - ghOptions - ) - const data = JSON.parse(stdout) as { body?: string } - return data.body ?? '' - } catch { - return '' - } -} - async function getWorkItemParticipants( repoPath: string, item: Pick, + resolvedRepository: GitHubApiRepository | null, connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - // 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)) - : await getOwnerRepo(repoPath, connectionId, ...localGitOptionArgs(localGitOptions)) + // Why: reuse the fan-out's repository identity so details cannot drift across hosts. + const ownerRepo = resolvedRepository if (!ownerRepo) { return [] } - if (rateLimitGuard('graphql').blocked) { + const ghOptions = { + ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), + ...githubHostExecOptions(ownerRepo) + } + if (repositoryRateLimitGuard(ownerRepo, 'graphql', ghOptions).blocked) { return [] } try { - noteRateLimitSpend('graphql') + noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions) const { stdout } = await ghExecFileAsync( [ 'api', @@ -825,7 +820,7 @@ async function getWorkItemParticipants( '-F', `isPr=${item.type === 'pr'}` ], - ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) + ghOptions ) const data = JSON.parse(stdout) as { data?: { @@ -858,16 +853,25 @@ async function getWorkItemParticipants( async function getGitHubUsersByLogin( repoPath: string, logins: string[], + repository: GitHubApiRepository | null, connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { + if (!repository) { + return [] + } const uniqueLogins = Array.from( new Set(logins.filter((login) => login && login !== 'ghost').map((login) => login.trim())) ).slice(0, 40) if (uniqueLogins.length === 0) { return [] } - if (rateLimitGuard('graphql').blocked) { + const ghOptions = { + ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), + ...githubHostExecOptions(repository) + } + const guard = repositoryRateLimitGuard(repository, 'graphql', ghOptions) + if (guard.blocked) { // 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)` @@ -881,10 +885,10 @@ async function getGitHubUsersByLogin( ) .join('\n') try { - noteRateLimitSpend('graphql') + noteRepositoryRateLimitSpend(repository, 'graphql', 1, ghOptions) const { stdout } = await ghExecFileAsync( ['api', 'graphql', '-f', `query=query { ${fields} }`], - ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) + ghOptions ) const data = JSON.parse(stdout) as { data?: Record< @@ -978,6 +982,7 @@ async function getMentionParticipants( >, comments: PRComment[], participants: GitHubAssignableUser[], + repository: GitHubApiRepository | null, connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { @@ -993,6 +998,7 @@ async function getMentionParticipants( const graphQlUsers = await getGitHubUsersByLogin( repoPath, visibleLogins, + repository, connectionId, localGitOptions ) @@ -1003,15 +1009,19 @@ async function getPRChecksForDetails( repoPath: string, prNumber: number, headSha: string | undefined, + repository: GitHubApiRepository | null, connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { + if (!repository) { + return [] + } try { return await getPRChecks( repoPath, prNumber, headSha, - null, + repository, undefined, connectionId, ...localGitOptionArgs(localGitOptions) @@ -1023,6 +1033,15 @@ async function getPRChecksForDetails( } } +async function withWorkItemDetailsPermit(operation: () => Promise): Promise { + await acquire() + try { + return await operation() + } finally { + release() + } +} + export async function getWorkItemDetails( repoPath: string, number: number, @@ -1030,7 +1049,6 @@ export async function getWorkItemDetails( connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - // 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, @@ -1042,13 +1060,19 @@ export async function getWorkItemDetails( return null } - await acquire() - try { - if (item.type === 'issue') { - // Why: one GraphQL round-trip returns everything (its participants include commenters, so getMentionParticipants is skipped); fall back to REST+GraphQL on any failure. + const resolvedRepository = + item.type === 'issue' + ? await getIssueGitHubApiRepository(repoPath, connectionId, localGitOptions) + : (await resolveGitHubRepoExecution(repoPath, item.prRepo, connectionId, localGitOptions)) + .ownerRepo + + if (item.type === 'issue') { + return withWorkItemDetailsPermit(async () => { + // Why: one GraphQL trip returns all issue details; fall back to the legacy fan-out on failure. const collapsed = await getIssueDetailsViaGraphQL( repoPath, item.number, + resolvedRepository, connectionId, localGitOptions ) @@ -1068,14 +1092,21 @@ export async function getWorkItemDetails( } // 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) + getIssueBodyAndComments( + repoPath, + item.number, + resolvedRepository, + connectionId, + localGitOptions + ), + getWorkItemParticipants(repoPath, item, resolvedRepository, connectionId, localGitOptions) ]) const mentionParticipants = await getMentionParticipants( repoPath, item, comments, participants, + resolvedRepository, connectionId, localGitOptions ) @@ -1087,45 +1118,81 @@ export async function getWorkItemDetails( participants: mentionParticipants, timelineItems } - } + }) + } - // PR: fetch body + comments + checks + files + head/base SHAs in parallel. - const [body, comments, shas, files, viewedStates, participants] = await Promise.all([ - getPRBody(repoPath, item.number, connectionId, localGitOptions), - getPRComments( - repoPath, - item.number, - undefined, - connectionId, - ...localGitOptionArgs(localGitOptions) + // Why: getPRComments and getPRChecks own their semaphore permits. Keeping an + // outer permit while awaiting either can deadlock four concurrent detail loads. + const [[metadata, files, viewedStates, participants], comments] = await Promise.all([ + Promise.all([ + withWorkItemDetailsPermit(() => + getPRMetadata(repoPath, item.number, resolvedRepository, connectionId, localGitOptions) ), - getPRHeadBaseSha(repoPath, item.number, connectionId, localGitOptions), - getPRFiles(repoPath, item.number, connectionId, localGitOptions), - getPRFileViewedStates(repoPath, item.number, connectionId, localGitOptions), - getWorkItemParticipants(repoPath, item, connectionId, localGitOptions) - ]) + withWorkItemDetailsPermit(() => + getPRFiles(repoPath, item.number, resolvedRepository, connectionId, localGitOptions) + ), + withWorkItemDetailsPermit(() => + getPRFileViewedStates( + repoPath, + item.number, + resolvedRepository, + connectionId, + localGitOptions + ) + ), + withWorkItemDetailsPermit(() => + getWorkItemParticipants(repoPath, item, resolvedRepository, connectionId, localGitOptions) + ) + ]), + resolvedRepository + ? getPRComments( + repoPath, + item.number, + { prRepo: resolvedRepository }, + connectionId, + ...localGitOptionArgs(localGitOptions) + ) + : Promise.resolve([]) + ]) - // 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) - ]) + // Why: mention hydration spawns gh directly while checks owns a permit; bound them without nesting. + const [mentionParticipants, checks] = await Promise.all([ + withWorkItemDetailsPermit(() => + getMentionParticipants( + repoPath, + item, + comments, + participants, + resolvedRepository, + connectionId, + localGitOptions + ) + ), + getPRChecksForDetails( + repoPath, + item.number, + metadata.headSha, + resolvedRepository, + connectionId, + localGitOptions + ) + ]) - return { - item: enrichItemDisplayAvatars(item, mentionParticipants), - body, - comments, - headSha: shas?.headSha, - baseSha: shas?.baseSha, - pullRequestId: viewedStates?.pullRequestId, - checks, - // 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 - } - } finally { - release() + return { + item: enrichItemDisplayAvatars( + resolvedRepository ? { ...item, prRepo: resolvedRepository } : item, + mentionParticipants + ), + body: metadata.body, + comments, + headSha: metadata.headSha, + baseSha: metadata.baseSha, + pullRequestId: viewedStates?.pullRequestId, + checks, + // 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 } } @@ -1134,12 +1201,20 @@ async function fetchContentAtRef(args: { repoPath: string connectionId?: string | null localGitOptions?: LocalGitExecOptions - owner: string - repo: string + ownerRepo: GitHubApiRepository path: string ref: string }): Promise<{ content: string; isBinary: boolean; tooLarge?: boolean }> { + const ghOptions = { + ...ghRepoExecOptions(githubRepoContext(args.repoPath, args.connectionId, args.localGitOptions)), + ...githubHostExecOptions(args.ownerRepo), + maxBuffer: GITHUB_RAW_CONTENT_MAX_BUFFER_BYTES + } + if (repositoryRateLimitGuard(args.ownerRepo, 'core', ghOptions).blocked) { + return { content: '', isBinary: false } + } try { + noteRepositoryRateLimitSpend(args.ownerRepo, 'core', 1, ghOptions) const { stdout } = await ghExecFileAsync( [ 'api', @@ -1147,14 +1222,9 @@ async function fetchContentAtRef(args: { '300s', '-H', 'Accept: application/vnd.github.raw', - `repos/${args.owner}/${args.repo}/contents/${encodeURI(args.path)}?ref=${encodeURIComponent(args.ref)}` + `repos/${args.ownerRepo.owner}/${args.ownerRepo.repo}/contents/${encodeGitHubContentPath(args.path)}?ref=${encodeURIComponent(args.ref)}` ], - { - ...ghRepoExecOptions( - githubRepoContext(args.repoPath, args.connectionId, args.localGitOptions) - ), - maxBuffer: GITHUB_RAW_CONTENT_MAX_BUFFER_BYTES - } + ghOptions ) // Heuristic: a NUL byte in the first 2KB means binary (execFile decodes as lossy utf-8). const sample = stdout.slice(0, 2048) @@ -1174,6 +1244,7 @@ export async function getPRFileContents(args: { repoPath: string connectionId?: string | null localGitOptions?: LocalGitExecOptions + prRepo?: GitHubApiRepository | null prNumber: number path: string oldPath?: string @@ -1181,10 +1252,11 @@ export async function getPRFileContents(args: { headSha: string baseSha: string }): Promise { - const ownerRepo = await getOwnerRepo( + const { ownerRepo } = await resolveGitHubRepoExecution( args.repoPath, + args.prRepo, args.connectionId, - ...localGitOptionArgs(args.localGitOptions) + args.localGitOptions ) if (!ownerRepo) { return { @@ -1209,8 +1281,7 @@ export async function getPRFileContents(args: { repoPath: args.repoPath, connectionId: args.connectionId, localGitOptions: args.localGitOptions, - owner: ownerRepo.owner, - repo: ownerRepo.repo, + ownerRepo, path: originalPath, ref: originalRef }) @@ -1223,8 +1294,7 @@ export async function getPRFileContents(args: { repoPath: args.repoPath, connectionId: args.connectionId, localGitOptions: args.localGitOptions, - owner: ownerRepo.owner, - repo: ownerRepo.repo, + ownerRepo, path: args.path, ref: args.headSha }) diff --git a/src/main/ipc/github.test.ts b/src/main/ipc/github.test.ts index 71f5d286624..bb1dd0cc1f6 100644 --- a/src/main/ipc/github.test.ts +++ b/src/main/ipc/github.test.ts @@ -856,7 +856,7 @@ describe('registerGitHubHandlers', () => { updatedAt: 0 } ] - const prRepo = { owner: 'acme', repo: 'orca' } + const prRepo = { owner: 'acme', repo: 'orca', host: 'github.acme-corp.com' } const localGitOptions = { wslDistro: 'Ubuntu' } getWorkItemMock.mockResolvedValue(null) getWorkItemByOwnerRepoMock.mockResolvedValue(null) @@ -883,6 +883,7 @@ describe('registerGitHubHandlers', () => { repoPath: '/workspace/repo', owner: 'acme', repo: 'orca', + host: prRepo.host, number: 42, type: 'pr' }) @@ -894,6 +895,7 @@ describe('registerGitHubHandlers', () => { await handlers['gh:prFileContents'](null, { repoPath: '/workspace/repo', prNumber: 42, + prRepo, path: 'src/app.ts', status: 'modified', headSha: 'head-sha', @@ -923,13 +925,15 @@ describe('registerGitHubHandlers', () => { await handlers['gh:resolveReviewThread'](null, { repoPath: '/workspace/repo', threadId: 'thread-1', - resolve: true + resolve: true, + prRepo }) await handlers['gh:setPRFileViewed']( { sender: { id: 1 } }, { repoPath: '/workspace/repo', prNumber: 42, + prRepo, pullRequestId: 'PR_kw', path: 'src/app.ts', viewed: true @@ -940,12 +944,12 @@ describe('registerGitHubHandlers', () => { { repoPath: '/workspace/repo', prNumber: 42, + prRepo, commentId: 11, body: ' Reply ', threadId: 'thread-1', path: 'src/app.ts', - line: 10, - prRepo + line: 10 } ) await handlers['gh:addPRReviewComment']( @@ -953,6 +957,7 @@ describe('registerGitHubHandlers', () => { { repoPath: '/workspace/repo', prNumber: 42, + prRepo, commitId: ' head-sha ', path: 'src/app.ts', line: 10, @@ -992,21 +997,24 @@ describe('registerGitHubHandlers', () => { { repoPath: '/workspace/repo', prNumber: 42, - updates: { state: 'closed' } + updates: { state: 'closed' }, + prRepo } ) await handlers['gh:rerunPRChecks'](null, { repoPath: '/workspace/repo', prNumber: 42, headSha: 'head-sha', - failedOnly: true + failedOnly: true, + prRepo }) await handlers['gh:requestPRReviewers']( { sender: { id: 1 } }, { repoPath: '/workspace/repo', prNumber: 42, - reviewers: ['octo'] + reviewers: ['octo'], + prRepo } ) await handlers['gh:removePRReviewers']( @@ -1014,7 +1022,8 @@ describe('registerGitHubHandlers', () => { { repoPath: '/workspace/repo', prNumber: 42, - reviewers: ['octo'] + reviewers: ['octo'], + prRepo } ) @@ -1035,7 +1044,7 @@ describe('registerGitHubHandlers', () => { localGitOptions ) expect(getPRFileContentsMock).toHaveBeenCalledWith( - expect.objectContaining({ repoPath: '/workspace/repo', localGitOptions }) + expect.objectContaining({ repoPath: '/workspace/repo', localGitOptions, prRepo }) ) expect(getPRChecksMock).toHaveBeenCalledWith( '/workspace/repo', @@ -1070,10 +1079,11 @@ describe('registerGitHubHandlers', () => { 'thread-1', true, null, + prRepo, localGitOptions ) expect(setPRFileViewedMock).toHaveBeenCalledWith( - expect.objectContaining({ repoPath: '/workspace/repo', localGitOptions }) + expect.objectContaining({ repoPath: '/workspace/repo', localGitOptions, prRepo }) ) expect(addPRReviewCommentReplyMock).toHaveBeenCalledWith( '/workspace/repo', @@ -1092,6 +1102,7 @@ describe('registerGitHubHandlers', () => { repoPath: '/workspace/repo', commitId: 'head-sha', body: 'Inline', + prRepo, localGitOptions }) ) @@ -1125,12 +1136,13 @@ describe('registerGitHubHandlers', () => { 42, { state: 'closed' }, null, + prRepo, localGitOptions ) expect(rerunPRChecksMock).toHaveBeenCalledWith( '/workspace/repo', 42, - { headSha: 'head-sha', failedOnly: true }, + { headSha: 'head-sha', failedOnly: true, prRepo }, null, localGitOptions ) @@ -1139,6 +1151,7 @@ describe('registerGitHubHandlers', () => { 42, ['octo'], null, + prRepo, localGitOptions ) expect(removePRReviewersMock).toHaveBeenCalledWith( @@ -1146,6 +1159,7 @@ describe('registerGitHubHandlers', () => { 42, ['octo'], null, + prRepo, localGitOptions ) }) diff --git a/src/main/ipc/github.ts b/src/main/ipc/github.ts index 84afec20c3b..a01225bfccf 100644 --- a/src/main/ipc/github.ts +++ b/src/main/ipc/github.ts @@ -93,6 +93,7 @@ import type { ClearProjectItemFieldArgs, DeleteIssueCommentBySlugArgs, GetProjectViewTableArgs, + ListAccessibleProjectsArgs, ListAssignableUsersBySlugArgs, ListIssueTypesBySlugArgs, ListLabelsBySlugArgs, @@ -474,6 +475,7 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi repoPath: string owner: string repo: string + host?: string number: number type: 'issue' | 'pr' } @@ -481,7 +483,9 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi const repo = assertRegisteredRepo(args, store) return getWorkItemByOwnerRepo( repo.path, - { owner: args.owner, repo: args.repo }, + // Why: Enterprise host identity must survive the IPC boundary or the + // lookup falls back to gh's default host for a same-named repo. + { owner: args.owner, repo: args.repo, ...(args.host ? { host: args.host } : {}) }, args.number, args.type, repoConnectionId(repo), @@ -539,6 +543,7 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi args: { repoPath: string prNumber: number + prRepo?: GitHubOwnerRepo | null path: string oldPath?: string status: GitHubPRFile['status'] @@ -551,6 +556,7 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi repoPath: repo.path, connectionId: repoConnectionId(repo), localGitOptions: localGitOptionArgs(store, repo)[0], + prRepo: args.prRepo ?? null, prNumber: args.prNumber, path: args.path, oldPath: args.oldPath, @@ -671,6 +677,7 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi sourceContext?: TaskSourceContext | null threadId: string resolve: boolean + prRepo?: GitHubOwnerRepo | null } ) => { const repo = assertRegisteredRepo(args, store) @@ -684,6 +691,7 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi args.threadId, args.resolve, repoConnectionId(repo), + args.prRepo ?? null, ...localGitOptionArgs(store, repo) ) } @@ -697,6 +705,7 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi repoPath: string repoId?: string prNumber: number + prRepo?: GitHubOwnerRepo | null pullRequestId: string path: string viewed: boolean @@ -717,6 +726,7 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi repoPath: repo.path, connectionId: repoConnectionId(repo), localGitOptions: localGitOptionArgs(store, repo)[0], + prRepo: args.prRepo ?? null, pullRequestId: args.pullRequestId.trim(), path: args.path, viewed: Boolean(args.viewed) @@ -795,6 +805,7 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi args: { repoPath: string prNumber: number + prRepo?: GitHubOwnerRepo | null commitId: string path: string line: number @@ -833,6 +844,7 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi } const result = await addPRReviewComment({ repoPath: repo.path, + prRepo: args.prRepo ?? null, prNumber: args.prNumber, commitId: args.commitId.trim(), path: args.path, @@ -947,7 +959,11 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi 'gh:updatePRState', async ( event, - args: RepoScopedArgs & { prNumber: number; updates: GitHubPullRequestStateUpdate } + args: RepoScopedArgs & { + prNumber: number + updates: GitHubPullRequestStateUpdate + prRepo?: GitHubOwnerRepo | null + } ) => { const repo = assertRegisteredRepo(args, store) if ( @@ -962,6 +978,7 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi args.prNumber, args.updates, repoConnectionId(repo), + args.prRepo ?? null, ...localGitOptionArgs(store, repo) ) if (result.ok) { @@ -978,7 +995,12 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi 'gh:rerunPRChecks', async ( _event, - args: RepoScopedArgs & { prNumber: number; headSha?: string; failedOnly?: boolean } + args: RepoScopedArgs & { + prNumber: number + headSha?: string + failedOnly?: boolean + prRepo?: GitHubOwnerRepo | null + } ) => { const repo = assertRegisteredRepo(args, store) if ( @@ -991,7 +1013,7 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi return rerunPRChecks( repo.path, args.prNumber, - { headSha: args.headSha, failedOnly: args.failedOnly }, + { headSha: args.headSha, failedOnly: args.failedOnly, prRepo: args.prRepo ?? null }, repoConnectionId(repo), ...localGitOptionArgs(store, repo) ) @@ -1000,13 +1022,21 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi ipcMain.handle( 'gh:requestPRReviewers', - async (event, args: RepoScopedArgs & { prNumber: number; reviewers: string[] }) => { + async ( + event, + args: RepoScopedArgs & { + prNumber: number + reviewers: string[] + prRepo?: GitHubOwnerRepo | null + } + ) => { const repo = assertRegisteredRepo(args, store) const result = await requestPRReviewers( repo.path, args.prNumber, args.reviewers, repoConnectionId(repo), + args.prRepo ?? null, ...localGitOptionArgs(store, repo) ) if (result.ok) { @@ -1021,13 +1051,21 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi ipcMain.handle( 'gh:removePRReviewers', - async (event, args: RepoScopedArgs & { prNumber: number; reviewers: string[] }) => { + async ( + event, + args: RepoScopedArgs & { + prNumber: number + reviewers: string[] + prRepo?: GitHubOwnerRepo | null + } + ) => { const repo = assertRegisteredRepo(args, store) const result = await removePRReviewers( repo.path, args.prNumber, args.reviewers, repoConnectionId(repo), + args.prRepo ?? null, ...localGitOptionArgs(store, repo) ) if (result.ok) { @@ -1157,7 +1195,9 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi getRateLimit(args?.force ? { force: true } : undefined) ) - ipcMain.handle('gh:diagnoseAuth', () => diagnoseGhAuth()) + ipcMain.handle('gh:diagnoseAuth', (_event, args?: { host?: string }) => + diagnoseGhAuth(args?.host) + ) // ── GitHub ProjectV2 view handlers ───────────────────────────────── // Why: registered unconditionally so enabling the experimental flag at @@ -1165,7 +1205,9 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi // Handlers never throw across IPC — every failure mode resolves through the // GitHubProjectViewError envelope. - ipcMain.handle('gh:listAccessibleProjects', () => listAccessibleProjects()) + ipcMain.handle('gh:listAccessibleProjects', (_event, args?: ListAccessibleProjectsArgs) => + listAccessibleProjects(args) + ) ipcMain.handle('gh:resolveProjectRef', (_event, args: ResolveProjectRefArgs) => resolveProjectRef(args) diff --git a/src/main/ipc/repos-remote.test.ts b/src/main/ipc/repos-remote.test.ts index d7786b7ca4e..61bbbc76ead 100644 --- a/src/main/ipc/repos-remote.test.ts +++ b/src/main/ipc/repos-remote.test.ts @@ -1881,6 +1881,61 @@ describe('repos:add + repos:clone', () => { expect(mockStore.addRepo).not.toHaveBeenCalled() }) + it('preserves the selected Enterprise host when aligning an existing folder', async () => { + const existing = { + id: 'repo-setup-enterprise', + path: '/tmp/from-setup-enterprise', + displayName: 'from-setup-enterprise', + kind: 'git', + badgeColor: '#22c55e' + } + const existingProject = { id: 'repo:repo-setup-enterprise', displayName: 'Existing' } + const selectedProject = { + id: 'github:github.acme-corp.com/acme/orca', + displayName: 'Enterprise project', + providerIdentity: { + provider: 'github', + owner: 'acme', + repo: 'orca', + host: 'github.acme-corp.com' + } + } + const setup = { + id: existing.id, + projectId: existingProject.id, + repoId: existing.id, + hostId: 'local', + path: existing.path, + displayName: existing.displayName, + setupState: 'ready', + setupMethod: 'legacy-repo' + } + let updatedRepo = existing + mockStore.getRepos.mockReturnValue([existing]) + mockStore.getProjects.mockReturnValue([existingProject, selectedProject]) + mockStore.getProjectHostSetups.mockReturnValue([setup]) + mockStore.updateRepo.mockImplementation((_repoId, updates) => { + updatedRepo = { ...updatedRepo, ...updates } + return updatedRepo + }) + + await handlers.get('projectHostSetups:setupExistingFolder')!(null, { + projectId: selectedProject.id, + hostId: 'local', + path: existing.path, + kind: 'git', + setupMethod: 'imported-existing-folder' + }) + + expect(mockStore.updateRepo).toHaveBeenNthCalledWith(1, existing.id, { + upstream: { + owner: 'acme', + repo: 'orca', + host: 'github.acme-corp.com' + } + }) + }) + it('prepares and invalidates roots when repos:update changes worktree base path', () => { const updated = { id: 'repo-update-root', diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index 54e440dba74..fd0ddcf770d 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -143,7 +143,8 @@ function alignRepoWithRequestedProject( const updated = store.updateRepo(repo.id, { upstream: { owner: project.providerIdentity.owner, - repo: project.providerIdentity.repo + repo: project.providerIdentity.repo, + ...(project.providerIdentity.host ? { host: project.providerIdentity.host } : {}) } }) if (!updated) { diff --git a/src/main/repo-icon-autodetect.test.ts b/src/main/repo-icon-autodetect.test.ts index e6abbe278f8..c491f344422 100644 --- a/src/main/repo-icon-autodetect.test.ts +++ b/src/main/repo-icon-autodetect.test.ts @@ -179,7 +179,8 @@ describe('detectRepoIcon', () => { source: 'github', label: 'stablyai/orca' }, - upstream: { owner: 'stablyai', repo: 'orca' } + // Why: fork parents resolve host-qualified so avatars/links stay on the fork's server. + upstream: { owner: 'stablyai', repo: 'orca', host: 'github.com' } }) }) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 6599359557e..27cdc239baf 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -5377,6 +5377,7 @@ describe('OrcaRuntimeService', () => { }) it('allows host integration slug helpers for SSH repos through provider-aware GitHub clients', async () => { + const prRepo = { owner: 'acme', repo: 'orca', host: 'github.acme.test' } getIssueMock.mockResolvedValueOnce({ number: 12, title: 'Remote issue' }) listGitHubIssuesMock.mockResolvedValueOnce({ items: [{ number: 7, title: 'Remote issue list item' }] @@ -5404,16 +5405,30 @@ describe('OrcaRuntimeService', () => { await expect(runtime.listRepoIssues('id:repo-1', 10)).resolves.toEqual([ { number: 7, title: 'Remote issue list item' } ]) - await expect(runtime.requestRepoPRReviewers('id:repo-1', 7, ['alex'])).resolves.toEqual({ - ok: true - }) - await expect(runtime.removeRepoPRReviewers('id:repo-1', 7, ['alex'])).resolves.toEqual({ + await expect(runtime.requestRepoPRReviewers('id:repo-1', 7, ['alex'], prRepo)).resolves.toEqual( + { + ok: true + } + ) + await expect(runtime.removeRepoPRReviewers('id:repo-1', 7, ['alex'], prRepo)).resolves.toEqual({ ok: true }) expect(getIssueMock).toHaveBeenCalledWith('/remote/repo', 12, 'ssh-1') expect(listGitHubIssuesMock).toHaveBeenCalledWith('/remote/repo', 10, undefined, 'ssh-1') - expect(requestGitHubPRReviewersMock).toHaveBeenCalledWith('/remote/repo', 7, ['alex'], 'ssh-1') - expect(removeGitHubPRReviewersMock).toHaveBeenCalledWith('/remote/repo', 7, ['alex'], 'ssh-1') + expect(requestGitHubPRReviewersMock).toHaveBeenCalledWith( + '/remote/repo', + 7, + ['alex'], + 'ssh-1', + prRepo + ) + expect(removeGitHubPRReviewersMock).toHaveBeenCalledWith( + '/remote/repo', + 7, + ['alex'], + 'ssh-1', + prRepo + ) }) it('routes runtime GitHub repo identity helpers through the selected WSL project runtime', async () => { @@ -5587,14 +5602,18 @@ describe('OrcaRuntimeService', () => { } const runtime = new OrcaRuntimeService(runtimeStore as never) const localGitOptions = { wslDistro: 'Ubuntu' } - const prRepo = { owner: 'acme', repo: 'orca' } + const prRepo = { owner: 'acme', repo: 'orca', host: 'github.acme.test' } await runtime.getRepoPRForBranch('id:repo-1', 'feature/wsl', 42, 43) await runtime.getRepoWorkItem('id:repo-1', 42, 'pr') await runtime.getRepoWorkItemByOwnerRepo('id:repo-1', prRepo, 42, 'pr') await runtime.getRepoWorkItemDetails('id:repo-1', 42, 'pr') await runtime.getRepoPRChecks('id:repo-1', 42, 'head-sha', prRepo, { noCache: true }) - await runtime.rerunRepoPRChecks('id:repo-1', 42, { headSha: 'head-sha', failedOnly: true }) + await runtime.rerunRepoPRChecks('id:repo-1', 42, { + headSha: 'head-sha', + failedOnly: true, + prRepo + }) await runtime.getRepoPRCheckDetails('id:repo-1', { checkRunId: 9, workflowRunId: 8, @@ -5605,13 +5624,15 @@ describe('OrcaRuntimeService', () => { await runtime.getRepoPRComments('id:repo-1', 42, prRepo, { noCache: true }) await runtime.getRepoPRFileContents('id:repo-1', { prNumber: 42, + prRepo, path: 'src/app.ts', status: 'modified', headSha: 'head-sha', baseSha: 'base-sha' }) - await runtime.resolveRepoReviewThread('id:repo-1', 'thread-1', true) + await runtime.resolveRepoReviewThread('id:repo-1', 'thread-1', true, prRepo) await runtime.setRepoPRFileViewed('id:repo-1', { + prRepo, pullRequestId: 'PR_kw', path: 'src/app.ts', viewed: true @@ -5620,11 +5641,12 @@ describe('OrcaRuntimeService', () => { await runtime.updateRepoPRDetails('id:repo-1', 42, { body: 'New body' }, prRepo) await runtime.mergeRepoPR('id:repo-1', 42, 'squash', prRepo) await runtime.setRepoPRAutoMerge('id:repo-1', 42, true, 'squash', prRepo) - await runtime.updateRepoPRState('id:repo-1', 42, { state: 'closed' }) - await runtime.requestRepoPRReviewers('id:repo-1', 42, ['octo']) - await runtime.removeRepoPRReviewers('id:repo-1', 42, ['octo']) + await runtime.updateRepoPRState('id:repo-1', 42, { state: 'closed' }, prRepo) + await runtime.requestRepoPRReviewers('id:repo-1', 42, ['octo'], prRepo) + await runtime.removeRepoPRReviewers('id:repo-1', 42, ['octo'], prRepo) await runtime.addRepoPRReviewComment('id:repo-1', { prNumber: 42, + prRepo, body: 'Inline', commitId: 'head-sha', path: 'src/app.ts', @@ -5684,7 +5706,7 @@ describe('OrcaRuntimeService', () => { expect(rerunGitHubPRChecksMock).toHaveBeenCalledWith( TEST_REPO_PATH, 42, - { headSha: 'head-sha', failedOnly: true }, + { headSha: 'head-sha', failedOnly: true, prRepo }, null, localGitOptions ) @@ -5708,17 +5730,18 @@ describe('OrcaRuntimeService', () => { localGitOptions ) expect(getGitHubPRFileContentsMock).toHaveBeenCalledWith( - expect.objectContaining({ repoPath: TEST_REPO_PATH, localGitOptions }) + expect.objectContaining({ repoPath: TEST_REPO_PATH, localGitOptions, prRepo }) ) expect(resolveGitHubReviewThreadMock).toHaveBeenCalledWith( TEST_REPO_PATH, 'thread-1', true, null, + prRepo, localGitOptions ) expect(setGitHubPRFileViewedMock).toHaveBeenCalledWith( - expect.objectContaining({ repoPath: TEST_REPO_PATH, localGitOptions }) + expect.objectContaining({ repoPath: TEST_REPO_PATH, localGitOptions, prRepo }) ) expect(updateGitHubPRTitleMock).toHaveBeenCalledWith( TEST_REPO_PATH, @@ -5758,6 +5781,7 @@ describe('OrcaRuntimeService', () => { 42, { state: 'closed' }, null, + prRepo, localGitOptions ) expect(requestGitHubPRReviewersMock).toHaveBeenCalledWith( @@ -5765,6 +5789,7 @@ describe('OrcaRuntimeService', () => { 42, ['octo'], null, + prRepo, localGitOptions ) expect(removeGitHubPRReviewersMock).toHaveBeenCalledWith( @@ -5772,12 +5797,14 @@ describe('OrcaRuntimeService', () => { 42, ['octo'], null, + prRepo, localGitOptions ) expect(addGitHubPRReviewCommentMock).toHaveBeenCalledWith( expect.objectContaining({ repoPath: TEST_REPO_PATH, localGitOptions, + prRepo, body: 'Inline' }) ) @@ -7656,7 +7683,7 @@ describe('OrcaRuntimeService', () => { kind: 'pr-link', link: { url: 'https://github.com/acme/orca/pull/42', - slug: { owner: 'acme', repo: 'orca' }, + slug: { owner: 'acme', repo: 'orca', host: 'github.com' }, number: 42 } }, @@ -7664,7 +7691,7 @@ describe('OrcaRuntimeService', () => { kind: 'pr-link', link: { url: 'https://github.com/acme/orca/pull/43', - slug: { owner: 'acme', repo: 'orca' }, + slug: { owner: 'acme', repo: 'orca', host: 'github.com' }, number: 43 } } diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 492d7abbb17..c7271fa7f74 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -619,6 +619,7 @@ import { import type { ClearProjectItemFieldArgs, GetProjectViewTableArgs, + ListAccessibleProjectsArgs, ListAssignableUsersBySlugArgs, ListIssueTypesBySlugArgs, ListLabelsBySlugArgs, @@ -12851,7 +12852,10 @@ export class OrcaRuntimeService { const updated = this.store.updateRepo(repo.id, { upstream: { owner: existingProject.providerIdentity.owner, - repo: existingProject.providerIdentity.repo + repo: existingProject.providerIdentity.repo, + ...(existingProject.providerIdentity.host + ? { host: existingProject.providerIdentity.host } + : {}) } }) if (!updated) { @@ -13952,7 +13956,7 @@ export class OrcaRuntimeService { return isWslUncPath(scope.path) ? 'linux' : process.platform } - async getRepoSlug(repoSelector: string): Promise<{ owner: string; repo: string } | null> { + async getRepoSlug(repoSelector: string): Promise { const repo = await this.resolveRepoSelector(repoSelector) const options = this.getHostedReviewExecutionOptions(repo) return options @@ -13960,7 +13964,7 @@ export class OrcaRuntimeService { : getRepoSlug(repo.path, repo.connectionId ?? null) } - async getRepoUpstream(repoSelector: string): Promise<{ owner: string; repo: string } | null> { + async getRepoUpstream(repoSelector: string): Promise { const repo = await this.resolveRepoSelector(repoSelector) const options = this.getHostedReviewExecutionOptions(repo) return options @@ -13981,7 +13985,7 @@ export class OrcaRuntimeService { if (repo.upstream !== undefined || repo.kind === 'folder' || repo.connectionId) { continue } - let upstream: { owner: string; repo: string } | null + let upstream: GitHubOwnerRepo | null try { upstream = await getRepoUpstream(repo.path, null) } catch { @@ -14055,7 +14059,7 @@ export class OrcaRuntimeService { async getRepoWorkItemByOwnerRepo( repoSelector: string, - ownerRepo: { owner: string; repo: string }, + ownerRepo: { owner: string; repo: string; host?: string }, number: number, type: 'issue' | 'pr' ): Promise>> { @@ -14669,7 +14673,11 @@ export class OrcaRuntimeService { async rerunRepoPRChecks( repoSelector: string, prNumber: number, - options?: { headSha?: string; failedOnly?: boolean } + options?: { + headSha?: string + failedOnly?: boolean + prRepo?: GitHubOwnerRepo | null + } ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) return rerunPRChecks( @@ -14720,6 +14728,7 @@ export class OrcaRuntimeService { repoSelector: string, args: { prNumber: number + prRepo?: GitHubOwnerRepo | null path: string oldPath?: string status: GitHubPRFile['status'] @@ -14739,7 +14748,8 @@ export class OrcaRuntimeService { async resolveRepoReviewThread( repoSelector: string, threadId: string, - resolve: boolean + resolve: boolean, + prRepo?: GitHubOwnerRepo | null ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) return resolveReviewThread( @@ -14747,6 +14757,7 @@ export class OrcaRuntimeService { threadId, resolve, repo.connectionId ?? null, + prRepo ?? null, ...this.getLocalGitExecutionOptionArgs(repo) ) } @@ -14754,6 +14765,7 @@ export class OrcaRuntimeService { async setRepoPRFileViewed( repoSelector: string, args: { + prRepo?: GitHubOwnerRepo | null pullRequestId: string path: string viewed: boolean @@ -14841,7 +14853,8 @@ export class OrcaRuntimeService { async updateRepoPRState( repoSelector: string, prNumber: number, - updates: GitHubPullRequestStateUpdate + updates: GitHubPullRequestStateUpdate, + prRepo?: GitHubOwnerRepo | null ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) return updatePRState( @@ -14849,6 +14862,7 @@ export class OrcaRuntimeService { prNumber, updates, repo.connectionId ?? null, + prRepo ?? null, ...this.getLocalGitExecutionOptionArgs(repo) ) } @@ -14856,7 +14870,8 @@ export class OrcaRuntimeService { async requestRepoPRReviewers( repoSelector: string, prNumber: number, - reviewers: string[] + reviewers: string[], + prRepo?: GitHubOwnerRepo | null ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) return requestPRReviewers( @@ -14864,6 +14879,7 @@ export class OrcaRuntimeService { prNumber, reviewers, repo.connectionId ?? null, + prRepo ?? null, ...this.getLocalGitExecutionOptionArgs(repo) ) } @@ -14871,7 +14887,8 @@ export class OrcaRuntimeService { async removeRepoPRReviewers( repoSelector: string, prNumber: number, - reviewers: string[] + reviewers: string[], + prRepo?: GitHubOwnerRepo | null ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) return removePRReviewers( @@ -14879,6 +14896,7 @@ export class OrcaRuntimeService { prNumber, reviewers, repo.connectionId ?? null, + prRepo ?? null, ...this.getLocalGitExecutionOptionArgs(repo) ) } @@ -14973,8 +14991,10 @@ export class OrcaRuntimeService { ) } - async listGitHubProjects(): Promise>> { - return listAccessibleProjects() + async listGitHubProjects( + args?: ListAccessibleProjectsArgs + ): Promise>> { + return listAccessibleProjects(args) } async listGitHubLabelsBySlug( diff --git a/src/main/runtime/rpc/methods/client-ui-schemas.ts b/src/main/runtime/rpc/methods/client-ui-schemas.ts index 48d014b0de9..74efa071a5f 100644 --- a/src/main/runtime/rpc/methods/client-ui-schemas.ts +++ b/src/main/runtime/rpc/methods/client-ui-schemas.ts @@ -111,7 +111,8 @@ const GitHubProjectRef = z .object({ owner: z.string(), ownerType: z.enum(['organization', 'user']), - number: z.number().int() + number: z.number().int(), + host: z.string().optional() }) .strict() const GitHubProjectSettings = z diff --git a/src/main/runtime/rpc/methods/client-ui.test.ts b/src/main/runtime/rpc/methods/client-ui.test.ts index 44efc0d1ae2..1cf3be1337b 100644 --- a/src/main/runtime/rpc/methods/client-ui.test.ts +++ b/src/main/runtime/rpc/methods/client-ui.test.ts @@ -32,7 +32,14 @@ describe('client UI RPC methods', () => { minimaxGroupId: 'group-42', minimaxUsageModels: 'general,abab6.5', githubProjects: { - pinned: [], + pinned: [ + { + owner: 'stablyai', + ownerType: 'organization' as const, + number: 1, + host: 'ghe.example:8443' + } + ], recent: [], lastViewByProject: {}, activeProject: null @@ -68,7 +75,12 @@ describe('client UI RPC methods', () => { lastViewByProject: { 'organization:stablyai:1': { viewId: 'view-1' } }, - activeProject: { owner: 'stablyai', ownerType: 'organization', number: 1 } + activeProject: { + owner: 'stablyai', + ownerType: 'organization' as const, + number: 1, + host: 'ghe.example:8443' + } } } const runtime = { diff --git a/src/main/runtime/rpc/methods/github.test.ts b/src/main/runtime/rpc/methods/github.test.ts index 2135507c3e9..06cc04d917e 100644 --- a/src/main/runtime/rpc/methods/github.test.ts +++ b/src/main/runtime/rpc/methods/github.test.ts @@ -175,7 +175,7 @@ describe('github RPC methods', () => { repo: 'repo-1', prNumber: 7, headSha: 'abc123', - prRepo: { owner: 'acme', repo: 'widgets' }, + prRepo: { owner: 'acme', repo: 'widgets', host: 'github.acme-corp.com' }, noCache: true }) ) @@ -184,7 +184,7 @@ describe('github RPC methods', () => { 'repo-1', 7, 'abc123', - { owner: 'acme', repo: 'widgets' }, + { owner: 'acme', repo: 'widgets', host: 'github.acme-corp.com' }, { noCache: true } @@ -235,6 +235,7 @@ describe('github RPC methods', () => { makeRequest('github.prFileContents', { repo: 'repo-1', prNumber: 7, + prRepo: { owner: 'acme', repo: 'widgets', host: 'github.acme.test' }, path: 'src/app.ts', status: 'modified', headSha: 'head', @@ -244,6 +245,7 @@ describe('github RPC methods', () => { expect(runtime.getRepoPRFileContents).toHaveBeenCalledWith('repo-1', { prNumber: 7, + prRepo: { owner: 'acme', repo: 'widgets', host: 'github.acme.test' }, path: 'src/app.ts', oldPath: undefined, status: 'modified', @@ -264,11 +266,16 @@ describe('github RPC methods', () => { makeRequest('github.resolveReviewThread', { repo: 'repo-1', threadId: 'PRRT_1', - resolve: true + resolve: true, + prRepo: { owner: 'acme', repo: 'widgets', host: 'github.acme.test' } }) ) - expect(runtime.resolveRepoReviewThread).toHaveBeenCalledWith('repo-1', 'PRRT_1', true) + expect(runtime.resolveRepoReviewThread).toHaveBeenCalledWith('repo-1', 'PRRT_1', true, { + owner: 'acme', + repo: 'widgets', + host: 'github.acme.test' + }) expect(response).toMatchObject({ ok: true, result: true }) }) @@ -282,6 +289,7 @@ describe('github RPC methods', () => { const response = await dispatcher.dispatch( makeRequest('github.setPRFileViewed', { repo: 'repo-1', + prRepo: { owner: 'acme', repo: 'widgets', host: 'github.acme.test' }, pullRequestId: 'PR_kwDO123', path: 'src/app.ts', viewed: true @@ -289,6 +297,7 @@ describe('github RPC methods', () => { ) expect(runtime.setRepoPRFileViewed).toHaveBeenCalledWith('repo-1', { + prRepo: { owner: 'acme', repo: 'widgets', host: 'github.acme.test' }, pullRequestId: 'PR_kwDO123', path: 'src/app.ts', viewed: true @@ -406,19 +415,29 @@ describe('github RPC methods', () => { makeRequest('github.requestPRReviewers', { repo: 'repo-1', prNumber: 7, - reviewers: ['octo'] + reviewers: ['octo'], + prRepo: { owner: 'acme', repo: 'widgets', host: 'github.acme.test' } }) ) const removeResponse = await dispatcher.dispatch( makeRequest('github.removePRReviewers', { repo: 'repo-1', prNumber: 7, - reviewers: ['octo'] + reviewers: ['octo'], + prRepo: { owner: 'acme', repo: 'widgets', host: 'github.acme.test' } }) ) - expect(runtime.requestRepoPRReviewers).toHaveBeenCalledWith('repo-1', 7, ['octo']) - expect(runtime.removeRepoPRReviewers).toHaveBeenCalledWith('repo-1', 7, ['octo']) + expect(runtime.requestRepoPRReviewers).toHaveBeenCalledWith('repo-1', 7, ['octo'], { + owner: 'acme', + repo: 'widgets', + host: 'github.acme.test' + }) + expect(runtime.removeRepoPRReviewers).toHaveBeenCalledWith('repo-1', 7, ['octo'], { + owner: 'acme', + repo: 'widgets', + host: 'github.acme.test' + }) expect(requestResponse).toMatchObject({ ok: true, result: { ok: true } }) expect(removeResponse).toMatchObject({ ok: true, result: { ok: true } }) }) @@ -434,11 +453,21 @@ describe('github RPC methods', () => { makeRequest('github.updatePRState', { repo: 'repo-1', prNumber: 7, + prRepo: { owner: 'acme', repo: 'widgets', host: 'github.acme.test' }, updates: { state: 'closed' } }) ) - expect(runtime.updateRepoPRState).toHaveBeenCalledWith('repo-1', 7, { state: 'closed' }) + expect(runtime.updateRepoPRState).toHaveBeenCalledWith( + 'repo-1', + 7, + { state: 'closed' }, + { + owner: 'acme', + repo: 'widgets', + host: 'github.acme.test' + } + ) expect(response).toMatchObject({ ok: true, result: { ok: true } }) }) @@ -542,6 +571,7 @@ describe('github RPC methods', () => { makeRequest('github.addPRReviewComment', { repo: 'repo-1', prNumber: 7, + prRepo: { owner: 'acme', repo: 'widgets', host: 'github.acme.test' }, commitId: 'head', path: 'src/app.ts', line: 12, @@ -552,6 +582,7 @@ describe('github RPC methods', () => { expect(runtime.addRepoPRReviewComment).toHaveBeenCalledWith('repo-1', { prNumber: 7, + prRepo: { owner: 'acme', repo: 'widgets', host: 'github.acme.test' }, commitId: 'head', path: 'src/app.ts', line: 12, diff --git a/src/main/runtime/rpc/methods/github.ts b/src/main/runtime/rpc/methods/github.ts index 44375181da0..858d22b72bb 100644 --- a/src/main/runtime/rpc/methods/github.ts +++ b/src/main/runtime/rpc/methods/github.ts @@ -26,6 +26,9 @@ const WorkItem = RepoSelector.extend({ const WorkItemByOwnerRepo = RepoSelector.extend({ owner: requiredString('Missing owner'), ownerRepo: requiredString('Missing repo'), + // Why: Enterprise host identity must survive RPC parsing; Zod strips + // undeclared fields before the runtime can host-qualify gh requests. + host: OptionalString, number: z.number().int().positive(), type: z.enum(['issue', 'pr']) }) @@ -42,7 +45,10 @@ const RateLimit = z.object({ const SlugRepo = z.object({ owner: requiredString('Missing owner'), - repo: requiredString('Missing repo') + repo: requiredString('Missing repo'), + // Why: Enterprise host identity must survive RPC parsing; Zod strips + // undeclared fields before the runtime can host-qualify gh requests. + host: OptionalString }) const SlugAssignableUsers = SlugRepo.extend({ @@ -86,6 +92,7 @@ const RerunPullRequestChecks = PullRequest.extend({ const PullRequestFileContents = RepoSelector.extend({ prNumber: z.number().int().positive(), + prRepo: SlugRepo.nullable().optional(), path: requiredString('Missing file path'), oldPath: OptionalString, status: z.enum(['added', 'removed', 'modified', 'renamed', 'copied', 'changed', 'unchanged']), @@ -94,12 +101,14 @@ const PullRequestFileContents = RepoSelector.extend({ }) const PullRequestFileViewed = RepoSelector.extend({ + prRepo: SlugRepo.nullable().optional(), pullRequestId: requiredString('Missing pull request ID'), path: requiredString('Missing file path'), viewed: z.boolean() }) const ReviewThread = RepoSelector.extend({ + prRepo: SlugRepo.nullable().optional(), threadId: requiredString('Missing thread ID'), resolve: z.boolean() }) @@ -134,6 +143,7 @@ const SetPrAutoMerge = RepoSelector.extend({ const UpdatePrState = RepoSelector.extend({ prNumber: z.number().int().positive(), + prRepo: SlugRepo.nullable().optional(), updates: z.object({ state: z.enum(['open', 'closed']) }) @@ -141,11 +151,13 @@ const UpdatePrState = RepoSelector.extend({ const RequestPrReviewers = RepoSelector.extend({ prNumber: z.number().int().positive(), + prRepo: SlugRepo.nullable().optional(), reviewers: z.array(z.string()).min(1) }) const RemovePrReviewers = RepoSelector.extend({ prNumber: z.number().int().positive(), + prRepo: SlugRepo.nullable().optional(), reviewers: z.array(z.string()).min(1) }) @@ -180,6 +192,7 @@ const IssueComment = RepoSelector.extend({ const PRReviewComment = RepoSelector.extend({ prNumber: z.number().int().positive(), + prRepo: SlugRepo.nullable().optional(), commitId: requiredString('Missing PR head SHA'), path: requiredString('File path required'), line: z.number().int().positive(), @@ -201,6 +214,9 @@ const ProjectOwnerType = z.enum(['organization', 'user']) const ProjectViewTable = z.object({ owner: requiredString('Missing owner'), + // Why: Enterprise host identity must survive RPC parsing; Zod strips + // undeclared fields before the runtime can host-qualify gh requests. + host: OptionalString, ownerType: ProjectOwnerType, projectNumber: z.number().int().positive(), viewId: OptionalString, @@ -215,17 +231,26 @@ const ProjectWorkItemDetailsBySlug = SlugRepo.extend({ }) const ProjectRef = z.object({ - input: requiredString('Missing project reference') + input: requiredString('Missing project reference'), + // Why: Enterprise host identity must survive RPC parsing; Zod strips + // undeclared fields before the runtime can host-qualify gh requests. + host: OptionalString }) const ProjectViews = z.object({ owner: requiredString('Missing owner'), + // Why: Enterprise host identity must survive RPC parsing; Zod strips + // undeclared fields before the runtime can host-qualify gh requests. + host: OptionalString, ownerType: ProjectOwnerType, projectNumber: z.number().int().positive() }) const ProjectItemField = z.object({ projectId: requiredString('Missing project ID'), + // Why: Enterprise host identity must survive RPC parsing; Zod strips + // undeclared fields before the runtime can host-qualify gh requests. + host: OptionalString, itemId: requiredString('Missing item ID'), fieldId: requiredString('Missing field ID'), value: z.any() @@ -233,6 +258,9 @@ const ProjectItemField = z.object({ const ClearProjectItemField = z.object({ projectId: requiredString('Missing project ID'), + // Why: Enterprise host identity must survive RPC parsing; Zod strips + // undeclared fields before the runtime can host-qualify gh requests. + host: OptionalString, itemId: requiredString('Missing item ID'), fieldId: requiredString('Missing field ID') }) @@ -240,6 +268,9 @@ const ClearProjectItemField = z.object({ const SlugIssueUpdate = z.object({ owner: requiredString('Missing owner'), repo: requiredString('Missing repo'), + // Why: Enterprise host identity must survive RPC parsing; Zod strips + // undeclared fields before the runtime can host-qualify gh requests. + host: OptionalString, number: z.number().int().positive(), updates: IssueUpdate }) @@ -247,6 +278,9 @@ const SlugIssueUpdate = z.object({ const SlugPullRequestUpdate = z.object({ owner: requiredString('Missing owner'), repo: requiredString('Missing repo'), + // Why: Enterprise host identity must survive RPC parsing; Zod strips + // undeclared fields before the runtime can host-qualify gh requests. + host: OptionalString, number: z.number().int().positive(), updates: z.object({ state: z.enum(['open', 'closed']).optional(), @@ -258,6 +292,9 @@ const SlugPullRequestUpdate = z.object({ const SlugIssueTypeUpdate = z.object({ owner: requiredString('Missing owner'), repo: requiredString('Missing repo'), + // Why: Enterprise host identity must survive RPC parsing; Zod strips + // undeclared fields before the runtime can host-qualify gh requests. + host: OptionalString, number: z.number().int().positive(), issueTypeId: z.string().nullable() }) @@ -265,6 +302,9 @@ const SlugIssueTypeUpdate = z.object({ const SlugIssueComment = z.object({ owner: requiredString('Missing owner'), repo: requiredString('Missing repo'), + // Why: Enterprise host identity must survive RPC parsing; Zod strips + // undeclared fields before the runtime can host-qualify gh requests. + host: OptionalString, number: z.number().int().positive(), body: requiredString('Comment body required') }) @@ -272,6 +312,9 @@ const SlugIssueComment = z.object({ const SlugIssueCommentEdit = z.object({ owner: requiredString('Missing owner'), repo: requiredString('Missing repo'), + // Why: Enterprise host identity must survive RPC parsing; Zod strips + // undeclared fields before the runtime can host-qualify gh requests. + host: OptionalString, commentId: z.number().int().positive(), body: requiredString('Comment body required') }) @@ -279,6 +322,9 @@ const SlugIssueCommentEdit = z.object({ const SlugIssueCommentDelete = z.object({ owner: requiredString('Missing owner'), repo: requiredString('Missing repo'), + // Why: Enterprise host identity must survive RPC parsing; Zod strips + // undeclared fields before the runtime can host-qualify gh requests. + host: OptionalString, commentId: z.number().int().positive() }) @@ -342,7 +388,11 @@ export const GITHUB_METHODS: RpcMethod[] = [ handler: async (params, { runtime }) => runtime.getRepoWorkItemByOwnerRepo( params.repo, - { owner: params.owner, repo: params.ownerRepo }, + { + owner: params.owner, + repo: params.ownerRepo, + ...(params.host ? { host: params.host } : {}) + }, params.number, params.type ) @@ -397,7 +447,8 @@ export const GITHUB_METHODS: RpcMethod[] = [ handler: async (params, { runtime }) => runtime.rerunRepoPRChecks(params.repo, params.prNumber, { headSha: params.headSha, - failedOnly: params.failedOnly + failedOnly: params.failedOnly, + prRepo: params.prRepo ?? null }) }), defineMethod({ @@ -414,6 +465,7 @@ export const GITHUB_METHODS: RpcMethod[] = [ handler: async (params, { runtime }) => runtime.getRepoPRFileContents(params.repo, { prNumber: params.prNumber, + prRepo: params.prRepo ?? null, path: params.path, oldPath: params.oldPath, status: params.status, @@ -425,13 +477,19 @@ export const GITHUB_METHODS: RpcMethod[] = [ name: 'github.resolveReviewThread', params: ReviewThread, handler: async (params, { runtime }) => - runtime.resolveRepoReviewThread(params.repo, params.threadId, params.resolve) + runtime.resolveRepoReviewThread( + params.repo, + params.threadId, + params.resolve, + params.prRepo ?? null + ) }), defineMethod({ name: 'github.setPRFileViewed', params: PullRequestFileViewed, handler: async (params, { runtime }) => runtime.setRepoPRFileViewed(params.repo, { + prRepo: params.prRepo ?? null, pullRequestId: params.pullRequestId, path: params.path, viewed: params.viewed @@ -476,19 +534,29 @@ export const GITHUB_METHODS: RpcMethod[] = [ name: 'github.updatePRState', params: UpdatePrState, handler: async (params, { runtime }) => - runtime.updateRepoPRState(params.repo, params.prNumber, params.updates) + runtime.updateRepoPRState(params.repo, params.prNumber, params.updates, params.prRepo ?? null) }), defineMethod({ name: 'github.requestPRReviewers', params: RequestPrReviewers, handler: async (params, { runtime }) => - runtime.requestRepoPRReviewers(params.repo, params.prNumber, params.reviewers) + runtime.requestRepoPRReviewers( + params.repo, + params.prNumber, + params.reviewers, + params.prRepo ?? null + ) }), defineMethod({ name: 'github.removePRReviewers', params: RemovePrReviewers, handler: async (params, { runtime }) => - runtime.removeRepoPRReviewers(params.repo, params.prNumber, params.reviewers) + runtime.removeRepoPRReviewers( + params.repo, + params.prNumber, + params.reviewers, + params.prRepo ?? null + ) }), defineMethod({ name: 'github.createIssue', @@ -521,6 +589,7 @@ export const GITHUB_METHODS: RpcMethod[] = [ handler: async (params, { runtime }) => runtime.addRepoPRReviewComment(params.repo, { prNumber: params.prNumber, + prRepo: params.prRepo ?? null, commitId: params.commitId, path: params.path, line: params.line, @@ -544,8 +613,8 @@ export const GITHUB_METHODS: RpcMethod[] = [ }), defineMethod({ name: 'github.project.listAccessible', - params: z.object({}), - handler: async (_params, { runtime }) => runtime.listGitHubProjects() + params: z.object({ host: OptionalString }), + handler: async (params, { runtime }) => runtime.listGitHubProjects(params) }), defineMethod({ name: 'github.project.listLabelsBySlug', diff --git a/src/main/runtime/runtime-rpc.test.ts b/src/main/runtime/runtime-rpc.test.ts index 7744bbe67eb..baa6ad47ee1 100644 --- a/src/main/runtime/runtime-rpc.test.ts +++ b/src/main/runtime/runtime-rpc.test.ts @@ -2361,7 +2361,8 @@ describe('OrcaRuntimeRpcServer', () => { path: 'src/app.ts', line: 10, startLine: undefined, - body: 'please fix' + body: 'please fix', + prRepo: null }) expect(addRepoPRReviewCommentReply).toHaveBeenCalledWith('id:repo-1', { prNumber: 456, @@ -2378,19 +2379,22 @@ describe('OrcaRuntimeRpcServer', () => { oldPath: undefined, status: 'modified', headSha: 'abc123', - baseSha: 'def456' + baseSha: 'def456', + prRepo: null }) expect(rerunRepoPRChecks).toHaveBeenCalledWith('id:repo-1', 456, { headSha: 'abc123', - failedOnly: true + failedOnly: true, + prRepo: null }) - expect(resolveRepoReviewThread).toHaveBeenCalledWith('id:repo-1', 'thread-1', true) + expect(resolveRepoReviewThread).toHaveBeenCalledWith('id:repo-1', 'thread-1', true, null) expect(setRepoPRFileViewed).toHaveBeenCalledWith('id:repo-1', { pullRequestId: 'PR_kw', path: 'src/app.ts', - viewed: true + viewed: true, + prRepo: null }) - expect(requestRepoPRReviewers).toHaveBeenCalledWith('id:repo-1', 456, ['alex']) + expect(requestRepoPRReviewers).toHaveBeenCalledWith('id:repo-1', 456, ['alex'], null) expect(mergeRepoPR).toHaveBeenCalledWith('id:repo-1', 456, 'squash', null) expect(addGitLabRepoIssueComment).toHaveBeenCalledWith('id:repo-1', 123, 'done', undefined) expect(addGitLabRepoMRComment).toHaveBeenCalledWith('id:repo-1', 456, 'ship it', undefined) diff --git a/src/main/source-control/forge-provider.test.ts b/src/main/source-control/forge-provider.test.ts index 95c5c9d2676..884b72f90de 100644 --- a/src/main/source-control/forge-provider.test.ts +++ b/src/main/source-control/forge-provider.test.ts @@ -81,6 +81,14 @@ import { getForgeProviderForRepository } from './forge-provider' +import { _resetOriginGitHubApiRepositoryCache } from '../github/github-api-repository' + +// The origin-repository cache is module-level state; reset it so slugs +// resolved by one test cannot leak into the next. +beforeEach(() => { + _resetOriginGitHubApiRepositoryCache() +}) + describe('forge provider interface', () => { beforeEach(() => { createGitHubPullRequestMock.mockReset() @@ -113,8 +121,9 @@ describe('forge provider interface', () => { // slug parsing returns null. Detection must claim it via the enterprise // resolver instead of falling through to Gitea's demand for ORCA_GITEA_TOKEN. getProjectSlugMock.mockResolvedValue(null) - getRepoSlugMock.mockResolvedValue(null) - getEnterpriseGitHubRepoSlugMock.mockResolvedValue({ + // Why: getRepoSlug resolves hosted identities itself now — a GHES remote + // comes back host-qualified instead of null + separate enterprise fallback. + getRepoSlugMock.mockResolvedValue({ owner: 'team', repo: 'orca', host: 'github.acme-corp.com' diff --git a/src/main/source-control/forge-provider.ts b/src/main/source-control/forge-provider.ts index 1a6ea4a4368..d4967486ca0 100644 --- a/src/main/source-control/forge-provider.ts +++ b/src/main/source-control/forge-provider.ts @@ -22,7 +22,6 @@ import { } from '../gitea/client' import { createGiteaPullRequest } from '../gitea/pull-request-creation' import { createGitHubPullRequest, getPRForBranchOutcome, getRepoSlug } from '../github/client' -import { getEnterpriseGitHubRepoSlug } from '../github/github-enterprise-repository' import { getMergeRequest, getMergeRequestForBranchOrThrow, getProjectSlug } from '../gitlab/client' import { createGitLabMergeRequest } from '../gitlab/merge-request-creation' import { @@ -126,25 +125,11 @@ function unwrapGitHubPRForBranchOutcome( const gitHubForgeProvider = { id: 'github', supportsReviewCreation: true, - resolveRepository: async (context) => { - const slug = await getRepoSlug( - context.repoPath, - context.connectionId, - ...hostedReviewExecutionArgs(context) - ) - if (slug) { - return slug - } - // Why: GHES remotes live on a custom host, so github.com-only slug parsing - // misses them and detection would otherwise fall through to Gitea (#8312). - // Claim the repo when gh is authenticated to its host — the same signal - // GitLab uses for self-hosted instances. - return getEnterpriseGitHubRepoSlug( - context.repoPath, - context.connectionId, - ...hostedReviewExecutionArgs(context) - ) - }, + // Why: getRepoSlug resolves hosted identities — GHES remotes are claimed when + // gh is authenticated to their host (the same signal GitLab uses for + // self-hosted instances), so detection never falls through to Gitea (#8312). + resolveRepository: async (context) => + getRepoSlug(context.repoPath, context.connectionId, ...hostedReviewExecutionArgs(context)), async getReviewForBranch(input) { const fallbackReviewNumber = input.linkedReviewNumber == null ? (input.fallbackReviewNumber ?? null) : null diff --git a/src/main/source-control/hosted-review-creation-eligibility.test.ts b/src/main/source-control/hosted-review-creation-eligibility.test.ts index 484ede6cbff..f2fa711ee54 100644 --- a/src/main/source-control/hosted-review-creation-eligibility.test.ts +++ b/src/main/source-control/hosted-review-creation-eligibility.test.ts @@ -117,6 +117,14 @@ vi.mock('./hosted-review', () => ({ import { createHostedReview, getHostedReviewCreationEligibility } from './hosted-review-creation' +import { _resetOriginGitHubApiRepositoryCache } from '../github/github-api-repository' + +// The origin-repository cache is module-level state; reset it so slugs +// resolved by one test cannot leak into the next. +beforeEach(() => { + _resetOriginGitHubApiRepositoryCache() +}) + function resetMocks(): void { for (const mock of [ createGitHubPullRequestMock, @@ -155,10 +163,17 @@ function mockGitHubProvider(): void { // resolver claims the repo and reports the host for the gh auth probe (#8312). function mockGitHubEnterpriseProvider(): void { getProjectSlugMock.mockResolvedValue(null) - getRepoSlugMock.mockResolvedValue(null) + // Why: getRepoSlug resolves hosted identities itself now — a GHES remote + // comes back host-qualified instead of null + separate enterprise fallback. + getRepoSlugMock.mockResolvedValue({ + owner: 'acme', + repo: 'orca', + host: 'github.acme-corp.com' + }) getBitbucketRepoSlugMock.mockResolvedValue(null) getAzureDevOpsRepoSlugMock.mockResolvedValue(null) getGiteaRepoSlugMock.mockResolvedValue(null) + // The auth gate still keys off the enterprise resolver (authed-GHES signal). getEnterpriseGitHubRepoSlugMock.mockResolvedValue({ owner: 'acme', repo: 'orca', diff --git a/src/main/source-control/hosted-review-creation.test.ts b/src/main/source-control/hosted-review-creation.test.ts index 04c751d7c3b..cb593ae6a00 100644 --- a/src/main/source-control/hosted-review-creation.test.ts +++ b/src/main/source-control/hosted-review-creation.test.ts @@ -117,6 +117,14 @@ vi.mock('./hosted-review', () => ({ import { createHostedReview } from './hosted-review-creation' +import { _resetOriginGitHubApiRepositoryCache } from '../github/github-api-repository' + +// The origin-repository cache is module-level state; reset it so slugs +// resolved by one test cannot leak into the next. +beforeEach(() => { + _resetOriginGitHubApiRepositoryCache() +}) + function resetMocks(): void { for (const mock of [ createGitHubPullRequestMock, @@ -155,10 +163,17 @@ function mockGitHubProvider(): void { // resolver claims the repo and reports the host for the gh auth probe (#8312). function mockGitHubEnterpriseProvider(): void { getProjectSlugMock.mockResolvedValue(null) - getRepoSlugMock.mockResolvedValue(null) + // Why: getRepoSlug resolves hosted identities itself now — a GHES remote + // comes back host-qualified instead of null + separate enterprise fallback. + getRepoSlugMock.mockResolvedValue({ + owner: 'acme', + repo: 'orca', + host: 'github.acme-corp.com' + }) getBitbucketRepoSlugMock.mockResolvedValue(null) getAzureDevOpsRepoSlugMock.mockResolvedValue(null) getGiteaRepoSlugMock.mockResolvedValue(null) + // The auth gate still keys off the enterprise resolver (authed-GHES signal). getEnterpriseGitHubRepoSlugMock.mockResolvedValue({ owner: 'acme', repo: 'orca', @@ -393,7 +408,7 @@ describe('createHostedReview', () => { ) expect(ghExecFileAsyncMock).toHaveBeenCalledWith( ['auth', 'status', '--hostname', 'github.com'], - { cwd: '/repo', wslDistro: 'Ubuntu' } + { cwd: '/repo', wslDistro: 'Ubuntu', host: 'github.com' } ) expect(createGitHubPullRequestMock).toHaveBeenCalledWith( '/repo', @@ -578,7 +593,7 @@ describe('createHostedReview', () => { expect(getUpstreamStatusMock).not.toHaveBeenCalled() expect(ghExecFileAsyncMock).toHaveBeenCalledWith( ['auth', 'status', '--hostname', 'github.com'], - {} + { host: 'github.com' } ) expect(createGitHubPullRequestMock).toHaveBeenCalledWith( '/remote/repo', diff --git a/src/main/source-control/hosted-review-creation.ts b/src/main/source-control/hosted-review-creation.ts index 9d95f6ddf02..618ef9bb684 100644 --- a/src/main/source-control/hosted-review-creation.ts +++ b/src/main/source-control/hosted-review-creation.ts @@ -68,9 +68,13 @@ async function isGitHubAuthenticated( } await acquire() try { + // Why: `host` scopes any rate-limit breaker trip to github.com — the host + // this probe actually targets — instead of a GH_HOST-derived scope. await ghExecFileAsync( ['auth', 'status', '--hostname', 'github.com'], - connectionId ? {} : { cwd: repoPath, ...getHostedReviewLocalGitOptions(options) } + connectionId + ? { host: 'github.com' } + : { cwd: repoPath, ...getHostedReviewLocalGitOptions(options), host: 'github.com' } ) return true } catch { diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index f10ba519bd9..d85797fdc1d 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -263,6 +263,7 @@ import type { GetProjectViewTableResult, GitHubProjectCommentMutationResult, GitHubProjectMutationResult, + ListAccessibleProjectsArgs, ListAccessibleProjectsResult, ListAssignableUsersBySlugArgs, ListAssignableUsersBySlugResult, @@ -1489,11 +1490,11 @@ export type PreloadApi = { repoSlug: (args: { repoPath: string repoId?: string - }) => Promise<{ owner: string; repo: string } | null> + }) => Promise<{ owner: string; repo: string; host?: string } | null> repoUpstream: (args: { repoPath: string repoId?: string - }) => Promise<{ owner: string; repo: string } | null> + }) => Promise<{ owner: string; repo: string; host?: string } | null> prForBranch: (args: { repoPath: string repoId?: string @@ -1532,6 +1533,7 @@ export type PreloadApi = { repoId?: string owner: string repo: string + host?: string number: number type: 'issue' | 'pr' }) => Promise | null> @@ -1550,6 +1552,7 @@ export type PreloadApi = { prFileContents: ( args: GitHubRepoSelectorArgs & { prNumber: number + prRepo?: GitHubOwnerRepo | null path: string oldPath?: string status: GitHubPRFile['status'] @@ -1603,6 +1606,7 @@ export type PreloadApi = { prNumber: number headSha?: string failedOnly?: boolean + prRepo?: GitHubOwnerRepo | null } ) => Promise<{ ok: true; count: number } | { ok: false; error: string }> prComments: (args: { @@ -1619,10 +1623,12 @@ export type PreloadApi = { sourceContext?: TaskSourceContext | null threadId: string resolve: boolean + prRepo?: GitHubOwnerRepo | null }) => Promise setPRFileViewed: ( args: GitHubRepoSelectorArgs & { prNumber: number + prRepo?: GitHubOwnerRepo | null pullRequestId: string path: string viewed: boolean @@ -1654,18 +1660,21 @@ export type PreloadApi = { args: GitHubRepoSelectorArgs & { prNumber: number updates: { state: 'open' | 'closed' } + prRepo?: GitHubOwnerRepo | null } ) => Promise<{ ok: true } | { ok: false; error: string }> requestPRReviewers: ( args: GitHubRepoSelectorArgs & { prNumber: number reviewers: string[] + prRepo?: GitHubOwnerRepo | null } ) => Promise<{ ok: true } | { ok: false; error: string }> removePRReviewers: ( args: GitHubRepoSelectorArgs & { prNumber: number reviewers: string[] + prRepo?: GitHubOwnerRepo | null } ) => Promise<{ ok: true } | { ok: false; error: string }> updateIssue: ( @@ -1728,9 +1737,11 @@ export type PreloadApi = { */ rateLimit: (args?: { force?: boolean }) => Promise /** Explains scope_missing ProjectV2 failures — notably a shell `GITHUB_TOKEN` shadowing the keyring credential, where `gh auth refresh` is a no-op. */ - diagnoseAuth: () => Promise + diagnoseAuth: (args?: { host?: string }) => Promise // ── ProjectV2 (GitHub Projects) ───────────────────────────────── - listAccessibleProjects: () => Promise + listAccessibleProjects: ( + args?: ListAccessibleProjectsArgs + ) => Promise resolveProjectRef: (args: ResolveProjectRefArgs) => Promise listProjectViews: (args: ListProjectViewsArgs) => Promise getProjectViewTable: (args: GetProjectViewTableArgs) => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 0021f55dfa7..04181c3b812 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -30,6 +30,7 @@ import type { GitHubAssignableUser, GitHubCommentResult, GitHubCreateIssueResult, + GitHubOwnerRepo, GitHubWorkItem, JiraProjectStatusOrder, GitPushTarget, @@ -113,6 +114,7 @@ import type { GetProjectViewTableResult, GitHubProjectCommentMutationResult, GitHubProjectMutationResult, + ListAccessibleProjectsArgs, ListAccessibleProjectsResult, ListAssignableUsersBySlugArgs, ListAssignableUsersBySlugResult, @@ -1199,6 +1201,7 @@ const api = { repoId?: string owner: string repo: string + host?: string number: number type: 'issue' | 'pr' }): Promise => ipcRenderer.invoke('gh:workItemByOwnerRepo', args), @@ -1223,6 +1226,7 @@ const api = { repoId?: string sourceContext?: TaskSourceContext | null prNumber: number + prRepo?: GitHubOwnerRepo | null path: string oldPath?: string status: string @@ -1265,7 +1269,7 @@ const api = { sourceContext?: TaskSourceContext | null prNumber: number headSha?: string - prRepo?: { owner: string; repo: string } | null + prRepo?: GitHubOwnerRepo | null noCache?: boolean }): Promise => ipcRenderer.invoke('gh:prChecks', args), @@ -1277,7 +1281,7 @@ const api = { workflowRunId?: number checkName?: string url?: string | null - prRepo?: { owner: string; repo: string } | null + prRepo?: GitHubOwnerRepo | null }): Promise => ipcRenderer.invoke('gh:prCheckDetails', args), rerunPRChecks: (args: { @@ -1287,6 +1291,7 @@ const api = { prNumber: number headSha?: string failedOnly?: boolean + prRepo?: GitHubOwnerRepo | null }): Promise<{ ok: true; count: number } | { ok: false; error: string }> => ipcRenderer.invoke('gh:rerunPRChecks', args), @@ -1295,7 +1300,7 @@ const api = { repoId?: string sourceContext?: TaskSourceContext | null prNumber: number - prRepo?: { owner: string; repo: string } | null + prRepo?: GitHubOwnerRepo | null noCache?: boolean }): Promise => ipcRenderer.invoke('gh:prComments', args), @@ -1305,6 +1310,7 @@ const api = { sourceContext?: TaskSourceContext | null threadId: string resolve: boolean + prRepo?: GitHubOwnerRepo | null }): Promise => ipcRenderer.invoke('gh:resolveReviewThread', args), setPRFileViewed: (args: { @@ -1312,6 +1318,7 @@ const api = { repoId?: string sourceContext?: TaskSourceContext | null prNumber: number + prRepo?: GitHubOwnerRepo | null pullRequestId: string path: string viewed: boolean @@ -1322,7 +1329,7 @@ const api = { repoId?: string prNumber: number title: string - prRepo?: { owner: string; repo: string } | null + prRepo?: GitHubOwnerRepo | null }): Promise => ipcRenderer.invoke('gh:updatePRTitle', args), mergePR: (args: { @@ -1331,7 +1338,7 @@ const api = { sourceContext?: TaskSourceContext | null prNumber: number method?: 'merge' | 'squash' | 'rebase' - prRepo?: { owner: string; repo: string } | null + prRepo?: GitHubOwnerRepo | null }): Promise<{ ok: true } | { ok: false; error: string }> => ipcRenderer.invoke('gh:mergePR', args), @@ -1342,7 +1349,7 @@ const api = { prNumber: number enabled: boolean method?: 'merge' | 'squash' | 'rebase' - prRepo?: { owner: string; repo: string } | null + prRepo?: GitHubOwnerRepo | null }): Promise<{ ok: true } | { ok: false; error: string }> => ipcRenderer.invoke('gh:setPRAutoMerge', args), @@ -1352,6 +1359,7 @@ const api = { sourceContext?: TaskSourceContext | null prNumber: number updates: { state: 'open' | 'closed' } + prRepo?: GitHubOwnerRepo | null }): Promise<{ ok: true } | { ok: false; error: string }> => ipcRenderer.invoke('gh:updatePRState', args), @@ -1361,6 +1369,7 @@ const api = { sourceContext?: TaskSourceContext | null prNumber: number reviewers: string[] + prRepo?: GitHubOwnerRepo | null }): Promise<{ ok: true } | { ok: false; error: string }> => ipcRenderer.invoke('gh:requestPRReviewers', args), @@ -1370,6 +1379,7 @@ const api = { sourceContext?: TaskSourceContext | null prNumber: number reviewers: string[] + prRepo?: GitHubOwnerRepo | null }): Promise<{ ok: true } | { ok: false; error: string }> => ipcRenderer.invoke('gh:removePRReviewers', args), @@ -1389,7 +1399,7 @@ const api = { number: number body: string type?: 'issue' | 'pr' - prRepo?: { owner: string; repo: string } | null + prRepo?: GitHubOwnerRepo | null }): Promise => ipcRenderer.invoke('gh:addIssueComment', args), addPRReviewCommentReply: (args: { @@ -1402,7 +1412,7 @@ const api = { threadId?: string path?: string line?: number - prRepo?: { owner: string; repo: string } | null + prRepo?: GitHubOwnerRepo | null }): Promise => ipcRenderer.invoke('gh:addPRReviewCommentReply', args), addPRReviewComment: (args: { @@ -1410,6 +1420,7 @@ const api = { repoId?: string sourceContext?: TaskSourceContext | null prNumber: number + prRepo?: GitHubOwnerRepo | null commitId: string path: string line: number @@ -1454,11 +1465,14 @@ const api = { rateLimit: (args?: { force?: boolean }): Promise => ipcRenderer.invoke('gh:rateLimit', args), - diagnoseAuth: (): Promise => ipcRenderer.invoke('gh:diagnoseAuth'), + diagnoseAuth: (args?: { host?: string }): Promise => + ipcRenderer.invoke('gh:diagnoseAuth', args), // ── ProjectV2 (GitHub Projects) ─────────────────────────────────── - listAccessibleProjects: (): Promise => - ipcRenderer.invoke('gh:listAccessibleProjects'), + listAccessibleProjects: ( + args?: ListAccessibleProjectsArgs + ): Promise => + ipcRenderer.invoke('gh:listAccessibleProjects', args), resolveProjectRef: (args: ResolveProjectRefArgs): Promise => ipcRenderer.invoke('gh:resolveProjectRef', args), listProjectViews: (args: ListProjectViewsArgs): Promise => diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx index 30c4a491bd4..815c8dac7e4 100644 --- a/src/renderer/src/components/GitHubItemDialog.tsx +++ b/src/renderer/src/components/GitHubItemDialog.tsx @@ -187,6 +187,8 @@ import { GITHUB_PR_MERGE_METHOD_LABELS, resolveGitHubPRMergeMethods } from '../../../shared/github-pr-merge-methods' +import { githubProjectHost } from '../../../shared/github-project-identity' +import { githubRepoIdentityKey } from '../../../shared/github-repository-identity-key' import { findGithubIssueWorkspaceAttachment, getGithubWorkItemWorkspaceAttachmentLabel @@ -227,18 +229,18 @@ import { type TaskPageGitHubCloseAction } from '@/components/task-page-github-status-actions' -// Why: the dialog lacks owner/repo context, so recover it from the canonical URL present on every item; non-GitHub hosts return null (matches the indicator's suppression rule). +// Why: the dialog lacks repository context, so recover its host-aware identity from the canonical item URL. function parseOwnerRepoFromItemUrl(url: string): GitHubOwnerRepo | null { try { const parsed = new URL(url) - if (parsed.hostname !== 'github.com') { + if ((parsed.protocol !== 'https:' && parsed.protocol !== 'http:') || !parsed.host) { return null } const segments = parsed.pathname.split('/').filter(Boolean) if (segments.length < 2) { return null } - return { owner: segments[0], repo: segments[1] } + return { owner: segments[0], repo: segments[1], host: parsed.host } } catch { return null } @@ -297,6 +299,8 @@ function normalizeItemDialogTab( export type GitHubItemDialogProjectOrigin = { owner: string repo: string + /** GitHub host (e.g. GHES); absent means github.com. */ + host?: string number: number type: 'issue' | 'pr' projectId: string @@ -304,6 +308,25 @@ export type GitHubItemDialogProjectOrigin = { cacheKey: string } +// Why: every PR mutation needs the same host-pinned identity so process GH_HOST +// cannot redirect a github.com item or a Project row to the wrong server. +function resolvePullRequestRepo( + item: Pick, + projectOrigin?: Pick +): GitHubOwnerRepo | null { + const repo = + item.prRepo ?? + (projectOrigin + ? { + owner: projectOrigin.owner, + repo: projectOrigin.repo, + host: projectOrigin.host + } + : null) ?? + parseOwnerRepoFromItemUrl(item.url) + return repo ? { ...repo, host: githubProjectHost(repo.host) } : null +} + type GitHubItemDialogProps = { workItem: GitHubWorkItem | null repoPath: string | null @@ -512,7 +535,8 @@ function PRAssigneesPanel({ slugOwner, slugRepo, assigneeLogins, - sourceSettings + sourceSettings, + projectOrigin?.host ?? assigneeSlug?.host ) const repoAssigneesByPath = useRepoAssignees(repoPath, item.repoId, sourceSettings) const repoAssignees = slugOwner && slugRepo ? repoAssigneesBySlug : repoAssigneesByPath @@ -683,12 +707,14 @@ function PRReviewersPanel({ loading, repoPath, sourceContext, + projectOrigin, onReviewersRequested }: { item: GitHubWorkItem loading: boolean repoPath: string | null sourceContext?: TaskSourceContext | null + projectOrigin?: GitHubItemDialogProjectOrigin onReviewersRequested: (reviewRequests: GitHubAssignableUser[]) => void }): React.JSX.Element { const [open, setOpen] = useState(false) @@ -788,19 +814,23 @@ function PRReviewersPanel({ return Array.from(byLogin.values()) }, [item.author, item.latestReviews, localReviewRequests]) - const reviewSlug = useMemo(() => parseOwnerRepoFromItemUrl(item.url), [item.url]) + const reviewRepo = useMemo( + () => resolvePullRequestRepo(item, projectOrigin), + [item, projectOrigin] + ) const reviewerMetadataBySlug = useRepoAssigneesBySlug( - open && reviewSlug ? reviewSlug.owner : null, - open && reviewSlug ? reviewSlug.repo : null, + open && reviewRepo ? reviewRepo.owner : null, + open && reviewRepo ? reviewRepo.repo : null, reviewerSeedUsers.map((user) => user.login), - sourceSettings + sourceSettings, + reviewRepo?.host ) const reviewerMetadataByPath = useRepoAssignees( - open && !reviewSlug ? repoPath : null, - open && !reviewSlug ? item.repoId : null, + open && !reviewRepo ? repoPath : null, + open && !reviewRepo ? item.repoId : null, sourceSettings ) - const reviewerMetadata = reviewSlug ? reviewerMetadataBySlug : reviewerMetadataByPath + const reviewerMetadata = reviewRepo ? reviewerMetadataBySlug : reviewerMetadataByPath const displayItem = { ...item, reviewRequests: localReviewRequests } const reviewers = getGitHubPRReviewerRows(displayItem) const authorLogin = item.author?.toLowerCase() ?? null @@ -930,7 +960,12 @@ function PRReviewersPanel({ ? await callRuntimeRpc<{ ok: boolean; error?: string }>( target, 'github.requestPRReviewers', - { repo: runtimeRepo, prNumber: item.number, reviewers: logins }, + { + repo: runtimeRepo, + prNumber: item.number, + reviewers: logins, + prRepo: reviewRepo + }, { timeoutMs: 30_000 } ) : await window.api.gh.requestPRReviewers({ @@ -938,7 +973,8 @@ function PRReviewersPanel({ repoId: item.repoId, sourceContext, prNumber: item.number, - reviewers: logins + reviewers: logins, + prRepo: reviewRepo }) if (!reviewerPanelMountedRef.current) { return @@ -1021,7 +1057,12 @@ function PRReviewersPanel({ ? await callRuntimeRpc<{ ok: boolean; error?: string }>( target, 'github.removePRReviewers', - { repo: runtimeRepo, prNumber: item.number, reviewers: logins }, + { + repo: runtimeRepo, + prNumber: item.number, + reviewers: logins, + prRepo: reviewRepo + }, { timeoutMs: 30_000 } ) : await window.api.gh.removePRReviewers({ @@ -1029,7 +1070,8 @@ function PRReviewersPanel({ repoId: item.repoId, sourceContext, prNumber: item.number, - reviewers: logins + reviewers: logins, + prRepo: reviewRepo }) if (!reviewerPanelMountedRef.current) { return @@ -1646,6 +1688,7 @@ function getPRFileContentCacheKey(args: { repoId: string sourceContext?: TaskSourceContext | null prNumber: number + prRepo?: GitHubOwnerRepo | null file: GitHubPRFile headSha: string baseSha: string @@ -1659,6 +1702,7 @@ function getPRFileContentCacheKey(args: { repositoryKey, sourceKey, args.prNumber, + args.prRepo ? githubRepoIdentityKey(args.prRepo) : '', args.file.path, args.file.oldPath ?? '', args.file.status, @@ -1672,6 +1716,7 @@ function loadPRFileContents(args: { repoId: string sourceContext?: TaskSourceContext | null prNumber: number + prRepo?: GitHubOwnerRepo | null file: GitHubPRFile headSha: string baseSha: string @@ -1692,6 +1737,7 @@ function loadPRFileContents(args: { { repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId), prNumber: args.prNumber, + prRepo: args.prRepo ?? null, path: args.file.path, oldPath: args.file.oldPath, status: args.file.status, @@ -1705,6 +1751,7 @@ function loadPRFileContents(args: { repoId: args.repoId, sourceContext: args.sourceContext, prNumber: args.prNumber, + prRepo: args.prRepo ?? null, path: args.file.path, oldPath: args.file.oldPath, status: args.file.status, @@ -1737,6 +1784,7 @@ function addIssueCommentForRepo(args: { number: number body: string type?: 'issue' | 'pr' + prRepo?: GitHubOwnerRepo | null }): Promise>> { const runtimeHost = getGitHubSourceRuntimeHost(args.sourceContext) if (runtimeHost) { @@ -1746,7 +1794,8 @@ function addIssueCommentForRepo(args: { { repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId), number: args.number, - body: args.body + body: args.body, + prRepo: args.prRepo ?? null }, { timeoutMs: 30_000 } ).then((result) => { @@ -1771,7 +1820,8 @@ function addIssueCommentForRepo(args: { sourceContext: args.sourceContext, number: args.number, body: args.body, - type: args.type + type: args.type, + prRepo: args.prRepo ?? null }) } @@ -1780,6 +1830,7 @@ function addPRReviewCommentForRepo(args: { repoPath: string sourceContext?: TaskSourceContext | null prNumber: number + prRepo?: GitHubOwnerRepo | null commitId: string path: string line: number @@ -1794,6 +1845,7 @@ function addPRReviewCommentForRepo(args: { { repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId), prNumber: args.prNumber, + prRepo: args.prRepo ?? null, commitId: args.commitId, path: args.path, line: args.line, @@ -1822,6 +1874,7 @@ function addPRReviewCommentForRepo(args: { repoId: args.repoId, sourceContext: args.sourceContext, prNumber: args.prNumber, + prRepo: args.prRepo ?? null, commitId: args.commitId, path: args.path, line: args.line, @@ -1835,6 +1888,7 @@ function addPRReviewCommentReplyForRepo(args: { repoPath: string sourceContext?: TaskSourceContext | null prNumber: number + prRepo?: GitHubOwnerRepo | null commentId: number body: string threadId?: string @@ -1849,6 +1903,7 @@ function addPRReviewCommentReplyForRepo(args: { { repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId), prNumber: args.prNumber, + prRepo: args.prRepo ?? null, commentId: args.commentId, body: args.body, threadId: args.threadId, @@ -1877,6 +1932,7 @@ function addPRReviewCommentReplyForRepo(args: { repoId: args.repoId, sourceContext: args.sourceContext, prNumber: args.prNumber, + prRepo: args.prRepo ?? null, commentId: args.commentId, body: args.body, threadId: args.threadId, @@ -1913,6 +1969,7 @@ function setPRFileViewedForRepo(args: { repoPath: string sourceContext?: TaskSourceContext | null prNumber: number + prRepo?: GitHubOwnerRepo | null pullRequestId: string path: string viewed: boolean @@ -1924,6 +1981,7 @@ function setPRFileViewedForRepo(args: { 'github.setPRFileViewed', { repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId), + prRepo: args.prRepo ?? null, pullRequestId: args.pullRequestId, path: args.path, viewed: args.viewed @@ -1950,6 +2008,7 @@ function setPRFileViewedForRepo(args: { repoId: args.repoId, sourceContext: args.sourceContext, prNumber: args.prNumber, + prRepo: args.prRepo ?? null, pullRequestId: args.pullRequestId, path: args.path, viewed: args.viewed @@ -2109,6 +2168,7 @@ type PRFilesCombinedDiffViewerProps = { repoId: string sourceContext?: TaskSourceContext | null prNumber: number + prRepo?: GitHubOwnerRepo | null prUrl: string headSha: string | undefined baseSha: string | undefined @@ -2124,6 +2184,7 @@ function PRFilesCombinedDiffViewer({ repoId, sourceContext, prNumber, + prRepo, prUrl, headSha, baseSha, @@ -2200,11 +2261,12 @@ function PRFilesCombinedDiffViewer({ JSON.stringify({ repoId, prNumber, + prRepo: prRepo ? githubRepoIdentityKey(prRepo) : null, headSha: headSha ?? null, baseSha: baseSha ?? null, files: diffEntrySignature }), - [baseSha, diffEntrySignature, headSha, prNumber, repoId] + [baseSha, diffEntrySignature, headSha, prNumber, prRepo, repoId] ) const [sections, setSections] = useState([]) const [sideBySide, setSideBySide] = useState(false) @@ -2298,6 +2360,7 @@ function PRFilesCombinedDiffViewer({ repoId, sourceContext, prNumber, + prRepo, file, headSha, baseSha @@ -2346,7 +2409,7 @@ function PRFilesCombinedDiffViewer({ ) }) }, - [baseSha, fileByPath, headSha, prNumber, repoId, repoPath, sourceContext] + [baseSha, fileByPath, headSha, prNumber, prRepo, repoId, repoPath, sourceContext] ) const retrySection = useCallback( @@ -2491,6 +2554,7 @@ function PRFilesCombinedDiffViewer({ repoId, sourceContext, prNumber, + prRepo, commitId: headSha, path: section.path, line: lineNumber, @@ -2513,7 +2577,7 @@ function PRFilesCombinedDiffViewer({ ) return true }, - [headSha, onCommentAdded, prNumber, repoId, repoPath, sourceContext] + [headSha, onCommentAdded, prNumber, prRepo, repoId, repoPath, sourceContext] ) const renderViewedCheckbox = useCallback( @@ -2661,6 +2725,7 @@ function CommentCodeContext({ repoId, sourceContext, prNumber, + prRepo, files, headSha, baseSha @@ -2670,6 +2735,7 @@ function CommentCodeContext({ repoId: string sourceContext?: TaskSourceContext | null prNumber: number + prRepo?: GitHubOwnerRepo | null files: GitHubPRFile[] headSha: string | undefined baseSha: string | undefined @@ -2693,7 +2759,16 @@ function CommentCodeContext({ return } let cancelled = false - loadPRFileContents({ repoPath, repoId, sourceContext, prNumber, file, headSha, baseSha }) + loadPRFileContents({ + repoPath, + repoId, + sourceContext, + prNumber, + prRepo, + file, + headSha, + baseSha + }) .then((result) => { if (!cancelled) { setContents(result) @@ -2707,7 +2782,7 @@ function CommentCodeContext({ return () => { cancelled = true } - }, [baseSha, file, headSha, line, prNumber, repoId, repoPath, sourceContext]) + }, [baseSha, file, headSha, line, prNumber, prRepo, repoId, repoPath, sourceContext]) const resolvedContextExpansionState = resolveCommentCodeContextExpansionState( contextExpansionState, @@ -3098,6 +3173,7 @@ function ConversationTab({ } const bodySlug = useMemo(() => parseOwnerRepoFromItemUrl(item.url), [item.url]) + const prRepo = useMemo(() => resolvePullRequestRepo(item, projectOrigin), [item, projectOrigin]) const markdownGitHubRepo = useMemo( () => (projectOrigin ? { owner: projectOrigin.owner, repo: projectOrigin.repo } : bodySlug), [bodySlug, projectOrigin] @@ -3171,6 +3247,7 @@ function ConversationTab({ repoId: item.repoId, sourceContext, prNumber: item.number, + prRepo, commentId: comment.id, body: replyBody, threadId: comment.threadId, @@ -3183,7 +3260,8 @@ function ConversationTab({ sourceContext, number: item.number, body: `@${comment.author} ${replyBody}`, - type: item.type + type: item.type, + prRepo }) if (!result.ok) { @@ -3204,6 +3282,7 @@ function ConversationTab({ item.repoId, item.type, onCommentAdded, + prRepo, repoPath, sourceContext ] @@ -3234,6 +3313,7 @@ function ConversationTab({ loading={loading} repoPath={repoPath} sourceContext={sourceContext} + projectOrigin={projectOrigin} onReviewersRequested={onReviewersRequested} />
- {browseError ? : null} + {browseError ? : null} {!browseError && partialFailures.length > 0 ? ( ) : null} @@ -439,11 +484,9 @@ export default function ProjectPicker({ activeProject, onSelect }: Props): React )} > {projectSettings.pinned.map((p) => { - const key = `${p.ownerType}:${p.owner}:${p.number}` + const key = githubProjectIdentityKey(p) const knownGood = projectSettings.lastViewByProject[key]?.viewId != null - const match = browseProjects.find( - (bp) => `${bp.ownerType}:${bp.owner}:${bp.number}` === key - ) + const match = browseProjects.find((bp) => githubProjectIdentityKey(bp) === key) return ( { await updateProjectSettings((prev) => ({ ...prev, - pinned: prev.pinned.filter( - (x) => `${x.ownerType}:${x.owner}:${x.number}` !== key - ) + pinned: prev.pinned.filter((x) => githubProjectIdentityKey(x) !== key) })) }} /> @@ -482,16 +524,13 @@ export default function ProjectPicker({ activeProject, onSelect }: Props): React .filter( (r) => !projectSettings.pinned.some( - (p) => - p.ownerType === r.ownerType && - p.owner === r.owner && - p.number === r.number + (p) => githubProjectIdentityKey(p) === githubProjectIdentityKey(r) ) ) .map((r) => { - const key = `${r.ownerType}:${r.owner}:${r.number}` + const key = githubProjectIdentityKey(r) const match = browseProjects.find( - (bp) => `${bp.ownerType}:${bp.owner}:${bp.number}` === key + (bp) => githubProjectIdentityKey(bp) === key ) const pinnable = projectSettings.lastViewByProject[key]?.viewId != null return ( @@ -505,7 +544,12 @@ export default function ProjectPicker({ activeProject, onSelect }: Props): React ...prev, pinned: [ ...prev.pinned, - { owner: r.owner, ownerType: r.ownerType, number: r.number } + { + owner: r.owner, + ownerType: r.ownerType, + number: r.number, + host: githubProjectHost(r.host) + } ].slice(0, 20) })) }} @@ -514,6 +558,7 @@ export default function ProjectPicker({ activeProject, onSelect }: Props): React owner: r.owner, ownerType: r.ownerType, number: r.number, + host: githubProjectHost(r.host), title: match?.title }) } @@ -546,7 +591,7 @@ export default function ProjectPicker({ activeProject, onSelect }: Props): React ) : null} {filteredBrowse.map((p) => ( @@ -554,6 +599,7 @@ export default function ProjectPicker({ activeProject, onSelect }: Props): React owner: p.owner, ownerType: p.ownerType, number: p.number, + host: githubProjectHost(p.host), title: p.title }) } @@ -784,12 +830,19 @@ function PartialFailuresBanner({ ) } -function AuthErrorBanner({ error }: { error: GitHubProjectViewError }): React.JSX.Element { +function AuthErrorBanner({ + error, + host +}: { + error: GitHubProjectViewError + host: string +}): React.JSX.Element { if (error.type === 'auth_required' || error.type === 'scope_missing') { return ( ) } @@ -801,41 +854,54 @@ function AuthErrorBanner({ error }: { error: GitHubProjectViewError }): React.JS ) } -function parseProjectInput( +export function parseProjectInput( input: string -): { owner: string; number: number; viewNumber?: number } | null { - if (!input) { +): { owner: string; number: number; host?: string; viewNumber?: number } | null { + const trimmed = input.trim() + if (!trimmed) { return null } - if (isGitHubProjectRefInputTooLarge(input)) { + if (isGitHubProjectRefInputTooLarge(trimmed)) { return null } // owner/number - const short = /^([A-Za-z0-9][A-Za-z0-9-]*)\/(\d+)$/.exec(input) + const short = /^([A-Za-z0-9][A-Za-z0-9-]*)\/(\d+)$/.exec(trimmed) if (short) { - return { owner: short[1], number: Number(short[2]) } + const number = Number(short[2]) + return Number.isSafeInteger(number) && number > 0 ? { owner: short[1], number } : null } try { - const url = new URL(input) - if (url.hostname !== 'github.com') { + const url = new URL(trimmed) + if ( + (url.protocol !== 'https:' && url.protocol !== 'http:') || + url.username || + url.password || + !url.host + ) { return null } const parts = url.pathname.split('/').filter(Boolean) + const hasView = parts.length === 6 && parts[4] === 'views' // /orgs/{owner}/projects/{n} or /users/{owner}/projects/{n}[/views/{viewNumber}] - if ((parts[0] === 'orgs' || parts[0] === 'users') && parts[2] === 'projects' && parts[3]) { + if ( + (parts[0] === 'orgs' || parts[0] === 'users') && + /^[A-Za-z0-9][A-Za-z0-9-]*$/.test(parts[1] ?? '') && + parts[2] === 'projects' && + (parts.length === 4 || hasView) + ) { const owner = parts[1] const number = Number(parts[3]) - if (Number.isNaN(number)) { + const viewNumber = hasView ? Number(parts[5]) : undefined + if ( + !Number.isSafeInteger(number) || + number < 1 || + (hasView && (!Number.isSafeInteger(viewNumber) || (viewNumber ?? 0) < 1)) + ) { return null } - let viewNumber: number | undefined - if (parts[4] === 'views' && parts[5]) { - const v = Number(parts[5]) - if (!Number.isNaN(v)) { - viewNumber = v - } - } - return { owner, number, viewNumber } + // Why: URL.host preserves non-default GHES ports; URL.hostname would + // silently route a project on :8443 to the server's default port. + return { owner, number, host: url.host.toLowerCase(), viewNumber } } } catch { return null diff --git a/src/renderer/src/components/github-project/ProjectRow.tsx b/src/renderer/src/components/github-project/ProjectRow.tsx index 8f23fca3e12..e59c58903eb 100644 --- a/src/renderer/src/components/github-project/ProjectRow.tsx +++ b/src/renderer/src/components/github-project/ProjectRow.tsx @@ -34,6 +34,7 @@ type Props = { onEditIssueType?: (issueType: GitHubIssueType | null) => void onStartWork?: () => void onOpenInBrowser?: () => void + sourceHost?: string sourceSettings: Pick | null | undefined } @@ -51,6 +52,7 @@ export default function ProjectRow({ onEditIssueType, onStartWork, onOpenInBrowser, + sourceHost, sourceSettings }: Props): React.JSX.Element { const disabled = row.itemType === 'REDACTED' @@ -100,6 +102,7 @@ export default function ProjectRow({ onEditLabels={onEditLabels} onEditIssueType={onEditIssueType} onOpenDialog={f.dataType === 'TITLE' ? onOpenDialog : undefined} + sourceHost={sourceHost} sourceSettings={sourceSettings} />
diff --git a/src/renderer/src/components/github-project/ProjectViewList.tsx b/src/renderer/src/components/github-project/ProjectViewList.tsx index 76752d3c314..fbc869b1107 100644 --- a/src/renderer/src/components/github-project/ProjectViewList.tsx +++ b/src/renderer/src/components/github-project/ProjectViewList.tsx @@ -258,6 +258,7 @@ export default function ProjectViewList({ onEditIssueType={(issueType) => onEditIssueType?.(row, issueType)} onStartWork={() => onStartWork?.(row)} onOpenInBrowser={() => onOpenInBrowser?.(row)} + sourceHost={table.project.host} sourceSettings={sourceSettings} /> )) diff --git a/src/renderer/src/components/github-project/ProjectViewWrapper.tsx b/src/renderer/src/components/github-project/ProjectViewWrapper.tsx index a4ced82fb0c..39f43539ee9 100644 --- a/src/renderer/src/components/github-project/ProjectViewWrapper.tsx +++ b/src/renderer/src/components/github-project/ProjectViewWrapper.tsx @@ -61,6 +61,10 @@ import { } from './project-visible-table-cache' import { translate } from '@/i18n/i18n' import { buildTaskSourceContextFromRepo } from '../../../../shared/task-source-context' +import { + githubProjectHost, + githubProjectIdentityKey +} from '../../../../shared/github-project-identity' type Props = { selectedRepoIds: ReadonlySet @@ -70,7 +74,12 @@ const ORCA_FEATURE_REQUEST_URL = 'https://github.com/stablyai/orca/issues/new' function listProjectViewsForRuntime( settings: Parameters[0], - args: { owner: string; ownerType: 'organization' | 'user'; projectNumber: number } + args: { + owner: string + ownerType: 'organization' | 'user' + projectNumber: number + host?: string + } ): Promise { const target = getActiveRuntimeTarget(settings) return target.kind === 'environment' @@ -85,6 +94,43 @@ function getProjectViewSourceScope(settings: Parameters label.name), + updatedAt: row.updatedAt, + author: null, + repoId, + prRepo + } +} + export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JSX.Element { const settings = useAppStore((s) => s.settings) const projectViewCache = useAppStore((s) => s.projectViewCache) @@ -134,6 +180,7 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS owner: selection.owner, ownerType: selection.ownerType, projectNumber: selection.projectNumber, + host: githubProjectHost(selection.host), ...(selection.viewId ? { viewId: selection.viewId } : {}), ...(queryOverride !== undefined ? { queryOverride } : {}) }, @@ -167,7 +214,7 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS if (!activeProject) { return } - const key = `${activeProject.ownerType}:${activeProject.owner}:${activeProject.number}` + const key = githubProjectIdentityKey(activeProject) const viewId = lastViewByProject[key]?.viewId if (!viewId) { return @@ -180,7 +227,8 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS activeProject.number, viewId, queryOverride, - projectViewSourceScope + projectViewSourceScope, + activeProject.host ) if (projectViewCache[cacheKey]?.data) { return @@ -190,6 +238,7 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS owner: activeProject.owner, ownerType: activeProject.ownerType, projectNumber: activeProject.number, + host: githubProjectHost(activeProject.host), viewId }, false, @@ -209,7 +258,7 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS if (!activeProject) { return } - const projectKey = `${projectViewSourceScope}:${activeProject.ownerType}:${activeProject.owner}:${activeProject.number}` + const projectKey = `${projectViewSourceScope}:${githubProjectIdentityKey(activeProject)}` if (viewListByProject[projectKey]) { return } @@ -217,7 +266,8 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS void listProjectViewsForRuntime(settings, { owner: activeProject.owner, ownerType: activeProject.ownerType, - projectNumber: activeProject.number + projectNumber: activeProject.number, + host: githubProjectHost(activeProject.host) }) .then((res) => { if (cancelled) { @@ -246,7 +296,7 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS if (!activeProject) { return } - const projectKey = `${activeProject.ownerType}:${activeProject.owner}:${activeProject.number}` + const projectKey = githubProjectIdentityKey(activeProject) const current = lastViewByProject[projectKey]?.viewId if (current === viewId) { return @@ -272,6 +322,7 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS owner: activeProject.owner, ownerType: activeProject.ownerType, projectNumber: activeProject.number, + host: githubProjectHost(activeProject.host), viewId }) }, @@ -282,7 +333,7 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS if (!activeProject) { return null } - const key = `${activeProject.ownerType}:${activeProject.owner}:${activeProject.number}` + const key = githubProjectIdentityKey(activeProject) const viewId = lastViewByProject[key]?.viewId if (!viewId) { return null @@ -298,7 +349,7 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS if (!activeProject) { return null } - const key = `${activeProject.ownerType}:${activeProject.owner}:${activeProject.number}` + const key = githubProjectIdentityKey(activeProject) const viewId = lastViewByProject[key]?.viewId if (!viewId) { return null @@ -309,7 +360,8 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS activeProject.number, viewId, currentAppliedOverride, - projectViewSourceScope + projectViewSourceScope, + activeProject.host ) }, [activeProject, lastViewByProject, currentAppliedOverride, projectViewSourceScope]) @@ -384,6 +436,7 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS const [repoNotInOrca, setRepoNotInOrca] = useState<{ owner: string repo: string + host?: string url: string | null } | null>(null) const liveRepoIds = useMemo(() => new Set(repos.map((repo) => repo.id)), [repos]) @@ -423,37 +476,6 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS setRepoNotInOrca(resolvedMissingRepoDialogs.repoNotInOrca) } - const buildWorkItem = useCallback( - (row: GitHubProjectRow, repoId: string): GitHubWorkItem | null => { - if (row.itemType !== 'ISSUE' && row.itemType !== 'PULL_REQUEST') { - return null - } - if (row.content.number == null || !row.content.url) { - return null - } - return { - id: `${row.itemType === 'PULL_REQUEST' ? 'pr' : 'issue'}:${row.content.number}`, - type: row.itemType === 'PULL_REQUEST' ? 'pr' : 'issue', - number: row.content.number, - title: row.content.title, - state: - row.content.state === 'MERGED' - ? 'merged' - : row.content.state === 'CLOSED' - ? 'closed' - : row.content.isDraft - ? 'draft' - : 'open', - url: row.content.url, - labels: row.content.labels.map((l) => l.name), - updatedAt: row.updatedAt, - author: null, - repoId - } - }, - [] - ) - const buildOrigin = useCallback( ( row: GitHubProjectRow, @@ -473,6 +495,7 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS return { owner, repo, + host: githubProjectHost(table.project.host), number: row.content.number, type: row.itemType === 'PULL_REQUEST' ? 'pr' : 'issue', projectId: table.project.id, @@ -506,6 +529,7 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS const resolution = resolveSelectedProjectRowRepo({ row, lookupSlug, + host: table.project.host, slugIndexReady, selectedRepoIds }) @@ -520,7 +544,7 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS return } if (resolution.status === 'selected_match') { - const workItem = buildWorkItem(row, resolution.repo.id) + const workItem = buildProjectWorkItem(row, resolution.repo.id, table.project.host) if (workItem) { setDialogRepoItem({ workItem, @@ -563,8 +587,7 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS lookupSlug, slugIndexReady, selectedRepoIds, - openProjectRowUrlWithToast, - buildWorkItem + openProjectRowUrlWithToast ] ) @@ -580,6 +603,7 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS const resolution = resolveSelectedProjectRowRepo({ row, lookupSlug, + host: table.project.host, slugIndexReady, selectedRepoIds }) @@ -597,6 +621,7 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS setRepoNotInOrca({ owner: origin.owner, repo: origin.repo, + host: origin.host, url: row.content.url ?? null }) return @@ -624,7 +649,7 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS if (resolution.status !== 'selected_match') { return } - const workItem = buildWorkItem(row, resolution.repo.id) + const workItem = buildProjectWorkItem(row, resolution.repo.id, table.project.host) if (!workItem) { return } @@ -649,8 +674,7 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS lookupSlug, slugIndexReady, selectedRepoIds, - openProjectRowUrlWithToast, - buildWorkItem + openProjectRowUrlWithToast ] ) @@ -729,13 +753,15 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS owner: activeProject.owner, ownerType: activeProject.ownerType, number: activeProject.number, + host: githubProjectHost(activeProject.host), title: table.project.title } : activeProject ? { owner: activeProject.owner, ownerType: activeProject.ownerType, - number: activeProject.number + number: activeProject.number, + host: githubProjectHost(activeProject.host) } : null } @@ -751,7 +777,7 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS if (!activeProject) { return } - const key = `${activeProject.ownerType}:${activeProject.owner}:${activeProject.number}` + const key = githubProjectIdentityKey(activeProject) const viewId = lastViewByProject[key]?.viewId if (!viewId) { return @@ -771,6 +797,7 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS owner: activeProject.owner, ownerType: activeProject.ownerType, projectNumber: activeProject.number, + host: githubProjectHost(activeProject.host), viewId }, true, @@ -806,7 +833,7 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS if (!activeProject || !currentCacheKey) { return } - const key = `${activeProject.ownerType}:${activeProject.owner}:${activeProject.number}` + const key = githubProjectIdentityKey(activeProject) const viewId = lastViewByProject[key]?.viewId if (!viewId) { return @@ -816,6 +843,7 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS owner: activeProject.owner, ownerType: activeProject.ownerType, projectNumber: activeProject.number, + host: githubProjectHost(activeProject.host), viewId }, true, @@ -855,7 +883,7 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS {activeProject ? (() => { - const projectKey = `${activeProject.ownerType}:${activeProject.owner}:${activeProject.number}` + const projectKey = githubProjectIdentityKey(activeProject) const scopedProjectKey = `${projectViewSourceScope}:${projectKey}` const views = viewListByProject[scopedProjectKey] ?? [] const activeViewId = lastViewByProject[projectKey]?.viewId ?? null @@ -882,6 +910,7 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS { if (selectedViewUrl) { void window.api.shell.openUrl(selectedViewUrl) @@ -1238,10 +1267,12 @@ function ViewTabStrip({ function ErrorState({ error, totalCount, + host, onOpenInGitHub }: { error: GitHubProjectViewError totalCount?: number + host?: string onOpenInGitHub: () => void }): React.JSX.Element { // Auth/scope errors get a richer `gh auth status` remediation UI; bail early before the generic block. @@ -1250,6 +1281,7 @@ function ErrorState({
+ ), + DropdownMenuSeparator: () =>
, + DropdownMenuSub: ({ children }: { children: ReactNode }) => <>{children}, + DropdownMenuSubContent: ({ children }: { children: ReactNode }) =>
{children}
, + DropdownMenuSubTrigger: ({ children }: { children: ReactNode }) =>
{children}
, + DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <>{children} +})) + +vi.mock('@/lib/sidebar-worktree-activation', () => ({ + activateWorktreeFromSidebar: mockStore.activateWorktreeFromSidebar +})) + +vi.mock('@/lib/worktree-activation', () => ({ + activateAndRevealWorktree: vi.fn() +})) + +vi.mock('@/runtime/runtime-rpc-client', () => ({ + getActiveRuntimeTarget: () => ({ kind: 'local' }), + callRuntimeRpc: vi.fn() +})) + +vi.mock('./WorktreeCardAgents', () => ({ + default: () =>
Agent row
, + SUPPRESS_WORKTREE_LIST_SCROLL_ADJUSTMENT_EVENT: 'orca:test-suppress-scroll-adjustment' +})) + +vi.mock('./WorktreeCard', async () => { + const ReactModule = await import('react') + const MockWorktreeCard = ReactModule.memo(function WorktreeCard({ + worktree + }: { + worktree: Worktree + }) { + cardRenderSpy(worktree.id) + return
+ }) + return { default: MockWorktreeCard } +}) + +function makeRepo(): Repo { + return { + id: 'repo-1', + path: '/tmp/card-memo-stability', + displayName: 'card-memo-stability', + badgeColor: '#999999', + addedAt: 1 + } +} + +function makeWorktree(args: { id: string; displayName: string; sortOrder: number }): Worktree { + return { + id: args.id, + instanceId: `${args.id}-instance`, + repoId: 'repo-1', + path: `/tmp/card-memo-stability/${args.id}`, + displayName: args.displayName, + branch: `${args.id}-branch`, + head: 'abc123', + isBare: false, + isMainWorktree: false, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: args.sortOrder, + lastActivityAt: args.sortOrder + } +} + +function makeFolderWorkspacePathStatusState(): Record { + return { + fetchFolderWorkspacePathStatus: vi.fn(), + folderWorkspacePathStatuses: {}, + folderWorkspaces: [], + getFolderWorkspacePathStatusCacheKey: (request: unknown) => JSON.stringify(request), + getFreshFolderWorkspacePathStatus: vi.fn(() => null) + } +} + +function setFlatWorktreeState(): void { + const repo = makeRepo() + const worktrees = [ + makeWorktree({ id: 'wt-a', displayName: 'alpha', sortOrder: 20 }), + makeWorktree({ id: 'wt-b', displayName: 'beta', sortOrder: 10 }) + ] + mockStore.state = { + ...makeFolderWorkspacePathStatusState(), + activeModal: '', + activeView: 'terminal', + activeWorktreeId: null, + agentStatusByPaneKey: {}, + agentStatusEpoch: 0, + browserTabsByWorktree: {}, + clearPendingRevealWorktreeId: vi.fn(), + collapsedGroups: new Set(), + deleteStateByWorktreeId: {}, + detectedWorktreesByRepo: {}, + fetchHostedReviewForBranch: vi.fn(), + fetchIssue: vi.fn(), + fetchLinearIssue: vi.fn(), + filterRepoIds: [], + gitConflictOperationByWorktree: {}, + groupBy: 'none', + hideDefaultBranchWorkspace: false, + hostedReviewCache: {}, + issueCache: {}, + linearIssueCache: {}, + linearStatus: null, + migrationUnsupportedByPtyId: {}, + openModal: mockStore.openModal, + openSettingsPage: vi.fn(), + openSettingsTarget: null, + openTaskPage: vi.fn(), + pendingRevealWorktree: null, + prCache: {}, + projectGroups: [], + ptyIdsByTabId: {}, + recordFeatureInteraction: vi.fn(), + remoteBranchConflictByWorktreeId: {}, + reorderRepos: vi.fn(), + reportVisibleGitHubPRRefreshCandidates: vi.fn(), + repos: [repo], + retainedAgentsByPaneKey: {}, + revealWorktreeInSidebar: vi.fn(), + runtimePaneTitlesByTabId: {}, + setFilterRepoIds: vi.fn(), + setHideDefaultBranchWorkspace: vi.fn(), + setRenamingWorktreeId: vi.fn(), + setShowSleepingWorkspaces: vi.fn(), + setSortBy: vi.fn(), + setWorktreesPinnedAndReveal: vi.fn(), + settings: null, + showSleepingWorkspaces: true, + sortBy: 'manual', + sortEpoch: 0, + sshConnectedGeneration: 0, + sshConnectionStates: new Map(), + sshTargetLabels: new Map(), + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + toggleCollapsedGroup: vi.fn(), + updateRepo: vi.fn(), + updateWorktreeMeta: vi.fn(), + updateWorktreesMeta: vi.fn(), + workspaceHostScope: 'all', + workspacePortScan: null, + workspaceStatuses: [], + worktreeCardProperties: ['status', 'pr', 'comment'] satisfies WorktreeCardProperty[], + worktreeLineageById: {}, + worktreesByRepo: { + [repo.id]: worktrees + } + } +} + +const mountedRoots: Root[] = [] + +async function renderList(root: Root): Promise { + await act(async () => { + // Why fresh ref objects each render: they defeat WorktreeList's own memo + // like a store-subscription re-render would, so the test exercises a full + // parent re-render and isolates whether the CARDS bail. + root.render( + + ) + }) +} + +describe('WorktreeCard memo bail-out across epoch bumps', () => { + beforeAll(async () => { + WorktreeList = (await import('./WorktreeList')).default as WorktreeListComponent + }, 60_000) + + beforeEach(() => { + vi.clearAllMocks() + setFlatWorktreeState() + }) + + afterEach(async () => { + await act(async () => { + for (const root of mountedRoots.splice(0)) { + root.unmount() + } + }) + document.body.innerHTML = '' + }) + + it('does not re-render cards on an order-preserving sortEpoch bump', async () => { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + mountedRoots.push(root) + + await renderList(root) + expect(container.querySelectorAll('[data-mock-worktree-card]')).toHaveLength(2) + + // Baseline: a parent re-render with unchanged store state must not + // re-invoke card render functions. + const countAfterMount = cardRenderSpy.mock.calls.length + await renderList(root) + expect(cardRenderSpy.mock.calls.length).toBe(countAfterMount) + + // Order-preserving epoch bump: same worktrees, same order, new epoch. + // Manual sort applies the bump without the smart-sort settle debounce. + mockStore.state = { ...mockStore.state, sortEpoch: 1 } + await renderList(root) + + expect(cardRenderSpy.mock.calls.length).toBe(countAfterMount) + }) + + it('re-renders a card when its own worktree data changes', async () => { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + mountedRoots.push(root) + + await renderList(root) + const countAfterMount = cardRenderSpy.mock.calls.length + + const worktrees = (mockStore.state.worktreesByRepo as Record)['repo-1']! + mockStore.state = { + ...mockStore.state, + sortEpoch: 2, + worktreesByRepo: { + 'repo-1': [{ ...worktrees[0]!, displayName: 'alpha renamed' }, worktrees[1]!] + } + } + await renderList(root) + + // The changed card re-renders; identity reuse must not freeze real updates. + expect(cardRenderSpy.mock.calls.length).toBeGreaterThan(countAfterMount) + expect(cardRenderSpy).toHaveBeenCalledWith('wt-a') + }) +}) diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index a71753eaa5d..fcc71abb98c 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -24,6 +24,8 @@ import { Trash2 } from 'lucide-react' import { useAppStore } from '@/store' +import { createLineageToggleHandlerCache } from './worktree-lineage-toggle-handler-cache' +import { reuseArrayIfEqual } from './worktree-agent-row-selectors' import { useShallow } from 'zustand/react/shallow' import type { AppState } from '@/store/types' import { @@ -312,6 +314,14 @@ type ProjectGroupDeleteDialogState = { removeContainedProjects: boolean } +// Why: epoch-driven recomputes often produce arrays whose contents and order are unchanged; reusing the previous identity when element-wise equal keeps downstream memos and React.memo'd cards bailing out. Safe only because elements (Worktree objects / id strings) are immutably REPLACED on change — never wrap arrays of mutated-in-place objects. +function useReusedArrayIdentity(next: T[]): T[] { + const previousRef = useRef(next) + const result = reuseArrayIfEqual(previousRef.current, next) + previousRef.current = result + return result +} + // Debounce re-sort after a sortEpoch bump so background score changes don't jar row positions. const SORT_SETTLE_MS = 3_000 const USER_SCROLL_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS = 500 @@ -2449,6 +2459,12 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp }, [recordCurrentScrollAnchor, toggleGroup] ) + // Why: memo'd WorktreeCard needs a per-group-key stable onLineageToggle + // identity to bail out of re-renders; see worktree-lineage-toggle-handler-cache. + const getLineageToggleHandler = useMemo( + () => createLineageToggleHandlerCache(toggleGroupWithScrollAnchor), + [toggleGroupWithScrollAnchor] + ) const navigateWorktree = useCallback( (direction: 'up' | 'down') => { @@ -4856,11 +4872,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp lineageChildrenStyle={lineageChildrenStyle} onLineageToggle={ lineageToggleGroupKey - ? (event) => { - event.preventDefault() - event.stopPropagation() - toggleGroupWithScrollAnchor(lineageToggleGroupKey) - } + ? getLineageToggleHandler(lineageToggleGroupKey) : undefined } /> @@ -5462,7 +5474,7 @@ const WorktreeList = React.memo(function WorktreeList({ }, [sortedIds, sortBy]) // Flatten/filter/sort via the shared utility so card order matches Cmd+1–9 numbering. - const visibleWorktrees = useMemo(() => { + const recomputedVisibleWorktrees = useMemo(() => { void agentStatusEpoch const ids = computeVisibleWorktreeIds(worktreesByRepo, sortedIds, { filterRepoIds, @@ -5514,6 +5526,10 @@ const WorktreeList = React.memo(function WorktreeList({ worktreeLineageById, worktreesByRepo ]) + // Why: agentStatusEpoch bumps recompute this memo even when membership and + // order are unchanged; keeping the previous identity stops the whole + // rows/sectionRows/renderedWorktrees chain from churning per epoch. + const visibleWorktrees = useReusedArrayIdentity(recomputedVisibleWorktrees) const worktrees = visibleWorktrees const collapsedGroups = useAppStore((s) => s.collapsedGroups) @@ -5842,9 +5858,14 @@ const WorktreeList = React.memo(function WorktreeList({ () => getRenderedWorktreesInSidebarOrder(sectionRows, pinnedDisplayPolicy), [pinnedDisplayPolicy, sectionRows] ) - const renderedWorktreeIds = useMemo( - () => uniqueWorktreeIds(renderedWorktrees.map((worktree) => worktree.id)), - [renderedWorktrees] + // Why: order-preserving sectionRows rebuilds must not give this array a new + // identity — updateSelectionForGesture depends on it, and a fresh identity + // there defeats React.memo bail-out for every WorktreeCard on epoch bumps. + const renderedWorktreeIds = useReusedArrayIdentity( + useMemo( + () => uniqueWorktreeIds(renderedWorktrees.map((worktree) => worktree.id)), + [renderedWorktrees] + ) ) const [selectedWorktreeIds, setSelectedWorktreeIds] = useState>(new Set()) const [selectionAnchorId, setSelectionAnchorId] = useState(null) @@ -5862,18 +5883,23 @@ const WorktreeList = React.memo(function WorktreeList({ setSelectionAnchorId(prunedSelection.anchorId) } - const selectedWorktrees = useMemo(() => { - if (selectedWorktreeIds.size === 0) { - return [] - } - const selected = new Map() - for (const worktree of renderedWorktrees) { - if (selectedWorktreeIds.has(worktree.id) && !selected.has(worktree.id)) { - selected.set(worktree.id, worktree) + // Why identity reuse: the empty/unchanged-selection case must keep one array + // identity — selectForContextMenu and both drag-start handlers depend on + // this array, and card memo bail-out depends on those staying stable. + const selectedWorktrees = useReusedArrayIdentity( + useMemo(() => { + if (selectedWorktreeIds.size === 0) { + return [] } - } - return Array.from(selected.values()) - }, [renderedWorktrees, selectedWorktreeIds]) + const selected = new Map() + for (const worktree of renderedWorktrees) { + if (selectedWorktreeIds.has(worktree.id) && !selected.has(worktree.id)) { + selected.set(worktree.id, worktree) + } + } + return Array.from(selected.values()) + }, [renderedWorktrees, selectedWorktreeIds]) + ) useEffect(() => { if (selectedWorktreeIds.size === 0) { diff --git a/src/renderer/src/components/sidebar/worktree-agent-row-selectors.ts b/src/renderer/src/components/sidebar/worktree-agent-row-selectors.ts index 35ff6853b27..f964d024128 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-row-selectors.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-row-selectors.ts @@ -51,7 +51,10 @@ let liveEntriesByWorktreeCache: LiveEntriesByWorktreeCache | null = null let migrationUnsupportedByWorktreeCache: MigrationUnsupportedByWorktreeCache | null = null let retainedEntriesByWorktreeCache: RetainedEntriesByWorktreeCache | null = null -function reuseArrayIfEqual(previous: T[] | undefined, next: T[]): T[] { +// Why exported: WorktreeList reuses this exact-equality identity check to keep +// derived arrays referentially stable across order-preserving epoch bumps so +// memo'd cards can bail out of re-render. +export function reuseArrayIfEqual(previous: T[] | undefined, next: T[]): T[] { if (!previous || previous.length !== next.length) { return next } diff --git a/src/renderer/src/components/sidebar/worktree-lineage-toggle-handler-cache.test.ts b/src/renderer/src/components/sidebar/worktree-lineage-toggle-handler-cache.test.ts new file mode 100644 index 00000000000..67056009295 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-lineage-toggle-handler-cache.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from 'vitest' +import type React from 'react' +import { createLineageToggleHandlerCache } from './worktree-lineage-toggle-handler-cache' + +const makeEvent = (): React.MouseEvent => + ({ + preventDefault: vi.fn(), + stopPropagation: vi.fn() + }) as unknown as React.MouseEvent + +describe('createLineageToggleHandlerCache', () => { + it('returns a referentially stable handler for the same group key across calls', () => { + const getHandler = createLineageToggleHandlerCache(vi.fn()) + + const first = getHandler('lineage:alpha') + const second = getHandler('lineage:alpha') + + // Why: stable identity is what lets React.memo'd WorktreeCard bail out. + expect(second).toBe(first) + }) + + it('returns distinct handlers for distinct group keys', () => { + const getHandler = createLineageToggleHandlerCache(vi.fn()) + + expect(getHandler('lineage:alpha')).not.toBe(getHandler('lineage:beta')) + }) + + it('prevents default, stops propagation, and toggles the bound group key', () => { + const toggleGroup = vi.fn() + const getHandler = createLineageToggleHandlerCache(toggleGroup) + const event = makeEvent() + + getHandler('lineage:alpha')(event) + + expect(event.preventDefault).toHaveBeenCalledTimes(1) + expect(event.stopPropagation).toHaveBeenCalledTimes(1) + expect(toggleGroup).toHaveBeenCalledExactlyOnceWith('lineage:alpha') + }) + + it('keeps each cached handler bound to its own group key', () => { + const toggleGroup = vi.fn() + const getHandler = createLineageToggleHandlerCache(toggleGroup) + + getHandler('lineage:beta')(makeEvent()) + + expect(toggleGroup).toHaveBeenCalledExactlyOnceWith('lineage:beta') + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-lineage-toggle-handler-cache.ts b/src/renderer/src/components/sidebar/worktree-lineage-toggle-handler-cache.ts new file mode 100644 index 00000000000..de9012f41ae --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-lineage-toggle-handler-cache.ts @@ -0,0 +1,26 @@ +import type React from 'react' + +export type LineageToggleHandler = (event: React.MouseEvent) => void + +// Why: WorktreeCard is React.memo'd; an inline arrow per row would give +// onLineageToggle a fresh identity every render and defeat the memo bail-out +// for every lineage-parent card on each sort/status epoch bump. Cache one +// handler per group key (bounded by lineage-group count) so identity is stable. +export const createLineageToggleHandlerCache = ( + toggleGroup: (groupKey: string) => void +): ((groupKey: string) => LineageToggleHandler) => { + const handlersByGroupKey = new Map() + return (groupKey: string) => { + const cached = handlersByGroupKey.get(groupKey) + if (cached) { + return cached + } + const handler: LineageToggleHandler = (event) => { + event.preventDefault() + event.stopPropagation() + toggleGroup(groupKey) + } + handlersByGroupKey.set(groupKey, handler) + return handler + } +} From 995ee888911fe15beb7140fb6a33cac00fb46dfa Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 20 Jul 2026 19:47:58 -0700 Subject: [PATCH 26/58] perf(persistence): single full-state serialization per save (#9381) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Every durable save serialized the ~1.5 MB durable state **twice**, synchronously on the main Electron thread: once in `computeStateHash()` (the plaintext hash for the no-op-write guard) and again in `buildStateToSave()` (the encrypted on-disk payload). The guard couldn't hash the payload directly because `encrypt()` on random-IV platforms produces different bytes each save for identical state. This PR collapses the two into **one** `JSON.stringify`. `buildStateToSave()` now returns `{ payload, stateHash }`: each encrypted secret slot is serialized as a **fresh per-slot random sentinel** (`orca-secret-slot-`), and after the single stringify each sentinel is substituted exactly once — to its ciphertext for the on-disk `payload`, and to its plaintext for the guard `hashInput`. The guard hash is therefore a pure function of the plaintext state (`hashInput === JSON.stringify(plaintext durable state)`), and the on-disk `payload` is byte-identical to the previous two-stringify output. No change to the save API, debounce timings, guard semantics, or on-disk format (compact JSON, same 3 encrypted fields). ### Why sentinels (not a string replace of the ciphertext) The substitution must be **position-exact**. A naive `payload.replace(ciphertext, plaintext)` — or even a `"key":"ciphertext"`-anchored replace — can be **mimicked by user-controlled state**, which would substitute the wrong site and let two *distinct* states normalize to the same guard hash → a real change is silently **not written** (data loss). This is reachable on deterministic-IV platforms (macOS / legacy-Linux OSCrypt use a constant IV, so a user can read their own ciphertext out of `orca-data.json`): - **Value vector**: a plaintext free-text field (e.g. `httpProxyBypassRules`) whose value equals a secret's ciphertext. - **Key vector**: `agentDefaultEnv` lets the user name an env var exactly after a secret field (e.g. `browserKagiSessionLink`) with a ciphertext value, producing a `"browserKagiSessionLink":""` token inside `settings` (before `ui`). A per-slot random sentinel is minted **after** all user data is captured, so it cannot occur anywhere else in the serialized state and matches only the intended secret slot — closing the entire class. (Both vectors were found by an adversarial fable→gpt-5.6-sol review loop; each has a store-level regression test that fails on the earlier approaches and passes now.) The empty-secret / `safeStorage`-unavailable / encrypt-failure cases (`blob === plaintext`) get no sentinel and reproduce `main`'s behavior byte-for-byte. ## Evidence - Full-state `JSON.stringify` calls per changed save: **2 → 1** (call-counting test). - Frontier-review benchmark on an 8 MiB real-method state: **no perf regression** (median 12.73 ms sentinel vs 13.77 ms two-stringify baseline). - Independent review modeled 625 adversarial durable states: every payload byte-identical to the pre-PR implementation, and every distinct state produced a distinct guard hash (no dropped-write collision). ## No-Regression Proof `src/main/persistence-single-serialize.test.ts` (nondeterministic **and** deterministic cipher mocks): - identical state with secrets set → no-op (inode unchanged); real change (incl. secret rotation) → writes - on-disk payload is ciphertext (plaintext appears nowhere), round-trips through reload/decrypt - empty secret + `isEncryptionAvailable() === false` → plaintext payload, guard still skips - sync `flushOrThrow()` also skips on identical state - exactly one full-state serialization per save - **regression (value vector)**: a plaintext field equal to a secret's ciphertext, then swapped → write is not skipped, round-trips - **regression (key vector)**: an `agentDefaultEnv` var named after a secret field holding its ciphertext, then swapped → write is not skipped, round-trips Suites: `persistence-single-serialize.test.ts` + `persistence.test.ts` all green; `pnpm run typecheck:node` clean; oxlint clean. ## ELI5 Orca saves your app state whenever something changes, and it used to build the whole (large) state twice each time — once to check "did anything actually change?" and once to write the file. Now it builds it once and reuses that copy for both jobs. To compare states safely it swaps each encrypted secret for a random one-time marker while checking, so no value you type can ever be mistaken for a secret and trick it into skipping a real save. What lands on disk is exactly the same, and your secrets stay encrypted. Made with [Orca](https://github.com/stablyai/orca) 🐋 --- src/main/persistence-single-serialize.test.ts | 291 ++++++++++++++++++ src/main/persistence.ts | 67 ++-- 2 files changed, 338 insertions(+), 20 deletions(-) create mode 100644 src/main/persistence-single-serialize.test.ts diff --git a/src/main/persistence-single-serialize.test.ts b/src/main/persistence-single-serialize.test.ts new file mode 100644 index 00000000000..66c3b4ab0f6 --- /dev/null +++ b/src/main/persistence-single-serialize.test.ts @@ -0,0 +1,291 @@ +// Why this file exists: persistence.test.ts mocks safeStorage.encryptString +// deterministically, which cannot catch the real-world hazard the single- +// stringify save guard must survive — encrypt() uses a random IV, so identical +// state produces different on-disk bytes each save. These tests mock a +// nondeterministic cipher and pin the guard + payload invariants. +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { readFileSync, rmSync, mkdtempSync, statSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { randomUUID } from 'node:crypto' + +const testState = { dir: '' } + +vi.mock('./ssh/ssh-config-parser', () => ({ + loadUserSshConfig: vi.fn(), + sshConfigHostsToTargets: vi.fn() +})) + +// Nondeterministic cipher: same plaintext → different ciphertext every call, +// like safeStorage's random IV. encryptionAvailable is toggleable per test. +// deterministic=true mimics macOS/legacy-Linux OSCrypt (constant IV → same +// plaintext always yields the same ciphertext), which is what makes the +// cross-field blob-collision reachable by a user. +const cipherState = { encryptionAvailable: true, deterministic: false } +const DETERMINISTIC_IV = 'd'.repeat(36) + +vi.mock('electron', () => ({ + app: { + getPath: () => testState.dir + }, + safeStorage: { + isEncryptionAvailable: () => cipherState.encryptionAvailable, + encryptString: (plaintext: string) => + Buffer.from( + `enc:${cipherState.deterministic ? DETERMINISTIC_IV : randomUUID()}:${plaintext}`, + 'utf-8' + ), + decryptString: (ciphertext: Buffer) => { + const decoded = ciphertext.toString('utf-8') + if (!decoded.startsWith('enc:')) { + throw new Error('invalid ciphertext') + } + return decoded.slice('enc:'.length + 36 + 1) + } + } +})) + +vi.mock('./telemetry/client', () => ({ + track: vi.fn() +})) + +vi.mock('./telemetry/cohort-classifier', () => ({ + getCohortAtEmit: vi.fn().mockReturnValue({ nth_repo_added: 2 }) +})) + +async function createStore() { + vi.resetModules() + const { Store, initDataPath } = await import('./persistence') + initDataPath() + return new Store() +} + +function dataFile(): string { + return join(testState.dir, 'orca-data.json') +} + +const SECRETS = { + opencodeSessionCookie: 'cookie-$&-value', + httpProxyUrl: 'http://user:p@ss@proxy.local:8080' +} as const +const KAGI_LINK = 'https://kagi.com/session?token=abc123' + +describe('persistence single-serialize save guard', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-test-')) + cipherState.encryptionAvailable = true + cipherState.deterministic = false + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + rmSync(testState.dir, { recursive: true, force: true }) + }) + + async function seedStoreWithSecrets() { + const store = await createStore() + store.updateSettings({ ...SECRETS }) + store.updateUI({ browserKagiSessionLink: KAGI_LINK }) + vi.advanceTimersByTime(1000) + await store.waitForPendingWrite() + return store + } + + it('skips the disk write when state is identical, even with secrets set (random-IV cipher)', async () => { + const store = await seedStoreWithSecrets() + const inoBefore = statSync(dataFile()).ino + + // Net no-op mutation burst: the encrypted payload bytes would differ + // (random IV), but the normalized guard hash must not. + const originalWidth = store.getUI().sidebarWidth + store.updateUI({ sidebarWidth: 512 }) + store.updateUI({ sidebarWidth: originalWidth }) + store.updateSettings({ ...SECRETS }) + vi.advanceTimersByTime(2000) + await store.waitForPendingWrite() + + expect(statSync(dataFile()).ino).toBe(inoBefore) + }) + + it('still writes when state actually changes (including a secret change)', async () => { + const store = await seedStoreWithSecrets() + const inoBefore = statSync(dataFile()).ino + + store.updateSettings({ opencodeSessionCookie: 'rotated-cookie' }) + vi.advanceTimersByTime(2000) + await store.waitForPendingWrite() + const inoAfter = statSync(dataFile()).ino + expect(inoAfter).not.toBe(inoBefore) + + store.updateUI({ sidebarWidth: 777 }) + vi.advanceTimersByTime(2000) + await store.waitForPendingWrite() + expect(statSync(dataFile()).ino).not.toBe(inoAfter) + }) + + it('writes encrypted secrets to disk and round-trips them through a reload', async () => { + await seedStoreWithSecrets() + + const raw = readFileSync(dataFile(), 'utf-8') + const persisted = JSON.parse(raw) as { + settings: { opencodeSessionCookie: string; httpProxyUrl: string } + ui: { browserKagiSessionLink: string } + } + // Secrets are ciphertext on disk, plaintext nowhere in the payload. + expect(persisted.settings.opencodeSessionCookie).not.toBe(SECRETS.opencodeSessionCookie) + expect(persisted.settings.httpProxyUrl).not.toBe(SECRETS.httpProxyUrl) + expect(persisted.ui.browserKagiSessionLink).not.toBe(KAGI_LINK) + expect(raw).not.toContain(SECRETS.opencodeSessionCookie) + expect(raw).not.toContain(KAGI_LINK) + + const reloaded = await createStore() + expect(reloaded.getSettings().opencodeSessionCookie).toBe(SECRETS.opencodeSessionCookie) + expect(reloaded.getSettings().httpProxyUrl).toBe(SECRETS.httpProxyUrl) + expect(reloaded.getUI().browserKagiSessionLink).toBe(KAGI_LINK) + }) + + it('handles empty secrets and unavailable encryption (payload stays plaintext, guard still skips)', async () => { + cipherState.encryptionAvailable = false + const store = await createStore() + store.updateSettings({ ...SECRETS, opencodeSessionCookie: '' }) + vi.advanceTimersByTime(1000) + await store.waitForPendingWrite() + + const persisted = JSON.parse(readFileSync(dataFile(), 'utf-8')) as { + settings: { opencodeSessionCookie: string; httpProxyUrl: string } + } + expect(persisted.settings.opencodeSessionCookie).toBe('') + expect(persisted.settings.httpProxyUrl).toBe(SECRETS.httpProxyUrl) + + const inoBefore = statSync(dataFile()).ino + store.updateSettings({ httpProxyUrl: SECRETS.httpProxyUrl }) + vi.advanceTimersByTime(2000) + await store.waitForPendingWrite() + expect(statSync(dataFile()).ino).toBe(inoBefore) + }) + + it('sync flush also skips on identical state with secrets set', async () => { + const store = await seedStoreWithSecrets() + const inoBefore = statSync(dataFile()).ino + + store.flushOrThrow() + + expect(statSync(dataFile()).ino).toBe(inoBefore) + }) + + it('performs exactly one full-state JSON.stringify per save (was two)', async () => { + const store = await seedStoreWithSecrets() + + // Count full-state serializations: only the durable-state payload is + // anywhere near this size in the save path (tiny stringifies elsewhere + // stay far below the threshold). + const original = JSON.stringify.bind(JSON) + let fullStateSerializations = 0 + const spy = vi.spyOn(JSON, 'stringify').mockImplementation((( + value: unknown, + ...rest: unknown[] + ) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const out = original(value as any, ...(rest as [any?, any?])) + if (typeof out === 'string' && out.length > 1_000) { + fullStateSerializations++ + } + return out + }) as typeof JSON.stringify) + try { + store.updateUI({ sidebarWidth: 640 }) + vi.advanceTimersByTime(2000) + await store.waitForPendingWrite() + } finally { + spy.mockRestore() + } + + expect(fullStateSerializations).toBe(1) + }) + + // Regression (adversarial review, gpt-5.6-sol round 1): the guard hash + // normalizes encrypted secrets back to plaintext. A user-controlled plaintext + // VALUE (httpProxyBypassRules) can equal a secret's ciphertext; on a + // deterministic cipher (macOS/legacy-Linux OSCrypt constant IV) a string + // search for the ciphertext would substitute the wrong site, letting two + // distinct states hash equal → a silently dropped write (data loss). The + // position-exact sentinel substitution must keep the hashes distinct. + it('persists a swap between a plaintext field and a secret when the plaintext equals the secret ciphertext (deterministic cipher)', async () => { + cipherState.deterministic = true + const store = await createStore() + + const P = 'cookie-plaintext-value' + // Persist cookie=P, then read its on-disk ciphertext C (what a user could + // copy out of orca-data.json). + store.updateSettings({ opencodeSessionCookie: P }) + vi.advanceTimersByTime(1000) + await store.waitForPendingWrite() + const C = ( + JSON.parse(readFileSync(dataFile(), 'utf-8')) as { + settings: { opencodeSessionCookie: string } + } + ).settings.opencodeSessionCookie + expect(C).not.toBe(P) // C is ciphertext + + // State 1: the plaintext bypass-rules field literally holds ciphertext C; + // cookie is still P (which also encrypts to C under the deterministic IV). + store.updateSettings({ httpProxyBypassRules: C, opencodeSessionCookie: P }) + vi.advanceTimersByTime(2000) + await store.waitForPendingWrite() + const inoState1 = statSync(dataFile()).ino + + // State 2 (distinct): swap the two values. Must be written, not skipped. + store.updateSettings({ httpProxyBypassRules: P, opencodeSessionCookie: C }) + vi.advanceTimersByTime(2000) + await store.waitForPendingWrite() + expect(statSync(dataFile()).ino).not.toBe(inoState1) + + // The swap round-trips through a reload — nothing was lost. + const reloaded = await createStore() + expect(reloaded.getSettings().httpProxyBypassRules).toBe(P) + expect(reloaded.getSettings().opencodeSessionCookie).toBe(C) + }) + + // Regression (adversarial review, gpt-5.6-sol round 2): the deeper variant of + // the same class — a user-controlled JSON KEY. agentDefaultEnv lets the user + // name an env var exactly after a secret field, so `"browserKagiSessionLink": + // ""` can appear (inside settings, before ui) as a non-secret + // entry. A key-anchored string replace would substitute that site instead of + // the real ui field. The per-slot sentinel is unguessable, so it only ever + // matches the real secret slot and the two distinct states stay distinct. + it('persists a swap when an agentDefaultEnv var is named after a secret field and holds its ciphertext (deterministic cipher)', async () => { + cipherState.deterministic = true + const store = await createStore() + + const K = 'https://kagi.com/session?token=SECRET' + store.updateUI({ browserKagiSessionLink: K }) + vi.advanceTimersByTime(1000) + await store.waitForPendingWrite() + const C = ( + JSON.parse(readFileSync(dataFile(), 'utf-8')) as { + ui: { browserKagiSessionLink: string } + } + ).ui.browserKagiSessionLink + expect(C).not.toBe(K) // C is ciphertext + + // State 1: env var literally named after the secret field, value = C; the + // real ui secret is still K. + store.updateSettings({ agentDefaultEnv: { claude: { browserKagiSessionLink: C } } }) + store.updateUI({ browserKagiSessionLink: K }) + vi.advanceTimersByTime(2000) + await store.waitForPendingWrite() + const inoState1 = statSync(dataFile()).ino + + // State 2 (distinct): swap — env var value = K, ui secret = C. Must write. + store.updateSettings({ agentDefaultEnv: { claude: { browserKagiSessionLink: K } } }) + store.updateUI({ browserKagiSessionLink: C }) + vi.advanceTimersByTime(2000) + await store.waitForPendingWrite() + expect(statSync(dataFile()).ino).not.toBe(inoState1) + + const reloaded = await createStore() + expect(reloaded.getSettings().agentDefaultEnv?.claude?.browserKagiSessionLink).toBe(K) + expect(reloaded.getUI().browserKagiSessionLink).toBe(C) + }) +}) diff --git a/src/main/persistence.ts b/src/main/persistence.ts index a1b3f307441..aba79a4561c 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -266,10 +266,6 @@ function decrypt(ciphertext: string): string { } } -function encryptOptionalSecret(value: string | null | undefined): string | null { - return value ? encrypt(value) : null -} - function decryptOptionalSecret(value: string | null | undefined): string | null { return value ? decrypt(value) : null } @@ -2536,7 +2532,7 @@ export class Store { private writeGeneration = 0 // Why: after a profile transfer rewrites this file on disk, a late flush of stale in-memory state would resurrect the moved project. private writesFrozen = false - // Plaintext hash at last write, to skip no-op writes; not the payload, since encrypt() uses a random IV per call. + // Content hash at last write, to skip no-op writes; derived from the payload with encrypted blobs normalized back to plaintext (see buildStateToSave), since encrypt() uses a random IV per call. private lastWrittenStateHash: string | null = null private firstPendingSaveAt: number | null = null private githubCacheDirty = false @@ -3506,27 +3502,61 @@ export class Store { return durable } - private computeStateHash(): string { - return createHash('sha1').update(JSON.stringify(this.getDurableState())).digest('hex') - } - - // Why: build payload synchronously so hash and serialized bytes reflect the same state tick (no await interleave). - private buildStateToSave(): string { + // Why: build payload synchronously so hash and serialized bytes reflect the same state tick (no await interleave). One full-state stringify serves both the on-disk payload and the no-op-write guard hash: each secret slot is serialized as a fresh unguessable sentinel, then sentinels are substituted to ciphertext for the payload and to plaintext for the hash. The hash is thus a pure function of plaintext state (skips a byte-identical rewrite) without a second stringify. + private buildStateToSave(): { payload: string; stateHash: string } { + // Why sentinels (not a blob/key string match): the substitution must be + // position-exact. A plain search for the ciphertext — or even for a + // `"key":"blob"` token — can be mimicked by user-controlled state (e.g. an + // agentDefaultEnv var named after a secret field, or a value equal to a + // ciphertext), which would substitute the wrong site and let two DISTINCT + // states normalize equal → a silently dropped write (data loss), reachable + // on deterministic-IV platforms (macOS/legacy-Linux OSCrypt). A per-slot + // random UUID can't occur anywhere else in the serialized state (the user + // sets their data before it is minted), so it appears exactly once. + const secretSubs: { sentinel: string; blob: string; plaintext: string }[] = [] + const encryptToSentinel = (plaintext: string): string => { + const blob = encrypt(plaintext) + // Deterministic already (empty secret / safeStorage unavailable / encrypt + // failure): blob === plaintext, so no normalization — and no sentinel, + // which also avoids substituting an empty or plaintext-shaped slot. + if (blob === plaintext) { + return blob + } + const sentinel = `orca-secret-slot-${randomUUID()}` + secretSubs.push({ sentinel, blob, plaintext }) + return sentinel + } // Why: clone before encrypting secrets so in-memory this.state stays plaintext. const stateToSave = { ...this.getDurableState(), settings: { ...this.state.settings, - opencodeSessionCookie: encrypt(this.state.settings.opencodeSessionCookie), - httpProxyUrl: encrypt(this.state.settings.httpProxyUrl ?? '') + opencodeSessionCookie: encryptToSentinel(this.state.settings.opencodeSessionCookie), + httpProxyUrl: encryptToSentinel(this.state.settings.httpProxyUrl ?? '') }, ui: { ...this.state.ui, - browserKagiSessionLink: encryptOptionalSecret(this.state.ui.browserKagiSessionLink) + browserKagiSessionLink: this.state.ui.browserKagiSessionLink + ? encryptToSentinel(this.state.ui.browserKagiSessionLink) + : null } } // Why compact: ~20% fewer bytes and less serialize time; all readers JSON.parse so formatting is irrelevant. - return JSON.stringify(stateToSave) + // One full-state stringify; secret slots currently hold sentinels. + const serialized = JSON.stringify(stateToSave) + // Substitute each unique sentinel exactly once: ciphertext for the on-disk + // payload, plaintext for the guard hash. Function-form replacement keeps + // `$` in blob/plaintext inert; both sides read the sentinel as JSON-escaped + // in `serialized`, so each replace is byte-for-byte position-exact. + let payload = serialized + let hashInput = serialized + for (const { sentinel, blob, plaintext } of secretSubs) { + const escapedSentinel = JSON.stringify(sentinel).slice(1, -1) + payload = payload.replace(escapedSentinel, () => blob) + hashInput = hashInput.replace(escapedSentinel, () => JSON.stringify(plaintext).slice(1, -1)) + } + const stateHash = createHash('sha1').update(hashInput).digest('hex') + return { payload, stateHash } } // Why: async writes avoid blocking the main Electron thread on every debounced save. @@ -3535,12 +3565,11 @@ export class Store { return } const gen = this.writeGeneration - const stateHash = this.computeStateHash() + const { payload, stateHash } = this.buildStateToSave() // Why: don't rewrite a byte-identical multi-MB file when state nets out to already-persisted. if (stateHash === this.lastWrittenStateHash) { return } - const payload = this.buildStateToSave() const dataFile = this.dataFile const dir = dirname(dataFile) await mkdir(dir, { recursive: true }).catch(() => {}) @@ -3580,7 +3609,7 @@ export class Store { if (this.writesFrozen) { return } - const stateHash = this.computeStateHash() + const { payload, stateHash } = this.buildStateToSave() // Why: matching hash means the file already holds this state; force overrides when an async rename may be racing past the gen check. if (!opts.force && stateHash === this.lastWrittenStateHash) { return @@ -3592,8 +3621,6 @@ export class Store { } const tmpFile = `${dataFile}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp` - const payload = this.buildStateToSave() - // Why: on any write/rename failure, remove the tmp file so shutdown crashes don't leak orphans. let renamed = false try { From cabb5dc05a80434641240177a291e8fbaf524485 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 20 Jul 2026 19:56:38 -0700 Subject: [PATCH 27/58] fix(editor): reconcile multi-byte markdown saves without byte-offset crash (#9492) (#9642) * fix(editor): reconcile multi-byte markdown saves without byte-offset crash (#9492) @sanity/diff-match-patch's adjustIndiciesToUcs2 treats patch.start1 (a char index) as a UTF-8 byte offset, so on multi-byte text (Chinese/emoji/accented Latin) its byte counter overshoots the seed target and throws "Failed to determine byte offset" when saving via Ctrl/Cmd+S. Pass allowExceedingIndices to applyPatches so it returns the nearest index instead of throwing; the fuzzy match relocates the hunk and branch 6's round-trip proof already rejects any misplacement, so the seed offset need not be exact. Regression introduced by #8862. Adds a position-sweep regression test over a mostly-Chinese doc. * fix(editor): preserve Unicode reconciliation seeds * test(editor): cover rolling Unicode patch seeds --- .../rich-markdown-source-reconcile.test.ts | 40 +++++++++++++++++ .../editor/rich-markdown-source-reconcile.ts | 45 +++++++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/src/renderer/src/components/editor/rich-markdown-source-reconcile.test.ts b/src/renderer/src/components/editor/rich-markdown-source-reconcile.test.ts index 9d221d40427..49320355817 100644 --- a/src/renderer/src/components/editor/rich-markdown-source-reconcile.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-source-reconcile.test.ts @@ -341,6 +341,46 @@ describe('reconcileSerializedMarkdown', () => { reconciled.includes('_alpha edited_') && reconciled.split('_beta_').length === 3 expect(landedInStyle || reconciled === edited).toBe(true) }) + + it('reconciles a multi-byte edit without throwing "Failed to determine byte offset" (#9492)', () => { + // Regression: dmp's byte/char index mixup crashes when the patch seed lands mid multi-byte run. + // Build a mostly-Chinese doc with non-canonical `_`/`*` markers and edit a position that + // previously threw; every such edit must reconcile and stay render-equal to `edited`. + const lines: string[] = [] + for (let i = 0; i < 20; i++) { + lines.push(`_强调${i}_ 😀 café 这是一段包含很多中文字符的文本内容用来测试多字节偏移问题`) + } + const originalSource = `# 标题\n\n${lines.join('\n')}\n` + const baseCanonical = fakeCanonicalize(originalSource) + + // Sweep insert positions so we exercise the offsets that used to overshoot the byte target. + const chars = [...baseCanonical] + for (let pos = 5; pos < chars.length - 5; pos += 3) { + const inserted = ['插', '😀', 'é'][Math.floor(pos / 3) % 3] + const edited = `${chars.slice(0, pos).join('')}${inserted}${chars.slice(pos).join('')}` + + const reconciled = reconcileWithFake(originalSource, edited) + + // Never throws or falls back to a whole-document canonical rewrite. + expect(reconciled).not.toBe(edited) + expect(fakeCanonicalize(reconciled).trimEnd()).toBe(edited.trimEnd()) + } + }) + + it('keeps later multi-hunk seeds in the rolling application coordinate space', () => { + const lines = Array.from({ length: 8 }, (_, i) => `_强调${i}_ ascii segment ${i} 中文 tail`) + const originalSource = `# 标题\n\n${lines.join('\n')}\n` + const baseCanonical = fakeCanonicalize(originalSource) + // Why: the first hunk changes the UTF-8/code-unit delta before the second seed is decoded. + const edited = baseCanonical + .replace('segment 1', 'segment 1 🚀🚀') + .replace('segment 6', 'segment 6 XYZ') + + const reconciled = reconcileWithFake(originalSource, edited) + + expect(reconciled).not.toBe(edited) + expect(fakeCanonicalize(reconciled)).toBe(edited) + }) }) describe('serializeRichMarkdownForReconcile (real editor pipeline)', () => { diff --git a/src/renderer/src/components/editor/rich-markdown-source-reconcile.ts b/src/renderer/src/components/editor/rich-markdown-source-reconcile.ts index d568535b782..9a047992b0f 100644 --- a/src/renderer/src/components/editor/rich-markdown-source-reconcile.ts +++ b/src/renderer/src/components/editor/rich-markdown-source-reconcile.ts @@ -80,6 +80,15 @@ export function reconcileSerializedMarkdown({ diffs = cleanupEfficiency(diffs) } const patches = makePatches(baseLf, diffs) + // Why: applyPatches decodes starts as UTF-8 offsets even though makePatches returns UTF-16 indices; encode against the divergent text being patched so decoding preserves the fuzzy-match seed. + const utf8Offsets = getUtf8OffsetsAtCodeUnitIndices( + originalSourceLf, + patches.flatMap((patch) => [patch.start1, patch.start2]) + ) + for (const patch of patches) { + patch.start1 = utf8Offsets.get(patch.start1) ?? 0 + patch.start2 = utf8Offsets.get(patch.start2) ?? 0 + } const [reconciledLf, results] = applyPatches(patches, originalSourceLf) // Branch 5: a hunk failed to locate in the non-canonical source → unreliable fuzzy match, fall back to canonical. @@ -122,6 +131,42 @@ function normalizeForSafety(text: string): string { return text.replace(/\r\n/g, '\n') } +function getUtf8OffsetsAtCodeUnitIndices( + text: string, + codeUnitIndices: number[] +): Map { + const targets = [...new Set(codeUnitIndices)].sort((a, b) => a - b) + const offsets = new Map() + let codeUnitIndex = 0 + let byteOffset = 0 + for (const target of targets) { + const boundedTarget = Math.max(0, Math.min(target, text.length)) + while (codeUnitIndex < boundedTarget) { + const codePoint = text.codePointAt(codeUnitIndex) + if (codePoint === undefined) { + break + } + byteOffset += utf8CodePointLength(codePoint) + codeUnitIndex += codePoint > 0xffff ? 2 : 1 + } + offsets.set(target, byteOffset) + } + return offsets +} + +function utf8CodePointLength(codePoint: number): number { + if (codePoint <= 0x7f) { + return 1 + } + if (codePoint <= 0x7ff) { + return 2 + } + if (codePoint <= 0xffff) { + return 3 + } + return 4 +} + function hasRepeatedHalfMatchSeed(textA: string, textB: string): boolean { // Match diff-match-patch's prefix/suffix trimming so a small edit isn't mistaken for a repetitive replacement. const minimumLength = Math.min(textA.length, textB.length) From ede57cf723acfef6a9e7e1d9639b47c282012e21 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Mon, 20 Jul 2026 23:16:36 -0400 Subject: [PATCH 28/58] fix(windows): paste copied image files (#9640) * fix(windows): paste copied image files * fix(windows): reject multi-file image paste --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- .../window/clipboard-ipc-handlers.test.ts | 91 ++++++ src/main/window/clipboard-ipc-handlers.ts | 18 +- .../clipboard-windows-image-file.test.ts | 282 ++++++++++++++++++ .../window/clipboard-windows-image-file.ts | 218 ++++++++++++++ 4 files changed, 607 insertions(+), 2 deletions(-) create mode 100644 src/main/window/clipboard-windows-image-file.test.ts create mode 100644 src/main/window/clipboard-windows-image-file.ts diff --git a/src/main/window/clipboard-ipc-handlers.test.ts b/src/main/window/clipboard-ipc-handlers.test.ts index e679e4ae034..1e0c32b3d62 100644 --- a/src/main/window/clipboard-ipc-handlers.test.ts +++ b/src/main/window/clipboard-ipc-handlers.test.ts @@ -16,8 +16,10 @@ const { fsReaddirMock, fsRmMock, fsWriteFileMock, + fsOpenMock, fsStatMock, clipboardReadTextMock, + clipboardReadBufferMock, clipboardWriteTextMock, clipboardReadImageMock, clipboardWriteImageMock, @@ -47,8 +49,10 @@ const { fsReaddirMock: vi.fn(), fsRmMock: vi.fn(), fsWriteFileMock: vi.fn(), + fsOpenMock: vi.fn(), fsStatMock: vi.fn(), clipboardReadTextMock: vi.fn(), + clipboardReadBufferMock: vi.fn(), clipboardWriteTextMock: vi.fn(), clipboardReadImageMock: vi.fn(), clipboardWriteImageMock: vi.fn(), @@ -67,6 +71,7 @@ vi.mock('node:fs/promises', () => ({ mkdir: fsMkdirMock, readdir: fsReaddirMock, rm: fsRmMock, + open: fsOpenMock, stat: fsStatMock, default: { writeFile: fsWriteFileMock @@ -91,6 +96,7 @@ vi.mock('electron', () => ({ }, clipboard: { readText: clipboardReadTextMock, + readBuffer: clipboardReadBufferMock, writeText: clipboardWriteTextMock, readImage: clipboardReadImageMock, writeImage: clipboardWriteImageMock, @@ -170,6 +176,12 @@ function dirent(name: string, directory = true): { name: string; isDirectory: () return { name, isDirectory: () => directory } } +function shellIdListArray(childCount: number): Buffer { + const value = Buffer.alloc(4 + 4 * (childCount + 1)) + value.writeUInt32LE(childCount) + return value +} + describe('registerClipboardHandlers', () => { beforeEach(() => { vi.spyOn(Date, 'now').mockReturnValue(1760000000000) @@ -186,9 +198,12 @@ describe('registerClipboardHandlers', () => { fsRmMock.mockReset() fsRmMock.mockResolvedValue(undefined) fsWriteFileMock.mockReset() + fsOpenMock.mockReset() fsStatMock.mockReset() fsStatMock.mockResolvedValue({}) clipboardReadTextMock.mockReset() + clipboardReadBufferMock.mockReset() + clipboardReadBufferMock.mockReturnValue(Buffer.alloc(0)) clipboardWriteTextMock.mockReset() clipboardReadImageMock.mockReset() clipboardWriteImageMock.mockReset() @@ -542,9 +557,85 @@ describe('registerClipboardHandlers', () => { handlers.get('clipboard:saveImageAsTempFile')?.(makeClipboardEvent(), undefined) ).resolves.toBe(expectedPath) expect(fsWriteFileMock).toHaveBeenCalledWith(expectedPath, png) + expect(clipboardReadBufferMock).not.toHaveBeenCalled() + expect(fsOpenMock).not.toHaveBeenCalled() expect(getSshFilesystemProviderMock).not.toHaveBeenCalled() }) + it('does not inspect FileNameW when an empty image clipboard is read outside Windows', async () => { + const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('linux') + clipboardReadImageMock.mockReturnValue({ isEmpty: () => true }) + + try { + registerClipboardHandlers({} as never) + + const handler = getRegisteredHandlers().get('clipboard:saveImageAsTempFile') + await expect(handler?.(makeClipboardEvent(), undefined)).resolves.toBeNull() + expect(clipboardReadBufferMock).not.toHaveBeenCalled() + expect(fsOpenMock).not.toHaveBeenCalled() + expect(nativeImageCreateFromBufferMock).not.toHaveBeenCalled() + } finally { + platformSpy.mockRestore() + } + }) + + it('routes a Windows Explorer FileNameW image through the target-aware attachment flow', async () => { + const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + const sourcePath = 'C:\\Users\\alice\\图片\\copied-image.png' + const png = Buffer.from([4, 3, 2, 1]) + clipboardReadImageMock.mockReturnValue({ isEmpty: () => true }) + clipboardReadBufferMock.mockImplementation((format: string) => + format === 'FileNameW' ? Buffer.from(`${sourcePath}\0`, 'utf16le') : shellIdListArray(1) + ) + const source = Buffer.alloc(24) + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(source) + source.writeUInt32BE(13, 8) + source.write('IHDR', 12, 'ascii') + source.writeUInt32BE(1, 16) + source.writeUInt32BE(1, 20) + const close = vi.fn().mockResolvedValue(undefined) + fsOpenMock.mockResolvedValue({ + close, + stat: vi.fn().mockResolvedValue({ isFile: () => true, size: source.byteLength }), + read: vi.fn(async (buffer: Buffer, offset: number, length: number, position: number) => { + const bytesRead = Math.min(Math.max(source.byteLength - position, 0), length) + source.copy(buffer, offset, position, position + bytesRead) + return { buffer, bytesRead } + }) + }) + nativeImageCreateFromBufferMock.mockReturnValue({ + getSize: () => ({ height: 1, width: 1 }), + isEmpty: () => false, + toPNG: () => png + }) + const writeFileBase64 = vi.fn().mockResolvedValue(undefined) + getSshFilesystemProviderMock.mockReturnValue({ + getTempDir: vi.fn().mockResolvedValue('/var/tmp'), + writeFileBase64 + }) + + try { + registerClipboardHandlers({} as never) + + const handler = getRegisteredHandlers().get('clipboard:saveImageAsTempFile') + await expect(handler?.(makeClipboardEvent(), { connectionId: 'ssh-1' })).resolves.toBe( + '/var/tmp/orca-paste-1760000000000-00000000-0000-4000-8000-000000000000.png' + ) + expect(clipboardReadBufferMock).toHaveBeenCalledWith('FileNameW') + expect(clipboardReadBufferMock).toHaveBeenCalledWith('Shell IDList Array') + expect(fsOpenMock).toHaveBeenCalledWith(sourcePath, 'r') + expect(nativeImageCreateFromBufferMock).toHaveBeenCalledWith(source) + expect(close).toHaveBeenCalled() + expect(writeFileBase64).toHaveBeenCalledWith( + '/var/tmp/orca-paste-1760000000000-00000000-0000-4000-8000-000000000000.png', + png.toString('base64') + ) + expect(fsWriteFileMock).not.toHaveBeenCalled() + } finally { + platformSpy.mockRestore() + } + }) + it('saves clipboard images through the selected remote runtime host', async () => { const png = Buffer.alloc(512 * 1024) const contentBase64 = png.toString('base64') diff --git a/src/main/window/clipboard-ipc-handlers.ts b/src/main/window/clipboard-ipc-handlers.ts index f6c98d6bb2d..47b4d5d47ff 100644 --- a/src/main/window/clipboard-ipc-handlers.ts +++ b/src/main/window/clipboard-ipc-handlers.ts @@ -7,7 +7,7 @@ import { type WebContents } from 'electron' import { spawn } from 'node:child_process' -import { stat } from 'node:fs/promises' +import { open, stat } from 'node:fs/promises' import type { Store } from '../persistence' import { isENOENT, PATH_ACCESS_DENIED_MESSAGE, resolveAuthorizedPath } from '../ipc/filesystem-auth' import { @@ -34,6 +34,7 @@ import { writeRemoteFileToClipboard } from './clipboard-remote-file-copy' import { saveClipboardImageBufferInRuntime } from './clipboard-runtime-image-upload' +import { readWindowsClipboardImageFileAsPng } from './clipboard-windows-image-file' let trustedClipboardRendererWebContentsId: number | null = null @@ -102,7 +103,20 @@ export function registerClipboardHandlers(store: Store): void { assertTrustedClipboardSender(event) const image = clipboard.readImage() if (image.isEmpty()) { - return null + if (process.platform !== 'win32') { + return null + } + const copiedFilePng = await readWindowsClipboardImageFileAsPng( + { + fileNameW: clipboard.readBuffer('FileNameW'), + shellIdListArray: clipboard.readBuffer('Shell IDList Array') + }, + { + createImageFromBuffer: (buffer) => nativeImage.createFromBuffer(buffer), + openFile: (filePath) => open(filePath, 'r') + } + ) + return copiedFilePng ? saveClipboardImageBufferForTarget(copiedFilePng, args) : null } assertClipboardImageDimensionsWithinLimit(image.getSize()) return saveClipboardImageBufferForTarget(image.toPNG(), args) diff --git a/src/main/window/clipboard-windows-image-file.test.ts b/src/main/window/clipboard-windows-image-file.test.ts new file mode 100644 index 00000000000..d69762066d6 --- /dev/null +++ b/src/main/window/clipboard-windows-image-file.test.ts @@ -0,0 +1,282 @@ +import { describe, expect, it, vi } from 'vitest' +import { + CLIPBOARD_IMAGE_MAX_PIXELS, + CLIPBOARD_IMAGE_MAX_SOURCE_BYTES +} from '../../shared/clipboard-image' +import { readWindowsClipboardImageFileAsPng } from './clipboard-windows-image-file' + +function fileNameW(filePath: string): Buffer { + return Buffer.from(`${filePath}\0`, 'utf16le') +} + +function clipboardFormats(filePath: string, shellItemCount = 1) { + const shellIdListArray = Buffer.alloc(4 + 4 * (shellItemCount + 1)) + shellIdListArray.writeUInt32LE(shellItemCount) + return { fileNameW: fileNameW(filePath), shellIdListArray } +} + +function pngHeader(width = 10, height = 10): Buffer { + const source = Buffer.alloc(24) + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(source) + source.writeUInt32BE(13, 8) + source.write('IHDR', 12, 'ascii') + source.writeUInt32BE(width, 16) + source.writeUInt32BE(height, 20) + return source +} + +function jpegHeader(width = 10, height = 10): Buffer { + return Buffer.from([ + 0xff, + 0xd8, + 0xff, + 0xe0, + 0x00, + 0x02, + 0xff, + 0xc2, + 0x00, + 0x07, + 0x08, + height >> 8, + height & 0xff, + width >> 8, + width & 0xff + ]) +} + +function image(png = Buffer.from([4, 3, 2, 1])) { + return { + getSize: () => ({ height: 10, width: 10 }), + isEmpty: () => false, + toPNG: () => png + } +} + +function fileHandle( + source: Buffer, + options: { chunkSize?: number; isFile?: boolean; size?: number } = {} +) { + const close = vi.fn().mockResolvedValue(undefined) + const read = vi.fn(async (buffer: Buffer, offset: number, length: number, position: number) => { + const bytesRead = Math.min( + Math.max(source.byteLength - position, 0), + length, + options.chunkSize ?? Number.POSITIVE_INFINITY + ) + source.copy(buffer, offset, position, position + bytesRead) + return { buffer, bytesRead } + }) + return { + close, + read, + stat: vi.fn().mockResolvedValue({ + isFile: () => options.isFile ?? true, + size: options.size ?? source.byteLength + }) + } +} + +describe('readWindowsClipboardImageFileAsPng', () => { + it.each([ + 'C:\\Users\\alice\\图片\\shot.PNG', + '\\\\server\\share\\shot.jpeg', + '\\\\?\\C:\\Users\\alice\\shot.jpg', + '\\\\?\\UNC\\server\\share\\shot.png' + ])('decodes and converts a bounded FileNameW path: %s', async (filePath) => { + const source = filePath.toLowerCase().endsWith('.png') ? pngHeader() : jpegHeader() + const png = Buffer.from([9, 8, 7]) + const handle = fileHandle(source, { chunkSize: 3 }) + const openFile = vi.fn().mockResolvedValue(handle) + const createImageFromBuffer = vi.fn(() => image(png) as never) + + await expect( + readWindowsClipboardImageFileAsPng(clipboardFormats(filePath), { + createImageFromBuffer, + openFile + }) + ).resolves.toEqual(png) + + expect(openFile).toHaveBeenCalledWith(filePath) + expect(handle.read.mock.calls.length).toBeGreaterThan(1) + expect(handle.close).toHaveBeenCalledOnce() + expect(createImageFromBuffer).toHaveBeenCalledWith(source) + }) + + it.each([ + ['empty', Buffer.alloc(0)], + ['odd byte count', Buffer.from([65, 0, 0])], + ['missing terminator', Buffer.from('C:\\shot.png', 'utf16le')], + ['relative path', fileNameW('shot.png')], + ['drive-relative path', fileNameW('C:shot.png')], + ['rooted drive-relative path', fileNameW('\\shot.png')], + ['device namespace', fileNameW('\\\\.\\pipe\\shot.png')], + ['UNC named-pipe namespace', fileNameW('\\\\server\\pipe\\shot.png')], + ['extended UNC named-pipe namespace', fileNameW('\\\\?\\UNC\\server\\pipe\\shot.png')], + ['multiple paths', Buffer.from('C:\\one.png\0C:\\two.png\0', 'utf16le')], + ['unsupported image type', fileNameW('C:\\shot.webp')], + ['oversized payload', Buffer.alloc(64 * 1024 + 2)] + ])('ignores malformed or unsupported %s payloads', async (_name, payload) => { + const openFile = vi.fn() + const createImageFromBuffer = vi.fn() + + await expect( + readWindowsClipboardImageFileAsPng( + { fileNameW: payload, shellIdListArray: Buffer.alloc(0) }, + { createImageFromBuffer, openFile } + ) + ).resolves.toBeNull() + + expect(openFile).not.toHaveBeenCalled() + expect(createImageFromBuffer).not.toHaveBeenCalled() + }) + + it('rejects an Explorer multi-selection when FileNameW exposes only its first path', async () => { + const openFile = vi.fn() + const createImageFromBuffer = vi.fn() + + await expect( + readWindowsClipboardImageFileAsPng(clipboardFormats('C:\\one.png', 2), { + createImageFromBuffer, + openFile + }) + ).resolves.toBeNull() + + expect(openFile).not.toHaveBeenCalled() + expect(createImageFromBuffer).not.toHaveBeenCalled() + }) + + it('ignores missing, inaccessible, and looping paths', async () => { + for (const code of ['ENOENT', 'EACCES', 'ELOOP']) { + const error = Object.assign(new Error(code), { code }) + const openFile = vi.fn().mockRejectedValue(error) + + await expect( + readWindowsClipboardImageFileAsPng(clipboardFormats('C:\\shot.png'), { + createImageFromBuffer: vi.fn(), + openFile + }) + ).resolves.toBeNull() + } + }) + + it('ignores directories and closes their handles', async () => { + const handle = fileHandle(Buffer.alloc(0), { isFile: false }) + const createImageFromBuffer = vi.fn() + + await expect( + readWindowsClipboardImageFileAsPng(clipboardFormats('C:\\images.png'), { + createImageFromBuffer, + openFile: vi.fn().mockResolvedValue(handle) + }) + ).resolves.toBeNull() + + expect(handle.read).not.toHaveBeenCalled() + expect(handle.close).toHaveBeenCalledOnce() + expect(createImageFromBuffer).not.toHaveBeenCalled() + }) + + it('rejects oversized sources before reading or decoding', async () => { + const handle = fileHandle(Buffer.alloc(0), { + size: CLIPBOARD_IMAGE_MAX_SOURCE_BYTES + 1 + }) + const createImageFromBuffer = vi.fn() + + await expect( + readWindowsClipboardImageFileAsPng(clipboardFormats('C:\\huge.png'), { + createImageFromBuffer, + openFile: vi.fn().mockResolvedValue(handle) + }) + ).rejects.toThrow('Clipboard image is too large') + + expect(handle.read).not.toHaveBeenCalled() + expect(handle.close).toHaveBeenCalledOnce() + expect(createImageFromBuffer).not.toHaveBeenCalled() + }) + + it('ignores a source that changes size after handle validation', async () => { + const handle = fileHandle(Buffer.from('grew'), { size: 3 }) + const createImageFromBuffer = vi.fn() + + await expect( + readWindowsClipboardImageFileAsPng(clipboardFormats('C:\\changed.png'), { + createImageFromBuffer, + openFile: vi.fn().mockResolvedValue(handle) + }) + ).resolves.toBeNull() + + expect(handle.close).toHaveBeenCalledOnce() + expect(createImageFromBuffer).not.toHaveBeenCalled() + }) + + it('ignores image bytes the native decoder cannot decode', async () => { + const handle = fileHandle(Buffer.from('not-an-image')) + + await expect( + readWindowsClipboardImageFileAsPng(clipboardFormats('C:\\invalid.png'), { + createImageFromBuffer: vi.fn(() => ({ isEmpty: () => true }) as never), + openFile: vi.fn().mockResolvedValue(handle) + }) + ).resolves.toBeNull() + }) + + it('rejects oversized encoded dimensions before native decoding', async () => { + const handle = fileHandle(pngHeader(CLIPBOARD_IMAGE_MAX_PIXELS + 1, 1)) + const createImageFromBuffer = vi.fn() + + await expect( + readWindowsClipboardImageFileAsPng(clipboardFormats('C:\\pixel-bomb.png'), { + createImageFromBuffer, + openFile: vi.fn().mockResolvedValue(handle) + }) + ).rejects.toThrow('Clipboard image is too large') + + expect(createImageFromBuffer).not.toHaveBeenCalled() + expect(handle.close).toHaveBeenCalledOnce() + }) + + it('bounds malformed JPEG marker scanning before native decoding', async () => { + const source = Buffer.alloc(2 + 2 * 4097) + source.set([0xff, 0xd8]) + for (let offset = 2; offset < source.byteLength; offset += 2) { + source.set([0xff, 0x01], offset) + } + const createImageFromBuffer = vi.fn() + + await expect( + readWindowsClipboardImageFileAsPng(clipboardFormats('C:\\marker-flood.jpg'), { + createImageFromBuffer, + openFile: vi.fn().mockResolvedValue(fileHandle(source)) + }) + ).resolves.toBeNull() + + expect(createImageFromBuffer).not.toHaveBeenCalled() + }) + + it('bounds decoded dimensions and converted PNG bytes', async () => { + const oversizedDimensions = fileHandle(pngHeader()) + await expect( + readWindowsClipboardImageFileAsPng(clipboardFormats('C:\\wide.png'), { + createImageFromBuffer: vi.fn( + () => + ({ + getSize: () => ({ height: 1, width: CLIPBOARD_IMAGE_MAX_PIXELS + 1 }), + isEmpty: () => false, + toPNG: vi.fn() + }) as never + ), + openFile: vi.fn().mockResolvedValue(oversizedDimensions) + }) + ).rejects.toThrow('Clipboard image is too large') + + const oversizedPng = fileHandle(pngHeader()) + await expect( + readWindowsClipboardImageFileAsPng(clipboardFormats('C:\\expanded.png'), { + createImageFromBuffer: vi.fn( + () => image(Buffer.alloc(CLIPBOARD_IMAGE_MAX_SOURCE_BYTES + 1)) as never + ), + openFile: vi.fn().mockResolvedValue(oversizedPng) + }) + ).rejects.toThrow('Clipboard image is too large') + }) +}) diff --git a/src/main/window/clipboard-windows-image-file.ts b/src/main/window/clipboard-windows-image-file.ts new file mode 100644 index 00000000000..3c26bb70c4f --- /dev/null +++ b/src/main/window/clipboard-windows-image-file.ts @@ -0,0 +1,218 @@ +import { win32 } from 'node:path' +import type { FileHandle } from 'node:fs/promises' +import type { NativeImage } from 'electron' +import { + assertClipboardImageByteLengthWithinLimit, + assertClipboardImageDimensionsWithinLimit +} from '../../shared/clipboard-image' + +type ClipboardImageFileHandle = Pick + +type WindowsClipboardImageFileDeps = { + createImageFromBuffer: (buffer: Buffer) => NativeImage + openFile: (filePath: string) => Promise +} + +type WindowsClipboardImageFileFormats = { + fileNameW: Buffer + shellIdListArray: Buffer +} + +const FILE_NAME_W_MAX_BYTES = 64 * 1024 +const FILE_READ_MAX_CALLS = 1024 +const IMAGE_FILE_EXTENSION_SET = new Set(['.jpeg', '.jpg', '.png']) +const JPEG_DIMENSION_SCAN_MAX_BYTES = 1024 * 1024 +const JPEG_DIMENSION_SCAN_MAX_MARKERS = 4096 +const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]) +const JPEG_START_OF_FRAME_MARKERS = new Set([ + 0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf +]) + +function isOrdinaryUncShare(share: string | undefined): boolean { + return typeof share === 'string' && share.toLowerCase() !== 'pipe' +} + +function isFullyQualifiedWindowsPath(filePath: string): boolean { + if (/^[A-Za-z]:[\\/]/.test(filePath)) { + return true + } + if (/^\\\\\?\\[A-Za-z]:\\/.test(filePath)) { + return true + } + const extendedUnc = /^\\\\\?\\UNC\\[^\\/]+\\([^\\/]+)(?:\\|$)/i.exec(filePath) + if (extendedUnc) { + return isOrdinaryUncShare(extendedUnc[1]) + } + const unc = /^[/\\]{2}(?![?.][/\\])[^/\\]+[/\\]([^/\\]+)(?:[/\\]|$)/.exec(filePath) + return isOrdinaryUncShare(unc?.[1]) +} + +function decodeFileNameW(value: Buffer): string | null { + if ( + value.byteLength < 2 || + value.byteLength > FILE_NAME_W_MAX_BYTES || + value.byteLength % 2 !== 0 || + value.readUInt16LE(value.byteLength - 2) !== 0 + ) { + return null + } + + let end = value.byteLength - 2 + while (end >= 2 && value.readUInt16LE(end - 2) === 0) { + end -= 2 + } + const filePath = value.subarray(0, end).toString('utf16le') + if (!filePath || filePath.includes('\0') || !isFullyQualifiedWindowsPath(filePath)) { + return null + } + return IMAGE_FILE_EXTENSION_SET.has(win32.extname(filePath).toLowerCase()) ? filePath : null +} + +function hasAtMostOneShellItem(value: Buffer): boolean { + if (value.byteLength === 0) { + return true + } + // Why: Explorer's FileNameW exposes only the first path even when its CIDA has multiple items. + return value.byteLength >= 12 && value.readUInt32LE(0) === 1 +} + +function readPngDimensions(source: Buffer): { height: number; width: number } | null { + if ( + source.byteLength < 24 || + !source.subarray(0, PNG_SIGNATURE.byteLength).equals(PNG_SIGNATURE) || + source.readUInt32BE(8) !== 13 || + source.toString('ascii', 12, 16) !== 'IHDR' + ) { + return null + } + return { height: source.readUInt32BE(20), width: source.readUInt32BE(16) } +} + +function readJpegDimensions(source: Buffer): { height: number; width: number } | null { + if (source.byteLength < 4 || source[0] !== 0xff || source[1] !== 0xd8) { + return null + } + let offset = 2 + let markersRead = 0 + const scanEnd = Math.min(source.byteLength, JPEG_DIMENSION_SCAN_MAX_BYTES) + while (offset < scanEnd && markersRead < JPEG_DIMENSION_SCAN_MAX_MARKERS) { + while (offset < scanEnd && source[offset] === 0xff) { + offset += 1 + } + const marker = source[offset] + offset += 1 + markersRead += 1 + if (marker === undefined || marker === 0x00 || marker === 0xd9 || marker === 0xda) { + return null + } + if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd8)) { + continue + } + if (offset + 2 > source.byteLength) { + return null + } + const segmentLength = source.readUInt16BE(offset) + if ( + segmentLength < 2 || + offset + segmentLength > source.byteLength || + offset + segmentLength > scanEnd + ) { + return null + } + if (JPEG_START_OF_FRAME_MARKERS.has(marker)) { + if (segmentLength < 7) { + return null + } + return { height: source.readUInt16BE(offset + 3), width: source.readUInt16BE(offset + 5) } + } + offset += segmentLength + } + return null +} + +function readImageDimensions(source: Buffer): { height: number; width: number } | null { + return readPngDimensions(source) ?? readJpegDimensions(source) +} + +async function readStableFile( + handle: ClipboardImageFileHandle, + expectedSize: number +): Promise { + const buffer = Buffer.alloc(expectedSize + 1) + let bytesRead = 0 + let readCalls = 0 + while (bytesRead < buffer.byteLength && readCalls < FILE_READ_MAX_CALLS) { + const result = await handle.read(buffer, bytesRead, buffer.byteLength - bytesRead, bytesRead) + readCalls += 1 + if (result.bytesRead === 0) { + break + } + bytesRead += result.bytesRead + } + return bytesRead === expectedSize ? buffer.subarray(0, bytesRead) : null +} + +export async function readWindowsClipboardImageFileAsPng( + { fileNameW, shellIdListArray }: WindowsClipboardImageFileFormats, + { createImageFromBuffer, openFile }: WindowsClipboardImageFileDeps +): Promise { + if (!hasAtMostOneShellItem(shellIdListArray)) { + return null + } + const filePath = decodeFileNameW(fileNameW) + if (!filePath) { + return null + } + + let handle: ClipboardImageFileHandle + try { + // Why: one handle keeps validation and the bounded read on the same file if its path changes. + handle = await openFile(filePath) + } catch { + return null + } + + let source: Buffer | null = null + try { + let file: Awaited> + try { + file = await handle.stat() + } catch { + return null + } + if (!file.isFile() || !Number.isSafeInteger(file.size) || file.size < 0) { + return null + } + assertClipboardImageByteLengthWithinLimit(file.size) + try { + source = await readStableFile(handle, file.size) + } catch { + return null + } + } finally { + await handle.close().catch(() => {}) + } + if (!source) { + return null + } + const encodedDimensions = readImageDimensions(source) + if (!encodedDimensions) { + return null + } + // Why: reject pixel bombs from metadata before NativeImage allocates decoded pixels. + assertClipboardImageDimensionsWithinLimit(encodedDimensions) + + let image: NativeImage + try { + image = createImageFromBuffer(source) + } catch { + return null + } + if (image.isEmpty()) { + return null + } + assertClipboardImageDimensionsWithinLimit(image.getSize()) + const png = image.toPNG() + assertClipboardImageByteLengthWithinLimit(png.byteLength) + return png +} From cc44acaaa361cde67be05b26cd04086e7d942753 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Mon, 20 Jul 2026 23:50:25 -0400 Subject: [PATCH 29/58] Fix Windows ConPTY OSC color reply leaks at the PTY owner (#9651) --- ...onpty-startup-query-and-focus-authority.md | 50 ++++-- ...n-foreground-confirmation-protocol.test.ts | 3 +- src/main/daemon/daemon-protocol-version.ts | 6 +- src/main/daemon/daemon-pty-adapter.test.ts | 5 +- src/main/daemon/daemon-server.test.ts | 3 + src/main/daemon/daemon-server.ts | 14 +- src/main/daemon/session.test.ts | 58 ++++++- src/main/daemon/session.ts | 3 + .../terminal-host-pty-owner-backend.test.ts | 104 +++++++++++ src/main/daemon/terminal-host.ts | 6 + src/main/ipc/pty.ts | 12 +- src/main/providers/local-pty-provider.test.ts | 45 ++++- src/main/providers/local-pty-provider.ts | 6 + src/main/providers/ssh-pty-provider.test.ts | 6 +- src/main/runtime/orca-runtime.test.ts | 36 ++++ src/main/runtime/orca-runtime.ts | 7 + src/main/runtime/rpc/methods/terminal.ts | 9 + src/main/runtime/runtime-rpc.test.ts | 6 + .../runtime/terminal-view-attribute-store.ts | 15 ++ src/relay/pty-handler.test.ts | 142 ++++++++++++--- src/relay/pty-handler.ts | 16 +- .../remote-runtime-pty-transport.test.ts | 6 +- .../remote-runtime-pty-transport.ts | 2 + src/shared/pty-owner-backend.test.ts | 18 ++ src/shared/pty-owner-backend.ts | 24 +++ src/shared/pty-startup-ingress-contract.ts | 55 ++++++ src/shared/pty-startup-ingress-intent.ts | 18 +- src/shared/pty-startup-ingress.test.ts | 152 ++++++++++++++-- src/shared/pty-startup-ingress.ts | 164 ++++++++---------- 29 files changed, 798 insertions(+), 193 deletions(-) create mode 100644 src/main/daemon/terminal-host-pty-owner-backend.test.ts create mode 100644 src/shared/pty-owner-backend.test.ts create mode 100644 src/shared/pty-owner-backend.ts create mode 100644 src/shared/pty-startup-ingress-contract.ts diff --git a/docs/reference/plans/2026-07-19-windows-conpty-startup-query-and-focus-authority.md b/docs/reference/plans/2026-07-19-windows-conpty-startup-query-and-focus-authority.md index 1a0c9922a35..e7b13f0fb29 100644 --- a/docs/reference/plans/2026-07-19-windows-conpty-startup-query-and-focus-authority.md +++ b/docs/reference/plans/2026-07-19-windows-conpty-startup-query-and-focus-authority.md @@ -2,7 +2,26 @@ Date: 2026-07-19 -Status: Final decision; implementation pending +Status: Implemented with the 2026-07-20 ownership amendment below + +## 2026-07-20 Ownership Amendment + +Fresh paired-runtime evidence showed that the display/controller platform is not a safe proxy for +the PTY backend. A macOS client can attach to a native ConPTY owned by a paired Windows runtime, so +renderer-side local/SSH/remote heuristics can transfer OSC 10/11 authority to the wrong responder. + +The PTY-owning process now classifies the backend from its own platform and the shell that actually +won spawn: `windows-conpty`, `windows-wsl`, or `posix-pty`. Native ConPTY consumes complete OSC 10/11 +queries before model, replay, or view delivery. During the bounded startup window it replies once +when validated theme colors are available; after the deadline, or without colors, it consumes the +query without replying. A consuming-view handshake does not transfer this authority. Split query +candidates remain bounded and private across authority-close, expiry, and snapshot barriers; a +candidate that proves malformed is released unchanged. + +WSL and POSIX PTYs continue to transfer authority to the normal visible/hidden responder after the +startup window. The same owner-side rule applies to local, daemon, SSH-relay, and paired-runtime +PTYs. This amendment supersedes contrary transfer/fallback language below; the echo projection is +selected only by the authoritative owner backend, never by renderer or connection metadata. ## Problem @@ -31,13 +50,13 @@ the failure requires an additional agent, timing, input-mode, or replay conditio ## Root Cause -The startup OSC responder currently runs in Electron main only after runtime ingestion -([`pty.ts`](../../../src/main/ipc/pty.ts)). `LocalPtyProvider` calls the runtime before its public -data listeners ([`local-pty-provider.ts`](../../../src/main/providers/local-pty-provider.ts)), while -daemon `Session` advances sequence state, writes its emulator, persists pending output, and fans out -data before Electron main receives it ([`session.ts`](../../../src/main/daemon/session.ts)). A -renderer-only filter can therefore hide the symptom without removing it from the authoritative -model, daemon history, snapshots, or remote delivery. +OSC responders downstream of the PTY owner do not know whether the bytes came from native ConPTY, +WSL, or a POSIX PTY. `LocalPtyProvider` calls the runtime before its public data listeners +([`local-pty-provider.ts`](../../../src/main/providers/local-pty-provider.ts)), while daemon `Session` +advances sequence state, writes its emulator, persists pending output, and fans out data before +Electron main receives it ([`session.ts`](../../../src/main/daemon/session.ts)). A renderer-only +filter can therefore misclassify paired runtimes and hide the symptom without removing it from the +authoritative model, daemon history, snapshots, or remote delivery. The corrupted echo is timing-dependent but its ordering failure is deterministic: any sanitizer downstream of an authoritative consumer is too late. The independent `[I` symptom has not yet met @@ -228,14 +247,15 @@ When registered for an agent spawn, it: hidden model nor a delivered renderer can answer it a second time; 6. begins echo recognition as soon as each individual reply is written. -If attributes or a provider are unavailable, the query is not consumed. It passes through unchanged -to normal query authority. A reattach never registers startup response state, matching current -behavior. +If attributes or a provider are unavailable, native ConPTY consumes the query without replying; +WSL and POSIX PTYs pass it through unchanged to normal query authority. A reattach never registers +startup response state, matching current behavior, but native ConPTY ownership still prevents a +downstream reply. ### Exact authority transfer -Startup query interception opens only for a fresh session whose atomic creation installed the -transaction before buffered output release. It closes at the first of: +Startup query response authority opens only for a fresh session whose atomic creation installed the +transaction before buffered output release. For WSL and POSIX PTYs it closes at the first of: - both OSC 10 and OSC 11 slots have been answered; - the startup deadline expires; @@ -243,7 +263,9 @@ transaction before buffered output release. It closes at the first of: hidden-runtime ownership mark is established; - the spawn fails, is cancelled, reattaches, or exits. -Closing query interception does not discard already-written reply candidates. Echo recognition has +Native ConPTY does not transfer OSC 10/11 authority at those boundaries: the deadline stops source +replies, while complete queries remain consumed for the life of the PTY. Closing response authority +does not discard already-written reply candidates. Echo recognition has its own bounded lifetime and may finish or drain after normal authority takes over. A close and a provider callback are ordered by the source owner's per-PTY ingress queue. Transport attachment to a daemon/relay client is not a consuming-view signal. Main sends the close over a versioned control diff --git a/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts b/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts index 028fb8f0c4c..8e8a483dfd1 100644 --- a/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts +++ b/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts @@ -3,9 +3,10 @@ import { PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION } from './types' describe('foreground-confirmation daemon protocol', () => { it('rejects daemons from before the fresh-confirmation RPC', () => { - expect(PROTOCOL_VERSION).toBe(24) + expect(PROTOCOL_VERSION).toBe(25) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(19) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(22) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(23) + expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(24) }) }) diff --git a/src/main/daemon/daemon-protocol-version.ts b/src/main/daemon/daemon-protocol-version.ts index 119a3bb14d7..6b5dfe8c376 100644 --- a/src/main/daemon/daemon-protocol-version.ts +++ b/src/main/daemon/daemon-protocol-version.ts @@ -1,10 +1,10 @@ // Why: daemons survive app updates, so wire behavior must be version-gated. -export const PROTOCOL_VERSION = 24 -export const PTY_STARTUP_INGRESS_PROTOCOL_VERSION = 24 +export const PROTOCOL_VERSION = 25 +export const PTY_STARTUP_INGRESS_PROTOCOL_VERSION = 25 export const GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION = 22 export const CLEAN_DISCONNECT_PROTOCOL_VERSION = 24 export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [ - 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23 + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24 ] as const export function supportsPtyStartupIngress(protocolVersion: number): boolean { diff --git a/src/main/daemon/daemon-pty-adapter.test.ts b/src/main/daemon/daemon-pty-adapter.test.ts index 9b022a44211..eb9d5d2755e 100644 --- a/src/main/daemon/daemon-pty-adapter.test.ts +++ b/src/main/daemon/daemon-pty-adapter.test.ts @@ -151,10 +151,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { rows: 24, startupIngress: { colors: { foreground: '#2e3434', background: '#ffffff' }, - deadlineMs: 5_000, - ...(process.platform === 'win32' - ? { echoProjection: 'windows-conpty-esc-stripped' as const } - : {}) + deadlineMs: 5_000 } }) const query = '\x1b]10;?\x07' diff --git a/src/main/daemon/daemon-server.test.ts b/src/main/daemon/daemon-server.test.ts index bf7077ee774..b6588e44e69 100644 --- a/src/main/daemon/daemon-server.test.ts +++ b/src/main/daemon/daemon-server.test.ts @@ -184,6 +184,9 @@ describe('DaemonServer', () => { isNew: true, pid: 55555 }) + await expect( + c.request('closeStartupQueryAuthority', { sessionId: 'test-session' }) + ).resolves.toEqual({ appliedSeq: 0 }) }) it('keeps RPC responsive and creates one subprocess while spawn preparation is pending', async () => { diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts index 8d529911c54..9da8c2e48e3 100644 --- a/src/main/daemon/daemon-server.ts +++ b/src/main/daemon/daemon-server.ts @@ -22,7 +22,6 @@ import { checkPtySpawnHealth } from './pty-subprocess' import { createNoopDaemonFileLog, type DaemonFileLog } from './daemon-file-log' import { isTuiAgent } from '../../shared/tui-agent-config' import { parsePtyStartupIngressIntent } from '../../shared/pty-startup-ingress' -import { isNativeWindowsLocalPtySpawn } from '../runtime/terminal-model-query-authority' import { unlinkOwnedDaemonPidFile, unlinkOwnedDaemonTokenFile } from './daemon-spawner' import { CLEAN_DISCONNECT_PROTOCOL_VERSION, @@ -699,13 +698,7 @@ export class DaemonServer { terminalWindowsPowerShellImplementation: p.terminalWindowsPowerShellImplementation, shellReadySupported: p.shellReadySupported, historySeed: p.historySeed, - startupIngress: parsePtyStartupIngressIntent(p.startupIngress, { - allowWindowsEchoProjection: isNativeWindowsLocalPtySpawn({ - connectionId: null, - cwd: p.cwd, - shellOverride: p.shellOverride - }) - }), + startupIngress: parsePtyStartupIngressIntent(p.startupIngress), ...(p.shellReadyTimeoutMs !== undefined ? { shellReadyTimeoutMs: p.shellReadyTimeoutMs } : {}), @@ -779,6 +772,11 @@ export class DaemonServer { this.cancelPendingPtySpawnPreparations(request.payload.sessionId) return {} + case 'closeStartupQueryAuthority': + return { + appliedSeq: this.host.closeStartupQueryAuthority(request.payload.sessionId) + } + case 'write': try { this.lastInputAtBySessionId.set(request.payload.sessionId, performance.now()) diff --git a/src/main/daemon/session.test.ts b/src/main/daemon/session.test.ts index b5446524ebf..1f709330e2d 100644 --- a/src/main/daemon/session.test.ts +++ b/src/main/daemon/session.test.ts @@ -109,8 +109,8 @@ describe('Session', () => { startupIngress?: { colors: { foreground: string; background: string } deadlineMs: number - echoProjection?: 'windows-conpty-esc-stripped' } + ownerBackend?: 'posix-pty' | 'windows-conpty' | 'windows-wsl' wslDistro?: string }): Session { session = new Session({ @@ -120,6 +120,7 @@ describe('Session', () => { ...(opts?.launchAgent ? { launchAgent: opts.launchAgent } : {}), wslDistro: opts?.wslDistro, subprocess, + ...(opts?.ownerBackend ? { ownerBackend: opts.ownerBackend } : {}), shellReadySupported: opts?.shellReadySupported ?? false, ...(opts?.startupIngress ? { startupIngress: opts.startupIngress } : {}), ...(opts?.shellReadyTimeoutMs !== undefined @@ -196,10 +197,10 @@ describe('Session', () => { it('classifies startup queries and cooked echoes before model, persistence, and fanout', () => { createSession({ + ownerBackend: 'windows-conpty', startupIngress: { colors: { foreground: '#2e3434', background: '#ffffff' }, - deadlineMs: 5_000, - echoProjection: 'windows-conpty-esc-stripped' + deadlineMs: 5_000 } }) const onData = vi.fn() @@ -229,10 +230,10 @@ describe('Session', () => { it('releases a held cooked-echo prefix before taking a snapshot', () => { createSession({ + ownerBackend: 'windows-conpty', startupIngress: { colors: { foreground: '#2e3434', background: '#ffffff' }, - deadlineMs: 5_000, - echoProjection: 'windows-conpty-esc-stripped' + deadlineMs: 5_000 } }) subprocess.simulateData('\x1b]10;?\x07') @@ -243,6 +244,53 @@ describe('Session', () => { expect(snapshot?.snapshotAnsi).toContain(']10;rgb:2e2e/') expect(snapshot?.outputSequence).toBe('\x1b]10;?\x07]10;rgb:2e2e/'.length) }) + + it('reproduces the legacy paired-runtime leak and removes its downstream producer', () => { + const query = '\x1b]10;?\x07' + const reply = '\x1b]10;rgb:2e2e/3434/3434\x1b\\' + const projectedEcho = ']10;rgb:2e2e/3434/3434\\' + createSession({ ownerBackend: 'posix-pty' }) + session.closeStartupQueryAuthority() + const legacyReplyProducers: string[] = [] + const legacyOnData = vi.fn((data: string) => { + if (data === query) { + legacyReplyProducers.push('remote-visible-renderer') + session.write(reply) + if (subprocess.written.at(-1) === reply) { + subprocess.simulateData(projectedEcho) + } + } + }) + session.attachClient({ onData: legacyOnData, onExit: () => {} }) + + subprocess.simulateData(query) + + expect(legacyReplyProducers).toEqual(['remote-visible-renderer']) + expect(subprocess.written).toEqual([reply]) + expect(legacyOnData.mock.calls).toEqual([[query], [projectedEcho]]) + expect(session.getSnapshot()?.snapshotAnsi).toContain(projectedEcho) + session.dispose() + + subprocess = createMockSubprocess() + createSession({ ownerBackend: 'windows-conpty' }) + session.closeStartupQueryAuthority() + const fixedReplyProducers: string[] = [] + const fixedOnData = vi.fn((data: string) => { + if (data === query) { + fixedReplyProducers.push('remote-visible-renderer') + session.write(reply) + } + }) + session.attachClient({ onData: fixedOnData, onExit: () => {} }) + + subprocess.simulateData(query) + subprocess.simulateData('prompt') + + expect(fixedReplyProducers).toEqual([]) + expect(subprocess.written).toEqual([]) + expect(fixedOnData.mock.calls).toEqual([['', query.length, true, query.length], ['prompt']]) + expect(session.getSnapshot()?.snapshotAnsi).not.toContain(']10;rgb') + }) }) describe('write', () => { diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index c88061403a4..67a51d6a37d 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -24,6 +24,7 @@ import type { TakePendingOutputResult, TerminalSnapshot } from './types' +import type { PtyOwnerBackend } from '../../shared/pty-owner-backend' const SHELL_READY_TIMEOUT_MS = 15_000 // Why: Codex skips marker-gated command delivery; this only bounds older daemon/local paths that still report shell-ready for Codex. @@ -85,6 +86,7 @@ export type SessionOptions = { // a reaper, dead sessions and their scrollback emulators accumulate for the daemon's lifetime. onExit?: (code: number) => void startupIngress?: PtyStartupIngressIntent + ownerBackend?: PtyOwnerBackend } type AttachedClient = { @@ -158,6 +160,7 @@ export class Session { this.postReadyFlushGate = new PostReadyFlushGate(() => this.flushPreReadyQueue()) this.startupIngress = new PtyStartupIngress({ ...(opts.startupIngress ? { intent: opts.startupIngress } : {}), + ...(opts.ownerBackend ? { ownerBackend: opts.ownerBackend } : {}), write: (data) => this.subprocess.write(data), onEmission: (emission) => this.emitSubprocessOutput(emission) }) diff --git a/src/main/daemon/terminal-host-pty-owner-backend.test.ts b/src/main/daemon/terminal-host-pty-owner-backend.test.ts new file mode 100644 index 00000000000..446e0923f39 --- /dev/null +++ b/src/main/daemon/terminal-host-pty-owner-backend.test.ts @@ -0,0 +1,104 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { SubprocessHandle } from './session' +import { TerminalHost } from './terminal-host' + +const killWithDescendantSweepMock = vi.hoisted(() => vi.fn()) +vi.mock('../pty-descendant-termination', () => ({ + killWithDescendantSweep: killWithDescendantSweepMock +})) + +type TestSubprocess = SubprocessHandle & { + emitData: (data: string) => void +} + +function createSubprocess(shellPath: string): TestSubprocess { + let onData: ((data: string) => void) | null = null + let onExit: ((code: number) => void) | null = null + return { + pid: 99_999, + shellPath, + getForegroundProcess: vi.fn(() => null), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(() => onExit?.(0)), + forceKill: vi.fn(() => onExit?.(137)), + signal: vi.fn(), + onData: (callback) => { + onData = callback + }, + onExit: (callback) => { + onExit = callback + }, + dispose: vi.fn(), + emitData: (data) => onData?.(data) + } +} + +describe('TerminalHost PTY owner backend', () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + let host: TerminalHost + + beforeEach(() => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + }) + + afterEach(async () => { + await host?.dispose() + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + }) + + async function createSession( + shellPath: string, + requestedWslDistro?: string, + onData = vi.fn() + ): Promise { + const subprocess = createSubprocess(shellPath) + host = new TerminalHost({ spawnSubprocess: () => subprocess }) + await host.createOrAttach({ + sessionId: 'owner-test', + cols: 80, + rows: 24, + ...(requestedWslDistro + ? { shellOverride: 'wsl.exe', terminalWindowsWslDistro: requestedWslDistro } + : { shellOverride: 'powershell.exe' }), + streamClient: { onData, onExit: vi.fn() } + }) + return subprocess + } + + it('uses the spawned native shell over stale requested WSL metadata', async () => { + const replyProducers: string[] = [] + const onData = vi.fn((data: string) => { + if (data === '\x1b]10;?\x07') { + replyProducers.push('renderer') + host.write('owner-test', '\x1b]10;rgb:ffff/ffff/ffff\x1b\\') + } + }) + const subprocess = await createSession('powershell.exe', 'Ubuntu', onData) + + subprocess.emitData('\x1b]10;?\x07') + + expect(replyProducers).toEqual([]) + expect(onData).toHaveBeenCalledWith('', '\x1b]10;?\x07'.length, true, '\x1b]10;?\x07'.length) + expect(subprocess.write).not.toHaveBeenCalled() + }) + + it('keeps replies for an actually spawned WSL shell', async () => { + const reply = '\x1b]10;rgb:ffff/ffff/ffff\x1b\\' + const replyProducers: string[] = [] + const onData = vi.fn((data: string) => { + if (data === '\x1b]10;?\x07') { + replyProducers.push('renderer') + host.write('owner-test', reply) + } + }) + const subprocess = await createSession('wsl.exe', undefined, onData) + + subprocess.emitData('\x1b]10;?\x07') + + expect(replyProducers).toEqual(['renderer']) + expect(subprocess.write).toHaveBeenCalledWith(reply) + }) +}) diff --git a/src/main/daemon/terminal-host.ts b/src/main/daemon/terminal-host.ts index ea1093572aa..e6e0eb01145 100644 --- a/src/main/daemon/terminal-host.ts +++ b/src/main/daemon/terminal-host.ts @@ -1,6 +1,7 @@ import { Session } from './session' import { normalizePtySize } from './daemon-pty-size' import { shellPathSupportsPtyStartupBarrier } from './shell-ready' +import { resolvePtyOwnerBackend } from '../../shared/pty-owner-backend' import { resolveProcessCwd } from '../providers/process-cwd' import { buildStartupCommandSubmission } from '../../shared/startup-command-submission' import { @@ -111,6 +112,11 @@ export class TerminalHost { terminalHandle: opts.env?.ORCA_TERMINAL_HANDLE, launchAgent: opts.launchAgent, subprocess, + ownerBackend: resolvePtyOwnerBackend({ + platform: process.platform, + shellPath: subprocess.shellPath, + wslDistro + }), shellReadySupported, historySeed: opts.historySeed, ...(opts.startupIngress ? { startupIngress: opts.startupIngress } : {}), diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index c14ba872cf6..4f2e0fa1b7c 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -2849,6 +2849,13 @@ export function registerPtyHandlers( env, ...(isMintedSessionId ? { isNewSession: true } : {}) } + const startupTerminalColorQueryReplyColors = getStartupTerminalColorQueryReplyColors(args) + if (startupTerminalColorQueryReplyColors) { + spawnOptions.startupIngress = { + colors: startupTerminalColorQueryReplyColors, + deadlineMs: 5_000 + } + } spawnOptions.envToDelete = mergePtyEnvDeletions( mergePtyEnvDeletions(authEnvToDelete, args.envToDelete ?? []), isDaemonHostSpawn ? getInheritedAgentHookEnvKeysToDelete(env) : [] @@ -3831,10 +3838,7 @@ export function registerPtyHandlers( if (startupTerminalColorQueryReplyColors) { spawnOptions.startupIngress = { colors: startupTerminalColorQueryReplyColors, - deadlineMs: 5_000, - ...(nativeWindowsConptySpawn - ? { echoProjection: 'windows-conpty-esc-stripped' as const } - : {}) + deadlineMs: 5_000 } } const existingPaneSpawn = reservationPaneKey diff --git a/src/main/providers/local-pty-provider.test.ts b/src/main/providers/local-pty-provider.test.ts index 8609d97d18f..94b657f39c6 100644 --- a/src/main/providers/local-pty-provider.test.ts +++ b/src/main/providers/local-pty-provider.test.ts @@ -1612,6 +1612,7 @@ describe('LocalPtyProvider', () => { }) it('classifies startup queries before runtime and public data listeners', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const runtimeData = vi.fn() const dataHandler = vi.fn() provider.configure({ onData: runtimeData }) @@ -1621,8 +1622,7 @@ describe('LocalPtyProvider', () => { rows: 24, startupIngress: { colors: { foreground: '#2e3434', background: '#ffffff' }, - deadlineMs: 5_000, - echoProjection: 'windows-conpty-esc-stripped' + deadlineMs: 5_000 } }) const onDataCb = mockProc.onData.mock.calls[0][0] @@ -1652,6 +1652,47 @@ describe('LocalPtyProvider', () => { ]) }) + it('consumes a native Windows OSC color query before renderer delivery', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const dataHandler = vi.fn() + provider.onData(dataHandler) + const { id } = await provider.spawn({ + cols: 80, + rows: 24, + shellOverride: 'powershell.exe' + }) + const onDataCb = mockProc.onData.mock.calls[0][0] + const query = '\x1b]10;?\x07' + + onDataCb(query) + + expect(dataHandler).toHaveBeenCalledWith({ + id, + data: '', + sequenceChars: query.length, + seq: query.length, + transformed: true + }) + expect(mockProc.write).not.toHaveBeenCalled() + }) + + it('keeps forwarded OSC color replies for a Windows-owned WSL PTY', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const { id } = await provider.spawn({ + cols: 80, + rows: 24, + shellOverride: 'wsl.exe', + terminalWindowsWslDistro: 'Ubuntu' + }) + const onDataCb = mockProc.onData.mock.calls[0][0] + const reply = '\x1b]11;rgb:ffff/ffff/ffff\x1b\\' + + onDataCb('\x1b]11;?\x07') + provider.write(id, reply) + + expect(mockProc.write).toHaveBeenCalledWith(reply) + }) + it('notifies exit listeners when PTY exits', async () => { const exitHandler = vi.fn() provider.onExit(exitHandler) diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 9eff8feeacf..276d0dd421c 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -66,6 +66,7 @@ import { ORCA_HERMES_STARTUP_QUERY_ENV } from '../../shared/hermes-startup-query import { PhysicalExitTracker } from '../../shared/physical-exit-tracker' import { mergeGitConfigEnvProtocol } from '../../shared/git-credential-prompt-env' import { PtyStartupIngress, type PtyIngressEmission } from '../../shared/pty-startup-ingress' +import { resolvePtyOwnerBackend } from '../../shared/pty-owner-backend' const PANE_IDENTITY_ENV_KEYS = [ 'ORCA_PANE_KEY', @@ -878,6 +879,11 @@ export class LocalPtyProvider implements IPtyProvider { } const startupIngress = new PtyStartupIngress({ ...(args.startupIngress ? { intent: args.startupIngress } : {}), + ownerBackend: resolvePtyOwnerBackend({ + platform: process.platform, + shellPath, + wslDistro: spawnedWslDistro + }), write: (data) => proc.write(data), onEmission: emitIngressData }) diff --git a/src/main/providers/ssh-pty-provider.test.ts b/src/main/providers/ssh-pty-provider.test.ts index 4a2bf02546a..cac21f2fe97 100644 --- a/src/main/providers/ssh-pty-provider.test.ts +++ b/src/main/providers/ssh-pty-provider.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi, beforeEach } from 'vitest' import { SshPtyProvider } from './ssh-pty-provider' import { POWERLEVEL10K_WIZARD_DISABLE_ENV } from '../pty/powerlevel10k-wizard-env' +import { PTY_STARTUP_INGRESS_VERSION } from '../../shared/pty-startup-ingress' type MockMultiplexer = { request: ReturnType @@ -60,7 +61,10 @@ describe('SshPtyProvider', () => { expect(mux.request).toHaveBeenCalledWith( 'pty.spawn', - expect.objectContaining({ startupIngressVersion: 1, startupIngress }) + expect.objectContaining({ + startupIngressVersion: PTY_STARTUP_INGRESS_VERSION, + startupIngress + }) ) }) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 27cdc239baf..55a513ffc13 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -98,6 +98,10 @@ import { RpcDispatcher } from './rpc/dispatcher' import type { RpcRequest } from './rpc/core' import { TERMINAL_METHODS } from './rpc/methods/terminal' import { beginWatcherInstall } from '../ipc/watcher-removal-gate' +import { + _resetTerminalViewAttributesForTest, + setTerminalViewAttributes +} from './terminal-view-attribute-store' const ORIGINAL_PLATFORM = process.platform const ORIGINAL_PLATFORM_DESCRIPTOR = Object.getOwnPropertyDescriptor(process, 'platform') @@ -587,6 +591,7 @@ vi.mock('../git/git-username', async () => { function resetRuntimeTestMocks(): void { resetPlatform() + _resetTerminalViewAttributesForTest() advertisedUrlWatcher.clear() electronMocks.BrowserWindow.fromId.mockReset() electronMocks.BrowserWindow.fromId.mockReturnValue(null) @@ -10246,6 +10251,37 @@ describe('OrcaRuntimeService', () => { }) }) + it('passes cached view colors to background agent spawns for source-owned startup replies', async () => { + setTerminalViewAttributes({ + foreground: [0xff, 0xff, 0xff], + background: [0x28, 0x2c, 0x34], + cursor: [0xff, 0xff, 0xff], + ansi: Array.from({ length: 256 }, () => [0, 0, 0] as [number, number, number]), + colorSchemeMode: 'dark', + cursorStyle: 'block', + cursorBlink: false + }) + const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' }) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + + await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { command: 'codex' }) + + expect(spawn).toHaveBeenCalledWith( + expect.objectContaining({ + terminalColorQueryReplies: { + foreground: '#ffffff', + background: '#282c34' + } + }) + ) + }) + it('applies Settings agent defaults to bare agent command terminal creates', async () => { const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' }) const runtimeStore = { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index c7271fa7f74..8fc9500c500 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -14,6 +14,7 @@ import { extractLastOsc7Uri, extractOscScanTail } from '../daemon/osc7-uri-extra import { parseFileUriPathParts } from '../daemon/osc7-file-uri' import type { AgentStatus } from '../../shared/agent-detection' import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges' +import type { TerminalOscColorQueryReplyColors } from '../../shared/terminal-osc-color-reply' import { createTerminalTitleTracker, stripBrailleSpinnerGlyphs, @@ -759,6 +760,7 @@ import { } from './terminal-model-query-authority' import { getTerminalViewAttributes, + getTerminalViewColorQueryReplyColors, registerTerminalViewAttributesApplier } from './terminal-view-attribute-store' import { killAllProcessesForWorktree, teardownRpcDeadline } from './worktree-teardown' @@ -1077,6 +1079,7 @@ type TerminalCreateOptions = { launchConfig?: WorktreeStartupLaunch['launchConfig'] launchToken?: string launchAgent?: TuiAgent + terminalColorQueryReplies?: TerminalOscColorQueryReplyColors viewMode?: 'terminal' | 'chat' startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] telemetry?: WorktreeStartupLaunch['telemetry'] @@ -1276,6 +1279,7 @@ type RuntimePtyController = { leafId?: string sessionId?: string persistHostSessionBinding?: boolean + terminalColorQueryReplies?: { foreground?: string; background?: string } }): Promise<{ id: string; wslDistro?: string }> write(ptyId: string, data: string): boolean kill(ptyId: string): boolean @@ -19190,6 +19194,8 @@ export class OrcaRuntimeService { tabId, agentTeamsPlan?.env ) + const terminalColorQueryReplies = + launchOpts.terminalColorQueryReplies ?? getTerminalViewColorQueryReplyColors() const result = await this.ptyController.spawn({ cols: 120, rows: 40, @@ -19211,6 +19217,7 @@ export class OrcaRuntimeService { preAllocatedHandle, tabId, leafId, + ...(terminalColorQueryReplies ? { terminalColorQueryReplies } : {}), ...(launchOpts.sessionId ? { sessionId: launchOpts.sessionId } : {}), // Why: a headless-created pane has no renderer session writer. Persist // its tab/leaf binding at spawn so a later promoted window reattaches diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 4be8d98c94d..83f550c75ef 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -915,6 +915,12 @@ const TerminalCreateParams = z.object({ .optional(), launchToken: OptionalString, launchAgent: z.string().refine(isTuiAgent).optional(), + terminalColorQueryReplies: z + .object({ + foreground: z.string().max(128).optional(), + background: z.string().max(128).optional() + }) + .optional(), title: OptionalString, focus: z.unknown().optional(), rendererBacked: z.unknown().optional(), @@ -1350,6 +1356,9 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ ...(params.launchConfig ? { launchConfig: params.launchConfig } : {}), ...(params.launchToken ? { launchToken: params.launchToken } : {}), ...(params.launchAgent ? { launchAgent: params.launchAgent } : {}), + ...(params.terminalColorQueryReplies + ? { terminalColorQueryReplies: params.terminalColorQueryReplies } + : {}), title: params.title, focus: params.focus === true, rendererBacked: params.rendererBacked === true, diff --git a/src/main/runtime/runtime-rpc.test.ts b/src/main/runtime/runtime-rpc.test.ts index baa6ad47ee1..2c5d069b941 100644 --- a/src/main/runtime/runtime-rpc.test.ts +++ b/src/main/runtime/runtime-rpc.test.ts @@ -3045,6 +3045,7 @@ describe('OrcaRuntimeRpcServer', () => { params: { worktree: 'id:repo-1::/tmp/worktree-a', command: "claude 'work on the issue'", + terminalColorQueryReplies: { foreground: '#ffffff', background: '#282c34' }, tabId: 'laptop-tab', leafId, presentation: 'background' @@ -3064,6 +3065,11 @@ describe('OrcaRuntimeRpcServer', () => { expect( (createResponse.result as { terminal?: { warning?: string } } | undefined)?.terminal?.warning ).toBeUndefined() + expect(spawn).toHaveBeenCalledWith( + expect.objectContaining({ + terminalColorQueryReplies: { foreground: '#ffffff', background: '#282c34' } + }) + ) runtime.onPtyData('laptop-created-pty', '\x1b]0;Claude working\x07', 456) runtime.onPtyData('laptop-created-pty', 'Claude is working...\r\n', 456) diff --git a/src/main/runtime/terminal-view-attribute-store.ts b/src/main/runtime/terminal-view-attribute-store.ts index 2743dd70e3b..be33324d81e 100644 --- a/src/main/runtime/terminal-view-attribute-store.ts +++ b/src/main/runtime/terminal-view-attribute-store.ts @@ -13,6 +13,7 @@ import { terminalViewAttributesEqual, type TerminalViewAttributes } from '../../shared/terminal-view-attributes' +import type { TerminalOscColorQueryReplyColors } from '../../shared/terminal-osc-color-reply' // Why module state (pattern of pty-hidden-delivery-gate.ts): pty.ts receives // the push, the runtime emulators consult it at reply time via the getter. @@ -50,6 +51,20 @@ export function getTerminalViewAttributes(): TerminalViewAttributes | null { return currentAttributes } +function rgbToCssHex(rgb: readonly [number, number, number]): string { + return `#${rgb.map((channel) => channel.toString(16).padStart(2, '0')).join('')}` +} + +export function getTerminalViewColorQueryReplyColors(): TerminalOscColorQueryReplyColors | null { + if (!currentAttributes) { + return null + } + return { + foreground: rgbToCssHex(currentAttributes.foreground), + background: rgbToCssHex(currentAttributes.background) + } +} + /** Test seam: reset module state between tests. */ export function _resetTerminalViewAttributesForTest(): void { currentAttributes = null diff --git a/src/relay/pty-handler.test.ts b/src/relay/pty-handler.test.ts index d7242234053..36881b2449d 100644 --- a/src/relay/pty-handler.test.ts +++ b/src/relay/pty-handler.test.ts @@ -1010,29 +1010,131 @@ describe('PtyHandler', () => { }) it('leaves startup queries untouched for an unsupported relay capability version', async () => { - let dataCallback: ((data: string) => void) | undefined - const term = { - ...mockPtyInstance, - onData: vi.fn((cb: (data: string) => void) => { - dataCallback = cb - }), - onExit: vi.fn() - } - mockPtySpawn.mockReturnValue(term) - await dispatcher.callRequest('pty.spawn', { - startupIngressVersion: PTY_STARTUP_INGRESS_VERSION - 1, - startupIngress: { - colors: { foreground: '#2e3434', background: '#ffffff' }, - deadlineMs: 5_000 + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) + try { + let dataCallback: ((data: string) => void) | undefined + const term = { + ...mockPtyInstance, + onData: vi.fn((cb: (data: string) => void) => { + dataCallback = cb + }), + onExit: vi.fn() } - }) + mockPtySpawn.mockReturnValue(term) + await dispatcher.callRequest('pty.spawn', { + startupIngressVersion: PTY_STARTUP_INGRESS_VERSION - 1, + startupIngress: { + colors: { foreground: '#2e3434', background: '#ffffff' }, + deadlineMs: 5_000 + } + }) - const query = '\x1b]10;?\x07' - dataCallback!(query) - vi.advanceTimersByTime(8) + const query = '\x1b]10;?\x07' + dataCallback!(query) + vi.advanceTimersByTime(8) - expect(term.write).not.toHaveBeenCalled() - expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', { id: 'pty-1', data: query }) + expect(term.write).not.toHaveBeenCalled() + expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', { id: 'pty-1', data: query }) + } finally { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + } + }) + + it('consumes a color query at a native Windows SSH relay owner', async () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + try { + let dataCallback: ((data: string) => void) | undefined + const term = { + ...mockPtyInstance, + onData: vi.fn((cb: (data: string) => void) => { + dataCallback = cb + }), + onExit: vi.fn() + } + mockPtySpawn.mockReturnValue(term) + await dispatcher.callRequest('pty.spawn', { shellOverride: 'powershell.exe' }) + + dataCallback!('\x1b]10;?\x07') + vi.advanceTimersByTime(8) + + expect(term.write).not.toHaveBeenCalled() + expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', { + id: 'pty-1', + data: '', + rawLength: '\x1b]10;?\x07'.length, + seq: '\x1b]10;?\x07'.length, + transformed: true + }) + } finally { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + } + }) + + it('forwards color queries from a POSIX SSH relay owner', async () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) + try { + let dataCallback: ((data: string) => void) | undefined + mockPtySpawn.mockReturnValue({ + ...mockPtyInstance, + onData: vi.fn((cb: (data: string) => void) => { + dataCallback = cb + }), + onExit: vi.fn() + }) + await dispatcher.callRequest('pty.spawn', { shellOverride: '/bin/bash' }) + const query = '\x1b]10;?\x07' + + dataCallback!(query) + vi.advanceTimersByTime(8) + + expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', { id: 'pty-1', data: query }) + } finally { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + } + }) + + it('keeps renderer color replies for a Windows SSH relay that owns WSL', async () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + try { + let dataCallback: ((data: string) => void) | undefined + const term = { + ...mockPtyInstance, + onData: vi.fn((cb: (data: string) => void) => { + dataCallback = cb + }), + onExit: vi.fn() + } + mockPtySpawn.mockReturnValue(term) + await dispatcher.callRequest('pty.spawn', { + shellOverride: 'wsl.exe', + terminalWindowsWslDistro: 'Ubuntu' + }) + const reply = '\x1b]11;rgb:ffff/ffff/ffff\x1b\\' + + dataCallback!('\x1b]11;?\x07') + vi.advanceTimersByTime(8) + dispatcher.callNotification('pty.data', { id: 'pty-1', data: reply }) + + expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', { + id: 'pty-1', + data: '\x1b]11;?\x07' + }) + expect(term.write).toHaveBeenCalledWith(reply) + } finally { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + } }) it('coalesces background PTY output before notifying the client', async () => { diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index ebdb7cea9e0..95a95023a57 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -45,6 +45,7 @@ import { parsePtyStartupIngressIntent, type PtyIngressEmission } from '../shared/pty-startup-ingress' +import { resolvePtyOwnerBackend, type PtyOwnerBackend } from '../shared/pty-owner-backend' function isMissingNodePtyNativeBinding(error: unknown): boolean { return ( @@ -80,6 +81,7 @@ type ManagedPty = { gracefulKillSent?: boolean startupIngress?: PtyStartupIngress startupIngressIntent?: ReturnType + ownerBackend: PtyOwnerBackend } type PendingPtyOutput = { @@ -500,6 +502,7 @@ export class PtyHandler { } managed.startupIngress ??= new PtyStartupIngress({ ...(managed.startupIngressIntent ? { intent: managed.startupIngressIntent } : {}), + ownerBackend: managed.ownerBackend, write: (data) => managed.pty.write(data), onEmission: emitIngressData }) @@ -901,9 +904,7 @@ export class PtyHandler { const worktreeId = typeof env?.ORCA_WORKTREE_ID === 'string' ? env.ORCA_WORKTREE_ID : undefined const startupIngressIntent = params.startupIngressVersion === PTY_STARTUP_INGRESS_VERSION - ? parsePtyStartupIngressIntent(params.startupIngress, { - allowWindowsEchoProjection: false - }) + ? parsePtyStartupIngressIntent(params.startupIngress) : undefined const managed: ManagedPty = { id, @@ -917,6 +918,11 @@ export class PtyHandler { ...(explicitTerm !== undefined ? { explicitTerm } : {}), envToDelete, gitCredentialPromptGuarded, + ownerBackend: resolvePtyOwnerBackend({ + platform: process.platform, + shellPath: shell, + wslDistro: terminalWindowsWslDistro + }), ...(startupIngressIntent ? { startupIngressIntent } : {}), ...(terminalHandle ? { terminalHandle } : {}), ...(shouldProviderDeliverCommand @@ -1324,6 +1330,10 @@ export class PtyHandler { ...(explicitTerm !== undefined ? { explicitTerm } : {}), envToDelete, gitCredentialPromptGuarded, + ownerBackend: resolvePtyOwnerBackend({ + platform: process.platform, + shellPath: shell + }), ...(entry.terminalHandle ? { terminalHandle: entry.terminalHandle } : {}) }) diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts index ea7e6dae470..f5ef551723a 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts @@ -1140,7 +1140,8 @@ describe('createRemoteRuntimePtyTransport', () => { leafId: 'pane:1', command: "codex 'linked issue context'", envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME'], - startupCommandDelivery: 'shell-ready' + startupCommandDelivery: 'shell-ready', + terminalColorQueryReplies: { foreground: '#ffffff', background: '#282c34' } }) await transport.connect({ url: '', callbacks: {} }) @@ -1152,7 +1153,8 @@ describe('createRemoteRuntimePtyTransport', () => { params: expect.objectContaining({ command: "codex 'linked issue context'", envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME'], - startupCommandDelivery: 'shell-ready' + startupCommandDelivery: 'shell-ready', + terminalColorQueryReplies: { foreground: '#ffffff', background: '#282c34' } }) }) ) diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts index f5edb5ab6d0..7c2f8d4d4e6 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts @@ -70,6 +70,7 @@ export function createRemoteRuntimePtyTransport( launchConfig, launchToken, launchAgent, + terminalColorQueryReplies, worktreeId, tabId, leafId, @@ -791,6 +792,7 @@ export function createRemoteRuntimePtyTransport( ...(launchConfigToSend !== undefined ? { launchConfig: launchConfigToSend } : {}), ...(launchTokenToSend !== undefined ? { launchToken: launchTokenToSend } : {}), ...(launchAgentToSend !== undefined ? { launchAgent: launchAgentToSend } : {}), + ...(terminalColorQueryReplies ? { terminalColorQueryReplies } : {}), tabId, leafId, focus: false, diff --git a/src/shared/pty-owner-backend.test.ts b/src/shared/pty-owner-backend.test.ts new file mode 100644 index 00000000000..cda932e69df --- /dev/null +++ b/src/shared/pty-owner-backend.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' +import { resolvePtyOwnerBackend } from './pty-owner-backend' + +describe('resolvePtyOwnerBackend', () => { + it.each([ + ['win32', 'powershell.exe', null, 'windows-conpty'], + ['win32', 'C:\\Program Files\\Git\\bin\\bash.exe', null, 'windows-conpty'], + ['win32', undefined, null, 'windows-conpty'], + ['win32', 'wsl.exe', null, 'windows-wsl'], + ['win32', 'C:\\Windows\\System32\\wsl.exe', 'Ubuntu', 'windows-wsl'], + ['win32', 'powershell.exe', 'Ubuntu', 'windows-conpty'], + ['win32', undefined, 'Ubuntu', 'windows-wsl'], + ['darwin', '/bin/zsh', null, 'posix-pty'], + ['linux', '/bin/bash', null, 'posix-pty'] + ] as const)('%s %s with %s resolves to %s', (platform, shellPath, wslDistro, expected) => { + expect(resolvePtyOwnerBackend({ platform, shellPath, wslDistro })).toBe(expected) + }) +}) diff --git a/src/shared/pty-owner-backend.ts b/src/shared/pty-owner-backend.ts new file mode 100644 index 00000000000..cbd27e09f60 --- /dev/null +++ b/src/shared/pty-owner-backend.ts @@ -0,0 +1,24 @@ +export type PtyOwnerBackend = 'posix-pty' | 'windows-conpty' | 'windows-wsl' + +function shellBasename(shellPath: string | undefined): string { + return shellPath?.replaceAll('\\', '/').split('/').pop()?.toLowerCase() ?? '' +} + +export function resolvePtyOwnerBackend(args: { + platform: NodeJS.Platform + shellPath: string | undefined + wslDistro?: string | null +}): PtyOwnerBackend { + if (args.platform !== 'win32') { + return 'posix-pty' + } + const shell = shellBasename(args.shellPath) + if (shell === 'wsl.exe' || shell === 'wsl') { + return 'windows-wsl' + } + // Why: requested WSL metadata can survive a spawn fallback; the winning native shell owns the PTY. + if (!shell && args.wslDistro) { + return 'windows-wsl' + } + return 'windows-conpty' +} diff --git a/src/shared/pty-startup-ingress-contract.ts b/src/shared/pty-startup-ingress-contract.ts new file mode 100644 index 00000000000..c8e865faa99 --- /dev/null +++ b/src/shared/pty-startup-ingress-contract.ts @@ -0,0 +1,55 @@ +import type { PtyOwnerBackend } from './pty-owner-backend' +import type { PtyStartupIngressIntent } from './pty-startup-ingress-intent' + +export type PtyIngressEmission = { + data: string + rawStartSeq: number + rawEndSeq: number + transformed: boolean +} + +export type PtyStartupIngressOptions = { + intent?: PtyStartupIngressIntent + ownerBackend?: PtyOwnerBackend + write: (data: string) => void + onEmission: (emission: PtyIngressEmission) => void +} + +export type PtyIngressSourceSpan = { + data: string + rawStartSeq: number + rawEndSeq: number +} + +export type PtyStartupIngressOperation = + | { kind: 'data'; chunk: PtyIngressSourceSpan } + | { kind: 'close-query' } + | { kind: 'snapshot' } + | { kind: 'teardown' } + | { kind: 'expire' } + +export function slicePtyIngressSourceSpan( + span: PtyIngressSourceSpan, + start: number, + end = span.data.length +): PtyIngressSourceSpan { + return { + data: span.data.slice(start, end), + rawStartSeq: span.rawStartSeq + start, + rawEndSeq: span.rawStartSeq + end + } +} + +export function combinePtyIngressSourceSpans( + first: PtyIngressSourceSpan | null, + second: PtyIngressSourceSpan +): PtyIngressSourceSpan { + if (!first) { + return second + } + return { + data: first.data + second.data, + rawStartSeq: first.rawStartSeq, + rawEndSeq: second.rawEndSeq + } +} diff --git a/src/shared/pty-startup-ingress-intent.ts b/src/shared/pty-startup-ingress-intent.ts index 8b58c1183b6..8fed61ee067 100644 --- a/src/shared/pty-startup-ingress-intent.ts +++ b/src/shared/pty-startup-ingress-intent.ts @@ -6,15 +6,11 @@ import { export type PtyStartupIngressIntent = { colors: TerminalOscColorQueryReplyColors deadlineMs: number - echoProjection?: 'windows-conpty-esc-stripped' } -export const PTY_STARTUP_INGRESS_VERSION = 1 +export const PTY_STARTUP_INGRESS_VERSION = 2 -export function parsePtyStartupIngressIntent( - value: unknown, - options: { allowWindowsEchoProjection: boolean } -): PtyStartupIngressIntent | undefined { +export function parsePtyStartupIngressIntent(value: unknown): PtyStartupIngressIntent | undefined { if (!value || typeof value !== 'object') { return undefined } @@ -37,16 +33,8 @@ export function parsePtyStartupIngressIntent( ) { return undefined } - const projection = record.echoProjection - if ( - projection !== undefined && - (projection !== 'windows-conpty-esc-stripped' || !options.allowWindowsEchoProjection) - ) { - return undefined - } return { colors: normalizedColors, - deadlineMs: record.deadlineMs, - ...(projection === 'windows-conpty-esc-stripped' ? { echoProjection: projection } : {}) + deadlineMs: record.deadlineMs } } diff --git a/src/shared/pty-startup-ingress.test.ts b/src/shared/pty-startup-ingress.test.ts index 55268f04f7b..a6ec33a371c 100644 --- a/src/shared/pty-startup-ingress.test.ts +++ b/src/shared/pty-startup-ingress.test.ts @@ -14,9 +14,9 @@ function createHarness(options: { projection?: boolean; nested?: (data: string) ingress = new PtyStartupIngress({ intent: { colors: COLORS, - deadlineMs: 5_000, - ...(options.projection ? { echoProjection: 'windows-conpty-esc-stripped' as const } : {}) + deadlineMs: 5_000 }, + ...(options.projection ? { ownerBackend: 'windows-conpty' as const } : {}), write: (data) => { writes.push(data) options.nested?.(data) @@ -33,26 +33,13 @@ function visible(emissions: readonly PtyIngressEmission[]): string { describe('PtyStartupIngress', () => { afterEach(() => vi.useRealTimers()) - it('validates intent bounds and rejects a Windows projection on isolated hosts', () => { + it('validates intent colors and deadline bounds', () => { const intent = { colors: COLORS, - deadlineMs: 5_000, - echoProjection: 'windows-conpty-esc-stripped' + deadlineMs: 5_000 } - expect(parsePtyStartupIngressIntent(intent, { allowWindowsEchoProjection: true })).toEqual( - intent - ) - expect(parsePtyStartupIngressIntent(intent, { allowWindowsEchoProjection: false })).toBe( - undefined - ) - expect( - parsePtyStartupIngressIntent( - { ...intent, deadlineMs: 30_001 }, - { - allowWindowsEchoProjection: true - } - ) - ).toBeUndefined() + expect(parsePtyStartupIngressIntent(intent)).toEqual(intent) + expect(parsePtyStartupIngressIntent({ ...intent, deadlineMs: 30_001 })).toBeUndefined() }) it('recognizes BEL/ST queries at every split and emits canonical replies', () => { @@ -146,6 +133,133 @@ describe('PtyStartupIngress', () => { ]) }) + it('consumes a native ConPTY color query before any downstream responder at every split', () => { + const query = '\x1b]11;?\x1b\\' + for (let split = 0; split <= query.length; split += 1) { + const writes: string[] = [] + const emissions: PtyIngressEmission[] = [] + const ingress = new PtyStartupIngress({ + ownerBackend: 'windows-conpty', + write: (data) => writes.push(data), + onEmission: (emission) => emissions.push(emission) + }) + ingress.closeQueryAuthority() + ingress.accept(query.slice(0, split)) + ingress.accept(query.slice(split)) + ingress.drainAndClose() + + expect(writes, `split ${split}`).toEqual([]) + expect(visible(emissions), `split ${split}`).toBe('') + expect(emissions, `split ${split}`).toEqual([ + { data: '', rawStartSeq: 0, rawEndSeq: query.length, transformed: true } + ]) + } + }) + + it('keeps native ConPTY startup authority until it can answer with owner-supplied colors', () => { + const writes: string[] = [] + const emissions: PtyIngressEmission[] = [] + const ingress = new PtyStartupIngress({ + intent: { colors: COLORS, deadlineMs: 5_000 }, + ownerBackend: 'windows-conpty', + write: (data) => writes.push(data), + onEmission: (emission) => emissions.push(emission) + }) + + ingress.accept('\x1b]10;') + ingress.closeQueryAuthority() + ingress.accept('?\x07') + + expect(writes).toEqual(['\x1b]10;rgb:2e2e/3434/3434\x1b\\']) + expect(visible(emissions)).toBe('') + }) + + it('keeps a split native ConPTY query private across close, expiry, and snapshot barriers', () => { + vi.useFakeTimers() + for (const barrier of ['close', 'expire', 'snapshot'] as const) { + const emissions: PtyIngressEmission[] = [] + const ingress = new PtyStartupIngress({ + ...(barrier === 'expire' ? { intent: { colors: COLORS, deadlineMs: 5_000 } } : {}), + ownerBackend: 'windows-conpty', + write: () => {}, + onEmission: (emission) => emissions.push(emission) + }) + ingress.accept('\x1b]10;') + if (barrier === 'close') { + ingress.closeQueryAuthority() + } else if (barrier === 'expire') { + vi.advanceTimersByTime(5_000) + } else { + ingress.snapshotBarrier() + } + expect(emissions, barrier).toEqual([]) + + ingress.accept('?\x07') + + expect(visible(emissions), barrier).toBe('') + expect(emissions, barrier).toEqual([ + { data: '', rawStartSeq: 0, rawEndSeq: '\x1b]10;?\x07'.length, transformed: true } + ]) + } + + const malformedEmissions: PtyIngressEmission[] = [] + const malformed = new PtyStartupIngress({ + ownerBackend: 'windows-conpty', + write: () => {}, + onEmission: (emission) => malformedEmissions.push(emission) + }) + malformed.accept('\x1b]10;') + malformed.snapshotBarrier() + malformed.accept('not-a-query\x07') + + expect(visible(malformedEmissions)).toBe('\x1b]10;not-a-query\x07') + }) + + it('releases a partial query immediately when source authority closes', () => { + const emissions: PtyIngressEmission[] = [] + const ingress = new PtyStartupIngress({ + intent: { colors: COLORS, deadlineMs: 5_000 }, + ownerBackend: 'posix-pty', + write: () => {}, + onEmission: (emission) => emissions.push(emission) + }) + + ingress.accept('\x1b]10;') + expect(emissions).toEqual([]) + ingress.closeQueryAuthority() + + expect(visible(emissions)).toBe('\x1b]10;') + }) + + it('keeps POSIX, WSL, malformed, and unrelated output unchanged', () => { + const input = 'typed\x1b[A\x1b]12;?\x1b\\\x1b]10;not-a-query\x07' + vi.useFakeTimers() + for (const ownerBackend of ['posix-pty', 'windows-wsl'] as const) { + const emissions: PtyIngressEmission[] = [] + const ingress = new PtyStartupIngress({ + ownerBackend, + write: () => {}, + onEmission: (emission) => emissions.push(emission) + }) + ingress.accept(`\x1b]10;?\x07${input}`) + expect(visible(emissions)).toBe(`\x1b]10;?\x07${input}`) + } + + const writes: string[] = [] + const emissions: PtyIngressEmission[] = [] + const nativeIngress = new PtyStartupIngress({ + intent: { colors: COLORS, deadlineMs: 5_000 }, + ownerBackend: 'windows-conpty', + write: (data) => writes.push(data), + onEmission: (emission) => emissions.push(emission) + }) + vi.advanceTimersByTime(5_001) + nativeIngress.accept(`${input}\x1b]10;?\x07`) + + expect(writes).toEqual([]) + expect(visible(emissions)).toBe(input) + }) + it('ignores callbacks after teardown without recreating the raw sequence domain', () => { const { ingress, emissions } = createHarness({ projection: true }) ingress.accept('\x1b]10;?\x07') diff --git a/src/shared/pty-startup-ingress.ts b/src/shared/pty-startup-ingress.ts index 56042a6c52d..0675eb92a4b 100644 --- a/src/shared/pty-startup-ingress.ts +++ b/src/shared/pty-startup-ingress.ts @@ -4,62 +4,25 @@ import { type TerminalOscColorQuerySlot } from './terminal-osc-color-reply' import type { PtyStartupIngressIntent } from './pty-startup-ingress-intent' +import type { PtyOwnerBackend } from './pty-owner-backend' +import { + combinePtyIngressSourceSpans, + slicePtyIngressSourceSpan, + type PtyIngressEmission, + type PtyIngressSourceSpan, + type PtyStartupIngressOperation, + type PtyStartupIngressOptions +} from './pty-startup-ingress-contract' export { PTY_STARTUP_INGRESS_VERSION, parsePtyStartupIngressIntent } from './pty-startup-ingress-intent' export type { PtyStartupIngressIntent } from './pty-startup-ingress-intent' - -export type PtyIngressEmission = { - data: string - rawStartSeq: number - rawEndSeq: number - transformed: boolean -} - -type PtyIngressSourceChunk = { - data: string - rawStartSeq: number - rawEndSeq: number -} - -type PendingOperation = - | { kind: 'data'; chunk: PtyIngressSourceChunk } - | { kind: 'close-query' } - | { kind: 'snapshot' } - | { kind: 'teardown' } - | { kind: 'expire' } - -type PendingSpan = PtyIngressSourceChunk - -export type PtyStartupIngressOptions = { - intent?: PtyStartupIngressIntent - write: (data: string) => void - onEmission: (emission: PtyIngressEmission) => void -} +export type { PtyIngressEmission, PtyStartupIngressOptions } from './pty-startup-ingress-contract' const MAX_QUERY_CANDIDATE_CHARS = 64 -function spanSlice(span: PendingSpan, start: number, end = span.data.length): PendingSpan { - return { - data: span.data.slice(start, end), - rawStartSeq: span.rawStartSeq + start, - rawEndSeq: span.rawStartSeq + end - } -} - -function combineSpans(first: PendingSpan | null, second: PendingSpan): PendingSpan { - if (!first) { - return second - } - return { - data: first.data + second.data, - rawStartSeq: first.rawStartSeq, - rawEndSeq: second.rawEndSeq - } -} - function projectedWindowsConptyReply(reply: string): string { // Why: the native provider harness observes ConPTY's cooked echo with ESC removed. return reply.replaceAll('\x1b', '') @@ -71,21 +34,23 @@ function projectedWindowsConptyReply(reply: string): string { */ export class PtyStartupIngress { private readonly intent: PtyStartupIngressIntent | undefined + private readonly ownerBackend: PtyOwnerBackend private readonly writeProvider: (data: string) => void private readonly onEmission: (emission: PtyIngressEmission) => void - private readonly operations: PendingOperation[] = [] + private readonly operations: PtyStartupIngressOperation[] = [] private readonly answeredSlots = new Set() private readonly expectedEchoes: string[] = [] private processing = false private closed = false private queryOpen: boolean private rawHighWater = 0 - private queryPending: PendingSpan | null = null - private echoPending: PendingSpan | null = null + private queryPending: PtyIngressSourceSpan | null = null + private echoPending: PtyIngressSourceSpan | null = null private deadlineTimer: ReturnType | null = null constructor(options: PtyStartupIngressOptions) { this.intent = options.intent + this.ownerBackend = options.ownerBackend ?? 'posix-pty' this.writeProvider = options.write this.onEmission = options.onEmission this.queryOpen = options.intent !== undefined @@ -129,7 +94,7 @@ export class PtyStartupIngress { return this.rawHighWater } - private enqueue(operation: PendingOperation): void { + private enqueue(operation: PtyStartupIngressOperation): void { if (this.closed) { return } @@ -139,7 +104,7 @@ export class PtyStartupIngress { } this.processing = true try { - let next: PendingOperation | undefined + let next: PtyStartupIngressOperation | undefined while ((next = this.operations.shift())) { this.applyOperation(next) } @@ -148,18 +113,24 @@ export class PtyStartupIngress { } } - private applyOperation(operation: PendingOperation): void { + private applyOperation(operation: PtyStartupIngressOperation): void { switch (operation.kind) { case 'data': this.processEchoSpan(operation.chunk) return case 'close-query': - this.queryOpen = false - this.releaseQueryPending() + if (this.ownerBackend !== 'windows-conpty') { + this.queryOpen = false + this.releaseQueryPending() + } + // Why: ConPTY cannot safely transfer color-query authority to a downstream view. return case 'expire': this.queryOpen = false - this.releaseAllPending() + this.releaseEchoPending() + if (this.ownerBackend !== 'windows-conpty') { + this.releaseQueryPending() + } this.expectedEchoes.length = 0 this.clearDeadline() return @@ -175,8 +146,8 @@ export class PtyStartupIngress { } } - private processEchoSpan(span: PendingSpan): void { - let input = combineSpans(this.echoPending, span) + private processEchoSpan(span: PtyIngressSourceSpan): void { + let input = combinePtyIngressSourceSpans(this.echoPending, span) this.echoPending = null while (this.expectedEchoes.length > 0) { @@ -197,8 +168,8 @@ export class PtyStartupIngress { } this.expectedEchoes.shift() - this.emit(spanSlice(input, 0, expected.length), true, '') - input = spanSlice(input, expected.length) + this.emit(slicePtyIngressSourceSpan(input, 0, expected.length), true, '') + input = slicePtyIngressSourceSpan(input, expected.length) if (input.data.length === 0) { return } @@ -207,32 +178,33 @@ export class PtyStartupIngress { this.processQuerySpan(input) } - private processQuerySpan(span: PendingSpan): void { - const input = combineSpans(this.queryPending, span) + private processQuerySpan(span: PtyIngressSourceSpan): void { + const input = combinePtyIngressSourceSpans(this.queryPending, span) this.queryPending = null - if (!this.queryOpen || !this.intent) { + const suppressConptyQuery = this.ownerBackend === 'windows-conpty' + if ((!this.queryOpen || !this.intent) && !suppressConptyQuery) { this.emit(input, false) return } - let offset = 0 - while (offset < input.data.length) { - const candidateIndex = input.data.indexOf('\x1b', offset) + let scanOffset = 0 + let emittedOffset = 0 + while (scanOffset < input.data.length) { + const candidateIndex = input.data.indexOf('\x1b', scanOffset) if (candidateIndex === -1) { - this.emit(spanSlice(input, offset), false) + this.emit(slicePtyIngressSourceSpan(input, emittedOffset), false) return } - if (candidateIndex > offset) { - this.emit(spanSlice(input, offset, candidateIndex), false) - } const query = parseTerminalOscColorQuery(input.data, candidateIndex) if (query.kind === 'none') { - this.emit(spanSlice(input, candidateIndex, candidateIndex + 1), false) - offset = candidateIndex + 1 + scanOffset = candidateIndex + 1 continue } if (query.kind === 'partial') { - const candidate = spanSlice(input, candidateIndex) + if (candidateIndex > emittedOffset) { + this.emit(slicePtyIngressSourceSpan(input, emittedOffset, candidateIndex), false) + } + const candidate = slicePtyIngressSourceSpan(input, candidateIndex) if (candidate.data.length <= MAX_QUERY_CANDIDATE_CHARS) { this.queryPending = candidate } else { @@ -241,13 +213,18 @@ export class PtyStartupIngress { return } - const querySpan = spanSlice(input, candidateIndex, query.endIndex) - if (!this.answerQuery(query.slots)) { - this.emit(querySpan, false) - } else { - this.emit(querySpan, true, '') + if (candidateIndex > emittedOffset) { + this.emit(slicePtyIngressSourceSpan(input, emittedOffset, candidateIndex), false) } - offset = query.endIndex + const querySpan = slicePtyIngressSourceSpan(input, candidateIndex, query.endIndex) + const answered = this.queryOpen && this.intent && this.answerQuery(query.slots) + if (answered || suppressConptyQuery) { + this.emit(querySpan, true, '') + } else { + this.emit(querySpan, false) + } + scanOffset = query.endIndex + emittedOffset = query.endIndex } } @@ -268,9 +245,7 @@ export class PtyStartupIngress { } this.answeredSlots.add(slot) const projected = - this.intent.echoProjection === 'windows-conpty-esc-stripped' - ? projectedWindowsConptyReply(reply) - : null + this.ownerBackend === 'windows-conpty' ? projectedWindowsConptyReply(reply) : null if (projected) { // Why: register before write because node-pty can synchronously re-enter onData. this.expectedEchoes.push(projected) @@ -303,25 +278,30 @@ export class PtyStartupIngress { } private releaseAllPending(): void { - const pending = this.echoPending ?? this.queryPending - this.echoPending = null - this.queryPending = null - if (pending) { - this.emit(pending, false) + this.releaseEchoPending() + this.releaseQueryPending() + } + + private releaseEchoPending(): void { + if (!this.echoPending) { + return } + const pending = this.echoPending + this.echoPending = null + this.emit(pending, false) } private releaseSnapshotPending(): void { if (this.echoPending) { - const pending = this.echoPending - this.echoPending = null this.expectedEchoes.shift() - this.emit(pending, false) + this.releaseEchoPending() + } + if (this.ownerBackend !== 'windows-conpty') { + this.releaseQueryPending() } - this.releaseQueryPending() } - private emit(span: PendingSpan, transformed: boolean, data = span.data): void { + private emit(span: PtyIngressSourceSpan, transformed: boolean, data = span.data): void { this.onEmission({ data, rawStartSeq: span.rawStartSeq, From 6c9be228ad92805c9bc227d575de05facd4dac06 Mon Sep 17 00:00:00 2001 From: chsong <116850020+chsong1@users.noreply.github.com> Date: Tue, 21 Jul 2026 12:53:31 +0900 Subject: [PATCH 30/58] fix(automations): retire owned desktop terminals (#9493) * fix(automations): retire owned desktop terminals (#9479) * fix(automations): diagnose late settlement failures * fix(automations): clear retired terminal identity and guard finalize close - Clear the run's terminalSessionId/PaneKey/PtyId when finalize() actually retires the owned terminal, so 'View run' resolves to the workspace/snapshot instead of dead-ending on a now-closed terminal. - Guard finalize()'s closeTab in a try/catch; a throwing close reports not-closed so the run keeps its still-valid terminal identity and no stale clear runs. Review follow-ups from PR #9493 (does not attempt the durable cross-reload reconciliation that #9479 needs). Co-authored-by: Orca --------- Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Co-authored-by: Orca --- .../terminal-pane/pty-transport.test.ts | 11 + .../use-terminal-pane-lifecycle.test.ts | 22 ++ .../hooks/useAutomationDispatchEvents.test.ts | 209 +++++++++++++++++- .../src/hooks/useAutomationDispatchEvents.ts | 109 +++++++-- .../lib/agent-background-session-contract.ts | 2 + .../lib/automation-terminal-ownership.test.ts | 202 +++++++++++++++++ .../src/lib/automation-terminal-ownership.ts | 162 ++++++++++++++ .../launch-agent-background-session.test.ts | 10 +- .../lib/launch-agent-background-session.ts | 24 +- 9 files changed, 711 insertions(+), 40 deletions(-) create mode 100644 src/renderer/src/lib/automation-terminal-ownership.test.ts create mode 100644 src/renderer/src/lib/automation-terminal-ownership.ts diff --git a/src/renderer/src/components/terminal-pane/pty-transport.test.ts b/src/renderer/src/components/terminal-pane/pty-transport.test.ts index a585199df49..48f4f919ed8 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.test.ts @@ -79,6 +79,17 @@ describe('createIpcPtyTransport', () => { transport.disconnect() }) + it('does not create a second kill authority when a mounted pane detaches', async () => { + const { createIpcPtyTransport } = await import('./pty-transport') + const kill = window.api.pty.kill as unknown as ReturnType + const transport = createIpcPtyTransport({}) + await transport.connect({ url: '', callbacks: {} }) + + transport.detach?.() + + expect(kill).not.toHaveBeenCalled() + }) + it('forwards requested environment deletions to the PTY spawn', async () => { const { createIpcPtyTransport } = await import('./pty-transport') const spawn = window.api.pty.spawn as unknown as ReturnType diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts index a2226262be4..a051000b1f9 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts @@ -190,6 +190,28 @@ describe('shouldDetachPaneTransportOnUnmount', () => { }) ).toBe(false) }) + + it('detaches a removed automation pane after closeTab takes teardown authority', () => { + expect( + shouldDetachPaneTransportOnUnmount({ + tabStillExists: false, + tabId: 'automation-tab', + ptyId: 'automation-pty', + worktreeTabs: [ + { + id: 'unrelated-tab', + ptyId: 'unrelated-pty', + worktreeId: 'wt-1', + title: 'Terminal 1', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }) + ).toBe(true) + }) }) describe('mapRestoredPaneTitlesByPaneId', () => { diff --git a/src/renderer/src/hooks/useAutomationDispatchEvents.test.ts b/src/renderer/src/hooks/useAutomationDispatchEvents.test.ts index b4c848f7e9f..562bbbbf25a 100644 --- a/src/renderer/src/hooks/useAutomationDispatchEvents.test.ts +++ b/src/renderer/src/hooks/useAutomationDispatchEvents.test.ts @@ -10,6 +10,8 @@ const mockCreateWorktree = vi.fn() const mockMarkDispatchResult = vi.fn() const mockOnDispatchRequested = vi.fn() const mockRendererReady = vi.fn() +const mockFinalizeTerminalOwnership = vi.fn() +const mockReleaseTerminalOwnership = vi.fn() const setupLaunch = { runnerScriptPath: '/tmp/setup.sh', @@ -151,8 +153,13 @@ describe('useAutomationDispatchEvents setup launch', () => { mockLaunchWorktreeBackgroundTerminals.mockResolvedValue(undefined) mockLaunchAgentBackgroundSession.mockResolvedValue({ tabId: 'agent-tab', + paneKey: 'agent-tab:7c6fb4e5-3bf1-4ff4-8259-03f7ae81c40d', ptyId: 'agent-pty', - startupPlan: {} + startupPlan: {}, + terminalOwnership: { + finalize: mockFinalizeTerminalOwnership, + release: mockReleaseTerminalOwnership + } }) mockOnDispatchRequested.mockReturnValue(() => {}) vi.stubGlobal('window', { @@ -311,4 +318,204 @@ describe('useAutomationDispatchEvents setup launch', () => { }) ) }) + + it('finalizes a fresh non-reuse terminal only after completed result persistence', async () => { + const order: string[] = [] + let launchArgs: { onAgentStatus?: (payload: { state: string }) => void } = {} + mockMarkDispatchResult.mockImplementation( + async (result: { status: string; terminalPaneKey?: string | null }) => { + // The retirement clear reuses status 'completed' but nulls the terminal + // identity; label it distinctly so ordering stays legible. + order.push( + result.status === 'completed' && result.terminalPaneKey === null + ? 'clear-terminal-identity' + : `persist:${result.status}` + ) + } + ) + mockFinalizeTerminalOwnership.mockImplementation(() => { + order.push('finalize') + return true + }) + mockLaunchAgentBackgroundSession.mockImplementation(async (args) => { + launchArgs = args + return { + tabId: 'agent-tab', + paneKey: 'agent-tab:7c6fb4e5-3bf1-4ff4-8259-03f7ae81c40d', + ptyId: 'agent-pty', + startupPlan: {}, + terminalOwnership: { + finalize: mockFinalizeTerminalOwnership, + release: mockReleaseTerminalOwnership + } + } + }) + + await registerAndDispatch() + launchArgs.onAgentStatus?.({ state: 'done' }) + await vi.waitFor(() => expect(mockFinalizeTerminalOwnership).toHaveBeenCalledOnce()) + + expect(order).toEqual([ + 'persist:dispatched', + 'persist:completed', + 'finalize', + 'clear-terminal-identity' + ]) + expect(mockReleaseTerminalOwnership).not.toHaveBeenCalled() + // Why: the retired terminal is gone; the run must drop its pane/pty pointers + // so "View run" resolves to the workspace/snapshot, not an unavailable terminal. + expect(mockMarkDispatchResult).toHaveBeenLastCalledWith({ + runId: expect.any(String), + status: 'completed', + terminalSessionId: null, + terminalPaneKey: null, + terminalPtyId: null + }) + }) + + it('consumes duplicate done and zero-exit completion through one finalizer', async () => { + let launchArgs: { + onAgentStatus?: (payload: { state: string }) => void + onExit?: (ptyId: string, code: number) => void + } = {} + mockLaunchAgentBackgroundSession.mockImplementation(async (args) => { + launchArgs = args + return { + tabId: 'agent-tab', + paneKey: 'agent-tab:7c6fb4e5-3bf1-4ff4-8259-03f7ae81c40d', + ptyId: 'agent-pty', + startupPlan: {}, + terminalOwnership: { + finalize: mockFinalizeTerminalOwnership, + release: mockReleaseTerminalOwnership + } + } + }) + + await registerAndDispatch() + launchArgs.onAgentStatus?.({ state: 'done' }) + launchArgs.onExit?.('agent-pty', 0) + launchArgs.onAgentStatus?.({ state: 'done' }) + await vi.waitFor(() => expect(mockFinalizeTerminalOwnership).toHaveBeenCalledOnce()) + + expect( + mockMarkDispatchResult.mock.calls.filter( + ([result]) => result.status === 'completed' && result.terminalPaneKey !== null + ) + ).toHaveLength(1) + expect(mockReleaseTerminalOwnership).not.toHaveBeenCalled() + }) + + it('releases ownership on nonzero exit without finalizing the tab', async () => { + let onExit: ((ptyId: string, code: number) => void) | undefined + mockLaunchAgentBackgroundSession.mockImplementation(async (args) => { + onExit = args.onExit + return { + tabId: 'agent-tab', + paneKey: 'agent-tab:7c6fb4e5-3bf1-4ff4-8259-03f7ae81c40d', + ptyId: 'agent-pty', + startupPlan: {}, + terminalOwnership: { + finalize: mockFinalizeTerminalOwnership, + release: mockReleaseTerminalOwnership + } + } + }) + + await registerAndDispatch() + onExit?.('agent-pty', 9) + await vi.waitFor(() => expect(mockReleaseTerminalOwnership).toHaveBeenCalledOnce()) + + expect(mockFinalizeTerminalOwnership).not.toHaveBeenCalled() + expect(mockMarkDispatchResult).toHaveBeenCalledWith( + expect.objectContaining({ status: 'dispatch_failed' }) + ) + }) + + it('releases ownership when dispatched result persistence rejects', async () => { + mockMarkDispatchResult.mockRejectedValueOnce(new Error('persistence unavailable')) + + await registerAndDispatch() + + expect(mockReleaseTerminalOwnership).toHaveBeenCalledOnce() + expect(mockFinalizeTerminalOwnership).not.toHaveBeenCalled() + expect(mockMarkDispatchResult).toHaveBeenLastCalledWith( + expect.objectContaining({ status: 'dispatch_failed' }) + ) + }) + + it('releases ownership when completed result persistence rejects', async () => { + mockMarkDispatchResult + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error('completion persistence unavailable')) + .mockResolvedValueOnce(undefined) + mockLaunchAgentBackgroundSession.mockImplementation(async (args) => { + args.onAgentStatus?.({ state: 'done' }) + return { + tabId: 'agent-tab', + paneKey: 'agent-tab:7c6fb4e5-3bf1-4ff4-8259-03f7ae81c40d', + ptyId: 'agent-pty', + startupPlan: {}, + terminalOwnership: { + finalize: mockFinalizeTerminalOwnership, + release: mockReleaseTerminalOwnership + } + } + }) + + await registerAndDispatch() + + expect(mockReleaseTerminalOwnership).toHaveBeenCalledOnce() + expect(mockFinalizeTerminalOwnership).not.toHaveBeenCalled() + expect(mockMarkDispatchResult).toHaveBeenLastCalledWith( + expect.objectContaining({ status: 'dispatch_failed' }) + ) + }) + + it('diagnoses a late completed-persistence rejection once without terminal cleanup', async () => { + let onAgentStatus: ((payload: { state: string }) => void) | undefined + const persistenceError = new Error('late completion persistence unavailable') + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + mockMarkDispatchResult.mockResolvedValueOnce(undefined).mockRejectedValueOnce(persistenceError) + mockLaunchAgentBackgroundSession.mockImplementation(async (args) => { + onAgentStatus = args.onAgentStatus + return { + tabId: 'agent-tab', + paneKey: 'agent-tab:7c6fb4e5-3bf1-4ff4-8259-03f7ae81c40d', + ptyId: 'agent-pty', + startupPlan: {}, + terminalOwnership: { + finalize: mockFinalizeTerminalOwnership, + release: mockReleaseTerminalOwnership + } + } + }) + + await registerAndDispatch() + onAgentStatus?.({ state: 'done' }) + onAgentStatus?.({ state: 'done' }) + await vi.waitFor(() => expect(errorSpy).toHaveBeenCalledOnce()) + + expect(errorSpy).toHaveBeenCalledWith( + '[automations] Failed to persist late automation result:', + persistenceError + ) + expect(mockReleaseTerminalOwnership).toHaveBeenCalledOnce() + expect(mockFinalizeTerminalOwnership).not.toHaveBeenCalled() + expect( + mockMarkDispatchResult.mock.calls.filter( + ([result]) => result.status === 'completed' && result.terminalPaneKey !== null + ) + ).toHaveLength(1) + errorSpy.mockRestore() + }) + + it('preserves a fresh reuse-enabled session as the future reuse seed', async () => { + mockFindReusableAutomationSession.mockReturnValue(null) + + await registerAndDispatch(makeAutomation({ reuseSession: true })) + + expect(mockReleaseTerminalOwnership).toHaveBeenCalledOnce() + expect(mockFinalizeTerminalOwnership).not.toHaveBeenCalled() + }) }) diff --git a/src/renderer/src/hooks/useAutomationDispatchEvents.ts b/src/renderer/src/hooks/useAutomationDispatchEvents.ts index 2de085ada5e..1679aaf1af5 100644 --- a/src/renderer/src/hooks/useAutomationDispatchEvents.ts +++ b/src/renderer/src/hooks/useAutomationDispatchEvents.ts @@ -23,6 +23,7 @@ import { } from '@/components/automations/automation-run-output-snapshot' import { translate } from '@/i18n/i18n' import { createBrowserUuid } from '@/lib/browser-uuid' +import type { AutomationTerminalOwnership } from '@/lib/automation-terminal-ownership' const AUTOMATIONS_CHANGED_EVENT = 'orca:automations-changed' const activeReuseDispatchTabIds = new Set() @@ -69,6 +70,17 @@ export function useAutomationDispatchEvents(): void { let dispatchWorkspaceDisplayName = automationWorktree?.displayName ?? run.workspaceDisplayName ?? null let precheckResult: AutomationPrecheckResult | null = null + let terminalOwnership: AutomationTerminalOwnership | null = null + const releaseTerminalOwnership = (): void => { + const ownership = terminalOwnership + terminalOwnership = null + ownership?.release() + } + const finalizeTerminalOwnership = (): boolean => { + const ownership = terminalOwnership + terminalOwnership = null + return ownership?.finalize() ?? false + } if (!repo) { await markDispatchResult({ @@ -275,26 +287,74 @@ export function useAutomationDispatchEvents(): void { } completionMarked = true cleanupRunObservers() - await markDispatchResult({ - runId: run.id, - status: 'completed', - workspaceId: worktree.id, - workspaceDisplayName: worktree.displayName, - outputSnapshot: getOutputSnapshot(), - precheckResult, - error: null - }) + try { + await markDispatchResult({ + runId: run.id, + status: 'completed', + workspaceId: worktree.id, + workspaceDisplayName: worktree.displayName, + outputSnapshot: getOutputSnapshot(), + precheckResult, + error: null + }) + } catch (error) { + releaseTerminalOwnership() + throw error + } + if (finalizeTerminalOwnership()) { + await clearRetiredRunTerminalIdentity() + } } - const markExitResult = (code: number): Promise => { + const clearRetiredRunTerminalIdentity = async (): Promise => { + // Why: the owned terminal was just retired, so the run's pane/pty + // pointers now reference a closed tab. Drop them (best-effort) so + // "View run" resolves to the workspace/snapshot instead of dead-ending + // on an unavailable terminal. + try { + await markDispatchResult({ + runId: run.id, + status: 'completed', + terminalSessionId: null, + terminalPaneKey: null, + terminalPtyId: null + }) + } catch (error) { + console.error('[automations] Failed to clear retired terminal identity:', error) + } + } + const markExitResult = async (code: number): Promise => { + if (completionMarked) { + return + } + completionMarked = true cleanupRunObservers() - return markDispatchResult({ - runId: run.id, - status: code === 0 ? 'completed' : 'dispatch_failed', - workspaceId: worktree.id, - workspaceDisplayName: worktree.displayName, - outputSnapshot: getOutputSnapshot(), - precheckResult, - error: code === 0 ? null : `Automation process exited with code ${code}.` + try { + await markDispatchResult({ + runId: run.id, + status: code === 0 ? 'completed' : 'dispatch_failed', + workspaceId: worktree.id, + workspaceDisplayName: worktree.displayName, + outputSnapshot: getOutputSnapshot(), + precheckResult, + error: code === 0 ? null : `Automation process exited with code ${code}.` + }) + } catch (error) { + releaseTerminalOwnership() + throw error + } + if (code === 0) { + if (finalizeTerminalOwnership()) { + await clearRetiredRunTerminalIdentity() + } + } else { + releaseTerminalOwnership() + } + } + const settleLateResult = (result: Promise): void => { + // Why: status/exit callbacks have no awaitable caller; the result + // path already releases ownership before propagating persistence errors. + void result.catch((error) => { + console.error('[automations] Failed to persist late automation result:', error) }) } const handleAgentDone = (): void => { @@ -305,7 +365,7 @@ export function useAutomationDispatchEvents(): void { pendingDone = true return } - void markCompletionResult() + settleLateResult(markCompletionResult()) } const observeAgentStatus = ( targetPaneKey: string, @@ -392,7 +452,7 @@ export function useAutomationDispatchEvents(): void { pendingExitCode = code return } - void markExitResult(code) + settleLateResult(markExitResult(code)) } }) observeAgentStatus(reusableSession.paneKey, reuseCompletionStartedAt, { @@ -449,12 +509,18 @@ export function useAutomationDispatchEvents(): void { pendingExitCode = code return } - void markExitResult(code) + settleLateResult(markExitResult(code)) } }) if (!result) { throw new Error('Unable to build an agent launch plan.') } + terminalOwnership = result.terminalOwnership + if (automation.reuseSession) { + // Why: the first fresh launch is the seed for later reuse and must + // survive completion under the same policy as an already-reused tab. + releaseTerminalOwnership() + } const launchedTabId = result.tabId observeAgentStatus(result.paneKey, dispatchStartedAt) try { @@ -494,6 +560,7 @@ export function useAutomationDispatchEvents(): void { currentState.setActiveTabType(focusBeforeDispatch.activeTabType) } } catch (error) { + releaseTerminalOwnership() await markDispatchResult({ runId: run.id, status: 'dispatch_failed', diff --git a/src/renderer/src/lib/agent-background-session-contract.ts b/src/renderer/src/lib/agent-background-session-contract.ts index 8b271567483..fe793aa24d3 100644 --- a/src/renderer/src/lib/agent-background-session-contract.ts +++ b/src/renderer/src/lib/agent-background-session-contract.ts @@ -2,6 +2,7 @@ import type { ParsedAgentStatusPayload } from '../../../shared/agent-status-type import type { LaunchSource } from '../../../shared/telemetry-events' import type { TuiAgent } from '../../../shared/types' import type { AgentStartupPlan } from '@/lib/tui-agent-startup' +import type { AutomationTerminalOwnership } from '@/lib/automation-terminal-ownership' export type LaunchAgentBackgroundSessionArgs = { agent: TuiAgent @@ -19,4 +20,5 @@ export type LaunchAgentBackgroundSessionResult = { paneKey: string ptyId: string startupPlan: AgentStartupPlan + terminalOwnership: AutomationTerminalOwnership | null } diff --git a/src/renderer/src/lib/automation-terminal-ownership.test.ts b/src/renderer/src/lib/automation-terminal-ownership.test.ts new file mode 100644 index 00000000000..9528bbdf384 --- /dev/null +++ b/src/renderer/src/lib/automation-terminal-ownership.test.ts @@ -0,0 +1,202 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AppState } from '@/store/types' +import { singlePaneLayoutSnapshot } from '@/store/slices/terminal-helpers' +import { + createAutomationTerminalOwnership, + type AutomationTerminalOwnershipStore +} from './automation-terminal-ownership' + +const WORKTREE_ID = 'worktree-1' +const TAB_ID = 'tab-1' +const LEAF_ID = '7c6fb4e5-3bf1-4ff4-8259-03f7ae81c40d' +const PANE_KEY = `${TAB_ID}:${LEAF_ID}` +const PTY_ID = 'pty-1' +const CREATED_AT = 100 + +type OwnershipState = Pick< + AppState, + | 'activeWorktreeId' + | 'activeTabId' + | 'activeTabType' + | 'tabsByWorktree' + | 'ptyIdsByTabId' + | 'terminalLayoutsByTabId' + | 'lastTerminalInputAtByPaneKey' + | 'closeTab' +> + +function createStore() { + const closeTab = vi.fn() + let state: OwnershipState = { + activeWorktreeId: 'other-worktree', + activeTabId: 'other-tab', + activeTabType: 'terminal' as const, + tabsByWorktree: { + [WORKTREE_ID]: [ + { + id: TAB_ID, + worktreeId: WORKTREE_ID, + ptyId: PTY_ID, + title: 'Automation', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: CREATED_AT + } + ] + }, + ptyIdsByTabId: { [TAB_ID]: [PTY_ID] }, + terminalLayoutsByTabId: { + [TAB_ID]: singlePaneLayoutSnapshot(LEAF_ID, PTY_ID) + }, + lastTerminalInputAtByPaneKey: {}, + closeTab + } + const listeners = new Set<(state: AppState, previousState: AppState) => void>() + const store: AutomationTerminalOwnershipStore = { + getState: () => state as unknown as AppState, + subscribe: (listener) => { + listeners.add(listener) + return () => listeners.delete(listener) + } + } + const update = (patch: Partial): void => { + const previousState = state + state = { ...state, ...patch } + for (const listener of listeners) { + listener(state as unknown as AppState, previousState as unknown as AppState) + } + } + return { closeTab, getState: () => state, store, update } +} + +function own( + store: AutomationTerminalOwnershipStore, + overrides: Partial[0]> = {} +) { + return createAutomationTerminalOwnership({ + store, + worktreeId: WORKTREE_ID, + tabId: TAB_ID, + paneKey: PANE_KEY, + ptyId: PTY_ID, + tabCreatedAt: CREATED_AT, + runtimeKind: 'desktop', + ...overrides + }) +} + +describe('automation terminal ownership', () => { + beforeEach(() => vi.clearAllMocks()) + + it('closes the exact fresh desktop tab once after the PTY exit binding is cleared', () => { + const { closeTab, store, update } = createStore() + const ownership = own(store) + update({ + ptyIdsByTabId: { [TAB_ID]: [] }, + tabsByWorktree: { + [WORKTREE_ID]: [{ ...store.getState().tabsByWorktree[WORKTREE_ID]![0]!, ptyId: null }] + } + }) + + expect(ownership.finalize()).toBe(true) + expect(ownership.finalize()).toBe(false) + expect(closeTab).toHaveBeenCalledTimes(1) + expect(closeTab).toHaveBeenCalledWith(TAB_ID, { + recordInteraction: false, + reason: 'cleanup' + }) + }) + + it('preserves a tab activated after launch even when focus later moves away', () => { + const { closeTab, store, update } = createStore() + const ownership = own(store) + + update({ activeWorktreeId: WORKTREE_ID, activeTabId: TAB_ID }) + update({ activeWorktreeId: 'other-worktree', activeTabId: 'other-tab' }) + + expect(ownership.finalize()).toBe(false) + expect(closeTab).not.toHaveBeenCalled() + }) + + it('preserves a tab that received user input after launch', () => { + const { closeTab, store, update } = createStore() + const ownership = own(store) + + update({ lastTerminalInputAtByPaneKey: { [PANE_KEY]: 200 } }) + + expect(ownership.finalize()).toBe(false) + expect(closeTab).not.toHaveBeenCalled() + }) + + it.each([ + ['tab PTY', { tabsByWorktree: undefined, ptyIdsByTabId: undefined, layoutPty: undefined }], + [ + 'PTY index', + { tabsByWorktree: null, ptyIdsByTabId: ['pty-replacement'], layoutPty: undefined } + ], + [ + 'pane layout', + { tabsByWorktree: null, ptyIdsByTabId: undefined, layoutPty: 'pty-replacement' } + ] + ])('refuses a replacement identity in the %s binding', (_label, drift) => { + const { closeTab, getState, store, update } = createStore() + const ownership = own(store) + const tab = getState().tabsByWorktree[WORKTREE_ID]![0]! + update({ + ...(drift.tabsByWorktree === undefined + ? { tabsByWorktree: { [WORKTREE_ID]: [{ ...tab, ptyId: 'pty-replacement' }] } } + : {}), + ...(drift.ptyIdsByTabId ? { ptyIdsByTabId: { [TAB_ID]: drift.ptyIdsByTabId } } : {}), + ...(drift.layoutPty + ? { + terminalLayoutsByTabId: { + [TAB_ID]: { + ...getState().terminalLayoutsByTabId[TAB_ID]!, + ptyIdsByLeafId: { [LEAF_ID]: drift.layoutPty } + } + } + } + : {}) + }) + + expect(ownership.finalize()).toBe(false) + expect(closeTab).not.toHaveBeenCalled() + }) + + it('refuses a tab recreated with the same id', () => { + const { closeTab, getState, store, update } = createStore() + const ownership = own(store) + update({ + tabsByWorktree: { + [WORKTREE_ID]: [ + { ...getState().tabsByWorktree[WORKTREE_ID]![0]!, createdAt: CREATED_AT + 1 } + ] + } + }) + + expect(ownership.finalize()).toBe(false) + expect(closeTab).not.toHaveBeenCalled() + }) + + it.each([ + ['remote runtime', { runtimeKind: 'environment' as const }], + ['remote PTY identity', { ptyId: 'remote:env-1@@terminal-1' }] + ])('never owns a %s terminal', (_label, overrides) => { + const { closeTab, store } = createStore() + const ownership = own(store, overrides) + + expect(ownership.finalize()).toBe(false) + expect(closeTab).not.toHaveBeenCalled() + }) + + it('release consumes ownership without closing the terminal', () => { + const { closeTab, store } = createStore() + const ownership = own(store) + + ownership.release() + + expect(ownership.finalize()).toBe(false) + expect(closeTab).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/lib/automation-terminal-ownership.ts b/src/renderer/src/lib/automation-terminal-ownership.ts new file mode 100644 index 00000000000..1f39a257a17 --- /dev/null +++ b/src/renderer/src/lib/automation-terminal-ownership.ts @@ -0,0 +1,162 @@ +import { useAppStore } from '@/store' +import type { AppState } from '@/store/types' +import type { TerminalTab } from '../../../shared/types' +import { parsePaneKey } from '../../../shared/stable-pane-id' +import { singlePaneLayoutSnapshot } from '@/store/slices/terminal-helpers' + +export type AutomationTerminalOwnershipStore = { + getState: () => AppState + subscribe: (listener: (state: AppState, previousState: AppState) => void) => () => void +} + +export type AutomationTerminalOwnership = { + finalize: () => boolean + release: () => void +} + +type CreateAutomationTerminalOwnershipArgs = { + store: AutomationTerminalOwnershipStore + worktreeId: string + tabId: string + paneKey: string + ptyId: string + tabCreatedAt: number + runtimeKind: 'desktop' | 'environment' +} + +function isOwnedTabIdentityCurrent( + state: AppState, + args: Omit +): boolean { + const parsedPane = parsePaneKey(args.paneKey) + if (!parsedPane || parsedPane.tabId !== args.tabId) { + return false + } + const matchingTabs = Object.entries(state.tabsByWorktree).flatMap(([worktreeId, tabs]) => + tabs.filter((tab) => tab.id === args.tabId).map((tab) => ({ tab, worktreeId })) + ) + const match = matchingTabs[0] + if ( + matchingTabs.length !== 1 || + !match || + match.worktreeId !== args.worktreeId || + match.tab.worktreeId !== args.worktreeId || + match.tab.createdAt !== args.tabCreatedAt + ) { + return false + } + if (match.tab.ptyId !== null && match.tab.ptyId !== args.ptyId) { + return false + } + if ((state.ptyIdsByTabId[args.tabId] ?? []).some((ptyId) => ptyId !== args.ptyId)) { + return false + } + const layout = state.terminalLayoutsByTabId[args.tabId] + const root = layout?.root + const ptyIdsByLeafId = layout?.ptyIdsByLeafId + if ( + !layout || + !root || + root.type !== 'leaf' || + root.leafId !== parsedPane.leafId || + layout.activeLeafId !== parsedPane.leafId || + Object.keys(ptyIdsByLeafId ?? {}).some((leafId) => leafId !== parsedPane.leafId) + ) { + return false + } + const layoutPtyId = ptyIdsByLeafId?.[parsedPane.leafId] + return layoutPtyId === undefined || layoutPtyId === args.ptyId +} + +export function createAutomationTerminalOwnership( + args: CreateAutomationTerminalOwnershipArgs +): AutomationTerminalOwnership { + let consumed = false + let userTookOver = false + const inputAtLaunch = args.store.getState().lastTerminalInputAtByPaneKey[args.paneKey] + const observeTakeover = (): void => { + const state = args.store.getState() + if ( + (state.activeWorktreeId === args.worktreeId && + state.activeTabId === args.tabId && + state.activeTabType === 'terminal') || + state.lastTerminalInputAtByPaneKey[args.paneKey] !== inputAtLaunch + ) { + userTookOver = true + } + } + const unsubscribe = args.store.subscribe(observeTakeover) + observeTakeover() + + const release = (): void => { + if (consumed) { + return + } + consumed = true + unsubscribe() + } + + return { + release, + finalize: () => { + if (consumed) { + return false + } + // Why: completion and exit can race; consume before any identity check so + // only the first successful-result path can retire this exact session. + consumed = true + unsubscribe() + observeTakeover() + if (args.runtimeKind !== 'desktop' || args.ptyId.startsWith('remote:') || userTookOver) { + return false + } + const state = args.store.getState() + if (!isOwnedTabIdentityCurrent(state, args)) { + return false + } + try { + // Why: closeTab centrally owns provider shutdown and pane removal; a + // direct kill here would create a second teardown authority. + state.closeTab(args.tabId, { recordInteraction: false, reason: 'cleanup' }) + } catch (error) { + // Why: a throwing close leaves the terminal alive; report not-closed so + // the run keeps its (still-valid) terminal identity and no stale clear runs. + console.error('[automations] Failed to close owned automation terminal:', error) + return false + } + return true + } + } +} + +export function bindAutomationTerminal( + tab: TerminalTab, + paneKey: string, + ptyId: string, + runtimeKind: 'local' | 'environment', + title?: string +): AutomationTerminalOwnership | null { + const parsedPane = parsePaneKey(paneKey) + if (!parsedPane || parsedPane.tabId !== tab.id) { + throw new Error('Automation terminal pane identity is invalid.') + } + const store = useAppStore.getState() + if (title) { + store.setTabCustomTitle(tab.id, title, { recordInteraction: false }) + } + store.updateTabPtyId(tab.id, ptyId) + store.setTabLayout(tab.id, singlePaneLayoutSnapshot(parsedPane.leafId, ptyId)) + const ownership = + runtimeKind === 'local' + ? createAutomationTerminalOwnership({ + store: useAppStore, + worktreeId: tab.worktreeId, + tabId: tab.id, + paneKey, + ptyId, + tabCreatedAt: tab.createdAt, + runtimeKind: 'desktop' + }) + : null + return ownership +} diff --git a/src/renderer/src/lib/launch-agent-background-session.test.ts b/src/renderer/src/lib/launch-agent-background-session.test.ts index 3422f48f6f5..2bce1932d90 100644 --- a/src/renderer/src/lib/launch-agent-background-session.test.ts +++ b/src/renderer/src/lib/launch-agent-background-session.test.ts @@ -38,8 +38,8 @@ function expectStablePaneSpawn(): string { } const state = { - activeRepoId: 'repo-1', activeWorktreeId: 'wt-1', + lastTerminalInputAtByPaneKey: {}, settings: { agentCmdOverrides: {}, activeRuntimeEnvironmentId: null as string | null, @@ -91,7 +91,8 @@ const state = { vi.mock('@/store', () => ({ useAppStore: { - getState: () => state + getState: () => state, + subscribe: vi.fn(() => () => {}) } })) @@ -127,8 +128,6 @@ describe('launchAgentBackgroundSession', () => { (args) => createCompatibleRuntimeStatusResponseIfNeeded(args) ?? mockRuntimeEnvironmentCall(args) ) - state.activeRepoId = 'repo-1' - state.activeWorktreeId = 'wt-1' state.settings = { agentCmdOverrides: {}, activeRuntimeEnvironmentId: null, @@ -854,7 +853,8 @@ describe('launchAgentBackgroundSession', () => { expect(result).toMatchObject({ tabId: 'tab-1', paneKey: `tab-1:${leafId}`, - ptyId: 'remote:env-1@@terminal-1' + ptyId: 'remote:env-1@@terminal-1', + terminalOwnership: null }) }) diff --git a/src/renderer/src/lib/launch-agent-background-session.ts b/src/renderer/src/lib/launch-agent-background-session.ts index 6ba76340cd9..15df8a92aef 100644 --- a/src/renderer/src/lib/launch-agent-background-session.ts +++ b/src/renderer/src/lib/launch-agent-background-session.ts @@ -39,6 +39,7 @@ import { shouldUseShellReadyStartupDelivery } from '../../../shared/codex-startu import { isMainTerminalSideEffectAuthorityForPty } from '@/components/terminal-pane/terminal-side-effect-facts-handler' import { resolveLocalWindowsAgentStartupShell } from '../../../shared/windows-terminal-shell' import { runBestEffortAgentBackgroundCleanups } from '@/lib/agent-background-session-cleanup' +import { bindAutomationTerminal } from '@/lib/automation-terminal-ownership' import { createBackgroundAgentStatusConsumer } from '@/lib/background-agent-status-consumer' export async function launchAgentBackgroundSession( @@ -105,9 +106,6 @@ export async function launchAgentBackgroundSession( activate: false, recordInteraction: false }) - if (title) { - store.setTabCustomTitle(tab.id, title, { recordInteraction: false }) - } // Why: agent hook callbacks are keyed by pane, and background automation // tabs never mount a TerminalPane to inject this env for us. createBrowserUuid // (not crypto.randomUUID) because the latter is undefined in non-secure @@ -147,11 +145,12 @@ export async function launchAgentBackgroundSession( const runtimeTarget = getActiveRuntimeTarget( getSettingsForWorktreeRuntimeOwner(store, worktreeId) ) - let ptyId = '' - let runtimeTerminalHandle: string | null = null + let ptyId = '', + runtimeTerminalHandle: string | null = null let returnedLaunchConfig: typeof startupPlan.launchConfig | undefined - let exitHandled = false - let eagerPtyBuffer: EagerPtyHandle | null = null + let exitHandled = false, + eagerPtyBuffer: EagerPtyHandle | null = null + let terminalOwnership: ReturnType = null let unsubscribeExit = (): void => {}, unsubscribeData = (): void => {} const handleExit = (exitPtyId: string, code: number): void => { @@ -258,8 +257,7 @@ export async function launchAgentBackgroundSession( if (returnedLaunchConfig) { store.registerAgentLaunchConfig(paneKey, returnedLaunchConfig, launchRegistration) } - store.updateTabPtyId(tab.id, ptyId) - store.setTabLayout(tab.id, singlePaneLayoutSnapshot(leafId, ptyId)) + terminalOwnership = bindAutomationTerminal(tab, paneKey, ptyId, runtimeTarget.kind, title) if (agent === 'command-code' && hasPrompt && !isFollowupPath) { // Why: Command Code does not expose a prompt-start hook; seed working for // hidden prompt launches so sidebar/activity surfaces do not stay idle. @@ -303,20 +301,20 @@ export async function launchAgentBackgroundSession( unsubscribeExit = subscribeToPtyExit(ptyId, (code) => handleExit(ptyId, code)) } - // Why: mount only after the explicit PTY is bound. Mounting at the earlier - // createTab boundary lets a slow SSH/remote spawn race TerminalPane's fresh - // spawn path and launch the agent twice. + // Why: bind the explicit PTY and ownership before mount; an earlier mount + // can double-spawn, while later tracking can miss user takeover. requestBackgroundTerminalWorktreeMount({ worktreeId, tabIds: [tab.id] }) if (pasteDraftAfterLaunch !== null) { scheduleAgentBackgroundDraft(tab.id, pasteDraftAfterLaunch, agent) } - return { tabId: tab.id, paneKey, ptyId, startupPlan } + return { tabId: tab.id, paneKey, ptyId, startupPlan, terminalOwnership } } catch (error) { // Why: terminal creation and stream subscription are separate remote calls. // A failure between them must not strand an invisible runtime terminal. exitHandled = true + terminalOwnership?.release() runBestEffortAgentBackgroundCleanups(unsubscribeExit, unsubscribeData) runBestEffortAgentBackgroundCleanups(() => eagerPtyBuffer?.dispose()) runBestEffortAgentBackgroundCleanups(() => sshStartupDelivery.clear()) From e3c8d96638d6e2f260effc46cd6b4f68609b3caa Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:05:40 -0700 Subject: [PATCH 31/58] Access the Floating Workspace from mobile (#8405) (#9523) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Access the Floating Workspace from mobile (#8405) Surface the desktop Floating Workspace (the global, repo-less scratchpad of terminal tabs under the synthetic `global-floating-terminal` id) on the mobile app so a Claude session left running there is reachable from a phone. Adds a terminal-icon button to the mobile host header (phone + tablet sidebar) that opens the existing Session screen for the floating id. The sentinel already had host-side RPC support (#5946: local runtime, homedir cwd, explicit-id fast paths in session.tabs.*); this wires up the mobile surface and gates it on a new `floatingWorkspaceEnabled` status flag so the entry hides on hosts that predate it or where the feature is disabled. The Session screen learns an `isFloatingWorkspaceRoute` flag (mirroring the existing `folder:` route pattern) that hides repo-backed surfaces — Files, Source Control, PR/checks, agent history — skips the diff-comment and GitHub probes, routes terminal URL taps to the phone browser, and limits the New Tab drawer to terminals + agents (browser/markdown creation resolves a real worktree host-side and stays desktop-only). useLiveWorktreeName short-circuits for the sentinel so it no longer polls worktree.show forever. Extracted the host status.get gating into a useHostStatusGates hook to keep the host screen under the max-lines ratchet. * Harden mobile Floating Workspace routing * Fix mobile host gate reuse race * Harden floating mobile session polling * fix(mobile): harden floating workspace route reuse * fix(mobile): skip floating workspace repo lookup * fix(mobile): clarify floating workspace header action --- mobile/app/h/[hostId]/index.tsx | 110 +++++------ .../app/h/[hostId]/session/[worktreeId].tsx | 168 ++++++++-------- mobile/src/session/floating-workspace.test.ts | 22 +++ mobile/src/session/floating-workspace.ts | 15 ++ .../mobile-new-tab-agent-loader.test.ts | 72 +++++++ .../session/mobile-new-tab-agent-loader.ts | 62 ++++++ .../mobile-session-startup-source.test.ts | 2 + .../session/use-live-worktree-name.test.ts | 82 ++++++++ mobile/src/session/use-live-worktree-name.ts | 33 +++- ...use-mobile-native-chat-readability.test.ts | 25 ++- .../use-mobile-native-chat-readability.ts | 11 +- mobile/src/transport/client-context.test.ts | 84 ++++++++ mobile/src/transport/client-context.tsx | 22 ++- .../src/transport/host-status-gates.test.ts | 179 ++++++++++++++++++ mobile/src/transport/host-status-gates.ts | 98 ++++++++++ .../src/worktree/host-worktree-rpc-types.ts | 3 + src/main/ipc/pty.test.ts | 17 ++ src/main/ipc/pty.ts | 7 + src/main/runtime/orca-runtime.test.ts | 80 ++++++++ src/main/runtime/orca-runtime.ts | 110 ++++++++++- src/shared/runtime-types.ts | 3 + 21 files changed, 1037 insertions(+), 168 deletions(-) create mode 100644 mobile/src/session/floating-workspace.test.ts create mode 100644 mobile/src/session/floating-workspace.ts create mode 100644 mobile/src/session/mobile-new-tab-agent-loader.test.ts create mode 100644 mobile/src/session/mobile-new-tab-agent-loader.ts create mode 100644 mobile/src/transport/host-status-gates.test.ts create mode 100644 mobile/src/transport/host-status-gates.ts diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx index 126744a984e..b406840ffb2 100644 --- a/mobile/app/h/[hostId]/index.tsx +++ b/mobile/app/h/[hostId]/index.tsx @@ -26,7 +26,8 @@ import { Filter, Check, UserCircle, - PanelLeftClose + PanelLeftClose, + SquareTerminal } from 'lucide-react-native' import type { RpcClient } from '../../../src/transport/rpc-client' import { loadHosts, updateLastConnected } from '../../../src/transport/host-store' @@ -57,6 +58,7 @@ import type { RepoIcon } from '../../../../src/shared/repo-icon' import { PickerModal } from '../../../src/components/PickerModal' import { ActionSheetContent } from '../../../src/components/ActionSheetModal' import { buildWorktreeNavigationActions } from '../../../src/agent-history/worktree-navigation-actions' +import { floatingWorkspaceSessionPath } from '../../../src/session/floating-workspace' import { ConfirmModal } from '../../../src/components/ConfirmModal' import { BottomDrawer } from '../../../src/components/BottomDrawer' import { ProtocolBlockScreen } from '../../../src/components/ProtocolBlockScreen' @@ -68,7 +70,7 @@ import { setCachedRepos } from '../../../src/cache/repo-cache' import { colors, radii, spacing, typography } from '../../../src/theme/mobile-theme' import { useResponsiveLayout } from '../../../src/layout/responsive-layout' import { leaveHostRoute } from '../../../src/host-route-exit' -import { evaluateCompat, type CompatVerdict } from '../../../src/transport/protocol-compat' +import { useHostStatusGates } from '../../../src/transport/host-status-gates' import { loadPinnedIds, savePinnedIds } from '../../../src/storage/preferences' import { createInitialHostRouteActionState, @@ -97,7 +99,7 @@ import { WORKSPACE_GROUP_OPTIONS as GROUP_OPTIONS, WORKSPACE_SORT_OPTIONS as SORT_OPTIONS } from '../../../src/worktree/workspace-list-picker-options' -import type { DesktopStatus, RepoSummary } from '../../../src/worktree/host-worktree-rpc-types' +import type { RepoSummary } from '../../../src/worktree/host-worktree-rpc-types' import type { WorkspaceStatusDefinition } from '../../../../src/shared/types' import { DEFAULT_MOBILE_WORKSPACE_STATUSES } from '../../../src/worktree/mobile-workspace-statuses' @@ -155,7 +157,6 @@ export function HostScreen({ const [repoIconsByName, setRepoIconsByName] = useState>(new Map()) const [hostName, setHostName] = useState('') const [error, setError] = useState('') - const [compatVerdict, setCompatVerdict] = useState({ kind: 'ok' }) const [lastKnownWorktrees, setLastKnownWorktrees] = useState(initialCache ?? []) const [search, setSearch] = useState('') const [showSearch, setShowSearch] = useState(false) @@ -175,7 +176,11 @@ export function HostScreen({ const [showGroupPicker, setShowGroupPicker] = useState(false) const [showFilterModal, setShowFilterModal] = useState(false) const [actionTarget, setActionTarget] = useState(null) - const [hostCapabilities, setHostCapabilities] = useState([]) + const { hostCapabilities, floatingWorkspaceEnabled, compatVerdict } = useHostStatusGates({ + hostId, + client, + connState + }) const [confirmDelete, setConfirmDelete] = useState(null) const [confirmRemoveHost, setConfirmRemoveHost] = useState(false) const [routeActionState, setRouteActionState] = useState(() => @@ -315,7 +320,6 @@ export function HostScreen({ useEffect(() => { setHostName('') setError('') - setCompatVerdict({ kind: 'ok' }) setRepoColorsByName(new Map()) setRepoIconsByName(new Map()) repoMetadataFetchedAtRef.current = 0 @@ -483,52 +487,6 @@ export function HostScreen({ [client, connState, hostId] ) - // Why: re-evaluate protocol compat on connect; today's constants are wide-open so this never blocks yet. - useEffect(() => { - if (connState !== 'connected' || !client) { - // Why: drop capabilities while disconnected/switching so a capability-gated action can't linger for a new host. - setHostCapabilities([]) - return - } - let cancelled = false - const requestClient = client - void (async () => { - try { - const response = await requestClient.sendRequest('status.get') - if (cancelled || clientRef.current !== requestClient) { - return - } - if (!response.ok) { - setHostCapabilities([]) - return - } - const status = (response as RpcSuccess).result as DesktopStatus & { - capabilities?: string[] - } - setHostCapabilities(status.capabilities ?? []) - const verdict = evaluateCompat({ - desktopProtocolVersion: status.protocolVersion, - desktopMinCompatibleMobileVersion: status.minCompatibleMobileVersion - }) - setCompatVerdict(verdict) - if (verdict.kind === 'blocked') { - // Why: support breadcrumb to confirm a block fired vs a render bug; no PII, just version ints. - console.warn('[protocol-compat] blocked', { - reason: verdict.reason, - desktopVersion: verdict.desktopVersion, - requiredMobileVersion: verdict.requiredMobileVersion, - requiredDesktopVersion: verdict.requiredDesktopVersion - }) - } - } catch { - // Why: sendRequest can throw on transport tear-down; treat as transient, keep the prior verdict. - } - })() - return () => { - cancelled = true - } - }, [connState, client]) - useFocusEffect( useCallback(() => { // Why: focus nudges reconnect and probes a possibly half-open socket; empty deps fire per focus, not per state flip (which defeats backoff). @@ -705,6 +663,12 @@ export function HostScreen({ [client, connState, hostId, navigateFromHostList] ) + const openFloatingWorkspace = useCallback(() => { + // Why: no worktree.activate here — the floating sentinel has no worktree + // record; session.tabs.list hydrates its host-owned tabs on open. + navigateFromHostList(floatingWorkspaceSessionPath(hostId)) + }, [hostId, navigateFromHostList]) + const handleSortChange = useCallback( (value: MobileSortMode) => { persistViewSettings({ sortMode: value }) @@ -870,6 +834,24 @@ export function HostScreen({ ) })()} + {!embedded && floatingWorkspaceEnabled ? ( + + + + ) : null} {embedded && onHideSidebar ? ( + {floatingWorkspaceEnabled ? ( + + + + ) : null} + () const isFolderWorkspaceRoute = worktreeId.startsWith('folder:') // Synthetic ids have no repo scope. + // Why: the floating sentinel has no repo/worktree, so repo-backed surfaces hide. + const isFloatingWorkspaceRoute = isFloatingWorkspaceWorktreeId(worktreeId) const router = useRouter() const insets = useSafeAreaInsets() // Why: shared client per host owned by RpcClientProvider (docs/mobile-shared-client-per-host.md). @@ -833,11 +833,13 @@ export default function SessionScreen() { const { isWideLayout } = useResponsiveLayout() const [activePanel, setActivePanel] = useState(null) const [sessionContentRowWidth, setSessionContentRowWidth] = useState(0) - const canDockPanel = canDockSessionPanel({ - isWideLayout, - availableWidth: sessionContentRowWidth, - dockWidth: HOST_DOCK_MIN_WIDTH - }) + const canDockPanel = + !isFloatingWorkspaceRoute && + canDockSessionPanel({ + isWideLayout, + availableWidth: sessionContentRowWidth, + dockWidth: HOST_DOCK_MIN_WIDTH + }) // Why: if rotation/split-screen makes the docked row too narrow, clear activePanel so it doesn't survive into overlay/push mode. useEffect(() => { if (!canDockPanel && activePanel !== null) { @@ -847,7 +849,9 @@ export default function SessionScreen() { // GitHub remote probe gates the PR dock icon so non-GitHub providers can't open the hosted-review surface; skip the unused identity RPCs. const { isGithubRepo: prIsGithubRepo, repoLoaded: prRepoContextLoaded } = useMobilePrBranchContext({ - client, + // Why: a null client parks the hook in its not-ready state — the floating + // sentinel has no repo to probe. + client: isFloatingWorkspaceRoute ? null : client, connState, worktreeId, includeBranchIdentity: false @@ -1933,7 +1937,7 @@ export default function SessionScreen() { ) const loadDiffComments = useCallback(async (): Promise => { - if (!client || connState !== 'connected' || !worktreeId) { + if (!client || connState !== 'connected' || !worktreeId || isFloatingWorkspaceRoute) { setDiffComments([]) return } @@ -1947,7 +1951,7 @@ export default function SessionScreen() { worktree?: { diffComments?: unknown } } setDiffComments(normalizeMobileDiffComments(result.worktree?.diffComments, worktreeId)) - }, [client, connState, worktreeId]) + }, [client, connState, worktreeId, isFloatingWorkspaceRoute]) const persistDiffComments = useCallback( async (comments: readonly DiffComment[]): Promise => { @@ -2621,7 +2625,7 @@ export default function SessionScreen() { showToast('Open Orca on the host to wake sleeping agents.', 3000) } } - if (client && created !== '1') { + if (client && created !== '1' && !isFloatingWorkspaceRoute) { // Why: hydrate host-owned tabs without pulling other paired clients (esp. desktop) into this worktree. void client .sendRequest('worktree.activate', { @@ -2644,7 +2648,7 @@ export default function SessionScreen() { } addTimer(() => void fetchTerminals({ allowEmptyLoaded: false }), 750) addTimer(() => void fetchTerminals({ allowEmptyLoaded: true }), 1500) - if (client && created === '1') { + if (client && created === '1' && !isFloatingWorkspaceRoute) { addTimer(() => { if (activeHandleRef.current) { return @@ -2672,7 +2676,16 @@ export default function SessionScreen() { clearTimeout(t) } } - }, [client, connState, created, fetchSessionTabs, fetchTerminals, showToast, worktreeId]) + }, [ + client, + connState, + created, + fetchSessionTabs, + fetchTerminals, + isFloatingWorkspaceRoute, + showToast, + worktreeId + ]) useEffect(() => { if (!client || connState !== 'connected') { @@ -3231,13 +3244,15 @@ export default function SessionScreen() { if (handle !== activeHandleRef.current) { return } - if (terminalLinkOpenMode === 'phone-browser') { + // Why: browser.tabCreate resolves a real worktree, which the floating + // sentinel doesn't have — open taps in the phone browser instead. + if (terminalLinkOpenMode === 'phone-browser' || isFloatingWorkspaceRoute) { void Linking.openURL(url).catch(() => {}) return } void handleCreateBrowserRef.current?.(url) }, - [terminalLinkOpenMode] + [terminalLinkOpenMode, isFloatingWorkspaceRoute] ) const toggleLiveInput = useCallback(() => { @@ -3575,7 +3590,8 @@ export default function SessionScreen() { }, []) const getActiveWorktreeConnectionId = useCallback(async (): Promise => { - if (!client) { + // Why: the floating workspace always runs on the paired host itself, never an SSH repo target. + if (!client || isFloatingWorkspaceRoute) { return null } const repoId = getRepoIdFromMobileWorktreeId(worktreeId) @@ -3586,7 +3602,7 @@ export default function SessionScreen() { const repos = ((repoResponse as RpcSuccess).result as { repos?: RuntimeRepoSummary[] }).repos ?? [] return repos.find((repo) => repo.id === repoId)?.connectionId?.trim() || null - }, [client, worktreeId]) + }, [client, isFloatingWorkspaceRoute, worktreeId]) const refreshCanPaste = useCallback(() => { void Promise.all([ @@ -3683,43 +3699,14 @@ export default function SessionScreen() { setCreateTabAgentOptions([]) void (async () => { - const [settingsResponse, repoResponse] = await Promise.all([ - client.sendRequest('settings.get'), - client.sendRequest('repo.list') - ]) - if (!settingsResponse.ok) { - throw new Error((settingsResponse as RpcFailure).error.message) - } - const settings = ( - (settingsResponse as RpcSuccess).result as { - settings?: MobileNewTabAgentSettings - } - ).settings - if (!repoResponse.ok) { - throw new Error((repoResponse as RpcFailure).error.message) - } - const repoId = getRepoIdFromMobileWorktreeId(worktreeId) - if (!repoId) { - throw new Error('worktree_repo_missing') - } - const repos = - ((repoResponse as RpcSuccess).result as { repos?: RuntimeRepoSummary[] }).repos ?? [] - const repo = repos.find((candidate) => candidate.id === repoId) - if (!repo) { - throw new Error('worktree_repo_not_found') - } - const connectionId = repo.connectionId?.trim() || null - const detectedResponse = connectionId - ? await client.sendRequest('preflight.detectRemoteAgents', { connectionId }) - : await client.sendRequest('preflight.detectAgents') - if (!detectedResponse.ok) { - throw new Error((detectedResponse as RpcFailure).error.message) - } + const options = await loadMobileNewTabAgentOptions({ + client, + worktreeId + }) if (stale) { return } - const detectedAgentIds = (detectedResponse as RpcSuccess).result as unknown[] - setCreateTabAgentOptions(buildMobileNewTabAgentOptions(settings, detectedAgentIds)) + setCreateTabAgentOptions(options) setCreateTabAgentLoadState('loaded') })().catch(() => { if (!stale) { @@ -4380,9 +4367,9 @@ export default function SessionScreen() { router.push(`/h/${hostId}/agent-history/${encodeURIComponent(worktreeId)}?${params.toString()}`) } const showAgentSessionHistoryAction = - !isFolderWorkspaceRoute && agentSessionHistorySupported === true + !isFolderWorkspaceRoute && !isFloatingWorkspaceRoute && agentSessionHistorySupported === true const showChecksAction = shouldShowSessionHeaderChecksAction({ - isFolderWorkspaceRoute, + isFolderWorkspaceRoute: isFolderWorkspaceRoute || isFloatingWorkspaceRoute, repoContextLoaded: prRepoContextLoaded, hostedChecksSupported: prIsGithubRepo }) @@ -4423,13 +4410,15 @@ export default function SessionScreen() { - handlePanelTap('files')} - /> - {!isFolderWorkspaceRoute && ( + {!isFloatingWorkspaceRoute && ( + handlePanelTap('files')} + /> + )} + {!isFolderWorkspaceRoute && !isFloatingWorkspaceRoute && ( setShowQuickCommands(false)} client={client} - repoId={isFolderWorkspaceRoute ? null : getRepoIdFromMobileWorktreeId(worktreeId) || null} + repoId={ + isFolderWorkspaceRoute || isFloatingWorkspaceRoute + ? null + : getRepoIdFromMobileWorktreeId(worktreeId) || null + } repoName={worktreeName || null} onLaunch={launchQuickCommand} /> @@ -5093,26 +5086,33 @@ export default function SessionScreen() { void handleCreateTerminal() } }, - { - label: 'Browser', - icon: Globe, - onPress: () => { - setShowCreateTabDrawer(false) - if (browserScreencastSupported !== true) { - showToast('Desktop update required for mobile browser streaming', 1600) - return - } - setShowCreateBrowserModal(true) - } - }, - { - label: 'Markdown Note', - icon: FileText, - onPress: () => { - setShowCreateTabDrawer(false) - void handleCreateMarkdownNote() - } - } + // Why: browser/markdown creation resolve a real worktree on the host + // (browser.tabCreate, files.createFile); the floating sentinel is + // terminal-only over RPC, so those options hide there. + ...(isFloatingWorkspaceRoute + ? [] + : [ + { + label: 'Browser', + icon: Globe, + onPress: () => { + setShowCreateTabDrawer(false) + if (browserScreencastSupported !== true) { + showToast('Desktop update required for mobile browser streaming', 1600) + return + } + setShowCreateBrowserModal(true) + } + }, + { + label: 'Markdown Note', + icon: FileText, + onPress: () => { + setShowCreateTabDrawer(false) + void handleCreateMarkdownNote() + } + } + ]) ]} onClose={() => setShowCreateTabDrawer(false)} /> diff --git a/mobile/src/session/floating-workspace.test.ts b/mobile/src/session/floating-workspace.test.ts new file mode 100644 index 00000000000..8f29646b49b --- /dev/null +++ b/mobile/src/session/floating-workspace.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { + FLOATING_WORKSPACE_WORKTREE_ID, + floatingWorkspaceSessionPath, + isFloatingWorkspaceWorktreeId +} from './floating-workspace' + +describe('floating workspace routing', () => { + it('matches only the desktop sentinel id', () => { + expect(isFloatingWorkspaceWorktreeId('global-floating-terminal')).toBe(true) + expect(isFloatingWorkspaceWorktreeId('repo-1::/worktree')).toBe(false) + expect(isFloatingWorkspaceWorktreeId('folder:group-1')).toBe(false) + expect(isFloatingWorkspaceWorktreeId(undefined)).toBe(false) + expect(isFloatingWorkspaceWorktreeId(null)).toBe(false) + }) + + it('builds the session route with an explicit title seed', () => { + expect(floatingWorkspaceSessionPath('host-1')).toBe( + `/h/host-1/session/${FLOATING_WORKSPACE_WORKTREE_ID}?name=Floating%20Workspace` + ) + }) +}) diff --git a/mobile/src/session/floating-workspace.ts b/mobile/src/session/floating-workspace.ts new file mode 100644 index 00000000000..acbc805ea33 --- /dev/null +++ b/mobile/src/session/floating-workspace.ts @@ -0,0 +1,15 @@ +// Mirrors FLOATING_TERMINAL_WORKTREE_ID in src/shared/constants.ts — the desktop +// Floating Workspace's synthetic id (no backing repo/worktree; always local runtime). +export const FLOATING_WORKSPACE_WORKTREE_ID = 'global-floating-terminal' + +export const FLOATING_WORKSPACE_TITLE = 'Floating Workspace' + +export function isFloatingWorkspaceWorktreeId(worktreeId: string | null | undefined): boolean { + return worktreeId === FLOATING_WORKSPACE_WORKTREE_ID +} + +// Route target for the host-header entry; the ?name param seeds the session +// screen title before tabs load. +export function floatingWorkspaceSessionPath(hostId: string | undefined): string { + return `/h/${hostId}/session/${FLOATING_WORKSPACE_WORKTREE_ID}?name=${encodeURIComponent(FLOATING_WORKSPACE_TITLE)}` +} diff --git a/mobile/src/session/mobile-new-tab-agent-loader.test.ts b/mobile/src/session/mobile-new-tab-agent-loader.test.ts new file mode 100644 index 00000000000..b9f5d9e174e --- /dev/null +++ b/mobile/src/session/mobile-new-tab-agent-loader.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { FLOATING_WORKSPACE_WORKTREE_ID } from './floating-workspace' +import { loadMobileNewTabAgentOptions } from './mobile-new-tab-agent-loader' + +function createClient( + handler: (method: string, params?: unknown) => Promise +): RpcClient & { sendRequest: ReturnType } { + return { + sendRequest: vi.fn(handler), + subscribe: vi.fn(() => () => {}) + } as unknown as RpcClient & { sendRequest: ReturnType } +} + +describe('mobile new-tab agent loading', () => { + it('detects agents locally for the floating workspace without listing repos', async () => { + const client = createClient(async (method) => { + if (method === 'settings.get') { + return { + ok: true, + result: { settings: { defaultTuiAgent: 'codex', disabledTuiAgents: [] } } + } + } + if (method === 'preflight.detectAgents') { + return { ok: true, result: ['claude', 'codex'] } + } + throw new Error(`unexpected request: ${method}`) + }) + + await expect( + loadMobileNewTabAgentOptions({ + client, + worktreeId: FLOATING_WORKSPACE_WORKTREE_ID + }) + ).resolves.toEqual([ + { agent: 'codex', label: 'Codex' }, + { agent: 'claude', label: 'Claude' } + ]) + expect(client.sendRequest.mock.calls.map(([method]) => method)).toEqual([ + 'preflight.detectAgents', + 'settings.get' + ]) + }) + + it('detects agents through the worktree repo connection for SSH sessions', async () => { + const client = createClient(async (method, params) => { + if (method === 'settings.get') { + return { ok: true, result: { settings: {} } } + } + if (method === 'repo.list') { + return { ok: true, result: { repos: [{ id: 'repo-1', connectionId: 'ssh-1' }] } } + } + if (method === 'preflight.detectRemoteAgents') { + expect(params).toEqual({ connectionId: 'ssh-1' }) + return { ok: true, result: ['claude'] } + } + throw new Error(`unexpected request: ${method}`) + }) + + await expect( + loadMobileNewTabAgentOptions({ + client, + worktreeId: 'repo-1::/remote/worktree' + }) + ).resolves.toEqual([{ agent: 'claude', label: 'Claude' }]) + expect(client.sendRequest.mock.calls.map(([method]) => method)).toEqual([ + 'repo.list', + 'settings.get', + 'preflight.detectRemoteAgents' + ]) + }) +}) diff --git a/mobile/src/session/mobile-new-tab-agent-loader.ts b/mobile/src/session/mobile-new-tab-agent-loader.ts new file mode 100644 index 00000000000..ea87e74ba0e --- /dev/null +++ b/mobile/src/session/mobile-new-tab-agent-loader.ts @@ -0,0 +1,62 @@ +import type { RpcClient } from '../transport/rpc-client' +import type { RpcFailure, RpcSuccess } from '../transport/types' +import { isFloatingWorkspaceWorktreeId } from './floating-workspace' +import { getRepoIdFromMobileWorktreeId } from './mobile-session-route-helpers' +import { + buildMobileNewTabAgentOptions, + type MobileNewTabAgentOption, + type MobileNewTabAgentSettings +} from './mobile-new-tab-agent-options' + +type RuntimeRepoSummary = { + id: string + connectionId?: string | null +} + +export async function loadMobileNewTabAgentOptions(args: { + client: RpcClient + worktreeId: string +}): Promise { + const { client, worktreeId } = args + // Why: the floating workspace runs on the paired host, so it has no repo connection to resolve. + const detectedAgentsRequest = isFloatingWorkspaceWorktreeId(worktreeId) + ? client.sendRequest('preflight.detectAgents') + : loadWorkspaceDetectedAgents(client, worktreeId) + const [settingsResponse, detectedResponse] = await Promise.all([ + client.sendRequest('settings.get'), + detectedAgentsRequest + ]) + if (!settingsResponse.ok) { + throw new Error((settingsResponse as RpcFailure).error.message) + } + if (!detectedResponse.ok) { + throw new Error((detectedResponse as RpcFailure).error.message) + } + const settings = ( + (settingsResponse as RpcSuccess).result as { + settings?: MobileNewTabAgentSettings + } + ).settings + return buildMobileNewTabAgentOptions( + settings, + (detectedResponse as RpcSuccess).result as unknown[] + ) +} + +async function loadWorkspaceDetectedAgents(client: RpcClient, worktreeId: string) { + const repoResponse = await client.sendRequest('repo.list') + if (!repoResponse.ok) { + throw new Error((repoResponse as RpcFailure).error.message) + } + const repoId = getRepoIdFromMobileWorktreeId(worktreeId) + const repos = + ((repoResponse as RpcSuccess).result as { repos?: RuntimeRepoSummary[] }).repos ?? [] + const repo = repos.find((candidate) => candidate.id === repoId) + if (!repo) { + throw new Error('worktree_repo_not_found') + } + const connectionId = repo.connectionId?.trim() || null + return connectionId + ? client.sendRequest('preflight.detectRemoteAgents', { connectionId }) + : client.sendRequest('preflight.detectAgents') +} diff --git a/mobile/src/session/mobile-session-startup-source.test.ts b/mobile/src/session/mobile-session-startup-source.test.ts index c4083b1a545..3f29816f242 100644 --- a/mobile/src/session/mobile-session-startup-source.test.ts +++ b/mobile/src/session/mobile-session-startup-source.test.ts @@ -36,6 +36,8 @@ describe('mobile session startup', () => { ) expect(startupEffect).toContain("void client\n .sendRequest('worktree.activate'") + expect(startupEffect).toContain("if (client && created !== '1' && !isFloatingWorkspaceRoute)") + expect(startupEffect).toContain("if (client && created === '1' && !isFloatingWorkspaceRoute)") expect(startupEffect).toContain('notifyClients: false') expect(startupEffect).not.toContain("await client\n .sendRequest('worktree.activate'") expect(startupEffect.indexOf("sendRequest('worktree.activate'")).toBeLessThan( diff --git a/mobile/src/session/use-live-worktree-name.test.ts b/mobile/src/session/use-live-worktree-name.test.ts index 97927c6b7d9..8ac565c7684 100644 --- a/mobile/src/session/use-live-worktree-name.test.ts +++ b/mobile/src/session/use-live-worktree-name.test.ts @@ -166,4 +166,86 @@ describe('useLiveWorktreeName request volume', () => { expect(unsubscribeStream).toHaveBeenCalledTimes(1) }) + + it('never calls worktree.show for the floating workspace sentinel', async () => { + let name = '' + function FloatingHarness(): null { + name = useLiveWorktreeName({ + client, + connState: 'connected', + routeName: undefined, + worktreeId: 'global-floating-terminal' + }) + return null + } + + const restoreConsoleError = suppressReactTestRendererDeprecationWarning() + try { + await act(async () => { + renderer = create(createElement(FloatingHarness)) + await Promise.resolve() + }) + } finally { + restoreConsoleError() + } + await act(async () => { + await vi.advanceTimersByTimeAsync(30_000) + }) + + expect(sendRequest).not.toHaveBeenCalled() + expect(subscribe).not.toHaveBeenCalled() + expect(name).toBe('Floating Workspace') + }) + + it('does not render stale names while switching through the floating route', async () => { + const firstNameByWorktree = new Map() + let renderer: ReactTestRenderer | null = null + + function RouteHarness(props: { routeName?: string; worktreeId: string }): null { + const name = useLiveWorktreeName({ + client, + connState: 'connected', + routeName: props.routeName, + worktreeId: props.worktreeId + }) + if (!firstNameByWorktree.has(props.worktreeId)) { + firstNameByWorktree.set(props.worktreeId, name) + } + return null + } + + const restoreConsoleError = suppressReactTestRendererDeprecationWarning() + try { + await act(async () => { + renderer = create( + createElement(RouteHarness, { + routeName: 'Previous workspace', + worktreeId: 'repo-1::/worktree' + }) + ) + await Promise.resolve() + }) + await act(async () => { + renderer?.update( + createElement(RouteHarness, { + worktreeId: 'global-floating-terminal' + }) + ) + }) + await act(async () => { + renderer?.update( + createElement(RouteHarness, { + routeName: 'Next workspace', + worktreeId: 'repo-2::/worktree' + }) + ) + }) + } finally { + restoreConsoleError() + act(() => renderer?.unmount()) + } + + expect(firstNameByWorktree.get('global-floating-terminal')).toBe('Floating Workspace') + expect(firstNameByWorktree.get('repo-2::/worktree')).toBe('Next workspace') + }) }) diff --git a/mobile/src/session/use-live-worktree-name.ts b/mobile/src/session/use-live-worktree-name.ts index bd21ec8f156..53ef1cf1bbf 100644 --- a/mobile/src/session/use-live-worktree-name.ts +++ b/mobile/src/session/use-live-worktree-name.ts @@ -5,6 +5,7 @@ import { getRepoIdFromWorktreeId } from '../../../src/shared/worktree-id' import type { RpcClient } from '../transport/rpc-client' import type { ConnectionState, RpcSuccess } from '../transport/types' import { getLiveWorktreeDisplayName, type WorktreeDisplayNameSource } from './worktree-display-name' +import { FLOATING_WORKSPACE_TITLE, isFloatingWorkspaceWorktreeId } from './floating-workspace' const WORKTREE_NAME_FALLBACK_POLL_MS = 3000 @@ -16,15 +17,26 @@ type Params = { } export function useLiveWorktreeName({ client, connState, routeName, worktreeId }: Params): string { - const [worktreeName, setWorktreeName] = useState(() => routeName?.trim() ?? '') + // Why: the floating sentinel has no worktree record, so worktree.show would + // fail forever and keep the 3s fallback poll alive; its title is fixed. + const isFloatingWorkspace = isFloatingWorkspaceWorktreeId(worktreeId) + const routeNameHint = routeName?.trim() ?? '' + const [worktreeName, setWorktreeName] = useState(() => ({ + worktreeId, + name: routeNameHint + })) useEffect(() => { - setWorktreeName(routeName?.trim() ?? '') - }, [routeName, worktreeId]) + setWorktreeName((current) => + current.worktreeId === worktreeId && current.name === routeNameHint + ? current + : { worktreeId, name: routeNameHint } + ) + }, [routeNameHint, worktreeId]) useFocusEffect( useCallback(() => { - if (!client || connState !== 'connected') { + if (isFloatingWorkspace || !client || connState !== 'connected') { return } let stale = false @@ -58,7 +70,11 @@ export function useLiveWorktreeName({ client, connState, routeName, worktreeId } ? getLiveWorktreeDisplayName([result.worktree], worktreeId) : null if (liveName) { - setWorktreeName((current) => (current === liveName ? current : liveName)) + setWorktreeName((current) => + current.worktreeId === worktreeId && current.name === liveName + ? current + : { worktreeId, name: liveName } + ) } hasSuccessfulRefresh = true if (eventStreamReady) { @@ -128,8 +144,11 @@ export function useLiveWorktreeName({ client, connState, routeName, worktreeId } stopFallbackPoll() unsubscribe() } - }, [client, connState, worktreeId]) + }, [client, connState, worktreeId, isFloatingWorkspace]) ) - return worktreeName + if (isFloatingWorkspace) { + return FLOATING_WORKSPACE_TITLE + } + return worktreeName.worktreeId === worktreeId ? worktreeName.name : routeNameHint } diff --git a/mobile/src/session/use-mobile-native-chat-readability.test.ts b/mobile/src/session/use-mobile-native-chat-readability.test.ts index 46feaf79217..41d08c3c32c 100644 --- a/mobile/src/session/use-mobile-native-chat-readability.test.ts +++ b/mobile/src/session/use-mobile-native-chat-readability.test.ts @@ -2,6 +2,7 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { RpcClient } from '../transport/rpc-client' +import { FLOATING_WORKSPACE_WORKTREE_ID } from './floating-workspace' import { useMobileNativeChatReadability } from './use-mobile-native-chat-readability' describe('useMobileNativeChatReadability', () => { @@ -18,15 +19,19 @@ describe('useMobileNativeChatReadability', () => { renderer = null }) - async function mount(connectionId: string | null): Promise { + async function mount( + connectionId: string | null, + worktreeId = 'repo::/worktree' + ): Promise> { + const sendRequest = vi.fn().mockResolvedValue({ + ok: true, + result: { repos: [{ id: 'repo', connectionId }] } + }) const client = { - sendRequest: vi.fn().mockResolvedValue({ - ok: true, - result: { repos: [{ id: 'repo', connectionId }] } - }) + sendRequest } as unknown as RpcClient function Harness(): null { - readable = useMobileNativeChatReadability(client, 'repo::/worktree') + readable = useMobileNativeChatReadability(client, worktreeId) return null } const original = console.error @@ -44,6 +49,7 @@ describe('useMobileNativeChatReadability', () => { } finally { consoleSpy.mockRestore() } + return sendRequest } it('admits local and runtime-owned transcript hosts', async () => { @@ -61,6 +67,13 @@ describe('useMobileNativeChatReadability', () => { expect(readable).toBe(false) }) + it('treats the host-local floating workspace as readable without listing repos', async () => { + const sendRequest = await mount(null, FLOATING_WORKSPACE_WORKTREE_ID) + + expect(readable).toBe(true) + expect(sendRequest).not.toHaveBeenCalled() + }) + it('fails closed immediately while a reused route resolves its new worktree', async () => { let resolveNext: (response: unknown) => void = () => {} const client = { diff --git a/mobile/src/session/use-mobile-native-chat-readability.ts b/mobile/src/session/use-mobile-native-chat-readability.ts index e952335fe23..e3729022d38 100644 --- a/mobile/src/session/use-mobile-native-chat-readability.ts +++ b/mobile/src/session/use-mobile-native-chat-readability.ts @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react' import type { RpcClient } from '../transport/rpc-client' +import { isFloatingWorkspaceWorktreeId } from './floating-workspace' import { isMobileNativeChatTranscriptReadable } from './mobile-native-chat-eligibility' import { getRepoIdFromMobileWorktreeId } from './mobile-session-route-helpers' @@ -10,12 +11,17 @@ export function useMobileNativeChatReadability( client: RpcClient | null, worktreeId: string ): boolean { + const isFloatingWorkspace = isFloatingWorkspaceWorktreeId(worktreeId) const [state, setState] = useState({ client: null, worktreeId: '', readable: false }) useEffect(() => { + // Why: the floating workspace always runs on the paired host and has no repo connection to resolve. + if (isFloatingWorkspace) { + return + } let active = true if (!client) { setState({ client, worktreeId, readable: false }) @@ -46,7 +52,10 @@ export function useMobileNativeChatReadability( return () => { active = false } - }, [client, worktreeId]) + }, [client, isFloatingWorkspace, worktreeId]) + if (isFloatingWorkspace) { + return true + } // Why: route reuse renders before its new effect resolves; never expose the // previous repo's readability under a different client/worktree key. return state.client === client && state.worktreeId === worktreeId ? state.readable : false diff --git a/mobile/src/transport/client-context.test.ts b/mobile/src/transport/client-context.test.ts index 121d0e42661..eacb68ab0b9 100644 --- a/mobile/src/transport/client-context.test.ts +++ b/mobile/src/transport/client-context.test.ts @@ -128,6 +128,90 @@ beforeEach(() => { }) describe('useHostClient', () => { + it('rebinds when Expo reuses a screen between two connected cached hosts', async () => { + const host2 = { ...HOST, id: 'host-2', name: 'Host 2' } + const client1 = makeFakeClient('connected') + const client2 = makeFakeClient('connected') + connectMock.mockReturnValueOnce(client1).mockReturnValueOnce(client2) + loadHostsMock.mockResolvedValue([HOST, host2]) + + let selectedHostId = HOST.id + let selectedClient: RpcClient | null = null + let selectedState: ConnectionState = 'disconnected' + let renderer: ReactTestRenderer | null = null + function Probe(): null { + const selected = useHostClient(selectedHostId) + selectedClient = selected.client + selectedState = selected.state + useHostClient(host2.id) + return null + } + + const restore = suppressReactTestRendererDeprecationWarning() + try { + await act(async () => { + renderer = create(createElement(RpcClientProvider, null, createElement(Probe))) + await Promise.resolve() + }) + expect(selectedClient).toBe(client1) + expect(selectedState).toBe('connected') + + selectedHostId = host2.id + client2.emitState('disconnected') + await act(async () => { + renderer?.update(createElement(RpcClientProvider, null, createElement(Probe))) + await Promise.resolve() + }) + + expect(selectedClient).toBe(client2) + expect(selectedState).toBe('disconnected') + expect(connectMock).toHaveBeenCalledTimes(2) + } finally { + restore() + act(() => renderer?.unmount()) + } + }) + + it('stays disconnected while a reused screen resolves an uncached host', async () => { + const client = makeFakeClient('connected') + connectMock.mockReturnValue(client) + loadHostsMock.mockResolvedValueOnce([HOST]).mockReturnValueOnce(new Promise(() => {})) + + let selectedHostId = HOST.id + let renderTick = 0 + const stateByRenderTick = new Map() + let renderer: ReactTestRenderer | null = null + function Probe(): null { + stateByRenderTick.set(renderTick, useHostClient(selectedHostId).state) + return null + } + + const restore = suppressReactTestRendererDeprecationWarning() + try { + await act(async () => { + renderer = create(createElement(RpcClientProvider, null, createElement(Probe))) + await Promise.resolve() + }) + expect(stateByRenderTick.get(0)).toBe('connected') + + selectedHostId = 'missing-host' + renderTick = 1 + await act(async () => { + renderer?.update(createElement(RpcClientProvider, null, createElement(Probe))) + }) + expect(stateByRenderTick.get(1)).toBe('disconnected') + + renderTick = 2 + await act(async () => { + renderer?.update(createElement(RpcClientProvider, null, createElement(Probe))) + }) + expect(stateByRenderTick.get(2)).toBe('disconnected') + } finally { + restore() + act(() => renderer?.unmount()) + } + }) + it('drops the closed client when the host entry is removed', async () => { const fake = makeFakeClient('connected') connectMock.mockReturnValue(fake) diff --git a/mobile/src/transport/client-context.tsx b/mobile/src/transport/client-context.tsx index 80ec7f11c1f..eec5ba40469 100644 --- a/mobile/src/transport/client-context.tsx +++ b/mobile/src/transport/client-context.tsx @@ -344,13 +344,16 @@ export function useHostClient(hostId: string | undefined): { hostId ? ctx.getState(hostId) : 'disconnected' ) const clientRef = useRef(null) + const clientHostIdRef = useRef(hostId) useEffect(() => { if (!hostId) { clientRef.current = null + clientHostIdRef.current = undefined setState('disconnected') return } + clientHostIdRef.current = hostId let cancelled = false // Subscribe before acquire so any state change during open is captured. const unsub = ctx.subscribeHostState(hostId, (next) => { @@ -370,28 +373,29 @@ export function useHostClient(hostId: string | undefined): { } }) const initial = ctx.acquire(hostId) + clientRef.current = initial + setState(ctx.getState(hostId)) if (initial) { - clientRef.current = initial - setState(ctx.getState(hostId)) + // Why: two cached hosts can both be connected, so equal state values cannot reveal the replacement client. + force((n) => n + 1) } return () => { cancelled = true unsub() ctx.release(hostId) clientRef.current = null + clientHostIdRef.current = undefined } }, [ctx, hostId]) - return { client: clientRef.current, state } + // Why: Expo can reuse the screen before effects bind the next host; never expose the prior host's client or state in that render. + const bound = clientHostIdRef.current === hostId + const boundState = bound ? state : hostId ? ctx.getState(hostId) : 'disconnected' + return { client: bound ? clientRef.current : null, state: boundState } } // Why: refcounting prevents a double-open when a host-detail screen shares one of these hosts. -export function useAllHostClients(hostIds: string[]): Array<{ - hostId: string - client: RpcClient - state: ConnectionState - path: MobileConnectionPath -}> { +export function useAllHostClients(hostIds: string[]) { const ctx = useRpcClientContext() // Stable key so we don't tear down on every render of the array. const key = useMemo(() => [...hostIds].sort().join(','), [hostIds]) diff --git a/mobile/src/transport/host-status-gates.test.ts b/mobile/src/transport/host-status-gates.test.ts new file mode 100644 index 00000000000..ce5974b1f84 --- /dev/null +++ b/mobile/src/transport/host-status-gates.test.ts @@ -0,0 +1,179 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { describe, expect, it, vi } from 'vitest' +import type { RpcClient } from './rpc-client' +import { useHostStatusGates, type HostStatusGates } from './host-status-gates' + +function suppressReactTestRendererDeprecationWarning(): () => void { + const originalConsoleError = console.error + const spy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + originalConsoleError(...args) + }) + return () => spy.mockRestore() +} + +describe('useHostStatusGates', () => { + it('clears every prior-host gate and ignores its late response while the client is replaced', async () => { + let resolveOldStatus: ((response: unknown) => void) | null = null + const pendingOldStatus = new Promise((resolve) => { + resolveOldStatus = resolve + }) + const oldSendRequest = vi.fn().mockReturnValue(pendingOldStatus) + const oldClient = { sendRequest: oldSendRequest } as unknown as RpcClient + const newSendRequest = vi.fn().mockResolvedValue({ + ok: true, + result: { + capabilities: ['terminal.quick-commands.v1'], + floatingWorkspaceEnabled: true + } + }) + const newClient = { sendRequest: newSendRequest } as unknown as RpcClient + let gates: HostStatusGates | null = null + const firstRenderByHost = new Map() + let renderer: ReactTestRenderer | null = null + + function Probe({ hostId, client }: { hostId: string; client: RpcClient }): null { + gates = useHostStatusGates({ hostId, client, connState: 'connected' }) + if (!firstRenderByHost.has(hostId)) { + firstRenderByHost.set(hostId, gates) + } + return null + } + + const restore = suppressReactTestRendererDeprecationWarning() + try { + await act(async () => { + renderer = create(createElement(Probe, { hostId: 'host-1', client: oldClient })) + }) + + await act(async () => { + renderer?.update(createElement(Probe, { hostId: 'host-2', client: newClient })) + await Promise.resolve() + }) + expect(firstRenderByHost.get('host-2')).toMatchObject({ + hostCapabilities: [], + floatingWorkspaceEnabled: false, + compatVerdict: { kind: 'ok' } + }) + expect(gates).toMatchObject({ + hostCapabilities: ['terminal.quick-commands.v1'], + floatingWorkspaceEnabled: true + }) + + await act(async () => { + resolveOldStatus?.({ + ok: true, + result: { + capabilities: ['browser.screencast.v1'], + floatingWorkspaceEnabled: true + } + }) + await pendingOldStatus + }) + expect(gates).toMatchObject({ + hostCapabilities: ['terminal.quick-commands.v1'], + floatingWorkspaceEnabled: true + }) + expect(oldSendRequest).toHaveBeenCalledOnce() + expect(newSendRequest).toHaveBeenCalledOnce() + } finally { + restore() + renderer?.unmount() + } + }) + + it('loads gates from the connected host', async () => { + const sendRequest = vi.fn().mockResolvedValue({ + ok: true, + result: { + capabilities: ['browser.screencast.v1'], + floatingWorkspaceEnabled: true + } + }) + const client = { sendRequest } as unknown as RpcClient + let gates: HostStatusGates | null = null + let renderer: ReactTestRenderer | null = null + + function Probe({ hostId }: { hostId: string }): null { + gates = useHostStatusGates({ hostId, client, connState: 'connected' }) + return null + } + + const restore = suppressReactTestRendererDeprecationWarning() + try { + await act(async () => { + renderer = create(createElement(Probe, { hostId: 'host-1' })) + await Promise.resolve() + }) + expect(gates).toMatchObject({ + hostCapabilities: ['browser.screencast.v1'], + floatingWorkspaceEnabled: true + }) + + expect(sendRequest).toHaveBeenCalledOnce() + } finally { + restore() + renderer?.unmount() + } + }) + + it('fails closed while the same client reconnects', async () => { + let resolveReconnect: ((response: unknown) => void) | null = null + const pendingReconnect = new Promise((resolve) => { + resolveReconnect = resolve + }) + const sendRequest = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + result: { capabilities: ['browser.screencast.v1'], floatingWorkspaceEnabled: true } + }) + .mockReturnValueOnce(pendingReconnect) + const client = { sendRequest } as unknown as RpcClient + let gates: HostStatusGates | null = null + let renderer: ReactTestRenderer | null = null + + function Probe({ connState }: { connState: 'connected' | 'disconnected' }): null { + gates = useHostStatusGates({ hostId: 'host-1', client, connState }) + return null + } + + const restore = suppressReactTestRendererDeprecationWarning() + try { + await act(async () => { + renderer = create(createElement(Probe, { connState: 'connected' })) + await Promise.resolve() + }) + expect(gates?.floatingWorkspaceEnabled).toBe(true) + + await act(async () => { + renderer?.update(createElement(Probe, { connState: 'disconnected' })) + }) + await act(async () => { + renderer?.update(createElement(Probe, { connState: 'connected' })) + }) + expect(gates).toMatchObject({ + hostCapabilities: [], + floatingWorkspaceEnabled: false + }) + + await act(async () => { + resolveReconnect?.({ + ok: true, + result: { capabilities: ['terminal.quick-commands.v1'], floatingWorkspaceEnabled: true } + }) + await pendingReconnect + }) + expect(gates).toMatchObject({ + hostCapabilities: ['terminal.quick-commands.v1'], + floatingWorkspaceEnabled: true + }) + } finally { + restore() + renderer?.unmount() + } + }) +}) diff --git a/mobile/src/transport/host-status-gates.ts b/mobile/src/transport/host-status-gates.ts new file mode 100644 index 00000000000..7a02cc87abb --- /dev/null +++ b/mobile/src/transport/host-status-gates.ts @@ -0,0 +1,98 @@ +import { useEffect, useState } from 'react' +import type { RpcClient } from './rpc-client' +import type { ConnectionState, RpcSuccess } from './types' +import { evaluateCompat, type CompatVerdict } from './protocol-compat' +import type { DesktopStatus } from '../worktree/host-worktree-rpc-types' + +export type HostStatusGates = { + hostCapabilities: string[] + floatingWorkspaceEnabled: boolean + compatVerdict: CompatVerdict +} + +type LoadedHostStatusGates = HostStatusGates & { + hostId: string | undefined + client: RpcClient +} + +const EMPTY_HOST_CAPABILITIES: string[] = [] + +// Reads status.get on connect for capabilities, protocol-compat verdict, and the +// floating-workspace flag. Compat constants are wide-open today so this never blocks yet. +export function useHostStatusGates(args: { + hostId: string | undefined + client: RpcClient | null + connState: ConnectionState +}): HostStatusGates { + const { hostId, client, connState } = args + const [loaded, setLoaded] = useState(null) + + useEffect(() => { + if (connState !== 'connected' || !client) { + // Why: reconnecting the same host/client must revalidate gates instead of reviving its prior status response. + setLoaded(null) + return + } + let cancelled = false + const requestClient = client + void (async () => { + try { + const response = await requestClient.sendRequest('status.get') + if (cancelled) { + return + } + if (!response.ok) { + return + } + const status = (response as RpcSuccess).result as DesktopStatus & { + capabilities?: string[] + } + const verdict = evaluateCompat({ + desktopProtocolVersion: status.protocolVersion, + desktopMinCompatibleMobileVersion: status.minCompatibleMobileVersion + }) + setLoaded({ + hostId, + client: requestClient, + hostCapabilities: status.capabilities ?? [], + floatingWorkspaceEnabled: status.floatingWorkspaceEnabled === true, + compatVerdict: verdict + }) + if (verdict.kind === 'blocked') { + // Why: support breadcrumb to confirm a block fired vs a render bug; no PII, just version ints. + console.warn('[protocol-compat] blocked', { + reason: verdict.reason, + desktopVersion: verdict.desktopVersion, + requiredMobileVersion: verdict.requiredMobileVersion, + requiredDesktopVersion: verdict.requiredDesktopVersion + }) + } + } catch { + // Why: sendRequest can throw on transport tear-down; the fail-closed return below keeps gated actions hidden. + } + })() + return () => { + cancelled = true + } + }, [client, connState, hostId]) + + // Why: effects run after render, so key loaded gates by host and client to fail closed during route reuse. + if ( + connState !== 'connected' || + !client || + !loaded || + loaded.hostId !== hostId || + loaded.client !== client + ) { + return { + hostCapabilities: EMPTY_HOST_CAPABILITIES, + floatingWorkspaceEnabled: false, + compatVerdict: { kind: 'ok' } + } + } + return { + hostCapabilities: loaded.hostCapabilities, + floatingWorkspaceEnabled: loaded.floatingWorkspaceEnabled, + compatVerdict: loaded.compatVerdict + } +} diff --git a/mobile/src/worktree/host-worktree-rpc-types.ts b/mobile/src/worktree/host-worktree-rpc-types.ts index 9d0d1a4f66d..037e4dcfa87 100644 --- a/mobile/src/worktree/host-worktree-rpc-types.ts +++ b/mobile/src/worktree/host-worktree-rpc-types.ts @@ -5,6 +5,9 @@ import type { ExecutionHostId } from '../../../src/shared/execution-host' export type DesktopStatus = { protocolVersion?: number minCompatibleMobileVersion?: number + // Why: absent on hosts that predate the mobile Floating Workspace entry; + // treat absence as unsupported and hide the entry. + floatingWorkspaceEnabled?: boolean } export type RepoSummary = { diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 5d147a9eebd..bafc64347c7 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -3623,6 +3623,23 @@ describe('registerPtyHandlers', () => { deletePtyOwnership('remote-pty') }) + it('routes runtime exact liveness without enumerating provider sessions', () => { + const provider = getLocalPtyProvider() + const hasPty = vi.spyOn(provider, 'hasPty').mockImplementation((id) => id === 'live-pty') + const listProcesses = vi.spyOn(provider, 'listProcesses') + const runtime = { setPtyController: vi.fn() } + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + const controller = runtime.setPtyController.mock.calls[0]?.[0] as { + hasPty: (ptyId: string) => boolean | null + } + + expect(controller.hasPty('live-pty')).toBe(true) + expect(controller.hasPty('missing-pty')).toBe(false) + expect(hasPty).toHaveBeenCalledTimes(2) + expect(listProcesses).not.toHaveBeenCalled() + }) + it('returns unavailable runtime confirmation for unsupported or missing providers', async () => { registerSshPtyProvider('ssh-1', {} as never) setPtyOwnership('unsupported-pty', 'ssh-1') diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 4f2e0fa1b7c..407ae52503f 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -3331,6 +3331,13 @@ export function registerPtyHandlers( /* best effort: renderer clear still handles local PTYs */ } }, + hasPty: (ptyId) => { + try { + return getProviderForPty(ptyId).hasPty?.(ptyId) ?? null + } catch { + return null + } + }, listProcesses: async () => { const providerSessions = await Promise.all([ localProvider.listProcesses(), diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 55a513ffc13..cda6f380a4a 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -1717,6 +1717,86 @@ describe('OrcaRuntimeService', () => { expect(runtime.getStatus().terminalWindowsShell).toBe('wsl.exe') }) + it('reports floating workspace availability from settings on status', () => { + expect(createRuntime().getStatus().floatingWorkspaceEnabled).toBe(true) + + const disabledRuntime = new OrcaRuntimeService({ + ...store, + getSettings: () => ({ + ...store.getSettings(), + floatingTerminalEnabled: false + }) + } as never) + expect(disabledRuntime.getStatus().floatingWorkspaceEnabled).toBe(false) + }) + + it('polls floating tabs with targeted PTY liveness and no repo/provider inventory', async () => { + const getRepos = vi.fn(store.getRepos) + const listProcesses = vi.fn().mockResolvedValue([]) + const floatingPtyId = `${FLOATING_TERMINAL_WORKTREE_ID}@@pty-1` + const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal({ + activeRepoId: null, + activeWorktreeId: FLOATING_TERMINAL_WORKTREE_ID, + activeTabIdByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: 'floating-tab' }, + tabsByWorktree: { + [FLOATING_TERMINAL_WORKTREE_ID]: [ + { + id: 'floating-tab', + ptyId: floatingPtyId, + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + title: 'Floating Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + 'floating-tab': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: floatingPtyId }) + } + }) + ) + const runtime = new OrcaRuntimeService({ ...runtimeStore, getRepos } as never) + const ptyController = { + livePtyIds: new Set([floatingPtyId]), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + hasPty(this: { livePtyIds: Set }, ptyId: string) { + return this.livePtyIds.has(ptyId) + }, + listProcesses + } + const hasPty = vi.spyOn(ptyController, 'hasPty') + runtime.setPtyController(ptyController) + + const tabs = await runtime.listMobileSessionTabs(`id:${FLOATING_TERMINAL_WORKTREE_ID}`) + const terminals = await runtime.listTerminals(`id:${FLOATING_TERMINAL_WORKTREE_ID}`) + await runtime.listMobileSessionTabs(`id:${FLOATING_TERMINAL_WORKTREE_ID}`) + await runtime.listTerminals(`id:${FLOATING_TERMINAL_WORKTREE_ID}`) + + expect(tabs.tabs).toEqual([ + expect.objectContaining({ + type: 'terminal', + parentTabId: 'floating-tab', + status: 'ready', + terminal: expect.any(String) + }) + ]) + expect(terminals.terminals).toEqual([ + expect.objectContaining({ + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + connected: true + }) + ]) + expect(hasPty).toHaveBeenCalledTimes(4) + expect(hasPty).toHaveBeenCalledWith(floatingPtyId) + expect(listProcesses).not.toHaveBeenCalled() + expect(getRepos).not.toHaveBeenCalled() + }) + it('advertises browser screencast only when a renderer window is available', () => { const runtime = createRuntime() electronMocks.BrowserWindow.fromId.mockReturnValue({ isDestroyed: () => false } as never) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 8fc9500c500..4a6bcefaa8e 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -902,6 +902,7 @@ type RuntimeStore = { agentDefaultArgs?: GlobalSettings['agentDefaultArgs'] agentDefaultEnv?: GlobalSettings['agentDefaultEnv'] terminalWindowsShell?: GlobalSettings['terminalWindowsShell'] + floatingTerminalEnabled?: GlobalSettings['floatingTerminalEnabled'] agentStatusHooksEnabled?: GlobalSettings['agentStatusHooksEnabled'] defaultTaskSource?: GlobalSettings['defaultTaskSource'] defaultTaskViewPreset?: GlobalSettings['defaultTaskViewPreset'] @@ -1293,6 +1294,8 @@ type RuntimePtyController = { hasChildProcesses?(ptyId: string): Promise clearBuffer?(ptyId: string): Promise resize?(ptyId: string, cols: number, rows: number): boolean + // Why: exact-id mobile polls should not enumerate every local and SSH PTY. + hasPty?(ptyId: string): boolean | null listProcesses?(): Promise serializeBuffer?( ptyId: string, @@ -3213,6 +3216,7 @@ export class OrcaRuntimeService { capabilities, hostPlatform: process.platform, terminalWindowsShell: this.store?.getSettings?.().terminalWindowsShell ?? null, + floatingWorkspaceEnabled: this.store?.getSettings?.().floatingTerminalEnabled !== false, protocolVersion: RUNTIME_PROTOCOL_VERSION, minCompatibleMobileVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION } @@ -3558,7 +3562,7 @@ export class OrcaRuntimeService { const explicitWorktreeId = this.getValidatedExplicitWorktreeIdSelector(worktreeSelector) if (explicitWorktreeId) { this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(explicitWorktreeId) - await this.refreshMobileSessionPtyRecords() + await this.refreshMobileSessionPtyRecords(explicitWorktreeId) return this.getMobileSessionTabsForWorktree(explicitWorktreeId) } const worktree = await this.resolveWorktreeSelector(worktreeSelector) @@ -4599,12 +4603,19 @@ export class OrcaRuntimeService { } } - private async refreshMobileSessionPtyRecords(): Promise { - if (!this.ptyController?.listProcesses) { + private async refreshMobileSessionPtyRecords( + targetWorktreeId: string | null = null + ): Promise { + if (!this.ptyController?.listProcesses && !this.ptyController?.hasPty) { return } - const resolvedWorktrees = await this.listResolvedWorktrees() - await this.refreshPtyWorktreeRecordsFromController(resolvedWorktrees) + // Why: floating PTY identity is explicit, so polling must not resolve every Git/SSH worktree. + const isFloatingWorkspace = targetWorktreeId === FLOATING_TERMINAL_WORKTREE_ID + const resolvedWorktrees = isFloatingWorkspace ? [] : await this.listResolvedWorktrees() + await this.refreshPtyWorktreeRecordsFromController( + resolvedWorktrees, + isFloatingWorkspace ? targetWorktreeId : null + ) } async activateMobileSessionTab( @@ -4617,7 +4628,7 @@ export class OrcaRuntimeService { const worktreeId = explicitWorktreeId ?? (await this.resolveWorktreeSelector(worktreeSelector)).id this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId) - await this.refreshMobileSessionPtyRecords() + await this.refreshMobileSessionPtyRecords(worktreeId) const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) const directTab = snapshot?.tabs.find((candidate) => candidate.id === tabId) const tab = leafId @@ -21983,6 +21994,12 @@ export class OrcaRuntimeService { resolvedWorktrees: ResolvedWorktree[], targetWorktreeId: string | null = null ): Promise | null> { + if (targetWorktreeId === FLOATING_TERMINAL_WORKTREE_ID) { + const targetedLiveness = this.refreshFloatingWorkspacePtyLiveness() + if (targetedLiveness !== null) { + return targetedLiveness + } + } if (!this.ptyController?.listProcesses) { return null } @@ -22027,6 +22044,87 @@ export class OrcaRuntimeService { return livePtyIds } + private refreshFloatingWorkspacePtyLiveness(): Set | null { + const controller = this.ptyController + if (!controller?.hasPty) { + return null + } + const knownPtyIds = new Set() + const persistedBindingByPtyId = new Map() + for (const pty of this.ptysById.values()) { + if (pty.worktreeId === FLOATING_TERMINAL_WORKTREE_ID) { + knownPtyIds.add(pty.ptyId) + } + } + for (const leaf of this.leaves.values()) { + if (leaf.worktreeId === FLOATING_TERMINAL_WORKTREE_ID && leaf.ptyId) { + knownPtyIds.add(leaf.ptyId) + } + } + const snapshot = this.mobileSessionTabsByWorktree.get(FLOATING_TERMINAL_WORKTREE_ID) + for (const tab of snapshot?.tabs ?? []) { + if (tab.type !== 'terminal') { + continue + } + if (tab.ptyId) { + knownPtyIds.add(tab.ptyId) + persistedBindingByPtyId.set(tab.ptyId, { + tabId: tab.parentTabId, + paneKey: this.getMobileTerminalPaneKey(tab) + }) + } + for (const [leafId, ptyId] of Object.entries(tab.parentLayout?.ptyIdsByLeafId ?? {})) { + knownPtyIds.add(ptyId) + persistedBindingByPtyId.set(ptyId, { + tabId: tab.parentTabId, + paneKey: isTerminalLeafId(leafId) + ? makePaneKey(tab.parentTabId, leafId) + : `${tab.parentTabId}:${/^pane:(\d+)$/.exec(leafId)?.[1] ?? leafId}` + }) + } + } + + const liveness = new Map() + try { + for (const ptyId of knownPtyIds) { + const live = controller.hasPty(ptyId) + if (live === null) { + return null + } + liveness.set(ptyId, live) + } + } catch { + return null + } + + const livePtyIds = new Set() + for (const [ptyId, live] of liveness) { + let pty = this.ptysById.get(ptyId) + if (live) { + livePtyIds.add(ptyId) + const binding = persistedBindingByPtyId.get(ptyId) + if (!pty && binding) { + // Why: a live daemon PTY restored from disk needs its pane identity before mobile can issue a safe handle. + pty = this.recordPtyWorktree(ptyId, FLOATING_TERMINAL_WORKTREE_ID, { + connected: true, + tabId: binding.tabId, + paneKey: binding.paneKey + }) + } + if (pty) { + pty.connected = true + pty.disconnectedAt = null + this.refreshPtyForegroundAgent(ptyId) + } + } else if (pty && !this.leafExistsForPty(ptyId)) { + pty.connected = false + pty.disconnectedAt ??= Date.now() + } + } + this.pruneDisconnectedPtyRecords() + return livePtyIds + } + private pruneDisconnectedPtyTranscript(pty: RuntimePtyWorktreeRecord): void { if (pty.connected) { return diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index a22a9b96c96..2223996ef06 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -77,6 +77,9 @@ export type RuntimeStatus = { // Why: legacy or saved WebSocket pairings may not carry scope metadata, so // the server stamps the authenticated token scope here for status.get only. deviceScope?: DeviceScope + // Why: mobile gates its Floating Workspace entry on this; absent on older + // hosts, false when the user disabled the feature in desktop settings. + floatingWorkspaceEnabled?: boolean // COMPAT(runtimeStatusMobileAliases): added 2026-05-15 for mobile builds // that still read these names; new desktop/CLI code uses the fields above. protocolVersion?: number From e2e3f82e40416792309c1d2507f1b8952924e9aa Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:10:11 -0700 Subject: [PATCH 32/58] fix(editor): surface file-save failures instead of swallowing them (#9657) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(editor): surface file-save failures instead of swallowing them handleSaveForFile wrapped requestEditorFileSave in an empty `catch {}`, so a rejected save vanished with no toast, no dirty-state change, and no log — the edit stayed dirty in the buffer and was lost on tab close (silent data loss, STA-2027). Only main.trace.ndjson revealed the file was never written. Route the rejection through a new notifyEditorSaveFailure helper that logs the error and shows a toast. Extracted to its own module so the reusable callers (mobile bridge, file-deletion autosave) can adopt it and to keep EditorPanel under the code-line budget. Follow-up to the multi-byte reconcile crash (that was the known trigger); this hardens the swallow that turned any save failure into silent loss. * fix(editor): preserve failed save result --- .../src/components/editor/ChangesModeView.tsx | 2 +- .../src/components/editor/EditorContent.tsx | 8 ++-- .../src/components/editor/EditorPanel.tsx | 18 ++++---- .../components/editor/EditorPanelShell.tsx | 4 +- .../src/components/editor/IpynbViewer.tsx | 7 +++- .../editor/editor-file-save-attempt.test.ts | 42 +++++++++++++++++++ .../editor/editor-file-save-attempt.ts | 20 +++++++++ .../editor/useEditorCmdSaveRequest.ts | 2 +- .../editor/useMarkdownDocuments.test.ts | 23 ++++++++++ .../components/editor/useMarkdownDocuments.ts | 20 +++++++-- src/renderer/src/i18n/locales/en.json | 9 ++++ src/renderer/src/i18n/locales/es.json | 9 ++++ src/renderer/src/i18n/locales/ja.json | 9 ++++ src/renderer/src/i18n/locales/ko.json | 9 ++++ src/renderer/src/i18n/locales/zh.json | 9 ++++ 15 files changed, 168 insertions(+), 23 deletions(-) create mode 100644 src/renderer/src/components/editor/editor-file-save-attempt.test.ts create mode 100644 src/renderer/src/components/editor/editor-file-save-attempt.ts create mode 100644 src/renderer/src/components/editor/useMarkdownDocuments.test.ts diff --git a/src/renderer/src/components/editor/ChangesModeView.tsx b/src/renderer/src/components/editor/ChangesModeView.tsx index 27d2ce943b3..9fac8364e80 100644 --- a/src/renderer/src/components/editor/ChangesModeView.tsx +++ b/src/renderer/src/components/editor/ChangesModeView.tsx @@ -34,7 +34,7 @@ export function ChangesModeView({ viewStateScopeId: string diffViewStateKey: string onContentChange: (content: string) => void - onSave: (content: string) => Promise + onSave: (content: string) => Promise }): React.JSX.Element { if (!dc) { return ( diff --git a/src/renderer/src/components/editor/EditorContent.tsx b/src/renderer/src/components/editor/EditorContent.tsx index e6ee0f33257..270622fdaf3 100644 --- a/src/renderer/src/components/editor/EditorContent.tsx +++ b/src/renderer/src/components/editor/EditorContent.tsx @@ -43,7 +43,7 @@ const IpynbViewer = lazy(() => import('./IpynbViewer')) // Why: module-level for a stable no-op identity so read-only tabs don't rebuild callbacks each render. const noopEditorContentChange = (_content: string): void => {} -const noopEditorSave = (_content: string): void => {} +const noopEditorSave = async (_content: string): Promise => false export function getMarkdownSourceLineOffset(frontMatterRaw: string): number { let offset = 0 @@ -164,8 +164,8 @@ export function EditorContent({ handleContentChange: (content: string) => void handleContentChangeForFile: (file: OpenFile, content: string) => void handleDirtyStateHint: (dirty: boolean) => void - handleSave: (content: string) => Promise - handleSaveForFile: (file: OpenFile, content: string) => Promise + handleSave: (content: string) => Promise + handleSaveForFile: (file: OpenFile, content: string) => Promise reloadContent: (file: OpenFile) => void }): React.JSX.Element { const editorViewStateKey = @@ -376,7 +376,7 @@ export function EditorContent({ : handleContentChange const onSaveWithFm = fm - ? (body: string): Promise => md.mdSave(prependFrontMatter(fm.raw, body)) + ? (body: string): Promise => md.mdSave(prependFrontMatter(fm.raw, body)) : md.mdSave return ( diff --git a/src/renderer/src/components/editor/EditorPanel.tsx b/src/renderer/src/components/editor/EditorPanel.tsx index 43a9276b15e..932806f58f4 100644 --- a/src/renderer/src/components/editor/EditorPanel.tsx +++ b/src/renderer/src/components/editor/EditorPanel.tsx @@ -6,7 +6,6 @@ import { openFilePreviewToSide } from '@/lib/file-preview' import { getEditorHeaderCopyState } from './editor-header' import { isLocalPathOpenBlocked, showLocalPathOpenBlockedToast } from '@/lib/local-path-open-guard' import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' -import { requestEditorFileSave } from './editor-autosave' import { exportActiveMarkdownToPdf } from './export-active-markdown' import type { EditorToggleValue } from './EditorViewToggle' import { EditorPanelShell } from './EditorPanelShell' @@ -24,6 +23,7 @@ import { selectEditorPanelGitStatusEntries } from './editor-panel-git-entry-selector' import { createEditorPanelDraftSelector } from './editor-panel-draft-selector' +import { attemptEditorFileSave } from './editor-file-save-attempt' function EditorPanelInner({ activeFileId: activeFileIdProp, @@ -173,9 +173,9 @@ function EditorPanelInner({ ) const handleSaveForFile = useCallback( - async (file: typeof activeFile, content: string) => { + async (file: typeof activeFile, content: string): Promise => { if (!file) { - return + return false } const saveTargetFile = file.mode === 'markdown-preview' @@ -185,22 +185,20 @@ function EditorPanelInner({ ) ?? null) : file if (!saveTargetFile) { - return + return false } if (saveTargetFile.isUntitled) { requestRenameForFile(saveTargetFile.id) - return + return false } - try { - await requestEditorFileSave({ fileId: saveTargetFile.id, fallbackContent: content }) - } catch {} + return attemptEditorFileSave({ fileId: saveTargetFile.id, fallbackContent: content }) }, [openFiles, requestRenameForFile] ) const handleSave = useCallback( - async (content: string) => { - await handleSaveForFile(activeFile, content) + async (content: string): Promise => { + return handleSaveForFile(activeFile, content) }, [activeFile, handleSaveForFile] ) diff --git a/src/renderer/src/components/editor/EditorPanelShell.tsx b/src/renderer/src/components/editor/EditorPanelShell.tsx index de63cd78b2d..b2636949df3 100644 --- a/src/renderer/src/components/editor/EditorPanelShell.tsx +++ b/src/renderer/src/components/editor/EditorPanelShell.tsx @@ -44,8 +44,8 @@ type EditorPanelShellProps = { onContentChange: (content: string) => void onContentChangeForFile: (file: OpenFile, content: string) => void onDirtyStateHint: (dirty: boolean) => void - onSave: (content: string) => Promise - onSaveForFile: (file: OpenFile, content: string) => Promise + onSave: (content: string) => Promise + onSaveForFile: (file: OpenFile, content: string) => Promise onReloadContent: (file: OpenFile) => void onCloseMarkdownTableOfContents: () => void onCloseRenameDialog: () => void diff --git a/src/renderer/src/components/editor/IpynbViewer.tsx b/src/renderer/src/components/editor/IpynbViewer.tsx index ecb20f2ff63..b71afececb2 100644 --- a/src/renderer/src/components/editor/IpynbViewer.tsx +++ b/src/renderer/src/components/editor/IpynbViewer.tsx @@ -80,7 +80,7 @@ type IpynbViewerProps = { scrollCacheKey: string onContentChange: (content: string) => void onDirtyStateHint: (dirty: boolean) => void - onSave: (content: string) => Promise + onSave: (content: string) => Promise } const NOTEBOOK_SOURCE_COMMIT_DELAY_MS = 400 @@ -843,7 +843,10 @@ export default function IpynbViewer({ setRunError(null) setRunningCellIndex(index) try { - await onSave(latestContent) + const didSave = await onSave(latestContent) + if (!didSave) { + return + } const result = await window.api.notebook.runPythonCell({ filePath, code: cell.source, diff --git a/src/renderer/src/components/editor/editor-file-save-attempt.test.ts b/src/renderer/src/components/editor/editor-file-save-attempt.test.ts new file mode 100644 index 00000000000..f194c33d9e4 --- /dev/null +++ b/src/renderer/src/components/editor/editor-file-save-attempt.test.ts @@ -0,0 +1,42 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { requestEditorFileSave, toastError } = vi.hoisted(() => ({ + requestEditorFileSave: vi.fn(), + toastError: vi.fn() +})) +vi.mock('./editor-autosave', () => ({ requestEditorFileSave })) +vi.mock('sonner', () => ({ toast: { error: toastError } })) +// Return the English fallback so the assertion is stable without initializing i18n. +vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) + +import { attemptEditorFileSave } from './editor-file-save-attempt' + +describe('attemptEditorFileSave', () => { + beforeEach(() => { + requestEditorFileSave.mockReset() + toastError.mockClear() + }) + afterEach(() => { + vi.restoreAllMocks() + }) + + it('reports success only after the save request resolves', async () => { + requestEditorFileSave.mockResolvedValue(undefined) + + await expect(attemptEditorFileSave({ fileId: 'file-1' })).resolves.toBe(true) + + expect(toastError).not.toHaveBeenCalled() + }) + + it('surfaces a failed save and reports failure to dependent actions (STA-2027)', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + const cause = new Error('disk full') + requestEditorFileSave.mockRejectedValue(cause) + + await expect(attemptEditorFileSave({ fileId: 'file-1' })).resolves.toBe(false) + + expect(toastError).toHaveBeenCalledTimes(1) + expect(toastError).toHaveBeenCalledWith('Failed to save the file. Please try again.') + expect(consoleError).toHaveBeenCalledWith('[editor] file save failed', cause) + }) +}) diff --git a/src/renderer/src/components/editor/editor-file-save-attempt.ts b/src/renderer/src/components/editor/editor-file-save-attempt.ts new file mode 100644 index 00000000000..9dcd8712942 --- /dev/null +++ b/src/renderer/src/components/editor/editor-file-save-attempt.ts @@ -0,0 +1,20 @@ +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' +import { requestEditorFileSave, type EditorSaveFileTarget } from './editor-autosave' + +export async function attemptEditorFileSave(target: EditorSaveFileTarget): Promise { + try { + await requestEditorFileSave(target) + return true + } catch (error) { + // Why: shortcut handlers need a non-throwing result, while dependent actions must not treat a rejected write as success. + console.error('[editor] file save failed', error) + toast.error( + translate( + 'auto.components.editor.editor.save.failure.notice.8c59ce5075', + 'Failed to save the file. Please try again.' + ) + ) + return false + } +} diff --git a/src/renderer/src/components/editor/useEditorCmdSaveRequest.ts b/src/renderer/src/components/editor/useEditorCmdSaveRequest.ts index f89a0ef24f8..41df5a64037 100644 --- a/src/renderer/src/components/editor/useEditorCmdSaveRequest.ts +++ b/src/renderer/src/components/editor/useEditorCmdSaveRequest.ts @@ -8,7 +8,7 @@ type UseEditorCmdSaveRequestParams = { activeFile: OpenFile | null openFiles: OpenFile[] fileContents: Record - handleSave: (content: string) => Promise + handleSave: (content: string) => Promise } export function useEditorCmdSaveRequest({ diff --git a/src/renderer/src/components/editor/useMarkdownDocuments.test.ts b/src/renderer/src/components/editor/useMarkdownDocuments.test.ts new file mode 100644 index 00000000000..a6d553c0f2b --- /dev/null +++ b/src/renderer/src/components/editor/useMarkdownDocuments.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it, vi } from 'vitest' +import { saveMarkdownAndRefreshDocuments } from './useMarkdownDocuments' + +describe('saveMarkdownAndRefreshDocuments', () => { + it('does not refresh the document index after a failed write', async () => { + const save = vi.fn().mockResolvedValue(false) + const refresh = vi.fn().mockResolvedValue(undefined) + + await expect(saveMarkdownAndRefreshDocuments('draft', save, refresh)).resolves.toBe(false) + + expect(save).toHaveBeenCalledWith('draft') + expect(refresh).not.toHaveBeenCalled() + }) + + it('refreshes the document index once after a successful write', async () => { + const save = vi.fn().mockResolvedValue(true) + const refresh = vi.fn().mockResolvedValue(undefined) + + await expect(saveMarkdownAndRefreshDocuments('draft', save, refresh)).resolves.toBe(true) + + expect(refresh).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/components/editor/useMarkdownDocuments.ts b/src/renderer/src/components/editor/useMarkdownDocuments.ts index ae7fca13de6..5e8e5a17fb8 100644 --- a/src/renderer/src/components/editor/useMarkdownDocuments.ts +++ b/src/renderer/src/components/editor/useMarkdownDocuments.ts @@ -17,6 +17,19 @@ type OpenMarkdownDocumentOptions = { anchor?: string | null } +export async function saveMarkdownAndRefreshDocuments( + content: string, + save: (content: string) => Promise, + refresh: () => Promise +): Promise { + const didSave = await save(content) + if (!didSave) { + return false + } + await refresh() + return true +} + type UseMarkdownDocumentsResult = { markdownDocuments: MarkdownDocument[] openMarkdownDocument: ( @@ -31,14 +44,14 @@ type UseMarkdownDocumentsResult = { options?: OpenMarkdownDocumentOptions ) => Promise } - mdSave: (content: string) => Promise + mdSave: (content: string) => Promise } export function useMarkdownDocuments( activeFile: OpenFile, isMarkdown: boolean, viewMode: MarkdownViewMode, - onSave: (content: string) => Promise + onSave: (content: string) => Promise ): UseMarkdownDocumentsResult { const worktreeId = activeFile.worktreeId // Why: PTY activity replaces worktree metadata; only a routing-path change @@ -179,7 +192,8 @@ export function useMarkdownDocuments( ) const mdSave = useCallback( - (content: string) => onSave(content).then(() => refreshMarkdownDocuments(true)), + (content: string) => + saveMarkdownAndRefreshDocuments(content, onSave, () => refreshMarkdownDocuments(true)), [onSave, refreshMarkdownDocuments] ) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 9d830a1aa56..3af70abb65f 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -12332,6 +12332,15 @@ }, "richMarkdownSourceOwningCutFeedback": { "selectLessContent": "Select less content or use code mode to cut preserved HTML citations." + }, + "editor": { + "save": { + "failure": { + "notice": { + "8c59ce5075": "Failed to save the file. Please try again." + } + } + } } }, "diff": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 2bd07533c66..5932350ebeb 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -12309,6 +12309,15 @@ }, "richMarkdownSourceOwningCutFeedback": { "selectLessContent": "Selecciona menos contenido o usa el modo de código para cortar citas HTML conservadas." + }, + "editor": { + "save": { + "failure": { + "notice": { + "8c59ce5075": "Failed to save the file. Please try again." + } + } + } } }, "diff": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index c76b43af82d..aa76a652aba 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -12309,6 +12309,15 @@ }, "richMarkdownSourceOwningCutFeedback": { "selectLessContent": "保持されたHTML引用を切り取るには、選択範囲を小さくするかコードモードを使用してください。" + }, + "editor": { + "save": { + "failure": { + "notice": { + "8c59ce5075": "Failed to save the file. Please try again." + } + } + } } }, "diff": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 755bc978803..d2a37623629 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -12309,6 +12309,15 @@ }, "richMarkdownSourceOwningCutFeedback": { "selectLessContent": "보존된 HTML 인용을 잘라내려면 더 적은 콘텐츠를 선택하거나 코드 모드를 사용하세요." + }, + "editor": { + "save": { + "failure": { + "notice": { + "8c59ce5075": "Failed to save the file. Please try again." + } + } + } } }, "diff": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index caba5e748ae..8a38f538366 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -12309,6 +12309,15 @@ }, "richMarkdownSourceOwningCutFeedback": { "selectLessContent": "请选择较少的内容,或使用代码模式剪切保留的 HTML 引用。" + }, + "editor": { + "save": { + "failure": { + "notice": { + "8c59ce5075": "Failed to save the file. Please try again." + } + } + } } }, "diff": { From bf4b2e3ffb8154ded4ff1304c6e9f08e7b7161a1 Mon Sep 17 00:00:00 2001 From: Gideon Date: Tue, 21 Jul 2026 13:12:28 +0900 Subject: [PATCH 33/58] fix(sidebar): attribute spinner-only pane titles to the launched agent (#9647) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(sidebar): attribute spinner-only pane titles to the launched agent A braille-spinner terminal title proves activity but carries no agent identity, so the title-derived sidebar row resolver dropped it. Agents without working status hooks — Codex over SSH (#8711) — only ever emit spinner+cwd titles, so their worktrees showed no agent row at all while Claude (hook-backed) rows appeared. Fall back to the tab's launch identity (tab.launchAgent) for spinner-only titles, mirroring the precedence the hook-row path already uses. Explicit title identity still wins, Claude-specific prefixes (✳ / '. ' / '* ') are unaffected, and panes without launch metadata keep the existing no-row behavior. * test(sidebar): pin spinner fallback no-launchAgent gate; note split-pane residual Adds a regression test asserting a spinner-only title with no tab launch identity produces no row, pinning the `launchAgent ?? null` gate that the two existing tests do not exercise. Expands the fallback comment to record why it is spinner-gated (vs the hook path's unconditional launchAgent fallback) and the split-pane braille-title residual. Co-authored-by: Orca --------- Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Co-authored-by: Orca --- .../worktree-title-derived-agent-rows.test.ts | 54 +++++++++++++++++++ .../worktree-title-derived-agent-rows.ts | 17 ++++-- 2 files changed, 68 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts index 1114dd9e174..b227cb37c68 100644 --- a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts +++ b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts @@ -201,6 +201,60 @@ describe('buildTitleDerivedAgentRows', () => { } }) + it('attributes a spinner-only title to the launched agent when the title has no identity', () => { + const launchAgent: TuiAgent = 'codex' + const rows = buildWorktreeAgentRows({ + tabs: [makeTab('tab-1', { launchAgent })], + entries: [], + retained: [], + runtimePaneTitlesByTabId: { + // Codex over SSH emits spinner + cwd titles with no agent name (#8711). + 'tab-1': { 1: '⠼ demo-repo' } + }, + ptyIdsByTabId: { 'tab-1': ['pty-codex-remote'] }, + terminalLayoutsByTabId: { 'tab-1': makeSingleLayout(LEAF_ID_1) }, + now: 2000 + }) + + expect( + rows.map((row) => [row.agentType, row.state, row.entry.prompt, row.entry.terminalTitle]) + ).toEqual([['codex', 'working', 'Codex', '⠼ demo-repo']]) + }) + + it('keeps explicit title identity over the launched agent', () => { + const launchAgent: TuiAgent = 'claude' + const rows = buildWorktreeAgentRows({ + tabs: [makeTab('tab-1', { launchAgent })], + entries: [], + retained: [], + runtimePaneTitlesByTabId: { + 'tab-1': { 1: '⠋ Codex' } + }, + ptyIdsByTabId: { 'tab-1': ['pty-explicit'] }, + terminalLayoutsByTabId: { 'tab-1': makeSingleLayout(LEAF_ID_1) }, + now: 2000 + }) + + expect(rows.map((row) => [row.agentType, row.state])).toEqual([['codex', 'working']]) + }) + + it('produces no row for a spinner-only title when the tab has no launch identity', () => { + const rows = buildWorktreeAgentRows({ + tabs: [makeTab('tab-1')], + entries: [], + retained: [], + runtimePaneTitlesByTabId: { + // Spinner activity but no identity and no launchAgent to attribute it to. + 'tab-1': { 1: '⠼ demo-repo' } + }, + ptyIdsByTabId: { 'tab-1': ['pty-anon'] }, + terminalLayoutsByTabId: { 'tab-1': makeSingleLayout(LEAF_ID_1) }, + now: 2000 + }) + + expect(rows).toHaveLength(0) + }) + it('does not turn generic Codex-launched task titles into Claude Code rows', () => { const launchAgent: TuiAgent = 'codex' const rows = buildWorktreeAgentRows({ diff --git a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts index 1c862b70505..068bdd599ce 100644 --- a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts @@ -1,5 +1,6 @@ import type { DashboardAgentRow } from '@/components/dashboard/useDashboardData' -import { isClaudeManagementTitle } from '@/lib/agent-status' +import { formatAgentTypeLabel, isClaudeManagementTitle } from '@/lib/agent-status' +import { containsBrailleSpinner } from '../../../../shared/agent-title-core' import { classifyTitleActivity, resolveTitleActivityLabel } from '@/lib/pane-agent-evidence' import { tabHasLivePty } from '@/lib/tab-has-live-pty' import type { @@ -142,10 +143,20 @@ function buildTitleDerivedAgentRow(args: { } const paneKey = makePaneKey(args.tab.id, args.leafId) const orchestration = args.runtimeAgentOrchestrationByPaneKey?.[paneKey] - const agentType = isClaudeAgentsTitle ? 'claude' : resolveTitleDerivedAgentType(title, label) + const titleAgentType = isClaudeAgentsTitle ? 'claude' : resolveTitleDerivedAgentType(title, label) + // Why: a braille spinner proves activity, not identity, so the resolver drops + // it. Hook-less agents over SSH (Codex, #8711) surface only spinner+cwd titles; + // fall back to the tab's launch identity instead of hiding the pane. Gated on + // the spinner on purpose — unlike the hook path's unconditional launchAgent + // fallback (resolveRowAgentType), this path manufactures agent-ness from a + // title alone, so a non-agent title must never become a row. Residual: a split + // pane whose own title carries a braille glyph is still attributed to launchAgent. + const agentType = + titleAgentType ?? (containsBrailleSpinner(title) ? (args.tab.launchAgent ?? null) : null) if (!agentType) { return null } + const rowLabel = titleAgentType ? label : formatAgentTypeLabel(agentType) const rowState = titleStatusToRowState(status) const secondary = status === 'permission' ? 'Needs input' : status === 'working' ? 'Running' : 'Idle' @@ -153,7 +164,7 @@ function buildTitleDerivedAgentRow(args: { const entry: AgentStatusEntry = { paneKey, state: entryState, - prompt: label, + prompt: rowLabel, updatedAt: args.now, stateStartedAt: args.now, stateHistory: [], From c540ab6d8ba2fb9387d3b618936944364ecc69e9 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Tue, 21 Jul 2026 00:15:39 -0400 Subject: [PATCH 34/58] fix(mobile): restore notification opt-in route compatibility (#9675) --- mobile/app/notification-opt-in.tsx | 12 +++ .../legacy-notification-opt-in-destination.ts | 19 +++++ .../legacy-notification-opt-in-route.test.ts | 74 +++++++++++++++++++ 3 files changed, 105 insertions(+) create mode 100644 mobile/app/notification-opt-in.tsx create mode 100644 mobile/src/onboarding/legacy-notification-opt-in-destination.ts create mode 100644 mobile/src/onboarding/legacy-notification-opt-in-route.test.ts diff --git a/mobile/app/notification-opt-in.tsx b/mobile/app/notification-opt-in.tsx new file mode 100644 index 00000000000..0cfdd822471 --- /dev/null +++ b/mobile/app/notification-opt-in.tsx @@ -0,0 +1,12 @@ +import { Redirect, useLocalSearchParams } from 'expo-router' +import { + legacyNotificationOptInDestination, + type LegacyNotificationOptInParams +} from '../src/onboarding/legacy-notification-opt-in-destination' + +export default function NotificationOptInRedirect() { + const params = useLocalSearchParams() + + // Why: restored stacks may contain this retired route; replacement keeps Back from reopening it. + return +} diff --git a/mobile/src/onboarding/legacy-notification-opt-in-destination.ts b/mobile/src/onboarding/legacy-notification-opt-in-destination.ts new file mode 100644 index 00000000000..d3f1a2c33ef --- /dev/null +++ b/mobile/src/onboarding/legacy-notification-opt-in-destination.ts @@ -0,0 +1,19 @@ +export type LegacyNotificationOptInParams = { + hostId?: string | string[] +} + +export type LegacyNotificationOptInDestination = { + pathname: '/mobile-onboarding' + params: { steps: 'notifications'; hostId?: string } +} + +/** Maps restored navigation and old deep links onto the canonical notifications step. */ +export function legacyNotificationOptInDestination( + params: LegacyNotificationOptInParams +): LegacyNotificationOptInDestination { + const hostId = Array.isArray(params.hostId) ? params.hostId[0] : params.hostId + return { + pathname: '/mobile-onboarding', + params: { steps: 'notifications', ...(hostId ? { hostId } : {}) } + } +} diff --git a/mobile/src/onboarding/legacy-notification-opt-in-route.test.ts b/mobile/src/onboarding/legacy-notification-opt-in-route.test.ts new file mode 100644 index 00000000000..c7efaaefd86 --- /dev/null +++ b/mobile/src/onboarding/legacy-notification-opt-in-route.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from 'vitest' +import NotificationOptInRedirect from '../../app/notification-opt-in' +import { legacyNotificationOptInDestination } from './legacy-notification-opt-in-destination' + +const mocks = vi.hoisted(() => ({ + params: {} as Record, + ensureNotificationPermissions: vi.fn() +})) + +vi.mock('expo-router', () => ({ + Redirect: 'Redirect', + useLocalSearchParams: () => mocks.params +})) + +vi.mock('../notifications/mobile-notifications', () => ({ + ensureNotificationPermissions: mocks.ensureNotificationPermissions +})) + +describe('legacy notification opt-in destination', () => { + it('selects only notifications and preserves a scalar hostId', () => { + expect(legacyNotificationOptInDestination({ hostId: 'paired-host' })).toEqual({ + pathname: '/mobile-onboarding', + params: { steps: 'notifications', hostId: 'paired-host' } + }) + }) + + it.each([ + [['first-host', 'second-host'], 'first-host'], + [[], undefined], + [undefined, undefined] + ] as const)('normalizes hostId %j to %s', (hostId, expectedHostId) => { + expect(legacyNotificationOptInDestination({ hostId })).toEqual({ + pathname: '/mobile-onboarding', + params: { + steps: 'notifications', + ...(expectedHostId ? { hostId: expectedHostId } : {}) + } + }) + }) + + it('ignores unrelated legacy parameters', () => { + const legacyParams = { + hostId: 'paired-host', + steps: 'session-view', + returnTo: '/settings' + } + + expect(legacyNotificationOptInDestination(legacyParams)).toEqual({ + pathname: '/mobile-onboarding', + params: { steps: 'notifications', hostId: 'paired-host' } + }) + }) +}) + +describe('/notification-opt-in compatibility route', () => { + it('is present and synchronously returns a replace-style redirect to the canonical route', () => { + mocks.params = { + hostId: ['paired-host', 'ignored-host'], + steps: 'session-view', + unrelated: 'ignored' + } + + expect(NotificationOptInRedirect()).toMatchObject({ + type: 'Redirect', + props: { + href: { + pathname: '/mobile-onboarding', + params: { steps: 'notifications', hostId: 'paired-host' } + } + } + }) + expect(mocks.ensureNotificationPermissions).not.toHaveBeenCalled() + }) +}) From 1293e1c0d8008e31fee01c76a99b589922557382 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Tue, 21 Jul 2026 00:16:08 -0400 Subject: [PATCH 35/58] Fix mixed-version mobile Codex session resume (#9678) --- .../session/ai-vault-resume-launch.test.ts | 54 ------ mobile/src/session/ai-vault-resume-launch.ts | 9 +- .../ai-vault-resume-preparation.test.ts | 163 ++++++++++++++++++ .../ai-vault-resume-preparation.test.ts | 30 ++++ src/shared/ai-vault-resume-preparation.ts | 13 ++ 5 files changed, 214 insertions(+), 55 deletions(-) create mode 100644 mobile/src/session/ai-vault-resume-preparation.test.ts create mode 100644 src/shared/ai-vault-resume-preparation.test.ts diff --git a/mobile/src/session/ai-vault-resume-launch.test.ts b/mobile/src/session/ai-vault-resume-launch.test.ts index 9868fd72cfc..fa096be4a03 100644 --- a/mobile/src/session/ai-vault-resume-launch.test.ts +++ b/mobile/src/session/ai-vault-resume-launch.test.ts @@ -4,7 +4,6 @@ import { buildMobileAiVaultResumeLaunch, buildMobileAiVaultResumeCommand, createMobileAiVaultResumeMutationRegistry, - prepareMobileAiVaultSessionResume, readMobileRuntimeHostPlatform, readMobileRuntimeTerminalWindowsShell, resolveMobileAiVaultResumePlatform, @@ -178,59 +177,6 @@ describe('buildMobileAiVaultResumeLaunch', () => { }) }) -describe('prepareMobileAiVaultSessionResume', () => { - it('materializes legacy shared-home sessions before building a real-home launch', async () => { - const legacy = session({ - agent: 'codex', - filePath: - '/Users/ada/Library/Application Support/orca/codex-runtime-home/home/sessions/2026/07/20/rollout-a.jsonl', - codexHome: '/Users/ada/Library/Application Support/orca/codex-runtime-home/home' - }) - const sendRequest = vi.fn().mockResolvedValue({ - ok: true, - result: { useRealCodexHome: true } - }) - - const prepared = await prepareMobileAiVaultSessionResume({ sendRequest }, legacy) - const launch = buildMobileAiVaultResumeLaunch({ session: prepared, hostPlatform: 'darwin' }) - - expect(sendRequest).toHaveBeenCalledWith( - 'aiVault.prepareSessionResume', - expect.objectContaining({ filePath: legacy.filePath, codexHome: legacy.codexHome }), - { timeoutMs: RESUME_RPC_TIMEOUT_MS } - ) - expect(launch.command).not.toContain('CODEX_HOME=') - expect(launch.envToDelete).toEqual(['CODEX_HOME', 'ORCA_CODEX_HOME']) - }) - - it('fails before terminal creation when targeted materialization fails', async () => { - const legacy = session({ - agent: 'codex', - codexHome: '/Users/ada/Library/Application Support/orca/codex-runtime-home/home' - }) - const sendRequest = vi.fn().mockResolvedValue({ - ok: false, - error: { message: 'Retry resume after checking session folder permissions.' } - }) - - await expect(prepareMobileAiVaultSessionResume({ sendRequest }, legacy)).rejects.toThrow( - /Retry resume/ - ) - }) - - it.each([ - '/Users/ada/.config/codex', - '/Users/ada/Library/Application Support/orca/codex-accounts/a/home', - '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.codex' - ])('preserves a non-legacy Codex home without an RPC: %s', async (codexHome) => { - const current = session({ agent: 'codex', codexHome }) - const sendRequest = vi.fn() - - await expect(prepareMobileAiVaultSessionResume({ sendRequest }, current)).resolves.toBe(current) - expect(sendRequest).not.toHaveBeenCalled() - }) -}) - describe('resumeAiVaultSessionInTerminal', () => { it('creates a fresh terminal and sends the command with Enter', async () => { const sendRequest = vi diff --git a/mobile/src/session/ai-vault-resume-launch.ts b/mobile/src/session/ai-vault-resume-launch.ts index 1a7b6a3641d..fa5e08d8492 100644 --- a/mobile/src/session/ai-vault-resume-launch.ts +++ b/mobile/src/session/ai-vault-resume-launch.ts @@ -4,7 +4,10 @@ import { buildAiVaultResumeShellCommand, realHomeCodexResumeEnvDeletion } from '../../../src/shared/ai-vault-types' -import { isLegacySharedCodexHome } from '../../../src/shared/ai-vault-resume-preparation' +import { + isAiVaultPrepareSessionResumeUnavailableError, + isLegacySharedCodexHome +} from '../../../src/shared/ai-vault-resume-preparation' import { isResumableTuiAgent } from '../../../src/shared/agent-session-resume' import type { SleepingAgentLaunchConfig } from '../../../src/shared/agent-session-resume' import { buildAgentResumeStartupPlan } from '../../../src/shared/tui-agent-startup' @@ -170,6 +173,10 @@ export async function prepareMobileAiVaultSessionResume( { timeoutMs: RESUME_RPC_TIMEOUT_MS } ) if (!response.ok) { + if (isAiVaultPrepareSessionResumeUnavailableError(response.error)) { + // Why: older hosts cannot prepare, but their shared home still supports the legacy resume path. + return session + } throw new Error( response.error?.message || 'Could not prepare this legacy Codex session. Retry resume.' ) diff --git a/mobile/src/session/ai-vault-resume-preparation.test.ts b/mobile/src/session/ai-vault-resume-preparation.test.ts new file mode 100644 index 00000000000..12c75ab55b3 --- /dev/null +++ b/mobile/src/session/ai-vault-resume-preparation.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AiVaultSession } from '../../../src/shared/ai-vault-types' +import { + buildMobileAiVaultResumeLaunch, + prepareMobileAiVaultSessionResume, + resumeAiVaultSessionInTerminal, + RESUME_RPC_TIMEOUT_MS +} from './ai-vault-resume-launch' + +const LEGACY_CODEX_HOME = '/Users/ada/Library/Application Support/orca/codex-runtime-home/home' + +function legacySession(overrides: Partial = {}): AiVaultSession { + return { + id: 'codex:legacy-1', + executionHostId: 'local', + agent: 'codex', + sessionId: 'legacy-1', + title: 'Resume me', + cwd: '/Users/ada/repo', + branch: 'main', + model: null, + filePath: `${LEGACY_CODEX_HOME}/sessions/2026/07/20/rollout-a.jsonl`, + codexHome: LEGACY_CODEX_HOME, + createdAt: null, + updatedAt: null, + modifiedAt: '2026-07-20T00:00:00.000Z', + messageCount: 2, + totalTokens: 10, + previewMessages: [], + queuedMessageCount: 0, + subagentTranscriptCount: 0, + resumeCommand: '', + subagent: null, + ...overrides + } +} + +describe('prepareMobileAiVaultSessionResume', () => { + it.each([ + { + code: 'forbidden', + message: "Method 'aiVault.prepareSessionResume' is not available to mobile clients" + }, + { + code: 'method_not_found', + message: 'Unknown method: aiVault.prepareSessionResume' + } + ])('uses the legacy command and environment on old-host $code', async (error) => { + const legacy = legacySession() + const sendRequest = vi + .fn() + .mockResolvedValueOnce({ ok: false, error }) + .mockResolvedValueOnce({ + ok: true, + result: { tab: { type: 'terminal', id: 'tab-1', terminal: 'pty-1', title: 'Terminal' } } + }) + .mockResolvedValueOnce({ ok: true, result: { send: { accepted: true } } }) + + const prepared = await prepareMobileAiVaultSessionResume({ sendRequest }, legacy) + const launch = buildMobileAiVaultResumeLaunch({ session: prepared, hostPlatform: 'darwin' }) + await resumeAiVaultSessionInTerminal({ sendRequest }, 'worktree-1', launch) + + expect(prepared).toBe(legacy) + expect(sendRequest.mock.calls[1]?.[1]).not.toHaveProperty('envToDelete') + expect(sendRequest).toHaveBeenNthCalledWith( + 3, + 'terminal.send', + { + terminal: 'pty-1', + text: `cd '/Users/ada/repo' && CODEX_HOME='${LEGACY_CODEX_HOME}' codex '--dangerously-bypass-approvals-and-sandbox' 'resume' 'legacy-1'`, + enter: true + }, + { timeoutMs: RESUME_RPC_TIMEOUT_MS } + ) + }) + + it('uses the real Codex home when a supported desktop requests it', async () => { + const legacy = legacySession() + const sendRequest = vi.fn().mockResolvedValue({ + ok: true, + result: { useRealCodexHome: true } + }) + + const prepared = await prepareMobileAiVaultSessionResume({ sendRequest }, legacy) + const launch = buildMobileAiVaultResumeLaunch({ session: prepared, hostPlatform: 'darwin' }) + + expect(sendRequest).toHaveBeenCalledWith( + 'aiVault.prepareSessionResume', + { + agent: 'codex', + filePath: legacy.filePath, + codexHome: LEGACY_CODEX_HOME, + executionHostId: 'local' + }, + { timeoutMs: RESUME_RPC_TIMEOUT_MS } + ) + expect(prepared).toEqual({ ...legacy, codexHome: null }) + expect(launch.command).toBe( + "cd '/Users/ada/repo' && codex '--dangerously-bypass-approvals-and-sandbox' 'resume' 'legacy-1'" + ) + expect(launch.envToDelete).toEqual(['CODEX_HOME', 'ORCA_CODEX_HOME']) + }) + + it('preserves the legacy resume path when a supported desktop declines real-home use', async () => { + const legacy = legacySession() + const sendRequest = vi.fn().mockResolvedValue({ + ok: true, + result: { useRealCodexHome: false } + }) + + const prepared = await prepareMobileAiVaultSessionResume({ sendRequest }, legacy) + const launch = buildMobileAiVaultResumeLaunch({ session: prepared, hostPlatform: 'darwin' }) + + expect(prepared).toBe(legacy) + expect(launch.command).toContain(`CODEX_HOME='${LEGACY_CODEX_HOME}'`) + expect(launch.envToDelete).toBeUndefined() + }) + + it.each([ + { agent: 'claude' as const, codexHome: null }, + { agent: 'codex' as const, codexHome: '/Users/ada/.config/codex' }, + { + agent: 'codex' as const, + codexHome: '/Users/ada/Library/Application Support/orca/codex-accounts/a/home' + }, + { agent: 'codex' as const, codexHome: '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.codex' } + ])('does not prepare non-legacy session $agent at $codexHome', async (overrides) => { + const current = legacySession(overrides) + const sendRequest = vi.fn() + + await expect(prepareMobileAiVaultSessionResume({ sendRequest }, current)).resolves.toBe(current) + expect(sendRequest).not.toHaveBeenCalled() + }) + + it.each([ + { + code: 'internal_error', + message: 'Retry resume after checking session folder permissions.' + }, + { + code: 'forbidden', + message: 'Session resume is forbidden' + } + ])('keeps genuine server error visible: $code', async (error) => { + const sendRequest = vi.fn().mockResolvedValue({ ok: false, error }) + + await expect( + prepareMobileAiVaultSessionResume({ sendRequest }, legacySession()) + ).rejects.toThrow(error.message) + }) + + it.each(['Request timed out: aiVault.prepareSessionResume', 'Connection interrupted'])( + 'keeps transport failure visible: %s', + async (message) => { + const failure = new Error(message) + const sendRequest = vi.fn().mockRejectedValue(failure) + + await expect( + prepareMobileAiVaultSessionResume({ sendRequest }, legacySession()) + ).rejects.toBe(failure) + } + ) +}) diff --git a/src/shared/ai-vault-resume-preparation.test.ts b/src/shared/ai-vault-resume-preparation.test.ts new file mode 100644 index 00000000000..3a50806987b --- /dev/null +++ b/src/shared/ai-vault-resume-preparation.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' +import { isAiVaultPrepareSessionResumeUnavailableError } from './ai-vault-resume-preparation' + +describe('isAiVaultPrepareSessionResumeUnavailableError', () => { + it('recognizes deterministic old-host responses', () => { + expect( + isAiVaultPrepareSessionResumeUnavailableError({ + code: 'forbidden', + message: "Method 'aiVault.prepareSessionResume' is not available to mobile clients" + }) + ).toBe(true) + expect( + isAiVaultPrepareSessionResumeUnavailableError({ + code: 'method_not_found', + message: 'Unknown method: aiVault.prepareSessionResume' + }) + ).toBe(true) + }) + + it.each([ + { + code: 'forbidden', + message: "Method 'aiVault.listSessions' is not available to mobile clients" + }, + { code: 'forbidden', message: 'Session resume is forbidden' }, + { code: 'internal_error', message: 'Method not found while preparing session' } + ])('rejects a non-capability error: $code / $message', (error) => { + expect(isAiVaultPrepareSessionResumeUnavailableError(error)).toBe(false) + }) +}) diff --git a/src/shared/ai-vault-resume-preparation.ts b/src/shared/ai-vault-resume-preparation.ts index 7100f5ce29c..4224f6835a7 100644 --- a/src/shared/ai-vault-resume-preparation.ts +++ b/src/shared/ai-vault-resume-preparation.ts @@ -13,6 +13,19 @@ export type AiVaultSessionResumePreparation = ( args: AiVaultPrepareSessionResumeArgs ) => Promise +const LEGACY_MOBILE_PREPARATION_FORBIDDEN_MESSAGE = + "Method 'aiVault.prepareSessionResume' is not available to mobile clients" + +export function isAiVaultPrepareSessionResumeUnavailableError(error: { + code: string + message: string +}): boolean { + return ( + error.code === 'method_not_found' || + (error.code === 'forbidden' && error.message === LEGACY_MOBILE_PREPARATION_FORBIDDEN_MESSAGE) + ) +} + export function isLegacySharedCodexHome(codexHome: string | null): boolean { if (!codexHome) { return false From a6f54acd0f48ec4df86d406d13d06a723061dfa5 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:24:54 -0700 Subject: [PATCH 36/58] fix(sidebar): stop the Live Ports hover card flickering on compact worktree cards (#9662) In compact worktree-card mode, two Radix HoverCard roots shared one open-state boolean, causing an open/close flicker loop when hovering the Live Ports plug. Un-shares the control so each hover owns its own state. Closes #9304 --- ....compact-ports-hover-independence.test.tsx | 258 ++++++++++++++++++ .../src/components/sidebar/WorktreeCard.tsx | 3 +- 2 files changed, 260 insertions(+), 1 deletion(-) create mode 100644 src/renderer/src/components/sidebar/WorktreeCard.compact-ports-hover-independence.test.tsx diff --git a/src/renderer/src/components/sidebar/WorktreeCard.compact-ports-hover-independence.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.compact-ports-hover-independence.test.tsx new file mode 100644 index 00000000000..ca60645185c --- /dev/null +++ b/src/renderer/src/components/sidebar/WorktreeCard.compact-ports-hover-independence.test.tsx @@ -0,0 +1,258 @@ +// @vitest-environment happy-dom + +import { act, type ReactNode } from 'react' +import React from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData' +import type { GlobalSettings, Repo, Worktree, WorktreeCardProperty } from '../../../../shared/types' +import type { WorkspacePortScanResult } from '../../../../shared/workspace-ports' + +const fetchHostedReviewForBranch = vi.fn() +const fetchIssue = vi.fn() +const fetchLinearIssue = vi.fn() +const setWorkspacePortScan = vi.fn() +const setWorkspacePortScanRefreshing = vi.fn() +const cacheTimerMocks = vi.hoisted(() => ({ + usePromptCacheCountdownStartedAt: vi.fn() +})) + +let worktreeCardProperties: WorktreeCardProperty[] = ['status', 'ports'] +let settings: Partial | null = { compactWorktreeCards: true } + +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: unknown) => unknown) => + selector({ + browserTabsByWorktree: {}, + agentActivityDisplayMode: undefined, + createBrowserTab: vi.fn(), + deleteStateByWorktreeId: {}, + fetchHostedReviewForBranch, + fetchIssue, + fetchLinearIssue, + gitConflictOperationByWorktree: {}, + hostedReviewCache: {}, + issueCache: {}, + linearIssueCache: {}, + openModal: vi.fn(), + openTaskPage: vi.fn(), + projectGroups: [], + ptyIdsByTabId: {}, + recordFeatureInteraction: vi.fn(), + remoteBranchConflictByWorktreeId: {}, + setRemoteBrowserPageHandle: vi.fn(), + setWorkspacePortScan, + setWorkspacePortScanRefreshing, + settings, + sshConnectionStates: new Map(), + sshTargetLabels: new Map(), + tabsByWorktree: {}, + updateWorktreeMeta: vi.fn(), + workspacePortScan, + worktreeCardProperties + }) +})) + +// Why: the real Radix HoverCard is controlled by each root's `open`/`onOpenChange`; expose them so the test can +// prove the compact title root and the ports root track independent open-state controllers. +const openChangeByRoot = new Map void>() +vi.mock('@/components/ui/hover-card', () => ({ + HoverCard: ({ + children, + open, + onOpenChange, + openDelay + }: { + children: ReactNode + open?: boolean + onOpenChange?: (open: boolean) => void + openDelay?: number + }) => ( +
{ + if (el && onOpenChange) { + openChangeByRoot.set(el, onOpenChange) + } + }} + > + {children} +
+ ), + HoverCardContent: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + HoverCardTrigger: ({ children }: { children: ReactNode }) => + React.isValidElement(children) ? ( + React.cloneElement(children as React.ReactElement>, { + 'data-hover-card-trigger': '' + }) + ) : ( + <>{children} + ) +})) + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children} +})) + +vi.mock('@/lib/sidebar-worktree-activation', () => ({ + activateWorktreeFromSidebar: vi.fn() +})) + +vi.mock('@/runtime/runtime-rpc-client', () => ({ + getActiveRuntimeTarget: () => ({ kind: 'local' }) +})) + +vi.mock('./use-worktree-activity-status', () => ({ + useWorktreeActivityStatus: () => 'active' +})) + +vi.mock('./CacheTimer', () => ({ + default: () => null, + usePromptCacheCountdownStartedAt: cacheTimerMocks.usePromptCacheCountdownStartedAt +})) + +vi.mock('./useWorktreeAgentRows', () => ({ + useWorktreeAgentRows: vi.fn(() => [] as DashboardAgentRowData[]) +})) + +vi.mock('./WorktreeCardAgents', () => ({ + default: () =>
+})) + +vi.mock('./SshDisconnectedDialog', () => ({ + SshDisconnectedDialog: () => null +})) + +vi.mock('./WorktreeContextMenu', () => ({ + default: ({ children }: { children: ReactNode }) => <>{children}, + CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus', + WORKTREE_CONTEXT_MENU_SCOPE_ATTR: 'data-orca-context-menu-scope', + WORKTREE_NATIVE_CONTEXT_MENU_ATTR: 'data-worktree-native-context-menu' +})) + +let workspacePortScan: { key: string; result: WorkspacePortScanResult } | null = null + +function makeRepo(): Repo { + return { id: 'repo-1', path: '/repo', displayName: 'orca', badgeColor: '#999999', addedAt: 1 } +} + +function makeWorktree(overrides: Partial = {}): Worktree { + return { + id: 'repo-1::/repo/worktrees/pr-456', + repoId: 'repo-1', + path: '/repo/worktrees/pr-456', + displayName: 'Fix stale GH PR', + branch: 'feature/local-branch', + head: 'abc123', + isBare: false, + isMainWorktree: false, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 1, + ...overrides + } +} + +function makePortScan(worktree: Worktree): { key: string; result: WorkspacePortScanResult } { + return { + key: 'repo-1', + result: { + platform: 'darwin', + scannedAt: 1, + ports: [ + { + id: '127.0.0.1:58941:1234', + bindHost: '127.0.0.1', + connectHost: '127.0.0.1', + port: 58941, + pid: 1234, + processName: 'node', + protocol: 'http', + kind: 'workspace', + owner: { + worktreeId: worktree.id, + repoId: worktree.repoId, + displayName: worktree.displayName, + path: worktree.path, + confidence: 'cwd' + } + } + ] + } + } +} + +function rootContaining(selector: string): HTMLElement { + const el = document.querySelector(selector) + if (!el) { + throw new Error(`No element matched ${selector}`) + } + const root = el.closest('[data-hovercard-root]') + if (!(root instanceof HTMLElement)) { + throw new Error(`No hover-card root ancestor for ${selector}`) + } + return root +} + +describe('WorktreeCard compact ports hover independence', () => { + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + vi.clearAllMocks() + openChangeByRoot.clear() + worktreeCardProperties = ['status', 'ports'] + settings = { compactWorktreeCards: true } + cacheTimerMocks.usePromptCacheCountdownStartedAt.mockReturnValue(null) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() + }) + + it('does not force the compact title hover open when the live-ports hover opens', async () => { + const worktree = makeWorktree() + workspacePortScan = makePortScan(worktree) + const { default: WorktreeCard } = await import('./WorktreeCard') + + act(() => { + root.render() + }) + + // Compact mode renders two hover roots: the title-wrapper root and the plug/ports root. + const titleRoot = rootContaining('[data-worktree-title-inline-rename]') + const portsRoot = rootContaining('[aria-label="1 live port"]') + expect(titleRoot).not.toBe(portsRoot) + expect(titleRoot.dataset.open).toBe('false') + expect(portsRoot.dataset.open).toBe('false') + + // Opening the ports hover must not drag the (separately anchored, wider) title card open — that cross-root + // force-open was the flicker loop in #9304. + const openPorts = openChangeByRoot.get(portsRoot) + expect(openPorts).toBeTypeOf('function') + act(() => { + openPorts?.(true) + }) + + expect(portsRoot.dataset.open).toBe('true') + expect(titleRoot.dataset.open).toBe('false') + }) +}) diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx index cabb1f3b08a..c8bc83be023 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx @@ -1205,7 +1205,8 @@ const WorktreeCard = React.memo(function WorktreeCard({ identityOrder="branch-first" detailsAfter={hasPorts ? : null} openDelay={100} - hoverControl={detailsHoverControl} + // Why: compact mode also renders the plug/badge hover root; sharing one open-state made hovering the + // plug force-open the wider title card and race it closed (#9304), so let this title hover own its state. onEditIssue={affiliateListMode ? undefined : handleEditIssue} onEditComment={affiliateListMode ? undefined : handleEditComment} onOpenGitHubIssueInOrca={ From 8847f615ac102409f1fcccf4953a4846876561b8 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:25:04 -0700 Subject: [PATCH 37/58] fix(terminal): restore clickable link hover after returning to the Orca window (#9659) xterm's Linkifier only re-runs providers when the hovered cell changes; window blur fires mouseleave which clears the current link but leaves the cell cache, so on refocus with a stationary pointer the link stays dead until a scroll. Adds the same guarded hover-cache reset the reveal/on-write paths already use, to the window-wake path. Closes #9116 --- .../terminal-visibility-resume.test.ts | 35 ++++++++++++++++++- .../terminal-visibility-resume.ts | 12 ++++++- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts index 29d9f27d287..4af5a12dce5 100644 --- a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts @@ -28,9 +28,11 @@ vi.mock('./terminal-webgl-atlas-recovery', () => ({ scheduleTabRevealWebglAtlasRecovery: () => scheduleTabRevealWebglAtlasRecovery() })) const resetTerminalLinkifierHoverState = vi.fn() +const isTerminalLinkifierHoverActive = vi.fn((_terminal: unknown) => false) vi.mock('@/lib/pane-manager/terminal-linkifier-hover-reset', () => ({ resetTerminalLinkifierHoverState: (terminal: unknown) => - resetTerminalLinkifierHoverState(terminal) + resetTerminalLinkifierHoverState(terminal), + isTerminalLinkifierHoverActive: (terminal: unknown) => isTerminalLinkifierHoverActive(terminal) })) type FakeManager = { @@ -115,6 +117,37 @@ describe('resumeTerminalVisibility reveal repaint', () => { expect(order).toEqual(['resume-rendering', 'reveal-repaint']) }) + it('resets each pane linkifier hover cache on window wake recovery so links recover without a scroll', () => { + const first = { name: 'pane-a' } + const second = { name: 'pane-b' } + const manager = createManager() + manager.getPanes.mockReturnValue([{ terminal: first }, { terminal: second }]) + + recoverVisibleTerminalWindowWake({ + manager: manager as never as PaneManager, + isActive: true, + clearGlyphAtlases: false + }) + + expect(resetTerminalLinkifierHoverState).toHaveBeenCalledWith(first) + expect(resetTerminalLinkifierHoverState).toHaveBeenCalledWith(second) + }) + + it('keeps a genuinely-hovered link intact on window wake recovery', () => { + const hovered = { name: 'hovered-pane' } + const manager = createManager() + manager.getPanes.mockReturnValue([{ terminal: hovered }]) + isTerminalLinkifierHoverActive.mockReturnValueOnce(true) + + recoverVisibleTerminalWindowWake({ + manager: manager as never as PaneManager, + isActive: true, + clearGlyphAtlases: false + }) + + expect(resetTerminalLinkifierHoverState).not.toHaveBeenCalled() + }) + it('schedules the atlas-clearing repaint on genuine wake recovery', () => { const manager = createManager() recoverVisibleTerminalWindowWake({ diff --git a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts index c330efb37e4..7fd0bcaff82 100644 --- a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts +++ b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts @@ -9,7 +9,10 @@ import { enforceTerminalCurrentScrollIntent, syncTerminalScrollIntentFromViewport } from '@/lib/pane-manager/terminal-scroll-intent' -import { resetTerminalLinkifierHoverState } from '@/lib/pane-manager/terminal-linkifier-hover-reset' +import { + isTerminalLinkifierHoverActive, + resetTerminalLinkifierHoverState +} from '@/lib/pane-manager/terminal-linkifier-hover-reset' import { fitAndFocusPanes, fitPanes, focusActivePane } from './pane-helpers' import { scheduleTabRevealWebglAtlasRecovery } from './terminal-webgl-atlas-recovery' @@ -143,6 +146,13 @@ export function recoverVisibleTerminalWindowWake({ for (const pane of manager.getPanes()) { requestTerminalBacklogRecovery(pane.terminal) flushTerminalOutput(pane.terminal, { maxChars: WINDOW_WAKE_FLUSH_CHARS }) + // Why: window blur fires mouseleave, clearing xterm's current link but not + // its hover cell cache; on refocus the stationary pointer sits on the same + // cell, so the link stays dead until a scroll. Skip while a link is hovered + // to avoid flickering its underline (same guard as the on-write reset). + if (!isTerminalLinkifierHoverActive(pane.terminal)) { + resetTerminalLinkifierHoverState(pane.terminal) + } } syncTerminalViewportIntents(manager) manager.resumeRendering() From 91ee29a80839ea8ad6741949396bbdb0136f4b8b Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:25:17 -0700 Subject: [PATCH 38/58] feat(editor): add an opt-in editor font family (default: follow terminal font) (#9658) The code editor was hard-wired to the terminal font, so Nerd Font 'Mono' CJK glyphs overlapped in the editor. Adds an opt-in editorFontFamily setting; default is empty so the resolved value is byte-identical to today for every existing user. Closes #9628 --- .../src/components/editor/DiffSectionBody.tsx | 6 +- .../src/components/editor/DiffSectionItem.tsx | 4 +- .../src/components/editor/DiffViewer.tsx | 4 +- .../src/components/editor/IpynbViewer.tsx | 10 +- .../components/editor/MonacoCodeExcerpt.tsx | 4 +- .../editor/MonacoEditor.font-family.test.tsx | 91 +++++++++++++++++++ .../src/components/editor/MonacoEditor.tsx | 4 +- .../settings/EditorFontFamilySetting.tsx | 56 ++++++++++++ .../settings/GeneralEditorSettingsSection.tsx | 55 ++++------- .../src/components/settings/GeneralPane.tsx | 8 +- .../src/components/settings/Settings.tsx | 2 + .../settings/auto-save-delay-draft.ts | 35 +++++++ .../settings/general-editor-search.ts | 15 +++ .../src/components/settings/setting-labels.ts | 1 + src/renderer/src/i18n/locales/en.json | 9 +- src/renderer/src/i18n/locales/es.json | 9 +- src/renderer/src/i18n/locales/ja.json | 9 +- src/renderer/src/i18n/locales/ko.json | 9 +- src/renderer/src/i18n/locales/zh.json | 9 +- src/renderer/src/lib/editor-font-zoom.test.ts | 55 ++++++++++- src/renderer/src/lib/editor-font-zoom.ts | 20 ++++ src/shared/constants.ts | 2 + src/shared/types.ts | 2 + 23 files changed, 361 insertions(+), 58 deletions(-) create mode 100644 src/renderer/src/components/editor/MonacoEditor.font-family.test.tsx create mode 100644 src/renderer/src/components/settings/EditorFontFamilySetting.tsx create mode 100644 src/renderer/src/components/settings/auto-save-delay-draft.ts diff --git a/src/renderer/src/components/editor/DiffSectionBody.tsx b/src/renderer/src/components/editor/DiffSectionBody.tsx index dbea8ab50ef..8a1dc3cef4d 100644 --- a/src/renderer/src/components/editor/DiffSectionBody.tsx +++ b/src/renderer/src/components/editor/DiffSectionBody.tsx @@ -36,7 +36,7 @@ type DiffSectionBodyProps = { isEditable: boolean diffEditorFontSize: number diffWordWrap?: boolean - terminalFontFamily?: string + editorFontFamily?: string onCancelComment: () => void onSubmitComment: (body: string) => Promise onRetrySection: (index: number) => void @@ -61,7 +61,7 @@ export function DiffSectionBody({ isEditable, diffEditorFontSize, diffWordWrap, - terminalFontFamily, + editorFontFamily, onCancelComment, onSubmitComment, onRetrySection, @@ -191,7 +191,7 @@ export function DiffSectionBody({ minimap: { enabled: false }, scrollBeyondLastLine: false, fontSize: diffEditorFontSize, - fontFamily: terminalFontFamily || 'monospace', + fontFamily: editorFontFamily || 'monospace', lineNumbers: 'on', ...buildDiffEditorWordWrapOptions(diffWordWrap), automaticLayout: true, diff --git a/src/renderer/src/components/editor/DiffSectionItem.tsx b/src/renderer/src/components/editor/DiffSectionItem.tsx index a070c38cb66..8b2c143f660 100644 --- a/src/renderer/src/components/editor/DiffSectionItem.tsx +++ b/src/renderer/src/components/editor/DiffSectionItem.tsx @@ -12,7 +12,7 @@ import type { editor as monacoEditor } from 'monaco-editor' import { monaco } from '@/lib/monaco-setup' import { detectLanguage } from '@/lib/language-detect' import { useAppStore } from '@/store' -import { computeDiffEditorFontSize } from '@/lib/editor-font-zoom' +import { computeDiffEditorFontSize, resolveEditorFontFamily } from '@/lib/editor-font-zoom' import { selectWorktreeDiffComments } from '@/store/worktree-diff-comments-selector' import { useDiffCommentDecorator, @@ -431,7 +431,7 @@ export function DiffSectionItem({ isEditable={isEditable} diffEditorFontSize={diffEditorFontSize} diffWordWrap={settings?.diffWordWrap} - terminalFontFamily={settings?.terminalFontFamily} + editorFontFamily={resolveEditorFontFamily(settings)} onCancelComment={() => setPopover(null)} onSubmitComment={handleSubmitComment} onRetrySection={retrySection} diff --git a/src/renderer/src/components/editor/DiffViewer.tsx b/src/renderer/src/components/editor/DiffViewer.tsx index 8bf174afb21..5aed5c9670d 100644 --- a/src/renderer/src/components/editor/DiffViewer.tsx +++ b/src/renderer/src/components/editor/DiffViewer.tsx @@ -4,7 +4,7 @@ import type { editor } from 'monaco-editor' import { useAppStore } from '@/store' import { diffViewStateCache, setWithLRU } from '@/lib/scroll-cache' import { monaco } from '@/lib/monaco-setup' -import { computeDiffEditorFontSize } from '@/lib/editor-font-zoom' +import { computeDiffEditorFontSize, resolveEditorFontFamily } from '@/lib/editor-font-zoom' import { useContextualCopySetup } from './useContextualCopySetup' import { selectWorktreeDiffComments } from '@/store/worktree-diff-comments-selector' import { useDiffCommentDecorator } from '../diff-comments/useDiffCommentDecorator' @@ -422,7 +422,7 @@ export default function DiffViewer({ minimap: { enabled: false }, scrollBeyondLastLine: false, fontSize: diffEditorFontSize, - fontFamily: settings?.terminalFontFamily || 'monospace', + fontFamily: resolveEditorFontFamily(settings), lineNumbers: 'on', ...buildDiffEditorWordWrapOptions(settings?.diffWordWrap), automaticLayout: true, diff --git a/src/renderer/src/components/editor/IpynbViewer.tsx b/src/renderer/src/components/editor/IpynbViewer.tsx index b71afececb2..bbf7ded32ed 100644 --- a/src/renderer/src/components/editor/IpynbViewer.tsx +++ b/src/renderer/src/components/editor/IpynbViewer.tsx @@ -32,7 +32,11 @@ import { Trash2 } from 'lucide-react' import { monaco } from '@/lib/monaco-setup' -import { computeEditorFontSize } from '@/lib/editor-font-zoom' +import { + computeEditorFontSize, + resolveEditorFontFamily, + resolveEditorFontFamilyOrInherit +} from '@/lib/editor-font-zoom' import { getConnectionId } from '@/lib/connection-context' import { resolveDocumentTheme } from '@/lib/document-theme' import { useAppStore } from '@/store' @@ -409,7 +413,7 @@ function CodeCell({ onChange={(value) => onChange(value ?? '')} options={{ automaticLayout: true, - fontFamily: settings?.terminalFontFamily || 'monospace', + fontFamily: resolveEditorFontFamily(settings), fontSize, glyphMargin: false, lineNumbersMinChars: 3, @@ -877,7 +881,7 @@ export default function IpynbViewer({
diff --git a/src/renderer/src/components/editor/MonacoCodeExcerpt.tsx b/src/renderer/src/components/editor/MonacoCodeExcerpt.tsx index 9c868a61130..a13ed13e1b7 100644 --- a/src/renderer/src/components/editor/MonacoCodeExcerpt.tsx +++ b/src/renderer/src/components/editor/MonacoCodeExcerpt.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useMemo, useState } from 'react' import { monaco } from '@/lib/monaco-setup' -import { computeEditorFontSize } from '@/lib/editor-font-zoom' +import { computeEditorFontSize, resolveEditorFontFamily } from '@/lib/editor-font-zoom' import { resolveDocumentTheme } from '@/lib/document-theme' import { useAppStore } from '@/store' import { cn } from '@/lib/utils' @@ -51,7 +51,7 @@ export default function MonacoCodeExcerpt({ settings?.terminalFontSize ?? 13, editorFontZoomLevel ) - const fontFamily = settings?.terminalFontFamily || 'monospace' + const fontFamily = resolveEditorFontFamily(settings) const isDark = resolveDocumentTheme(settings?.theme ?? 'system') const code = useMemo(() => lines.join('\n'), [lines]) const [htmlLines, setHtmlLines] = useState(() => lines.map(() => '')) diff --git a/src/renderer/src/components/editor/MonacoEditor.font-family.test.tsx b/src/renderer/src/components/editor/MonacoEditor.font-family.test.tsx new file mode 100644 index 00000000000..9e49a3d6ed3 --- /dev/null +++ b/src/renderer/src/components/editor/MonacoEditor.font-family.test.tsx @@ -0,0 +1,91 @@ +// @vitest-environment happy-dom +import { cleanup, render } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const editorProps = vi.hoisted(() => ({ current: null as Record | null })) +const storeState = vi.hoisted(() => ({ + current: { + theme: 'dark', + terminalFontSize: 13, + terminalFontFamily: 'D2Coding Nerd Font Mono', + editorFontFamily: '' + } as Record +})) + +vi.mock('@monaco-editor/react', () => ({ + default: (props: Record) => { + editorProps.current = props + return null + }, + loader: { config: vi.fn() } +})) +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: Record) => unknown) => + selector({ + settings: storeState.current, + editorFontZoomLevel: 0, + setPendingEditorReveal: vi.fn(), + setEditorCursorLine: vi.fn(), + addDiffComment: vi.fn(), + deleteDiffComment: vi.fn(), + updateDiffComment: vi.fn(), + scrollToDiffCommentId: null, + setScrollToDiffCommentId: vi.fn(), + worktreeDiffComments: {} + }) +})) +vi.mock('../diff-comments/useDiffCommentDecorator', () => ({ + useDiffCommentDecorator: vi.fn() +})) +vi.mock('./useContextualCopySetup', () => ({ + useContextualCopySetup: () => ({ setupCopy: vi.fn(), toastNode: null }) +})) + +import MonacoEditor from './MonacoEditor' + +function renderEditor(): void { + render( + + ) +} + +afterEach(() => { + cleanup() + editorProps.current = null +}) + +describe('MonacoEditor font family', () => { + it('follows the terminal font when no editor font override is set', () => { + storeState.current = { + theme: 'dark', + terminalFontSize: 13, + terminalFontFamily: 'D2Coding Nerd Font Mono', + editorFontFamily: '' + } + renderEditor() + const options = editorProps.current?.options as Record | undefined + expect(options?.fontFamily).toBe('D2Coding Nerd Font Mono') + }) + + it('uses the opt-in editor font override instead of the terminal font', () => { + storeState.current = { + theme: 'dark', + terminalFontSize: 13, + terminalFontFamily: 'D2Coding Nerd Font Mono', + editorFontFamily: 'D2Coding Nerd Font' + } + renderEditor() + const options = editorProps.current?.options as Record | undefined + expect(options?.fontFamily).toBe('D2Coding Nerd Font') + }) +}) diff --git a/src/renderer/src/components/editor/MonacoEditor.tsx b/src/renderer/src/components/editor/MonacoEditor.tsx index 8caad87f633..dbb5bd0b241 100644 --- a/src/renderer/src/components/editor/MonacoEditor.tsx +++ b/src/renderer/src/components/editor/MonacoEditor.tsx @@ -8,7 +8,7 @@ import type { MarkdownDocument } from '../../../../shared/types' import { useAppStore } from '@/store' import { scrollTopCache, cursorPositionCache, setWithLRU } from '@/lib/scroll-cache' import '@/lib/monaco-setup' -import { computeEditorFontSize } from '@/lib/editor-font-zoom' +import { computeEditorFontSize, resolveEditorFontFamily } from '@/lib/editor-font-zoom' import { registerFileSearchSelectedTextProvider } from '@/lib/file-search-selection' import { useContextualCopySetup } from './useContextualCopySetup' @@ -152,7 +152,7 @@ export default function MonacoEditor({ settings?.terminalFontSize ?? 13, editorFontZoomLevel ) - const editorFontFamily = settings?.terminalFontFamily || 'monospace' + const editorFontFamily = resolveEditorFontFamily(settings) const editorWordWrap = settings?.editorWordWrap const estimatedAutoHeight = useMemo(() => { if (!autoHeight) { diff --git a/src/renderer/src/components/settings/EditorFontFamilySetting.tsx b/src/renderer/src/components/settings/EditorFontFamilySetting.tsx new file mode 100644 index 00000000000..911a4e2fde1 --- /dev/null +++ b/src/renderer/src/components/settings/EditorFontFamilySetting.tsx @@ -0,0 +1,56 @@ +import type React from 'react' +import type { GlobalSettings } from '../../../../shared/types' +import { translate } from '@/i18n/i18n' +import { SearchableSetting } from './SearchableSetting' +import { FontAutocomplete, SettingsRow } from './SettingsFormControls' + +type EditorFontFamilySettingProps = { + settings: GlobalSettings + updateSettings: (updates: Partial) => void + fontSuggestions: string[] + onRequestFontSuggestions?: () => void +} + +export function EditorFontFamilySetting({ + settings, + updateSettings, + fontSuggestions, + onRequestFontSuggestions +}: EditorFontFamilySettingProps): React.JSX.Element { + return ( + + updateSettings({ editorFontFamily: value })} + /> + } + /> + + ) +} diff --git a/src/renderer/src/components/settings/GeneralEditorSettingsSection.tsx b/src/renderer/src/components/settings/GeneralEditorSettingsSection.tsx index 2a524600b07..b8d710b6886 100644 --- a/src/renderer/src/components/settings/GeneralEditorSettingsSection.tsx +++ b/src/renderer/src/components/settings/GeneralEditorSettingsSection.tsx @@ -18,51 +18,25 @@ import { import { translate } from '@/i18n/i18n' import { RichMarkdownSpellcheckSetting } from './RichMarkdownSpellcheckSetting' import { EditorWordWrapSetting } from './EditorWordWrapSetting' - -export type AutoSaveDelayDraftState = { - sourceDelayMs: number - draft: string -} - -export function createAutoSaveDelayDraftState( - editorAutoSaveDelayMs: number -): AutoSaveDelayDraftState { - return { - sourceDelayMs: editorAutoSaveDelayMs, - draft: String(editorAutoSaveDelayMs) - } -} - -function resolveAutoSaveDelayDraftState( - state: AutoSaveDelayDraftState, - editorAutoSaveDelayMs: number -): AutoSaveDelayDraftState { - return state.sourceDelayMs === editorAutoSaveDelayMs - ? state - : createAutoSaveDelayDraftState(editorAutoSaveDelayMs) -} - -export function updateAutoSaveDelayDraftState( - state: AutoSaveDelayDraftState, - editorAutoSaveDelayMs: number, - draft: string -): AutoSaveDelayDraftState { - return { - // Why: settings persistence is async, so a committed draft must stay tied - // to the current source until the persisted value reloads. - ...resolveAutoSaveDelayDraftState(state, editorAutoSaveDelayMs), - draft - } -} +import { EditorFontFamilySetting } from './EditorFontFamilySetting' +import { + createAutoSaveDelayDraftState, + resolveAutoSaveDelayDraftState, + updateAutoSaveDelayDraftState +} from './auto-save-delay-draft' type GeneralEditorSettingsSectionProps = { settings: GlobalSettings updateSettings: (updates: Partial) => void + fontSuggestions: string[] + onRequestFontSuggestions?: () => void } export function GeneralEditorSettingsSection({ settings, - updateSettings + updateSettings, + fontSuggestions, + onRequestFontSuggestions }: GeneralEditorSettingsSectionProps): React.JSX.Element { const [autoSaveDelayDraftState, setAutoSaveDelayDraftState] = useState(() => createAutoSaveDelayDraftState(settings.editorAutoSaveDelayMs) @@ -249,6 +223,13 @@ export function GeneralEditorSettingsSection({ /> + + [number] @@ -79,6 +79,8 @@ const EMPTY_WSL_DISTROS: string[] = [] type GeneralPaneProps = { settings: GlobalSettings updateSettings: (updates: Partial) => void + fontSuggestions: string[] + onRequestFontSuggestions?: () => void wslSupportedPlatform?: boolean wslAvailable?: boolean wslDistros?: string[] @@ -88,6 +90,8 @@ type GeneralPaneProps = { export function GeneralPane({ settings, updateSettings, + fontSuggestions, + onRequestFontSuggestions, wslSupportedPlatform, wslAvailable, wslDistros = EMPTY_WSL_DISTROS, @@ -177,6 +181,8 @@ export function GeneralPane({ key="editor" settings={settings} updateSettings={updateSettings} + fontSuggestions={fontSuggestions} + onRequestFontSuggestions={onRequestFontSuggestions} /> ) : null, matchesSettingsSearch(searchQuery, getGeneralCliSearchEntries()) ? ( diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index 4c4f99ad4eb..d35ca40237a 100644 --- a/src/renderer/src/components/settings/Settings.tsx +++ b/src/renderer/src/components/settings/Settings.tsx @@ -1290,6 +1290,8 @@ function Settings(): React.JSX.Element { [ ) ] }, + { + title: translate( + 'auto.components.settings.general.search.editorFontFamily', + 'Editor Font Family' + ), + description: translate( + 'auto.components.settings.general.search.editorFontFamilyDesc', + 'Font used by file editors and diff views. Leave empty to follow the terminal font.' + ), + keywords: [ + ...translateSearchKeyword('auto.components.settings.general.search.e1ee631696', 'editor'), + ...translateSearchKeyword('auto.components.settings.general.search.editorFontKw', 'font'), + ...translateSearchKeyword('auto.components.settings.general.search.3ca5ab78a5', 'code') + ] + }, { title: translate('auto.components.settings.general.search.e61157e926', 'Editor Word Wrap'), description: translate( diff --git a/src/renderer/src/components/settings/setting-labels.ts b/src/renderer/src/components/settings/setting-labels.ts index 0f108fadeae..95eb3a26c7c 100644 --- a/src/renderer/src/components/settings/setting-labels.ts +++ b/src/renderer/src/components/settings/setting-labels.ts @@ -3,6 +3,7 @@ import type { GlobalSettings } from '../../../../shared/types' export const SETTING_LABELS: Partial> = { terminalFontSize: 'Font Size', terminalFontFamily: 'Font Family', + editorFontFamily: 'Editor Font Family', terminalFontWeight: 'Font Weight', terminalLineHeight: 'Line Height', terminalScrollSensitivity: 'Normal Scroll Speed', diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 3af70abb65f..95a9c72f64c 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -7879,7 +7879,9 @@ "d2d2d929c0": "Rich Markdown Spellcheck", "4497e2e2bb": "Show browser spelling underlines and suggestions while editing rich Markdown.", "e61157e926": "Editor Word Wrap", - "005be5c699": "Wrap long lines in file editors instead of requiring horizontal scrolling." + "005be5c699": "Wrap long lines in file editors instead of requiring horizontal scrolling.", + "editorFontFamily": "Editor Font Family", + "editorFontFamilyDesc": "Font used by file editors and diff views. Leave empty to follow the terminal font." } }, "git": { @@ -9271,6 +9273,11 @@ }, "MobileRelayBetaNotice": { "notice": "Orca Relay is in beta." + }, + "EditorFontFamilySetting": { + "title": "Editor Font Family", + "description": "Font used by file editors and diff views. Leave empty to follow the terminal font.", + "placeholder": "Same as terminal font" } }, "right": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 5932350ebeb..2f0ded12e1c 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -7819,7 +7819,9 @@ "d2d2d929c0": "Corrector ortográfico de Rich Markdown", "4497e2e2bb": "Muestra subrayados y sugerencias ortográficas del navegador al editar Rich Markdown.", "e61157e926": "Ajuste de línea en el editor", - "005be5c699": "Ajusta las líneas largas en editores de archivos en lugar de requerir desplazamiento horizontal." + "005be5c699": "Ajusta las líneas largas en editores de archivos en lugar de requerir desplazamiento horizontal.", + "editorFontFamily": "Editor Font Family", + "editorFontFamilyDesc": "Font used by file editors and diff views. Leave empty to follow the terminal font." } }, "git": { @@ -9248,6 +9250,11 @@ }, "MobileRelayBetaNotice": { "notice": "Orca Relay is in beta." + }, + "EditorFontFamilySetting": { + "title": "Editor Font Family", + "description": "Font used by file editors and diff views. Leave empty to follow the terminal font.", + "placeholder": "Same as terminal font" } }, "right": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index aa76a652aba..713a93f15f9 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -7841,7 +7841,9 @@ "d2d2d929c0": "Rich Markdown Spellcheck", "4497e2e2bb": "Show browser spelling underlines and suggestions while editing rich Markdown.", "e61157e926": "エディターのワードラップ", - "005be5c699": "水平スクロールを使わずに、ファイルエディターで長い行を折り返します。" + "005be5c699": "水平スクロールを使わずに、ファイルエディターで長い行を折り返します。", + "editorFontFamily": "Editor Font Family", + "editorFontFamilyDesc": "Font used by file editors and diff views. Leave empty to follow the terminal font." } }, "git": { @@ -9248,6 +9250,11 @@ }, "MobileRelayBetaNotice": { "notice": "Orca Relay is in beta." + }, + "EditorFontFamilySetting": { + "title": "Editor Font Family", + "description": "Font used by file editors and diff views. Leave empty to follow the terminal font.", + "placeholder": "Same as terminal font" } }, "right": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index d2a37623629..d1a8a8ac854 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -7804,7 +7804,9 @@ "d2d2d929c0": "리치 Markdown 맞춤법 검사", "4497e2e2bb": "리치 Markdown을 편집하는 동안 브라우저 맞춤법 밑줄과 제안을 표시합니다.", "e61157e926": "편집기 자동 줄 바꿈", - "005be5c699": "가로 스크롤 대신 파일 편집기에서 긴 줄을 자동으로 줄 바꿈합니다." + "005be5c699": "가로 스크롤 대신 파일 편집기에서 긴 줄을 자동으로 줄 바꿈합니다.", + "editorFontFamily": "Editor Font Family", + "editorFontFamilyDesc": "Font used by file editors and diff views. Leave empty to follow the terminal font." } }, "git": { @@ -9248,6 +9250,11 @@ }, "MobileRelayBetaNotice": { "notice": "Orca Relay is in beta." + }, + "EditorFontFamilySetting": { + "title": "Editor Font Family", + "description": "Font used by file editors and diff views. Leave empty to follow the terminal font.", + "placeholder": "Same as terminal font" } }, "right": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 8a38f538366..1a3caf2499a 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -7804,7 +7804,9 @@ "d2d2d929c0": "Rich Markdown Spellcheck", "4497e2e2bb": "在编辑富 Markdown 时显示浏览器拼写下划线和建议。", "e61157e926": "编辑器自动换行", - "005be5c699": "在文件编辑器中自动换行长行,而无需水平滚动。" + "005be5c699": "在文件编辑器中自动换行长行,而无需水平滚动。", + "editorFontFamily": "Editor Font Family", + "editorFontFamilyDesc": "Font used by file editors and diff views. Leave empty to follow the terminal font." } }, "git": { @@ -9248,6 +9250,11 @@ }, "MobileRelayBetaNotice": { "notice": "Orca Relay is in beta." + }, + "EditorFontFamilySetting": { + "title": "Editor Font Family", + "description": "Font used by file editors and diff views. Leave empty to follow the terminal font.", + "placeholder": "Same as terminal font" } }, "right": { diff --git a/src/renderer/src/lib/editor-font-zoom.test.ts b/src/renderer/src/lib/editor-font-zoom.test.ts index e59a4efc9f5..7ae12537621 100644 --- a/src/renderer/src/lib/editor-font-zoom.test.ts +++ b/src/renderer/src/lib/editor-font-zoom.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest' -import { computeDiffEditorFontSize, computeEditorFontSize } from './editor-font-zoom' +import { + computeDiffEditorFontSize, + computeEditorFontSize, + resolveEditorFontFamily, + resolveEditorFontFamilyOrInherit +} from './editor-font-zoom' describe('editor font zoom', () => { it('keeps diff editors smaller than regular editor surfaces', () => { @@ -13,3 +18,51 @@ describe('editor font zoom', () => { expect(computeDiffEditorFontSize(24, 18)).toBe(32) }) }) + +describe('resolveEditorFontFamily', () => { + it('follows the terminal font when no editor font is set (byte-identical to legacy behavior)', () => { + expect(resolveEditorFontFamily({ terminalFontFamily: 'D2Coding Nerd Font Mono' })).toBe( + 'D2Coding Nerd Font Mono' + ) + }) + + it('treats an empty/whitespace editor font as unset and follows the terminal font', () => { + expect(resolveEditorFontFamily({ editorFontFamily: '', terminalFontFamily: 'Menlo' })).toBe( + 'Menlo' + ) + expect(resolveEditorFontFamily({ editorFontFamily: ' ', terminalFontFamily: 'Menlo' })).toBe( + 'Menlo' + ) + }) + + it('uses the editor font override when the user opts in', () => { + expect( + resolveEditorFontFamily({ editorFontFamily: 'JetBrains Mono', terminalFontFamily: 'Menlo' }) + ).toBe('JetBrains Mono') + }) + + it('falls back to monospace when neither font is set', () => { + expect(resolveEditorFontFamily(undefined)).toBe('monospace') + expect(resolveEditorFontFamily({})).toBe('monospace') + }) +}) + +describe('resolveEditorFontFamilyOrInherit', () => { + it('returns undefined (inherit UI font) when neither font is set', () => { + expect(resolveEditorFontFamilyOrInherit({})).toBeUndefined() + expect(resolveEditorFontFamilyOrInherit(undefined)).toBeUndefined() + }) + + it('follows the terminal font when no editor override is set', () => { + expect(resolveEditorFontFamilyOrInherit({ terminalFontFamily: 'Menlo' })).toBe('Menlo') + }) + + it('uses the editor font override when set', () => { + expect( + resolveEditorFontFamilyOrInherit({ + editorFontFamily: 'Fira Code', + terminalFontFamily: 'Menlo' + }) + ).toBe('Fira Code') + }) +}) diff --git a/src/renderer/src/lib/editor-font-zoom.ts b/src/renderer/src/lib/editor-font-zoom.ts index 88c9d702f96..a06bd556263 100644 --- a/src/renderer/src/lib/editor-font-zoom.ts +++ b/src/renderer/src/lib/editor-font-zoom.ts @@ -30,3 +30,23 @@ export function computeDiffEditorFontSize(baseFontSize: number, zoomLevel: numbe // terminal font size makes review views feel oversized relative to app chrome. return computeEditorFontSize(baseFontSize - 0.5, zoomLevel) } + +export type EditorFontFamilySettings = { + editorFontFamily?: string + terminalFontFamily?: string +} + +/** + * Why: the editor font is opt-in and defaults to empty, so an unset value must + * keep falling back to the terminal font exactly as before the setting existed. + */ +export function resolveEditorFontFamily(settings?: EditorFontFamilySettings | null): string { + return settings?.editorFontFamily?.trim() || settings?.terminalFontFamily || 'monospace' +} + +/** Same resolution, but keeps the notebook shell's "no font set → inherit UI font" fallback. */ +export function resolveEditorFontFamilyOrInherit( + settings?: EditorFontFamilySettings | null +): string | undefined { + return settings?.editorFontFamily?.trim() || settings?.terminalFontFamily || undefined +} diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 1ccd9e4883f..41b290eb06f 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -191,6 +191,8 @@ export function getDefaultSettings(homedir: string): GlobalSettings { editorAutoSave: false, editorAutoSaveDelayMs: DEFAULT_EDITOR_AUTO_SAVE_DELAY_MS, editorMinimapEnabled: false, + // Why empty: the editor keeps following the terminal font unless the user opts in. + editorFontFamily: '', editorWordWrap: true, richMarkdownSpellcheckEnabled: true, markdownReviewToolsEnabled: true, diff --git a/src/shared/types.ts b/src/shared/types.ts index d9b597f4638..1e722b695f7 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2602,6 +2602,8 @@ export type GlobalSettings = { editorAutoSave: boolean editorAutoSaveDelayMs: number editorMinimapEnabled: boolean + /** Opt-in code-editor font; empty (the default) keeps following `terminalFontFamily`. */ + editorFontFamily?: string /** Defaults on for profiles saved before file-editor wrapping became configurable. */ editorWordWrap?: boolean /** Persisted opt-out for browser spellcheck noise in rich Markdown editing surfaces. */ From 008c71df401118580d1162981de59f14b0ecc5b0 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:25:20 -0700 Subject: [PATCH 39/58] fix(terminal): stop leaking Orca's own NODE_ENV into spawned terminals (#9649) Orca spread its whole process.env into every spawned terminal, so a dev build leaked NODE_ENV=development into integrated terminals (breaking next build, Vitest, etc.). Strips the inherited value at all three PTY-spawn sites (daemon, local provider, relay); a value the user sets themselves still wins. Supersedes #9058, which patched only the relay path and so missed the reported (local daemon) repro. Closes #9057 Co-authored-by: John Debay --- src/main/daemon/pty-subprocess.test.ts | 49 +++++++++++++++++++ src/main/daemon/pty-subprocess.ts | 3 +- src/main/providers/local-pty-provider.test.ts | 38 ++++++++++++++ src/main/providers/local-pty-provider.ts | 3 +- src/main/pty/build-mode-env.test.ts | 22 +++++++++ src/main/pty/build-mode-env.ts | 9 ++++ src/relay/pty-handler.test.ts | 42 ++++++++++++++++ src/relay/pty-handler.ts | 3 +- 8 files changed, 166 insertions(+), 3 deletions(-) create mode 100644 src/main/pty/build-mode-env.test.ts create mode 100644 src/main/pty/build-mode-env.ts diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index 9f983708f07..1930b6e23b5 100644 --- a/src/main/daemon/pty-subprocess.test.ts +++ b/src/main/daemon/pty-subprocess.test.ts @@ -1218,6 +1218,55 @@ describe('createPtySubprocess', () => { expect(env.ELECTRON_RUN_AS_NODE).toBeUndefined() }) + it('does not inherit NODE_ENV from the daemon process env', () => { + // Why: a dev-mode Orca forks the daemon with NODE_ENV=development; leaking + // Orca's build mode into user shells breaks `next build` and Vitest. + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + const previous = process.env.NODE_ENV + process.env.NODE_ENV = 'development' + + try { + createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24 }) + } finally { + if (previous === undefined) { + delete process.env.NODE_ENV + } else { + process.env.NODE_ENV = previous + } + } + + const env = spawnMock.mock.calls.at(-1)?.[2].env + expect(env.NODE_ENV).toBeUndefined() + expect(env.PATH).toBe(process.env.PATH) + }) + + it('keeps an explicitly requested NODE_ENV for daemon PTY shells', () => { + // Why: only the ambient value is stripped; a caller-supplied NODE_ENV still wins. + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + const previous = process.env.NODE_ENV + process.env.NODE_ENV = 'development' + + try { + createPtySubprocess({ + sessionId: 'test', + cols: 80, + rows: 24, + env: { NODE_ENV: 'production' } + }) + } finally { + if (previous === undefined) { + delete process.env.NODE_ENV + } else { + process.env.NODE_ENV = previous + } + } + + const env = spawnMock.mock.calls.at(-1)?.[2].env + expect(env.NODE_ENV).toBe('production') + }) + it('does not inherit AppImage runtime env into daemon PTY shells', () => { const proc = mockPtyProcess() spawnMock.mockReturnValue(proc) diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index bf0662d32b7..8066770c526 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -31,6 +31,7 @@ import { isPwshAvailable } from '../pwsh' import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-wsl-env' import { removeInheritedNoColor } from '../pty/terminal-color-env' import { removeAppImageRuntimeEnv } from '../pty/appimage-terminal-env' +import { stripInheritedBuildModeEnv } from '../pty/build-mode-env' import { parseWslPath } from '../wsl' import { addWslEnvKeys } from '../wsl-env' import { @@ -559,7 +560,7 @@ function spawnDaemonPtyWithWindowsFallback(args: { export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandle { const size = normalizePtySize(opts.cols, opts.rows) const env: Record = { - ...mergeGitConfigEnvProtocol(process.env, opts.env), + ...mergeGitConfigEnvProtocol(stripInheritedBuildModeEnv(process.env), opts.env), TERM: 'xterm-256color', COLORTERM: 'truecolor', TERM_PROGRAM: 'Orca', diff --git a/src/main/providers/local-pty-provider.test.ts b/src/main/providers/local-pty-provider.test.ts index 94b657f39c6..a32e7ed2b6a 100644 --- a/src/main/providers/local-pty-provider.test.ts +++ b/src/main/providers/local-pty-provider.test.ts @@ -441,6 +441,44 @@ describe('LocalPtyProvider', () => { expect(spawnCall[2].env.CUSTOM_VAR).toBe('custom-value') }) + it('does not inherit NODE_ENV from the Orca process env', async () => { + // Why: NODE_ENV in Orca's process is Orca's build mode (electron-vite sets + // `development` in dev runs); leaking it breaks `next build` and Vitest. + const previous = process.env.NODE_ENV + process.env.NODE_ENV = 'development' + try { + await provider.spawn({ cols: 80, rows: 24 }) + } finally { + if (previous === undefined) { + delete process.env.NODE_ENV + } else { + process.env.NODE_ENV = previous + } + } + + const spawnCall = spawnMock.mock.calls.at(-1)! + expect(spawnCall[2].env.NODE_ENV).toBeUndefined() + expect(spawnCall[2].env.PATH).toBe(process.env.PATH) + }) + + it('keeps an explicitly requested NODE_ENV for spawned terminals', async () => { + // Why: only the ambient value is stripped; a caller-supplied NODE_ENV still wins. + const previous = process.env.NODE_ENV + process.env.NODE_ENV = 'development' + try { + await provider.spawn({ cols: 80, rows: 24, env: { NODE_ENV: 'production' } }) + } finally { + if (previous === undefined) { + delete process.env.NODE_ENV + } else { + process.env.NODE_ENV = previous + } + } + + const spawnCall = spawnMock.mock.calls.at(-1)! + expect(spawnCall[2].env.NODE_ENV).toBe('production') + }) + it('suppresses the first-run Powerlevel10k wizard for spawned terminals', async () => { await provider.spawn({ cols: 80, rows: 24 }) diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 276d0dd421c..e4a30c8af45 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -37,6 +37,7 @@ import { import type { ShellReadySignal } from './local-pty-shell-ready' import { removeInheritedNoColor } from '../pty/terminal-color-env' import { removeAppImageRuntimeEnv } from '../pty/appimage-terminal-env' +import { stripInheritedBuildModeEnv } from '../pty/build-mode-env' import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-wsl-env' import { addWslEnvKeys } from '../wsl-env' import { @@ -636,7 +637,7 @@ export class LocalPtyProvider implements IPtyProvider { validateWorkingDirectory(validationCwd) const spawnEnv: Record = { - ...mergeGitConfigEnvProtocol(process.env, args.env), + ...mergeGitConfigEnvProtocol(stripInheritedBuildModeEnv(process.env), args.env), TERM: 'xterm-256color', COLORTERM: 'truecolor', TERM_PROGRAM: 'Orca', diff --git a/src/main/pty/build-mode-env.test.ts b/src/main/pty/build-mode-env.test.ts new file mode 100644 index 00000000000..22df13bba7d --- /dev/null +++ b/src/main/pty/build-mode-env.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { stripInheritedBuildModeEnv } from './build-mode-env' + +describe('stripInheritedBuildModeEnv', () => { + it('drops NODE_ENV while keeping every other inherited variable', () => { + const env = { NODE_ENV: 'development', PATH: '/usr/bin', HOME: '/home/tester' } + + expect(stripInheritedBuildModeEnv(env)).toEqual({ PATH: '/usr/bin', HOME: '/home/tester' }) + }) + + it('does not mutate the source env', () => { + const env = { NODE_ENV: 'development' } + + stripInheritedBuildModeEnv(env) + + expect(env.NODE_ENV).toBe('development') + }) + + it('is a no-op when NODE_ENV is unset', () => { + expect(stripInheritedBuildModeEnv({ PATH: '/usr/bin' })).toEqual({ PATH: '/usr/bin' }) + }) +}) diff --git a/src/main/pty/build-mode-env.ts b/src/main/pty/build-mode-env.ts new file mode 100644 index 00000000000..de82874abea --- /dev/null +++ b/src/main/pty/build-mode-env.ts @@ -0,0 +1,9 @@ +/** Return a copy of an inherited environment without Orca's own build-mode flag. */ +export function stripInheritedBuildModeEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + // Why: NODE_ENV in Orca's process is Orca's build mode (electron-vite sets + // `development` in dev runs), not the user's; leaking it makes `next build` + // and Vitest take the wrong branch. Caller/renderer env and shell rc still win. + const next = { ...env } + delete next.NODE_ENV + return next +} diff --git a/src/relay/pty-handler.test.ts b/src/relay/pty-handler.test.ts index 36881b2449d..3e01840c7b5 100644 --- a/src/relay/pty-handler.test.ts +++ b/src/relay/pty-handler.test.ts @@ -182,6 +182,48 @@ describe('PtyHandler', () => { expect(handler.activePtyCount).toBe(1) }) + it("does not forward Orca's own NODE_ENV into the spawned shell", async () => { + // Why: NODE_ENV in the relay host process is a build-mode flag, not the + // user's; leaking it breaks `next build` and Vitest in the terminal. + const previous = process.env.NODE_ENV + process.env.NODE_ENV = 'development' + try { + await dispatcher.callRequest('pty.spawn', { cols: 80, rows: 24 }) + } finally { + if (previous === undefined) { + delete process.env.NODE_ENV + } else { + process.env.NODE_ENV = previous + } + } + + const spawnOptions = mockPtySpawn.mock.calls[0][2] as { env: Record } + expect(spawnOptions.env.NODE_ENV).toBeUndefined() + expect(spawnOptions.env.PATH).toBe(process.env.PATH) + }) + + it('keeps a renderer-supplied NODE_ENV for the spawned shell', async () => { + // Why: only the ambient value is stripped; an explicit request still wins. + const previous = process.env.NODE_ENV + process.env.NODE_ENV = 'development' + try { + await dispatcher.callRequest('pty.spawn', { + cols: 80, + rows: 24, + env: { NODE_ENV: 'production' } + }) + } finally { + if (previous === undefined) { + delete process.env.NODE_ENV + } else { + process.env.NODE_ENV = previous + } + } + + const spawnOptions = mockPtySpawn.mock.calls[0][2] as { env: Record } + expect(spawnOptions.env.NODE_ENV).toBe('production') + }) + it('normalizes a missing native binding as degraded node-pty availability', async () => { mockPtySpawn.mockImplementationOnce(() => { throw new Error( diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 95a95023a57..b9c4fae001a 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -39,6 +39,7 @@ import { } from '../shared/git-credential-prompt-env' import { isTuiAgent } from '../shared/tui-agent-config' import { forceKillPosixPtyProcessGroups } from '../main/pty/posix-pty-process-groups' +import { stripInheritedBuildModeEnv } from '../main/pty/build-mode-env' import { PTY_STARTUP_INGRESS_VERSION, PtyStartupIngress, @@ -389,7 +390,7 @@ export class PtyHandler { ): Record { const baseEnv = mergeGitConfigEnvProtocol( { - ...process.env, + ...stripInheritedBuildModeEnv(process.env), TERM: 'xterm-256color', COLORTERM: 'truecolor', TERM_PROGRAM: 'Orca', From 05c32c475766330e4ea2482388c3eb0af8e2b622 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Tue, 21 Jul 2026 00:36:15 -0400 Subject: [PATCH 40/58] fix(runtime): isolate navigation across paired clients (#9664) --- .../run-multi-client-navigation-e2e.mjs | 41 ++ mobile/app/h/[hostId]/index.tsx | 7 +- .../app/h/[hostId]/session/[worktreeId].tsx | 32 +- .../session/ai-vault-resume-launch.test.ts | 10 +- mobile/src/session/ai-vault-resume-launch.ts | 5 +- .../mobile-session-startup-source.test.ts | 8 +- .../mobile-session-tab-activation.test.ts | 16 +- .../session/mobile-session-tab-activation.ts | 3 +- .../src/session/pr-ai-triage-launch.test.ts | 5 +- mobile/src/session/pr-ai-triage-launch.ts | 5 +- .../use-mobile-diff-review-send-actions.ts | 5 +- .../mobile-worktree-activation-source.test.ts | 1 + package.json | 1 + src/cli/handlers/terminal.ts | 3 +- src/cli/index.test.ts | 5 +- src/main/index.ts | 9 +- .../client-session-tab-selection.test.ts | 141 +++++ .../runtime/client-session-tab-selection.ts | 195 ++++++ ...t-navigation-isolation.integration.test.ts | 581 ++++++++++++++++++ src/main/runtime/orca-runtime.test.ts | 56 ++ src/main/runtime/orca-runtime.ts | 402 ++++++++---- src/main/runtime/rpc/core.ts | 2 + src/main/runtime/rpc/dispatcher.ts | 4 + .../rpc/methods/session-tabs-schemas.test.ts | 13 +- .../rpc/methods/session-tabs-schemas.ts | 6 +- .../runtime/rpc/methods/session-tabs.test.ts | 77 ++- src/main/runtime/rpc/methods/session-tabs.ts | 44 +- .../rpc/methods/terminal-navigation.test.ts | 71 +++ src/main/runtime/rpc/methods/terminal.ts | 18 +- .../rpc/methods/worktree-schemas.test.ts | 11 +- .../runtime/rpc/methods/worktree-schemas.ts | 4 +- src/main/runtime/rpc/methods/worktree.test.ts | 6 +- src/main/runtime/rpc/methods/worktree.ts | 8 +- src/main/runtime/rpc/streaming.test.ts | 24 + src/main/runtime/runtime-rpc.ts | 3 + .../remote-runtime-pty-transport.test.ts | 16 +- .../remote-runtime-pty-transport.ts | 4 +- .../terminal-handle-links.test.ts | 12 +- .../terminal-pane/terminal-handle-links.ts | 2 +- .../terminal-orchestration-task-links.ts | 2 +- .../worktree-activation-created-agent.test.ts | 6 +- .../worktree-activation-empty-remote.test.ts | 4 +- src/renderer/src/lib/worktree-activation.ts | 3 +- .../src/runtime/web-runtime-session.test.ts | 16 +- .../src/runtime/web-runtime-session.ts | 19 +- .../src/runtime/web-session-tabs-sync.test.ts | 167 ++--- .../src/runtime/web-session-tabs-sync.ts | 81 ++- src/shared/runtime-navigation.test.ts | 25 + src/shared/runtime-navigation.ts | 26 + src/shared/runtime-types.ts | 2 + tests/e2e/global-setup.ts | 16 + .../multi-client-navigation-isolation.spec.ts | 178 ++++++ 52 files changed, 2088 insertions(+), 313 deletions(-) create mode 100644 config/scripts/run-multi-client-navigation-e2e.mjs create mode 100644 src/main/runtime/client-session-tab-selection.test.ts create mode 100644 src/main/runtime/client-session-tab-selection.ts create mode 100644 src/main/runtime/multi-client-navigation-isolation.integration.test.ts create mode 100644 src/main/runtime/rpc/methods/terminal-navigation.test.ts create mode 100644 src/shared/runtime-navigation.test.ts create mode 100644 src/shared/runtime-navigation.ts create mode 100644 tests/e2e/multi-client-navigation-isolation.spec.ts diff --git a/config/scripts/run-multi-client-navigation-e2e.mjs b/config/scripts/run-multi-client-navigation-e2e.mjs new file mode 100644 index 00000000000..cf9365f6406 --- /dev/null +++ b/config/scripts/run-multi-client-navigation-e2e.mjs @@ -0,0 +1,41 @@ +import { spawnSync } from 'node:child_process' + +const rawExtraArgs = process.argv.slice(2) +const extraArgs = rawExtraArgs[0] === '--' ? rawExtraArgs.slice(1) : rawExtraArgs +const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm' +const env = { + ...process.env, + ORCA_E2E_WEB_CLIENT: '1' +} + +// Why: Node's CVE-2024-27980 hardening rejects .cmd spawns without shell on Windows. +const spawnOptions = { + stdio: 'inherit', + env, + shell: process.platform === 'win32' +} + +const runtime = spawnSync(pnpm, ['run', 'ensure:electron-runtime'], spawnOptions) + +if (runtime.status !== 0) { + process.exit(runtime.status ?? 1) +} + +const result = spawnSync( + pnpm, + [ + 'exec', + 'playwright', + 'test', + 'tests/e2e/multi-client-navigation-isolation.spec.ts', + '--config', + 'tests/playwright.config.ts', + '--project', + 'electron-headless', + '--workers=1', + ...extraArgs + ], + spawnOptions +) + +process.exit(result.status ?? 1) diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx index b406840ffb2..5a6760508f2 100644 --- a/mobile/app/h/[hostId]/index.tsx +++ b/mobile/app/h/[hostId]/index.tsx @@ -632,8 +632,7 @@ export function HostScreen({ router.push(target) return } - const targetPath = target.split('?')[0] ?? target - if (pathname === targetPath) { + if (pathname === (target.split('?')[0] ?? target)) { return } if (pathname === `/h/${hostId}`) { @@ -649,11 +648,11 @@ export function HostScreen({ (item: Worktree) => { setOptimisticActiveWorktreeId(item.worktreeId) if (client && connState === 'connected') { - // Why: notifyClients:false hydrates host tabs without pulling desktop into this worktree. void client .sendRequest('worktree.activate', { worktree: `id:${item.worktreeId}`, - notifyClients: false + notifyClients: false, + navigation: 'caller' }) .catch(() => null) } diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 9ed22a07af4..76b0055d1e7 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -209,10 +209,7 @@ import { getMobileTerminalActionSheetActions } from '../../../../src/session/mob import * as nativeChatTerminalStream from '../../../../src/session/mobile-native-chat-terminal-stream' import { useMobileNativeChatTerminalStream } from '../../../../src/session/use-mobile-native-chat-terminal-stream' import { subscribeMobileTerminalSafely } from '../../../../src/session/mobile-terminal-stream-subscribe' -import { - activateMobileSessionTab, - focusMobileTerminal -} from '../../../../src/session/mobile-session-tab-activation' +import { activateMobileSessionTab } from '../../../../src/session/mobile-session-tab-activation' import { MobileTerminalDiagnostics } from '../../../../src/session/mobile-terminal-diagnostics' import { getRepoIdFromMobileWorktreeId, @@ -2630,7 +2627,8 @@ export default function SessionScreen() { void client .sendRequest('worktree.activate', { worktree: `id:${worktreeId}`, - notifyClients: false + notifyClients: false, + navigation: 'caller' }) .then((response) => reportActivationOutcome(response.ok ? response : null)) .catch(() => null) @@ -2657,7 +2655,8 @@ export default function SessionScreen() { const activationResponse = await client .sendRequest('worktree.activate', { worktree: `id:${worktreeId}`, - notifyClients: false + notifyClients: false, + navigation: 'caller' }) .catch(() => null) reportActivationOutcome(activationResponse?.ok ? activationResponse : null) @@ -2780,7 +2779,7 @@ export default function SessionScreen() { }, []) ) - // Why: unsubscribe restores old dims (clears phone-fit banner); resubscribe phone-fits the new one; terminal.focus makes desktop follow. + // Why: unsubscribe restores old dims (clears phone-fit banner); resubscribe phone-fits the new one. const switchTab = useCallback( (handle: string) => { triggerSelection() @@ -2808,13 +2807,12 @@ export default function SessionScreen() { } subscribeToTerminal(handle) if (client) { - void focusMobileTerminal(client, handle).catch(() => {}) if (matchingTab) { - // Why: persist selection for headless hosts; snapshot gate stops this phone-local ack from impersonating desktop focus. void activateMobileSessionTab(client, { worktree: `id:${worktreeId}`, tabId: matchingTab.id, - notifyClients: false + notifyClients: false, + navigation: 'caller' }).catch(() => {}) } } @@ -2853,7 +2851,8 @@ export default function SessionScreen() { void activateMobileSessionTab(client, { worktree: `id:${worktreeId}`, tabId: tab.id, - notifyClients: false + notifyClients: false, + navigation: 'caller' }).catch(() => {}) } return @@ -2876,7 +2875,8 @@ export default function SessionScreen() { void activateMobileSessionTab(client, { worktree: `id:${worktreeId}`, tabId: tab.id, - notifyClients: false + notifyClients: false, + navigation: 'caller' }).catch(() => {}) } if (tab.type === 'browser') { @@ -3750,7 +3750,10 @@ export default function SessionScreen() { ? { startupCommandDelivery: options.startupCommandDelivery } : {}), ...(options?.agentPrompt ? { agentPrompt: options.agentPrompt } : {}), - ...(agent ? { agent } : {}) + ...(agent ? { agent } : {}), + activate: false, + select: true, + navigation: 'caller' }) if (response.ok) { const result = (response as RpcSuccess).result as TerminalCreateResult @@ -4145,7 +4148,8 @@ export default function SessionScreen() { worktree: `id:${worktreeId}`, tabId: activePendingTerminalTab.id, leafId: activePendingTerminalTab.leafId, - notifyClients: false + notifyClients: false, + navigation: 'caller' }) .then((response) => { if (!response.ok) { diff --git a/mobile/src/session/ai-vault-resume-launch.test.ts b/mobile/src/session/ai-vault-resume-launch.test.ts index fa096be4a03..36d39ceb5ec 100644 --- a/mobile/src/session/ai-vault-resume-launch.test.ts +++ b/mobile/src/session/ai-vault-resume-launch.test.ts @@ -198,7 +198,10 @@ describe('resumeAiVaultSessionInTerminal', () => { agentEnv: { ANTHROPIC_BASE_URL: 'http://localhost:3000' } }, launchAgent: 'claude', - clientMutationId: 'resume-1' + clientMutationId: 'resume-1', + activate: false, + select: true, + navigation: 'caller' }) ).resolves.toMatchObject({ id: 'tab-1', terminal: 'pty-1' }) expect(sendRequest).toHaveBeenNthCalledWith( @@ -214,7 +217,10 @@ describe('resumeAiVaultSessionInTerminal', () => { agentEnv: { ANTHROPIC_BASE_URL: 'http://localhost:3000' } }, launchAgent: 'claude', - clientMutationId: 'resume-1' + clientMutationId: 'resume-1', + activate: false, + select: true, + navigation: 'caller' }, // Why: a socket drop mid-resume must reject within the request timeout // instead of parking on the reconnect waiter with the spinner pinned. diff --git a/mobile/src/session/ai-vault-resume-launch.ts b/mobile/src/session/ai-vault-resume-launch.ts index fa5e08d8492..dce63378656 100644 --- a/mobile/src/session/ai-vault-resume-launch.ts +++ b/mobile/src/session/ai-vault-resume-launch.ts @@ -201,7 +201,10 @@ export async function resumeAiVaultSessionInTerminal( ...(launch.envToDelete ? { envToDelete: launch.envToDelete } : {}), ...(launch.launchConfig ? { launchConfig: launch.launchConfig } : {}), ...(launch.launchAgent ? { launchAgent: launch.launchAgent } : {}), - ...(launch.clientMutationId ? { clientMutationId: launch.clientMutationId } : {}) + ...(launch.clientMutationId ? { clientMutationId: launch.clientMutationId } : {}), + activate: false, + select: true, + navigation: 'caller' }, { timeoutMs: RESUME_RPC_TIMEOUT_MS } ) diff --git a/mobile/src/session/mobile-session-startup-source.test.ts b/mobile/src/session/mobile-session-startup-source.test.ts index 3f29816f242..58454c1d981 100644 --- a/mobile/src/session/mobile-session-startup-source.test.ts +++ b/mobile/src/session/mobile-session-startup-source.test.ts @@ -39,6 +39,7 @@ describe('mobile session startup', () => { expect(startupEffect).toContain("if (client && created !== '1' && !isFloatingWorkspaceRoute)") expect(startupEffect).toContain("if (client && created === '1' && !isFloatingWorkspaceRoute)") expect(startupEffect).toContain('notifyClients: false') + expect(startupEffect).toContain("navigation: 'caller'") expect(startupEffect).not.toContain("await client\n .sendRequest('worktree.activate'") expect(startupEffect.indexOf("sendRequest('worktree.activate'")).toBeLessThan( startupEffect.indexOf('await fetchSessionTabs()') @@ -64,21 +65,23 @@ describe('mobile session startup', () => { expect(pendingActivationEffect).toContain('tabId: activePendingTerminalTab.id') expect(pendingActivationEffect).toContain('leafId: activePendingTerminalTab.leafId') expect(pendingActivationEffect).toContain('notifyClients: false') + expect(pendingActivationEffect).toContain("navigation: 'caller'") expect(pendingActivationEffect).toContain( 'applySessionTabs((response as RpcSuccess).result as SessionTabsResult)' ) expect(pendingActivationEffect).toContain('scheduleDelayedAction(() => void fetchSessionTabs()') }) - it('mirrors ready terminal taps while persisting them for headless hosts', () => { + it('keeps ready terminal taps local while publishing caller selection', () => { const readyTerminalSwitch = sliceBetween( 'const switchTab = useCallback(', 'const switchSessionTab = useCallback(' ) - expect(readyTerminalSwitch).toContain('focusMobileTerminal(client, handle)') + expect(readyTerminalSwitch).not.toContain('focusMobileTerminal(client, handle)') expect(readyTerminalSwitch).toContain('activateMobileSessionTab(client,') expect(readyTerminalSwitch).toContain('notifyClients: false') + expect(readyTerminalSwitch).toContain("navigation: 'caller'") }) it('keeps background and pending session-tab activation local to the phone', () => { @@ -87,6 +90,7 @@ describe('mobile session startup', () => { expect(activationRequests).toHaveLength(4) for (const request of activationRequests) { expect(request.slice(0, request.indexOf('})'))).toContain('notifyClients: false') + expect(request.slice(0, request.indexOf('})'))).toContain("navigation: 'caller'") } }) diff --git a/mobile/src/session/mobile-session-tab-activation.test.ts b/mobile/src/session/mobile-session-tab-activation.test.ts index d448d7e2f53..5e7503f24d4 100644 --- a/mobile/src/session/mobile-session-tab-activation.test.ts +++ b/mobile/src/session/mobile-session-tab-activation.test.ts @@ -25,8 +25,14 @@ describe('mobile session tab activation', () => { } ) expect(sendRequest).toHaveBeenCalledTimes(2) - expect(sendRequest).toHaveBeenNthCalledWith(1, 'terminal.focus', { terminal: 'terminal-1' }) - expect(sendRequest).toHaveBeenNthCalledWith(2, 'terminal.focus', { terminal: 'terminal-1' }) + expect(sendRequest).toHaveBeenNthCalledWith(1, 'terminal.focus', { + terminal: 'terminal-1', + navigation: 'host' + }) + expect(sendRequest).toHaveBeenNthCalledWith(2, 'terminal.focus', { + terminal: 'terminal-1', + navigation: 'host' + }) }) it('retries session-tab activation with the same target after cutover', async () => { @@ -38,7 +44,8 @@ describe('mobile session tab activation', () => { worktree: 'id:worktree-1', tabId: 'tab-1', leafId: 'leaf-1', - notifyClients: false as const + notifyClients: false as const, + navigation: 'caller' as const } await expect(activateMobileSessionTab(clientWith(sendRequest), params)).resolves.toMatchObject({ @@ -67,7 +74,8 @@ describe('mobile session tab activation', () => { activateMobileSessionTab(clientWith(sendRequest), { worktree: 'id:worktree-1', tabId: 'tab-1', - notifyClients: false + notifyClients: false, + navigation: 'caller' }) ).rejects.toBeInstanceOf(LogicalClientCutoverError) expect(sendRequest).toHaveBeenCalledTimes(2) diff --git a/mobile/src/session/mobile-session-tab-activation.ts b/mobile/src/session/mobile-session-tab-activation.ts index 4b07bff1eee..8a8bd80fc96 100644 --- a/mobile/src/session/mobile-session-tab-activation.ts +++ b/mobile/src/session/mobile-session-tab-activation.ts @@ -14,6 +14,7 @@ type MobileSessionTabActivationParams = { tabId: string leafId?: string notifyClients: false + navigation: 'caller' } async function retryIdempotentActivationAfterCutover( @@ -72,7 +73,7 @@ export function focusMobileTerminal( terminal: string ): Promise { return retryIdempotentActivationAfterCutover( - () => client.sendRequest('terminal.focus', { terminal }), + () => client.sendRequest('terminal.focus', { terminal, navigation: 'host' }), 'terminal.focus', terminal ) diff --git a/mobile/src/session/pr-ai-triage-launch.test.ts b/mobile/src/session/pr-ai-triage-launch.test.ts index 97cf15f2dce..c2273c9046e 100644 --- a/mobile/src/session/pr-ai-triage-launch.test.ts +++ b/mobile/src/session/pr-ai-triage-launch.test.ts @@ -24,7 +24,10 @@ describe('createTerminalAndSendPrompt', () => { await createTerminalAndSendPrompt(client, 'wt-1', 'do the thing') expect(client.sendRequest).toHaveBeenNthCalledWith(1, 'session.tabs.createTerminal', { - worktree: 'id:wt-1' + worktree: 'id:wt-1', + activate: false, + select: true, + navigation: 'caller' }) expect(client.sendRequest).toHaveBeenNthCalledWith(2, 'terminal.send', { terminal: 'term-1', diff --git a/mobile/src/session/pr-ai-triage-launch.ts b/mobile/src/session/pr-ai-triage-launch.ts index acf75524cfe..6d6c0bbc8b2 100644 --- a/mobile/src/session/pr-ai-triage-launch.ts +++ b/mobile/src/session/pr-ai-triage-launch.ts @@ -16,7 +16,10 @@ export async function createTerminalAndSendPrompt( prompt: string ): Promise { const created = await client.sendRequest('session.tabs.createTerminal', { - worktree: `id:${worktreeId}` + worktree: `id:${worktreeId}`, + activate: false, + select: true, + navigation: 'caller' }) if (!created.ok) { throw new Error(created.error?.message || 'Failed to create terminal') diff --git a/mobile/src/session/use-mobile-diff-review-send-actions.ts b/mobile/src/session/use-mobile-diff-review-send-actions.ts index 5300100a476..8c4f34e821d 100644 --- a/mobile/src/session/use-mobile-diff-review-send-actions.ts +++ b/mobile/src/session/use-mobile-diff-review-send-actions.ts @@ -99,7 +99,10 @@ export function useMobileDiffReviewSendActions(input: SendActionsInput) { throw new Error('Waiting for desktop...') } const response = await client.sendRequest('session.tabs.createTerminal', { - worktree: `id:${worktreeId}` + worktree: `id:${worktreeId}`, + activate: false, + select: true, + navigation: 'caller' }) if (!response.ok) { throw new Error(response.error?.message || 'Failed to create terminal') diff --git a/mobile/src/worktree/mobile-worktree-activation-source.test.ts b/mobile/src/worktree/mobile-worktree-activation-source.test.ts index d32d8c4cdaa..0b06f21f70b 100644 --- a/mobile/src/worktree/mobile-worktree-activation-source.test.ts +++ b/mobile/src/worktree/mobile-worktree-activation-source.test.ts @@ -20,5 +20,6 @@ describe('mobile worktree activation', () => { expect(openSession).toContain("sendRequest('worktree.activate'") expect(openSession).toContain('notifyClients: false') + expect(openSession).toContain("navigation: 'caller'") }) }) diff --git a/package.json b/package.json index 7984fd7c1d6..2422dcf48af 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,7 @@ "build:mac:release": "node config/scripts/verify-macos-release-env.mjs && ORCA_MAC_RELEASE=1 pnpm run build:desktop && ORCA_MAC_RELEASE=1 pnpm run build:computer-macos && ORCA_MAC_RELEASE=1 pnpm run build:notification-status-macos && pnpm run ensure:electron-runtime && ORCA_MAC_RELEASE=1 electron-builder --config config/electron-builder.config.cjs --mac", "build:linux": "pnpm run build:desktop && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --linux AppImage deb", "test:e2e": "pnpm run ensure:electron-runtime && npx playwright test --config tests/playwright.config.ts --project=electron-headless", + "test:e2e:multi-client-navigation": "node config/scripts/run-multi-client-navigation-e2e.mjs", "test:e2e:floating-mobile-emulator": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/floating-mobile-emulator-tab.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", "test:e2e:terminal-rendering-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts tests/e2e/terminal-webgl-atlas-budget.spec.ts --grep @terminal-rendering-golden --config tests/playwright.config.ts --project electron-headless --workers=1", "test:e2e:terminal-rendering-release-evidence": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-opencode-emoji-table-rendering.spec.ts tests/e2e/terminal-long-table-scroll-restore.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=2", diff --git a/src/cli/handlers/terminal.ts b/src/cli/handlers/terminal.ts index 1359f5cce13..f8c9d482858 100644 --- a/src/cli/handlers/terminal.ts +++ b/src/cli/handlers/terminal.ts @@ -45,7 +45,8 @@ const DEFAULT_TERMINAL_WAIT_RPC_TIMEOUT_MS = 5 * 60 * 1000 const terminalFocusHandler: CommandHandler = async ({ flags, client, cwd, json }) => { const result = await client.call<{ focus: RuntimeTerminalFocus }>('terminal.focus', { - terminal: await getTerminalHandle(flags, cwd, client) + terminal: await getTerminalHandle(flags, cwd, client), + navigation: 'host' }) printResult(result, json, formatTerminalFocus) } diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index d464c17af83..5804c95510e 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -191,7 +191,10 @@ describe('command aliases dispatch to the canonical handler', () => { await main(['terminal', 'focus', '--terminal', 'term_abc', '--json'], '/tmp/repo') - expect(callMock).toHaveBeenCalledWith('terminal.focus', expect.objectContaining({})) + expect(callMock).toHaveBeenCalledWith( + 'terminal.focus', + expect.objectContaining({ navigation: 'host' }) + ) }) it('serves `agent-context --json` without contacting the runtime', async () => { diff --git a/src/main/index.ts b/src/main/index.ts index 23d89720adb..6af71bd5f85 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1399,8 +1399,13 @@ function getServeOptions(argv = process.argv): ServeOptions { } function getBundledWebClientRoot(): string | undefined { - const root = join(app.getAppPath(), 'out', 'web') - return existsSync(join(root, 'web-index.html')) ? root : undefined + const appPath = app.getAppPath() + const roots = [ + join(appPath, 'out', 'web'), + // Why: unpacked electron-vite entrypoints set appPath to out/main, next to the web bundle. + join(appPath, '..', 'web') + ] + return roots.find((root) => existsSync(join(root, 'web-index.html'))) } async function renderTerminalPairingQr(pairingUrl: string): Promise { diff --git a/src/main/runtime/client-session-tab-selection.test.ts b/src/main/runtime/client-session-tab-selection.test.ts new file mode 100644 index 00000000000..c75ffd2ac78 --- /dev/null +++ b/src/main/runtime/client-session-tab-selection.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from 'vitest' +import type { RuntimeMobileSessionTabsResult } from '../../shared/runtime-types' +import { + activateClientSessionTabSelection, + ClientSessionTabSelectionStore, + deriveClientSessionTabSelection, + projectClientSessionTabSelection +} from './client-session-tab-selection' + +function snapshot(activeTabId = 'terminal-a::leaf-a'): RuntimeMobileSessionTabsResult { + const tabs = [ + { + type: 'terminal' as const, + id: 'terminal-a::leaf-a', + parentTabId: 'terminal-a', + leafId: 'leaf-a', + title: 'A', + isActive: activeTabId === 'terminal-a::leaf-a', + status: 'ready' as const, + terminal: 'term-a' + }, + { + type: 'terminal' as const, + id: 'terminal-a::leaf-b', + parentTabId: 'terminal-a', + leafId: 'leaf-b', + title: 'A split', + isActive: activeTabId === 'terminal-a::leaf-b', + status: 'ready' as const, + terminal: 'term-b' + }, + { + type: 'browser' as const, + id: 'browser-unified', + browserWorkspaceId: 'browser-workspace', + browserPageId: 'page-1', + title: 'Browser', + url: 'about:blank', + loading: false, + canGoBack: false, + canGoForward: false, + isActive: activeTabId === 'browser-unified' + } + ] + return { + worktree: 'wt-1', + publicationEpoch: 'renderer:1', + snapshotVersion: 1, + activeGroupId: 'group-left', + activeTabId, + activeTabType: activeTabId === 'browser-unified' ? 'browser' : 'terminal', + tabGroups: [ + { id: 'group-left', activeTabId: 'terminal-a', tabOrder: ['terminal-a'] }, + { id: 'group-right', activeTabId: 'browser-unified', tabOrder: ['browser-unified'] } + ], + tabs + } +} + +describe('client session-tab selection', () => { + it('keeps a client selection when a later host snapshot activates another tab', () => { + const initial = snapshot() + const selected = activateClientSessionTabSelection( + initial, + deriveClientSessionTabSelection(initial), + 'browser-unified' + ) + const hostChanged = snapshot('terminal-a::leaf-b') + + const projected = projectClientSessionTabSelection(hostChanged, selected) + + expect(projected.snapshot.activeTabId).toBe('browser-unified') + expect(projected.snapshot.activeGroupId).toBe('group-right') + expect(projected.snapshot.tabGroups?.[1]?.activeTabId).toBe('browser-unified') + }) + + it('tracks split-leaf focus while group selection uses the parent tab id', () => { + const initial = snapshot() + const selected = activateClientSessionTabSelection( + initial, + deriveClientSessionTabSelection(initial), + 'terminal-a::leaf-b' + ) + + const projected = projectClientSessionTabSelection(initial, selected) + + expect(projected.snapshot.activeTabId).toBe('terminal-a::leaf-b') + expect(projected.snapshot.tabGroups?.[0]?.activeTabId).toBe('terminal-a') + }) + + it('falls back within the selected group when the selected tab disappears', () => { + const initial = snapshot() + const selected = activateClientSessionTabSelection( + initial, + deriveClientSessionTabSelection(initial), + 'terminal-a::leaf-b' + ) + const removed = { + ...initial, + activeGroupId: 'group-left', + activeTabId: 'terminal-a::leaf-a', + activeTabType: 'terminal' as const, + tabs: initial.tabs.filter((tab) => tab.id !== 'terminal-a::leaf-b') + } + + const projected = projectClientSessionTabSelection(removed, selected) + + expect(projected.snapshot.activeGroupId).toBe('group-left') + expect(projected.snapshot.activeTabId).toBe('terminal-a::leaf-a') + }) + + it('namespaces projections by device and discards revoked state', () => { + const store = new ClientSessionTabSelectionStore() + const initial = snapshot() + + store.project(initial, 'device-a') + store.project(initial, 'device-b') + const selectedA = store.activate(initial, 'device-a', 'browser-unified') + + expect(selectedA.activeTabId).toBe('browser-unified') + expect(selectedA.publicationEpoch).toBe('renderer:1:client-navigation') + expect(selectedA.snapshotVersion).toBe(2) + expect(store.project(initial, 'device-b').activeTabId).toBe('terminal-a::leaf-a') + const selectedAgain = store.activate(initial, 'device-a', 'terminal-a::leaf-b') + expect(selectedAgain.snapshotVersion).toBe(3) + expect(selectedAgain.activeTabId).toBe('terminal-a::leaf-b') + store.forgetClient('device-a') + expect(store.project(initial, 'device-a').activeTabId).toBe('terminal-a::leaf-a') + }) + + it('does not expose host focus when initializing a new paired device', () => { + const store = new ClientSessionTabSelectionStore() + const hostFocusedBrowser = snapshot('browser-unified') + + const projected = store.project(hostFocusedBrowser, 'new-device') + + expect(projected.activeTabId).toBe('terminal-a::leaf-a') + expect(projected.activeGroupId).toBe('group-left') + expect(projected.tabs.find((tab) => tab.isActive)?.id).toBe('terminal-a::leaf-a') + }) +}) diff --git a/src/main/runtime/client-session-tab-selection.ts b/src/main/runtime/client-session-tab-selection.ts new file mode 100644 index 00000000000..d3cb60e67b7 --- /dev/null +++ b/src/main/runtime/client-session-tab-selection.ts @@ -0,0 +1,195 @@ +import type { + RuntimeMobileSessionClientTab, + RuntimeMobileSessionTabsResult +} from '../../shared/runtime-types' + +export type ClientSessionTabSelection = { + activeTabId: string | null + activeGroupId: string | null + activeTabIdByGroupId: Readonly> +} + +type StoredClientSessionTabSelection = { + selection: ClientSessionTabSelection + revision: number +} + +function emptyClientSessionTabSelection(): ClientSessionTabSelection { + return { activeTabId: null, activeGroupId: null, activeTabIdByGroupId: {} } +} + +function topLevelTabId(tab: RuntimeMobileSessionClientTab): string { + if (tab.type === 'terminal') { + return tab.parentTabId + } + return tab.id +} + +function findTabByTopLevelId( + snapshot: RuntimeMobileSessionTabsResult, + topLevelId: string | null | undefined +): RuntimeMobileSessionClientTab | null { + if (!topLevelId) { + return null + } + return snapshot.tabs.find((tab) => topLevelTabId(tab) === topLevelId) ?? null +} + +export function deriveClientSessionTabSelection( + snapshot: RuntimeMobileSessionTabsResult +): ClientSessionTabSelection { + return { + activeTabId: snapshot.activeTabId, + activeGroupId: snapshot.activeGroupId, + activeTabIdByGroupId: Object.fromEntries( + snapshot.tabGroups?.flatMap((group) => + group.activeTabId ? [[group.id, group.activeTabId] as const] : [] + ) ?? [] + ) + } +} + +export function activateClientSessionTabSelection( + snapshot: RuntimeMobileSessionTabsResult, + selection: ClientSessionTabSelection, + activeTabId: string +): ClientSessionTabSelection { + const activeTab = snapshot.tabs.find((tab) => tab.id === activeTabId) + if (!activeTab) { + return selection + } + const activeTopLevelTabId = topLevelTabId(activeTab) + const activeGroup = snapshot.tabGroups?.find((group) => + group.tabOrder.includes(activeTopLevelTabId) + ) + return { + activeTabId, + activeGroupId: activeGroup?.id ?? selection.activeGroupId, + activeTabIdByGroupId: activeGroup + ? { ...selection.activeTabIdByGroupId, [activeGroup.id]: activeTopLevelTabId } + : selection.activeTabIdByGroupId + } +} + +export function projectClientSessionTabSelection( + snapshot: RuntimeMobileSessionTabsResult, + selection: ClientSessionTabSelection +): { snapshot: RuntimeMobileSessionTabsResult; selection: ClientSessionTabSelection } { + const selectedGroup = snapshot.tabGroups?.find((group) => group.id === selection.activeGroupId) + // Why: preserve the client's leaf and group choices before falling back to shared snapshot order. + const activeTab = + snapshot.tabs.find((tab) => tab.id === selection.activeTabId) ?? + findTabByTopLevelId( + snapshot, + selectedGroup ? selection.activeTabIdByGroupId[selectedGroup.id] : null + ) ?? + findTabByTopLevelId(snapshot, selectedGroup?.tabOrder[0]) ?? + snapshot.tabs[0] ?? + null + const activeTopLevelTabId = activeTab ? topLevelTabId(activeTab) : null + const activeTabIdByGroupId: Record = {} + const tabGroups = snapshot.tabGroups?.map((group) => { + const selected = selection.activeTabIdByGroupId[group.id] + const activeTabId = + (selected && group.tabOrder.includes(selected) ? selected : null) ?? group.tabOrder[0] ?? null + if (activeTabId) { + activeTabIdByGroupId[group.id] = activeTabId + } + return { ...group, activeTabId } + }) + const activeGroupId = + tabGroups?.find((group) => + activeTopLevelTabId ? group.tabOrder.includes(activeTopLevelTabId) : false + )?.id ?? + (selection.activeGroupId && tabGroups?.some((group) => group.id === selection.activeGroupId) + ? selection.activeGroupId + : null) ?? + tabGroups?.[0]?.id ?? + null + const nextSelection: ClientSessionTabSelection = { + activeTabId: activeTab?.id ?? null, + activeGroupId, + activeTabIdByGroupId + } + return { + selection: nextSelection, + snapshot: { + ...snapshot, + activeGroupId, + activeTabId: activeTab?.id ?? null, + activeTabType: activeTab?.type ?? null, + ...(tabGroups ? { tabGroups } : {}), + tabs: snapshot.tabs.map((tab) => ({ ...tab, isActive: tab.id === activeTab?.id })) + } + } +} + +export class ClientSessionTabSelectionStore { + private statesByClient = new Map>() + + private getStatesByWorktree( + clientNavigationId: string + ): Map { + let statesByWorktree = this.statesByClient.get(clientNavigationId) + if (!statesByWorktree) { + statesByWorktree = new Map() + this.statesByClient.set(clientNavigationId, statesByWorktree) + } + return statesByWorktree + } + + project( + snapshot: RuntimeMobileSessionTabsResult, + clientNavigationId?: string + ): RuntimeMobileSessionTabsResult { + if (!clientNavigationId) { + return snapshot + } + const statesByWorktree = this.getStatesByWorktree(clientNavigationId) + const state = statesByWorktree.get(snapshot.worktree) ?? { + // Why: host focus is private navigation; a new paired device starts from deterministic topology instead of inheriting it. + selection: emptyClientSessionTabSelection(), + revision: 0 + } + const projected = projectClientSessionTabSelection(snapshot, state.selection) + statesByWorktree.set(snapshot.worktree, { + selection: projected.selection, + revision: state.revision + }) + return { + ...projected.snapshot, + publicationEpoch: `${snapshot.publicationEpoch}:client-navigation`, + snapshotVersion: snapshot.snapshotVersion + state.revision + } + } + + activate( + snapshot: RuntimeMobileSessionTabsResult, + clientNavigationId: string, + activeTabId: string + ): RuntimeMobileSessionTabsResult { + const statesByWorktree = this.getStatesByWorktree(clientNavigationId) + const state = statesByWorktree.get(snapshot.worktree) ?? { + selection: emptyClientSessionTabSelection(), + revision: 0 + } + statesByWorktree.set(snapshot.worktree, { + selection: activateClientSessionTabSelection(snapshot, state.selection, activeTabId), + revision: state.revision + 1 + }) + return this.project(snapshot, clientNavigationId) + } + + forgetClient(clientNavigationId: string): void { + this.statesByClient.delete(clientNavigationId) + } + + forgetWorktree(worktreeId: string): void { + for (const [clientNavigationId, statesByWorktree] of this.statesByClient) { + statesByWorktree.delete(worktreeId) + if (statesByWorktree.size === 0) { + this.statesByClient.delete(clientNavigationId) + } + } + } +} diff --git a/src/main/runtime/multi-client-navigation-isolation.integration.test.ts b/src/main/runtime/multi-client-navigation-isolation.integration.test.ts new file mode 100644 index 00000000000..3c918f7b4b2 --- /dev/null +++ b/src/main/runtime/multi-client-navigation-isolation.integration.test.ts @@ -0,0 +1,581 @@ +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import WebSocket from 'ws' +import { parsePairingCode } from '../../shared/pairing' +import type { RuntimeMobileSessionTabsResult } from '../../shared/runtime-types' +import { OrcaRuntimeService } from './orca-runtime' +import { decrypt, deriveSharedKey, encrypt, generateKeyPair } from './rpc/e2ee-crypto' +import { OrcaRuntimeRpcServer } from './runtime-rpc' + +const REPO_ID = 'repo-1' +const worktreeId = (name: string): string => `${REPO_ID}::/tmp/${name}` +const HOST_WORKTREE_ID = worktreeId('host') +const CLIENT_A_WORKTREE_ID = worktreeId('client-a') +const CLIENT_A2_WORKTREE_ID = worktreeId('client-a2') +const CLIENT_B_WORKTREE_ID = worktreeId('client-b') +const SESSION_WORKTREE_ID = worktreeId('session') + +vi.mock('../git/worktree', () => ({ + listWorktrees: vi.fn().mockResolvedValue( + ['host', 'client-a', 'client-a2', 'client-b', 'session'].map((name) => ({ + path: `/tmp/${name}`, + head: name, + branch: name, + isBare: false, + isMainWorktree: false + })) + ), + listWorktreesStrict: vi.fn().mockResolvedValue([]) +})) + +type PairedSession = { + ws: WebSocket + sharedKey: Uint8Array +} + +type ResponseReader = { + next: ( + id: string, + predicate?: (response: Record) => boolean + ) => Promise> + dispose: () => void +} + +function makeStore() { + const worktreeMeta = Object.fromEntries( + [ + HOST_WORKTREE_ID, + CLIENT_A_WORKTREE_ID, + CLIENT_A2_WORKTREE_ID, + CLIENT_B_WORKTREE_ID, + SESSION_WORKTREE_ID + ].map((id) => [ + id, + { + displayName: id.split('/').at(-1) ?? id, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + instanceId: id + } + ]) + ) + return { + getRepo: (id: string) => (id === REPO_ID ? makeStore().getRepos()[0] : undefined), + getRepos: () => [ + { + id: REPO_ID, + path: '/tmp/repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1 + } + ], + addRepo: () => {}, + updateRepo: () => undefined as never, + getAllWorktreeMeta: () => worktreeMeta, + getWorktreeMeta: (id: string) => worktreeMeta[id], + setWorktreeMeta: () => undefined as never, + removeWorktreeMeta: () => {}, + getSettings: () => ({ + workspaceDir: '/tmp/workspaces', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: false, + branchPrefix: 'none', + branchPrefixCustom: '' + }) + } +} + +function connect(endpoint: string): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(endpoint) + ws.once('open', () => resolve(ws)) + ws.once('error', reject) + }) +} + +function nextMessage(ws: WebSocket): Promise { + return new Promise((resolve) => { + ws.once('message', (data) => resolve(typeof data === 'string' ? data : data.toString('utf-8'))) + }) +} + +async function authenticate(pairingUrl: string): Promise { + const pairing = parsePairingCode(pairingUrl) + if (!pairing) { + throw new Error('invalid_pairing_url') + } + const ws = await connect(pairing.endpoint) + const keys = generateKeyPair() + const serverPublicKey = Uint8Array.from(Buffer.from(pairing.publicKeyB64, 'base64')) + const sharedKey = deriveSharedKey(keys.secretKey, serverPublicKey) + ws.send( + JSON.stringify({ + type: 'e2ee_hello', + publicKeyB64: Buffer.from(keys.publicKey).toString('base64') + }) + ) + expect(JSON.parse(await nextMessage(ws))).toEqual({ type: 'e2ee_ready' }) + ws.send( + encrypt(JSON.stringify({ type: 'e2ee_auth', deviceToken: pairing.deviceToken }), sharedKey) + ) + expect(JSON.parse(decrypt(await nextMessage(ws), sharedKey)!)).toEqual({ + type: 'e2ee_authenticated' + }) + return { ws, sharedKey } +} + +function send(session: PairedSession, request: Record): void { + session.ws.send(encrypt(JSON.stringify(request), session.sharedKey)) +} + +function createReader(session: PairedSession): ResponseReader { + type Waiter = { + id: string + predicate: (response: Record) => boolean + resolve: (response: Record) => void + } + const queued: Record[] = [] + const waiters: Waiter[] = [] + const onMessage = (data: WebSocket.RawData): void => { + const plaintext = decrypt( + typeof data === 'string' ? data : data.toString('utf-8'), + session.sharedKey + ) + if (!plaintext) { + return + } + const response = JSON.parse(plaintext) as Record + const waiterIndex = waiters.findIndex( + (waiter) => response.id === waiter.id && waiter.predicate(response) + ) + if (waiterIndex === -1) { + queued.push(response) + return + } + waiters.splice(waiterIndex, 1)[0]?.resolve(response) + } + session.ws.on('message', onMessage) + return { + next: (id, predicate = () => true) => { + const queuedIndex = queued.findIndex((response) => response.id === id && predicate(response)) + if (queuedIndex !== -1) { + return Promise.resolve(queued.splice(queuedIndex, 1)[0]!) + } + return new Promise((resolve) => waiters.push({ id, predicate, resolve })) + }, + dispose: () => { + session.ws.off('message', onMessage) + queued.length = 0 + waiters.length = 0 + } + } +} + +function resultType(response: Record): string | undefined { + return (response.result as { type?: string } | undefined)?.type +} + +function activeTabId(response: Record): string | null { + return (response.result as RuntimeMobileSessionTabsResult | undefined)?.activeTabId ?? null +} + +function seedSessionTabs(runtime: OrcaRuntimeService): void { + const tabs = ['host-tab', 'client-a-tab', 'client-a2-tab', 'client-b-tab'].map((id, index) => ({ + type: 'terminal' as const, + id, + parentTabId: id, + leafId: `pane:${index + 1}`, + ptyId: `pty-${index + 1}`, + title: id, + isActive: id === 'host-tab' + })) + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [], + mobileSessionTabs: [ + { + worktree: SESSION_WORKTREE_ID, + publicationEpoch: 'renderer:host', + snapshotVersion: 1, + activeGroupId: 'group-1', + activeTabId: 'host-tab', + activeTabType: 'terminal', + tabGroups: [ + { + id: 'group-1', + activeTabId: 'host-tab', + tabOrder: tabs.map((tab) => tab.parentTabId) + } + ], + tabs + } + ] + }) + for (let index = 0; index < tabs.length; index += 1) { + runtime.registerPty(`pty-${index + 1}`, SESSION_WORKTREE_ID) + } +} + +describe('paired runtime navigation isolation', () => { + const servers: OrcaRuntimeRpcServer[] = [] + const sessions: PairedSession[] = [] + const readers: ResponseReader[] = [] + + afterEach(async () => { + for (const reader of readers.splice(0)) { + reader.dispose() + } + for (const session of sessions.splice(0)) { + session.ws.close() + } + await Promise.all(servers.splice(0).map((server) => server.stop())) + }) + + async function startHarness() { + const hostSelections = { worktreeId: HOST_WORKTREE_ID, tabId: 'host-tab' } + const activateWorktree = vi.fn((_repoId: string, nextWorktreeId: string) => { + hostSelections.worktreeId = nextWorktreeId + }) + const focusTerminal = vi.fn((nextTabId: string) => { + hostSelections.tabId = nextTabId + }) + const runtime = new OrcaRuntimeService(makeStore() as never) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree, + createTerminal: vi.fn(), + revealTerminalSession: vi.fn(), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal, + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + runtime.attachWindow(1) + runtime.markGraphReady(1) + seedSessionTabs(runtime) + + const server = new OrcaRuntimeRpcServer({ + runtime, + userDataPath: mkdtempSync(join(tmpdir(), 'orca-navigation-isolation-')), + enableWebSocket: true, + wsPort: 0 + }) + servers.push(server) + await server.start() + + const offerA = server.createPairingOffer({ + address: '127.0.0.1', + name: 'client-a', + scope: 'runtime' + }) + if (!offerA.available) { + throw new Error('pairing_unavailable') + } + const clientA = await authenticate(offerA.pairingUrl) + sessions.push(clientA) + const offerB = server.createPairingOffer({ + address: '127.0.0.1', + name: 'client-b', + scope: 'runtime' + }) + if (!offerB.available) { + throw new Error('pairing_unavailable') + } + const clientB = await authenticate(offerB.pairingUrl) + sessions.push(clientB) + expect(offerA.deviceId).not.toBe(offerB.deviceId) + expect(parsePairingCode(offerA.pairingUrl)?.deviceToken).not.toBe( + parsePairingCode(offerB.pairingUrl)?.deviceToken + ) + + const readerA = createReader(clientA) + const readerB = createReader(clientB) + readers.push(readerA, readerB) + return { + runtime, + hostSelections, + activateWorktree, + focusTerminal, + clientA, + clientB, + pairingUrlA: offerA.pairingUrl, + readerA, + readerB + } + } + + it('keeps worktree navigation local to each paired runtime client by default', async () => { + const harness = await startHarness() + const clientSelections = { + a: CLIENT_A_WORKTREE_ID, + b: CLIENT_B_WORKTREE_ID + } + for (const [session, id] of [ + [harness.clientA, 'events-a'], + [harness.clientB, 'events-b'] + ] as const) { + send(session, { id, method: 'runtime.clientEvents.subscribe' }) + } + await Promise.all([ + harness.readerA.next('events-a', (response) => resultType(response) === 'ready'), + harness.readerB.next('events-b', (response) => resultType(response) === 'ready') + ]) + + clientSelections.a = CLIENT_A2_WORKTREE_ID + send(harness.clientA, { + id: 'activate-a2', + method: 'worktree.activate', + params: { + worktree: `id:${CLIENT_A2_WORKTREE_ID}`, + notifyClients: true + } + }) + await expect(harness.readerA.next('activate-a2')).resolves.toMatchObject({ ok: true }) + + harness.runtime.notifyWorktreesChangedForRemoteClients(REPO_ID) + const [nextA, nextB] = await Promise.all([ + harness.readerA.next('events-a'), + harness.readerB.next('events-b') + ]) + expect([resultType(nextA), resultType(nextB)]).toEqual(['worktreesChanged', 'worktreesChanged']) + expect(clientSelections).toEqual({ + a: CLIENT_A2_WORKTREE_ID, + b: CLIENT_B_WORKTREE_ID + }) + expect(harness.hostSelections.worktreeId).toBe(HOST_WORKTREE_ID) + expect(harness.activateWorktree).not.toHaveBeenCalled() + }) + + it('projects session-tab activation only to the paired caller across fanout and reconnect', async () => { + const harness = await startHarness() + + send(harness.clientA, { + id: 'select-a', + method: 'session.tabs.activate', + params: { + worktree: `id:${SESSION_WORKTREE_ID}`, + tabId: 'client-a-tab', + navigation: 'caller', + notifyClients: false + } + }) + send(harness.clientB, { + id: 'select-b', + method: 'session.tabs.activate', + params: { + worktree: `id:${SESSION_WORKTREE_ID}`, + tabId: 'client-b-tab', + navigation: 'caller', + notifyClients: false + } + }) + expect(activeTabId(await harness.readerA.next('select-a'))).toBe('client-a-tab') + expect(activeTabId(await harness.readerB.next('select-b'))).toBe('client-b-tab') + + for (const [session, id] of [ + [harness.clientA, 'tabs-a'], + [harness.clientB, 'tabs-b'] + ] as const) { + send(session, { + id, + method: 'session.tabs.subscribe', + params: { worktree: `id:${SESSION_WORKTREE_ID}` } + }) + } + expect(activeTabId(await harness.readerA.next('tabs-a'))).toBe('client-a-tab') + expect(activeTabId(await harness.readerB.next('tabs-b'))).toBe('client-b-tab') + + send(harness.clientA, { + id: 'select-a2', + method: 'session.tabs.activate', + params: { + worktree: `id:${SESSION_WORKTREE_ID}`, + tabId: 'client-a2-tab', + navigation: 'caller', + notifyClients: false + } + }) + expect(activeTabId(await harness.readerA.next('select-a2'))).toBe('client-a2-tab') + + harness.runtime.notifyMobileSessionTabsChanged(SESSION_WORKTREE_ID) + const [updateA, updateB] = await Promise.all([ + harness.readerA.next('tabs-a', (response) => resultType(response) === 'updated'), + harness.readerB.next('tabs-b', (response) => resultType(response) === 'updated') + ]) + expect(activeTabId(updateA)).toBe('client-a2-tab') + expect(activeTabId(updateB)).toBe('client-b-tab') + expect(harness.hostSelections.tabId).toBe('host-tab') + expect(harness.focusTerminal).not.toHaveBeenCalled() + expect( + (await harness.runtime.listMobileSessionTabs(`id:${SESSION_WORKTREE_ID}`)).activeTabId + ).toBe('host-tab') + + harness.readerA.dispose() + harness.clientA.ws.close() + const reconnectedA = await authenticate(harness.pairingUrlA) + sessions.push(reconnectedA) + const reconnectedReaderA = createReader(reconnectedA) + readers.push(reconnectedReaderA) + send(reconnectedA, { + id: 'tabs-a-reconnected', + method: 'session.tabs.list', + params: { worktree: `id:${SESSION_WORKTREE_ID}` } + }) + expect(activeTabId(await reconnectedReaderA.next('tabs-a-reconnected'))).toBe('client-a2-tab') + }) + + it('routes explicit host and paired-client follow intent without changing the default', async () => { + const harness = await startHarness() + for (const [session, id] of [ + [harness.clientA, 'events-a'], + [harness.clientB, 'events-b'] + ] as const) { + send(session, { id, method: 'runtime.clientEvents.subscribe' }) + } + await Promise.all([ + harness.readerA.next('events-a', (response) => resultType(response) === 'ready'), + harness.readerB.next('events-b', (response) => resultType(response) === 'ready') + ]) + + send(harness.clientA, { + id: 'host-follow', + method: 'worktree.activate', + params: { worktree: `id:${CLIENT_A_WORKTREE_ID}`, navigation: 'host' } + }) + await harness.readerA.next('host-follow') + harness.runtime.notifyWorktreesChangedForRemoteClients(REPO_ID) + expect(resultType(await harness.readerA.next('events-a'))).toBe('worktreesChanged') + expect(resultType(await harness.readerB.next('events-b'))).toBe('worktreesChanged') + expect(harness.hostSelections.worktreeId).toBe(CLIENT_A_WORKTREE_ID) + + send(harness.clientB, { + id: 'clients-follow', + method: 'worktree.activate', + params: { worktree: `id:${CLIENT_B_WORKTREE_ID}`, navigation: 'clients' } + }) + await harness.readerB.next('clients-follow') + const [eventA, eventB] = await Promise.all([ + harness.readerA.next('events-a'), + harness.readerB.next('events-b') + ]) + expect([resultType(eventA), resultType(eventB)]).toEqual([ + 'activateWorktree', + 'activateWorktree' + ]) + expect(harness.hostSelections.worktreeId).toBe(CLIENT_A_WORKTREE_ID) + + for (const [session, id] of [ + [harness.clientA, 'tabs-a'], + [harness.clientB, 'tabs-b'] + ] as const) { + send(session, { + id, + method: 'session.tabs.subscribe', + params: { worktree: `id:${SESSION_WORKTREE_ID}` } + }) + } + await Promise.all([harness.readerA.next('tabs-a'), harness.readerB.next('tabs-b')]) + send(harness.clientA, { + id: 'tabs-host-follow', + method: 'session.tabs.activate', + params: { + worktree: `id:${SESSION_WORKTREE_ID}`, + tabId: 'client-a-tab', + navigation: 'host' + } + }) + expect(activeTabId(await harness.readerA.next('tabs-host-follow'))).toBe('client-a-tab') + expect( + activeTabId( + await harness.readerA.next('tabs-a', (response) => resultType(response) === 'updated') + ) + ).toBe('client-a-tab') + expect(harness.hostSelections.tabId).toBe('client-a-tab') + + send(harness.clientB, { + id: 'tabs-clients-follow', + method: 'session.tabs.activate', + params: { + worktree: `id:${SESSION_WORKTREE_ID}`, + tabId: 'client-b-tab', + navigation: 'clients' + } + }) + await harness.readerB.next('tabs-clients-follow') + const [tabsA, tabsB] = await Promise.all([ + harness.readerA.next('tabs-a', (response) => resultType(response) === 'updated'), + harness.readerB.next('tabs-b', (response) => resultType(response) === 'updated') + ]) + expect([activeTabId(tabsA), activeTabId(tabsB)]).toEqual(['client-b-tab', 'client-b-tab']) + expect( + [tabsA, tabsB].map( + (response) => + (response.result as RuntimeMobileSessionTabsResult | undefined)?.navigationIntent + ) + ).toEqual(['follow', 'follow']) + expect(harness.hostSelections.tabId).toBe('client-a-tab') + }) + + it('isolates crossed clients across two runtime servers', async () => { + const serverOne = await startHarness() + const serverTwo = await startHarness() + for (const [session, id] of [ + [serverTwo.clientA, 'server-two-events-a'], + [serverTwo.clientB, 'server-two-events-b'] + ] as const) { + send(session, { id, method: 'runtime.clientEvents.subscribe' }) + } + await Promise.all([ + serverTwo.readerA.next('server-two-events-a', (response) => resultType(response) === 'ready'), + serverTwo.readerB.next('server-two-events-b', (response) => resultType(response) === 'ready') + ]) + + send(serverOne.clientA, { + id: 'server-one-worktree', + method: 'worktree.activate', + params: { worktree: `id:${CLIENT_A2_WORKTREE_ID}`, notifyClients: true } + }) + await serverOne.readerA.next('server-one-worktree') + serverTwo.runtime.notifyWorktreesChangedForRemoteClients(REPO_ID) + expect(resultType(await serverTwo.readerA.next('server-two-events-a'))).toBe('worktreesChanged') + expect(resultType(await serverTwo.readerB.next('server-two-events-b'))).toBe('worktreesChanged') + expect(serverOne.hostSelections.worktreeId).toBe(HOST_WORKTREE_ID) + expect(serverTwo.hostSelections.worktreeId).toBe(HOST_WORKTREE_ID) + + for (const [session, reader, requestId, tabId] of [ + [serverOne.clientA, serverOne.readerA, 's1-a', 'client-a2-tab'], + [serverOne.clientB, serverOne.readerB, 's1-b', 'client-b-tab'], + [serverTwo.clientA, serverTwo.readerA, 's2-a', 'client-a-tab'], + [serverTwo.clientB, serverTwo.readerB, 's2-b', 'host-tab'] + ] as const) { + send(session, { + id: requestId, + method: 'session.tabs.activate', + params: { + worktree: `id:${SESSION_WORKTREE_ID}`, + tabId, + navigation: 'caller', + notifyClients: false + } + }) + expect(activeTabId(await reader.next(requestId))).toBe(tabId) + } + + expect(serverOne.hostSelections.tabId).toBe('host-tab') + expect(serverTwo.hostSelections.tabId).toBe('host-tab') + }) +}) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index cda6f380a4a..da5644ec0ad 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -18999,6 +18999,62 @@ describe('OrcaRuntimeService', () => { ]) }) + it('selects a created terminal only for the paired caller', async () => { + let spawnIndex = 0 + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn(async () => ({ id: `pty-headless-${++spawnIndex}` })), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + const hostTerminal = await runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`) + + const callerTerminal = await runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { + clientNavigationId: 'device-a', + navigation: 'caller' + }) + + expect((await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).activeTabId).toBe( + hostTerminal.tab.id + ) + expect( + (await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`, 'device-a')).activeTabId + ).toBe(callerTerminal.tab.id) + expect( + (await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`, 'device-b')).activeTabId + ).toBe(hostTerminal.tab.id) + }) + + it('scopes terminal-create idempotency to the paired caller', async () => { + let spawnIndex = 0 + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn(async () => ({ id: `pty-headless-${++spawnIndex}` })), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + + const [createdA, createdB] = await Promise.all([ + runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { + clientNavigationId: 'device-a', + navigation: 'caller', + clientMutationId: 'same-mutation' + }), + runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { + clientNavigationId: 'device-b', + navigation: 'caller', + clientMutationId: 'same-mutation' + }) + ]) + + expect(createdA.tab.id).not.toBe(createdB.tab.id) + expect(spawnIndex).toBe(2) + }) + it('creates mobile session terminals for folder workspaces in a headless runtime server', async () => { const folderPath = await mkdtemp(join(tmpdir(), 'orca-mobile-folder-workspace-')) const spawn = vi.fn().mockResolvedValue({ id: 'pty-mobile-folder' }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 4a6bcefaa8e..6c6cc553610 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -155,6 +155,11 @@ import { import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume' import type { RuntimeClientEvent } from '../../shared/runtime-client-events' import { toRuntimeActivateWorktreeEvent } from '../../shared/runtime-client-events' +import { + navigationTargetsClients, + navigationTargetsHost, + type RuntimeNavigationTarget +} from '../../shared/runtime-navigation' import type { SshConnectionState } from '../../shared/ssh-types' import { closeTerminalTabInWorkspaceSession } from '../../shared/workspace-session-terminal-tab-close' import type { @@ -371,6 +376,12 @@ import { RuntimeEmulatorCommands, setEmulatorBridge } from './orca-runtime-emula import type { EmulatorBridge } from '../emulator/emulator-bridge' import { RuntimeFileCommands } from './orca-runtime-files' import { RuntimeGitCommands } from './orca-runtime-git' +import { + activateClientSessionTabSelection, + ClientSessionTabSelectionStore, + deriveClientSessionTabSelection, + projectClientSessionTabSelection +} from './client-session-tab-selection' import type { PtyProviderBufferSnapshot } from '../providers/types' import { ClaudeAgentTeamsService } from './claude-agent-teams-service' import type { @@ -2254,6 +2265,7 @@ export class OrcaRuntimeService { private authoritativeWindowId: number | null = null private tabs = new Map() private mobileSessionTabsByWorktree = new Map() + private clientSessionTabSelections = new ClientSessionTabSelectionStore() // Why: idempotency map for mobile terminal creation — a retried create with the // same clientMutationId returns the in-flight operation instead of duplicating. private mobileTerminalCreateByMutationId = new Map< @@ -2277,7 +2289,10 @@ export class OrcaRuntimeService { viewMode?: 'terminal' | 'chat' } >() - private mobileSessionTabListeners = new Set<(snapshot: RuntimeMobileSessionTabsResult) => void>() + private mobileSessionTabListeners = new Set<{ + listener: (snapshot: RuntimeMobileSessionTabsResult) => void + clientNavigationId?: string + }>() // Why: coalesces title/status-driven session.tabs emits so spinner churn // doesn't fan out (and per-client JSON.stringify) a snapshot several times a // second. Emit reads the latest snapshot, so only the freshest version ships. @@ -3302,8 +3317,28 @@ export class OrcaRuntimeService { setup?: CreateWorktreeResult['setup'], startup?: WorktreeStartupLaunch, defaultTabs?: CreateWorktreeResult['defaultTabs'] + ): void { + this.notifyHostActivateWorktree(repoId, worktreeId, setup, startup, defaultTabs) + this.notifyClientsActivateWorktree(repoId, worktreeId, setup, startup, defaultTabs) + } + + private notifyHostActivateWorktree( + repoId: string, + worktreeId: string, + setup?: CreateWorktreeResult['setup'], + startup?: WorktreeStartupLaunch, + defaultTabs?: CreateWorktreeResult['defaultTabs'] ): void { this.notifier?.activateWorktree(repoId, worktreeId, setup, startup, defaultTabs) + } + + private notifyClientsActivateWorktree( + repoId: string, + worktreeId: string, + setup?: CreateWorktreeResult['setup'], + startup?: WorktreeStartupLaunch, + defaultTabs?: CreateWorktreeResult['defaultTabs'] + ): void { this.emitClientEvent( toRuntimeActivateWorktreeEvent(repoId, worktreeId, setup, startup, defaultTabs) ) @@ -3558,24 +3593,32 @@ export class OrcaRuntimeService { } } - async listMobileSessionTabs(worktreeSelector: string): Promise { + async listMobileSessionTabs( + worktreeSelector: string, + clientNavigationId?: string + ): Promise { const explicitWorktreeId = this.getValidatedExplicitWorktreeIdSelector(worktreeSelector) if (explicitWorktreeId) { this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(explicitWorktreeId) await this.refreshMobileSessionPtyRecords(explicitWorktreeId) - return this.getMobileSessionTabsForWorktree(explicitWorktreeId) + return this.getMobileSessionTabsForWorktree(explicitWorktreeId, clientNavigationId) } const worktree = await this.resolveWorktreeSelector(worktreeSelector) this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktree.id) await this.refreshMobileSessionPtyRecords() - return this.getMobileSessionTabsForWorktree(worktree.id) + return this.getMobileSessionTabsForWorktree(worktree.id, clientNavigationId) } - async listAllMobileSessionTabs(): Promise { + async listAllMobileSessionTabs( + clientNavigationId?: string + ): Promise { this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession() await this.refreshMobileSessionPtyRecords() return [...this.mobileSessionTabsByWorktree.values()].map((snapshot) => - this.toMobileSessionTabsResult(snapshot) + this.clientSessionTabSelections.project( + this.toMobileSessionTabsResult(snapshot), + clientNavigationId + ) ) } @@ -4598,8 +4641,10 @@ export class OrcaRuntimeService { return } const result = this.toMobileSessionTabsResult(snapshot) - for (const listener of this.mobileSessionTabListeners) { - listener(result) + for (const subscription of this.mobileSessionTabListeners) { + subscription.listener( + this.clientSessionTabSelections.project(result, subscription.clientNavigationId) + ) } } @@ -4622,8 +4667,14 @@ export class OrcaRuntimeService { worktreeSelector: string, tabId: string, leafId?: string, - opts: { notifyClients?: boolean } = {} + opts: { + notifyClients?: boolean + clientNavigationId?: string + navigation?: RuntimeNavigationTarget + } = {} ): Promise { + const navigation = opts.navigation ?? (opts.notifyClients === false ? 'caller' : 'all') + const targetsHost = navigationTargetsHost(navigation) const explicitWorktreeId = this.getValidatedExplicitWorktreeIdSelector(worktreeSelector) const worktreeId = explicitWorktreeId ?? (await this.resolveWorktreeSelector(worktreeSelector)).id @@ -4661,7 +4712,7 @@ export class OrcaRuntimeService { const shouldMaterializePendingTerminal = publicTab?.type === 'terminal' && publicTab.status !== 'ready' && - (opts.notifyClients === false || + (!targetsHost || !this.notifier?.focusTerminal || this.shouldMaterializeHeadlessMobileSessionTab(snapshot!, tab)) if (shouldMaterializePendingTerminal) { @@ -4689,7 +4740,7 @@ export class OrcaRuntimeService { } } try { - await this.createHeadlessMobileSessionTerminal(worktreeId, true, undefined, { + await this.createHeadlessMobileSessionTerminal(worktreeId, targetsHost, undefined, { identity: { tabId: tab.parentTabId, leafId: tab.leafId, @@ -4711,82 +4762,107 @@ export class OrcaRuntimeService { } throw err } - return this.getMobileSessionTabsForWorktree(worktreeId) + return this.applyMobileSessionTabNavigation( + this.getMobileSessionTabsForWorktree(worktreeId), + tab.id, + navigation, + opts.clientNavigationId + ) } + const callerSnapshot = this.getMobileSessionTabsForWorktree( + worktreeId, + opts.clientNavigationId + ) const activeSibling = tab.id === tabId || leafId ? null - : snapshot?.tabs.find( - (candidate): candidate is RuntimeMobileSessionTerminalTab => + : (callerSnapshot.tabs.find( + (candidate) => candidate.type === 'terminal' && candidate.parentTabId === tab.parentTabId && candidate.isActive - ) + ) as RuntimeMobileSessionTerminalTab | undefined) const targetTab = activeSibling ?? tab - if (opts.notifyClients === false) { - this.activateMobileSessionTabForRemoteClient(worktreeId, snapshot!, targetTab) - return this.getMobileSessionTabsForWorktree(worktreeId) - } - if (!this.notifier?.focusTerminal) { + if (targetsHost && !this.notifier?.focusTerminal) { if ( !targetTab.isActive && this.shouldPersistHeadlessMobileSessionActivation(snapshot!, targetTab) ) { this.activateHeadlessMobileSessionTerminalTab(worktreeId, snapshot!, targetTab) } - return this.getMobileSessionTabsForWorktree(worktreeId) + } else if (targetsHost) { + this.notifier?.focusTerminal?.(targetTab.parentTabId, worktreeId, targetTab.leafId) } - this.notifier?.focusTerminal(targetTab.parentTabId, worktreeId, targetTab.leafId) + return this.applyMobileSessionTabNavigation( + this.getMobileSessionTabsForWorktree(worktreeId), + targetTab.id, + navigation, + opts.clientNavigationId + ) } else if (tab.type === 'browser') { - if (opts.notifyClients === false) { - this.activateMobileSessionTabForRemoteClient(worktreeId, snapshot!, tab) - return this.getMobileSessionTabsForWorktree(worktreeId) - } // Why: browser mobile tabs are renderer-owned unified tabs; focusing the // session tab keeps desktop tab order/group state authoritative. - this.notifier?.focusEditorTab?.(tab.id, worktreeId) + if (targetsHost) { + this.notifier?.focusEditorTab?.(tab.id, worktreeId) + } } else { - if (opts.notifyClients === false) { - this.activateMobileSessionTabForRemoteClient(worktreeId, snapshot!, tab) - return this.getMobileSessionTabsForWorktree(worktreeId) + if (targetsHost) { + this.notifier?.focusEditorTab?.(tab.id, worktreeId) } - this.notifier?.focusEditorTab?.(tab.id, worktreeId) } - return this.getMobileSessionTabsForWorktree(worktreeId) + return this.applyMobileSessionTabNavigation( + this.getMobileSessionTabsForWorktree(worktreeId), + tab.id, + navigation, + opts.clientNavigationId + ) } - private activateMobileSessionTabForRemoteClient( - worktreeId: string, - snapshot: RuntimeMobileSessionTabsSnapshot, - activeTab: RuntimeMobileSessionSnapshotTab - ): void { - // Why: phone tab selection should update the mobile snapshot without - // asking desktop renderers to focus the phone's background worktree. - const activeTopLevelId = activeTab.type === 'terminal' ? activeTab.parentTabId : activeTab.id - const tabs = snapshot.tabs.map((tab) => ({ - ...tab, - isActive: tab.id === activeTab.id - })) - const tabGroups = snapshot.tabGroups?.map((group) => - group.tabOrder.includes(activeTopLevelId) - ? { ...group, activeTabId: activeTopLevelId } - : group - ) - const activeGroupId = - tabGroups?.find((group) => group.tabOrder.includes(activeTopLevelId))?.id ?? - snapshot.activeGroupId - const nextSnapshot: RuntimeMobileSessionTabsSnapshot = { - ...snapshot, - publicationEpoch: `mobile-local:${Date.now().toString(36)}`, - snapshotVersion: snapshot.snapshotVersion + 1, - activeGroupId, - activeTabId: activeTab.id, - activeTabType: activeTab.type, - ...(tabGroups ? { tabGroups } : {}), - tabs + private applyMobileSessionTabNavigation( + snapshot: RuntimeMobileSessionTabsResult, + activeTabId: string, + navigation: RuntimeNavigationTarget, + clientNavigationId?: string + ): RuntimeMobileSessionTabsResult { + let callerSnapshot: RuntimeMobileSessionTabsResult | null = null + if (navigationTargetsClients(navigation)) { + // Why: follow is live intent; disconnected devices must not inherit stale navigation on reconnect. + const ids = new Set( + [...this.mobileSessionTabListeners] + .map((subscription) => subscription.clientNavigationId) + .filter((id): id is string => Boolean(id)) + ) + if (clientNavigationId) { + ids.add(clientNavigationId) + } + for (const id of ids) { + const projected = this.clientSessionTabSelections.activate(snapshot, id, activeTabId) + this.emitMobileSessionTabsSnapshotToClient(projected, id, true) + if (id === clientNavigationId) { + callerSnapshot = projected + } + } + } else if (clientNavigationId) { + // Why: follow-host still starts as caller navigation; the host is an additional target, not a replacement owner. + callerSnapshot = this.clientSessionTabSelections.activate( + snapshot, + clientNavigationId, + activeTabId + ) + this.emitMobileSessionTabsSnapshotToClient(callerSnapshot, clientNavigationId) } - this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot) - this.emitMobileSessionTabsSnapshot(nextSnapshot) + if (clientNavigationId) { + return callerSnapshot ?? this.clientSessionTabSelections.project(snapshot, clientNavigationId) + } + if (navigation === 'caller') { + const selection = activateClientSessionTabSelection( + snapshot, + deriveClientSessionTabSelection(snapshot), + activeTabId + ) + return projectClientSessionTabSelection(snapshot, selection).snapshot + } + return snapshot } private shouldMaterializeHeadlessMobileSessionTab( @@ -5990,17 +6066,23 @@ export class OrcaRuntimeService { } onMobileSessionTabsChanged( - listener: (snapshot: RuntimeMobileSessionTabsResult) => void + listener: (snapshot: RuntimeMobileSessionTabsResult) => void, + clientNavigationId?: string ): () => void { - this.mobileSessionTabListeners.add(listener) + const subscription = { listener, clientNavigationId } + this.mobileSessionTabListeners.add(subscription) return () => { // Why: flush pending coalesced notifies before dropping this listener so a // subscriber closing mid-window still receives the latest settled state. this.mobileSessionTabsNotifyCoalescer.flushAll() - this.mobileSessionTabListeners.delete(listener) + this.mobileSessionTabListeners.delete(subscription) } } + forgetClientNavigationState(clientNavigationId: string): void { + this.clientSessionTabSelections.forgetClient(clientNavigationId) + } + // Why: terminal handles are normally created lazily when first referenced via // RPC, but agents need their own handle at spawn time (via ORCA_TERMINAL_HANDLE // env var) so they can self-identify in orchestration messages without an @@ -15555,7 +15637,11 @@ export class OrcaRuntimeService { async activateManagedWorktree( worktreeSelector: string, - opts: { notifyClients?: boolean; clientKind?: 'mobile' | 'runtime' } = {} + opts: { + notifyClients?: boolean + clientKind?: 'mobile' | 'runtime' + navigation?: RuntimeNavigationTarget + } = {} ): Promise<{ repoId: string worktreeId: string @@ -15571,8 +15657,11 @@ export class OrcaRuntimeService { if (!repo) { throw new Error('repo_not_found') } + const navigation = opts.navigation ?? (opts.notifyClients === false ? 'caller' : 'all') + const targetsHost = navigationTargetsHost(navigation) + const targetsClients = navigationTargetsClients(navigation) - if (opts.notifyClients === false && this.store?.getWorktreeMeta(worktree.id)?.isUnread) { + if (!targetsHost && this.store?.getWorktreeMeta(worktree.id)?.isUnread) { // Why: mobile/web session activation intentionally bypasses renderer // selection, so the runtime must acknowledge the unread state itself. this.store.setWorktreeMeta(worktree.id, { isUnread: false }) @@ -15581,11 +15670,17 @@ export class OrcaRuntimeService { let sleepingAgentWake: 'requested' | 'unsupported-headless' | 'not-applicable' = 'not-applicable' - if (opts.notifyClients !== false) { + if (targetsHost || targetsClients) { // Why: inactive worktree terminal panes are renderer-owned and may not have // live PTYs until the desktop activates the worktree and mounts them. - this.notifyActivateWorktree(repo.id, worktree.id) - } else { + if (targetsHost) { + this.notifyHostActivateWorktree(repo.id, worktree.id) + } + if (targetsClients) { + this.notifyClientsActivateWorktree(repo.id, worktree.id) + } + } + if (!targetsHost) { // Why: mobile/web selection needs fresh session surfaces without forcing // every attached desktop renderer to navigate to the phone's workspace. this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktree.id, { @@ -19463,35 +19558,58 @@ export class OrcaRuntimeService { launchAgent?: TuiAgent viewMode?: 'terminal' | 'chat' activate?: boolean + select?: boolean + clientNavigationId?: string + navigation?: RuntimeNavigationTarget clientMutationId?: string signal?: AbortSignal } = {} ): Promise { + const navigation = opts.navigation ?? 'all' + const select = opts.select ?? opts.activate !== false + const runOpts = { + ...opts, + activate: select && navigationTargetsHost(navigation) + } const mutationId = opts.clientMutationId + let result: RuntimeMobileSessionCreateTerminalResult if (!mutationId) { - return this.runCreateMobileSessionTerminal(worktreeSelector, opts) - } - const mutationKey = `${worktreeSelector}\0${mutationId}` - // Why: a retried create (double-tap, reconnect replay) with the same - // idempotency key must return the in-flight operation instead of spawning a - // duplicate terminal. Successes are kept briefly so a retry whose response - // was lost in transit reuses the created terminal; failures are dropped - // immediately so a retry can start a fresh create. - const inflight = this.mobileTerminalCreateByMutationId.get(mutationKey) - if (inflight) { - return inflight - } - const run = this.runCreateMobileSessionTerminal(worktreeSelector, opts) - this.mobileTerminalCreateByMutationId.set(mutationKey, run) - const drop = (): void => { - if (this.mobileTerminalCreateByMutationId.get(mutationKey) === run) { - this.mobileTerminalCreateByMutationId.delete(mutationKey) + result = await this.runCreateMobileSessionTerminal(worktreeSelector, runOpts) + } else { + // Why: idempotency is caller-owned; two paired devices may reuse the same mutation id without sharing a result. + const mutationKey = `${opts.clientNavigationId ?? 'local'}\0${worktreeSelector}\0${mutationId}` + // Why: a retried create (double-tap, reconnect replay) with the same + // idempotency key must return the in-flight operation instead of spawning a + // duplicate terminal. Successes are kept briefly so a retry whose response + // was lost in transit reuses the created terminal; failures are dropped + // immediately so a retry can start a fresh create. + const inflight = this.mobileTerminalCreateByMutationId.get(mutationKey) + const run = inflight ?? this.runCreateMobileSessionTerminal(worktreeSelector, runOpts) + if (!inflight) { + this.mobileTerminalCreateByMutationId.set(mutationKey, run) + const drop = (): void => { + if (this.mobileTerminalCreateByMutationId.get(mutationKey) === run) { + this.mobileTerminalCreateByMutationId.delete(mutationKey) + } + } + void run.then(() => { + setTimeout(drop, MOBILE_TERMINAL_CREATE_RESULT_TTL_MS).unref?.() + }, drop) } + result = await run } - void run.then(() => { - setTimeout(drop, MOBILE_TERMINAL_CREATE_RESULT_TTL_MS).unref?.() - }, drop) - return run + if (select) { + const worktreeId = + this.getValidatedExplicitWorktreeIdSelector(worktreeSelector) ?? + (await this.resolveWorktreeSelector(worktreeSelector)).id + this.applyMobileSessionTabNavigation( + this.getMobileSessionTabsForWorktree(worktreeId), + result.tab.id, + navigation, + opts.clientNavigationId + ) + } + return result } private async runCreateMobileSessionTerminal( @@ -19888,8 +20006,10 @@ export class OrcaRuntimeService { } this.mobileSessionTabsByWorktree.set(worktreeId, next) const result = this.toMobileSessionTabsResult(next) - for (const listener of this.mobileSessionTabListeners) { - listener(result) + for (const subscription of this.mobileSessionTabListeners) { + subscription.listener( + this.clientSessionTabSelections.project(result, subscription.clientNavigationId) + ) } const created = result.tabs.find((candidate) => candidate.id === tab.id) if (!created || created.type !== 'terminal') { @@ -20241,24 +20361,30 @@ export class OrcaRuntimeService { return null } - async focusTerminal(handle: string): Promise { + async focusTerminal( + handle: string, + options: { navigateHost?: boolean } = {} + ): Promise { const pty = this.getLivePtyForHandle(handle) if (pty) { if (!pty.pty.connected) { throw new Error('terminal_exited') } const parsedPaneKey = parsePaneKey(pty.pty.paneKey ?? '') - const revealed = await this.notifier?.revealTerminalSession?.(pty.pty.worktreeId, { - ptyId: pty.pty.ptyId, - title: getLatestPtyTitle(pty.pty), - ...(pty.pty.launchConfig - ? { launchConfig: copySleepingAgentLaunchConfig(pty.pty.launchConfig) } - : {}), - ...(pty.pty.launchToken ? { launchToken: pty.pty.launchToken } : {}), - ...(pty.pty.launchAgent ? { launchAgent: pty.pty.launchAgent } : {}), - ...(pty.pty.tabId !== null ? { tabId: pty.pty.tabId } : {}), - ...(parsedPaneKey ? { leafId: parsedPaneKey.leafId } : {}) - }) + const revealed = + options.navigateHost === false + ? undefined + : await this.notifier?.revealTerminalSession?.(pty.pty.worktreeId, { + ptyId: pty.pty.ptyId, + title: getLatestPtyTitle(pty.pty), + ...(pty.pty.launchConfig + ? { launchConfig: copySleepingAgentLaunchConfig(pty.pty.launchConfig) } + : {}), + ...(pty.pty.launchToken ? { launchToken: pty.pty.launchToken } : {}), + ...(pty.pty.launchAgent ? { launchAgent: pty.pty.launchAgent } : {}), + ...(pty.pty.tabId !== null ? { tabId: pty.pty.tabId } : {}), + ...(parsedPaneKey ? { leafId: parsedPaneKey.leafId } : {}) + }) return { handle, tabId: revealed?.tabId ?? pty.pty.tabId ?? pty.record.tabId, @@ -20267,7 +20393,9 @@ export class OrcaRuntimeService { } this.assertGraphReady() const { leaf } = this.getLiveLeafForHandle(handle) - this.notifier?.focusTerminal(leaf.tabId, leaf.worktreeId, leaf.leafId) + if (options.navigateHost !== false) { + this.notifier?.focusTerminal(leaf.tabId, leaf.worktreeId, leaf.leafId) + } return { handle, tabId: leaf.tabId, worktreeId: leaf.worktreeId } } @@ -22463,9 +22591,12 @@ export class OrcaRuntimeService { activeTabType: null, tabs: [] } - for (const listener of this.mobileSessionTabListeners) { - listener(removed) + for (const subscription of this.mobileSessionTabListeners) { + subscription.listener( + this.clientSessionTabSelections.project(removed, subscription.clientNavigationId) + ) } + this.clientSessionTabSelections.forgetWorktree(worktreeId) } notifyMobileSessionTabsChanged(worktreeId?: string): void { @@ -22498,8 +22629,10 @@ export class OrcaRuntimeService { } // Why: browser bridge events are already worktree-scoped; don't fan out every workspace snapshot during navigation/tab churn. const result = this.toMobileSessionTabsResult(snapshot) - for (const listener of this.mobileSessionTabListeners) { - listener(result) + for (const subscription of this.mobileSessionTabListeners) { + subscription.listener( + this.clientSessionTabSelections.project(result, subscription.clientNavigationId) + ) } } @@ -22509,26 +22642,49 @@ export class OrcaRuntimeService { } for (const snapshot of this.mobileSessionTabsByWorktree.values()) { const result = this.toMobileSessionTabsResult(snapshot) - for (const listener of this.mobileSessionTabListeners) { - listener(result) + for (const subscription of this.mobileSessionTabListeners) { + subscription.listener( + this.clientSessionTabSelections.project(result, subscription.clientNavigationId) + ) } } } - private getMobileSessionTabsForWorktree(worktreeId: string): RuntimeMobileSessionTabsResult { + private getMobileSessionTabsForWorktree( + worktreeId: string, + clientNavigationId?: string + ): RuntimeMobileSessionTabsResult { const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) if (!snapshot) { - return { - worktree: worktreeId, - publicationEpoch: 'none', - snapshotVersion: 0, - activeGroupId: null, - activeTabId: null, - activeTabType: null, - tabs: [] + return this.clientSessionTabSelections.project( + { + worktree: worktreeId, + publicationEpoch: 'none', + snapshotVersion: 0, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + }, + clientNavigationId + ) + } + return this.clientSessionTabSelections.project( + this.toMobileSessionTabsResult(snapshot), + clientNavigationId + ) + } + + private emitMobileSessionTabsSnapshotToClient( + projected: RuntimeMobileSessionTabsResult, + clientNavigationId: string, + follow = false + ): void { + for (const subscription of this.mobileSessionTabListeners) { + if (subscription.clientNavigationId === clientNavigationId) { + subscription.listener(follow ? { ...projected, navigationIntent: 'follow' } : projected) } } - return this.toMobileSessionTabsResult(snapshot) } private async resolveMobileMarkdownWorktreeId( diff --git a/src/main/runtime/rpc/core.ts b/src/main/runtime/rpc/core.ts index 3a01789f157..061ff895a4a 100644 --- a/src/main/runtime/rpc/core.ts +++ b/src/main/runtime/rpc/core.ts @@ -56,6 +56,8 @@ export type RpcContext = { requestId?: string // Why: paired mobile device token; state-owning handlers use it to clean up when that device disconnects. clientId?: string + // Why: navigation is keyed by revocable device identity, never by the bearer credential or transient socket id. + pairedDeviceId?: string // Why: lets handlers gate mobile payload truncation to phones only; undefined for in-process callers → treat as full-class (no clip). clientKind?: 'mobile' | 'runtime' pairing?: PairingRpcContext diff --git a/src/main/runtime/rpc/dispatcher.ts b/src/main/runtime/rpc/dispatcher.ts index bf7e26fb461..5b677088074 100644 --- a/src/main/runtime/rpc/dispatcher.ts +++ b/src/main/runtime/rpc/dispatcher.ts @@ -102,6 +102,7 @@ export class RpcDispatcher { connectionId?: string signal?: AbortSignal clientId?: string + pairedDeviceId?: string clientKind?: 'mobile' | 'runtime' pairing?: PairingRpcContext sendBinary?: (bytes: Uint8Array) => boolean | void @@ -136,6 +137,7 @@ export class RpcDispatcher { requestId: request.id, connectionId: options?.connectionId, clientId: options?.clientId, + pairedDeviceId: options?.pairedDeviceId, clientKind: options?.clientKind, pairing: options?.pairing, sendBinary: options?.sendBinary, @@ -171,7 +173,9 @@ export class RpcDispatcher { requestId: request.id, connectionId: options?.connectionId, clientId: options?.clientId, + pairedDeviceId: options?.pairedDeviceId, clientKind: options?.clientKind, + pairing: options?.pairing, sendBinary: options?.sendBinary, registerBinaryStreamHandler: options?.registerBinaryStreamHandler }, diff --git a/src/main/runtime/rpc/methods/session-tabs-schemas.test.ts b/src/main/runtime/rpc/methods/session-tabs-schemas.test.ts index 2af6206f0e1..0df074cbce3 100644 --- a/src/main/runtime/rpc/methods/session-tabs-schemas.test.ts +++ b/src/main/runtime/rpc/methods/session-tabs-schemas.test.ts @@ -1,8 +1,19 @@ import { describe, expect, it } from 'vitest' -import { UpdatePaneLayout } from './session-tabs-schemas' +import { ActivateTab, UpdatePaneLayout } from './session-tabs-schemas' const WT = 'id:wt' +describe('ActivateTab.navigation', () => { + it('accepts declared targets and rejects unknown fanout', () => { + expect(ActivateTab.parse({ worktree: WT, tabId: 'tab', navigation: 'all' }).navigation).toBe( + 'all' + ) + expect( + ActivateTab.safeParse({ worktree: WT, tabId: 'tab', navigation: 'others' }).success + ).toBe(false) + }) +}) + describe('UpdatePaneLayout.root (untrusted remote pane-layout tree)', () => { it('accepts a valid split tree', () => { const parsed = UpdatePaneLayout.parse({ diff --git a/src/main/runtime/rpc/methods/session-tabs-schemas.ts b/src/main/runtime/rpc/methods/session-tabs-schemas.ts index 7f984f29b74..3d9f678695b 100644 --- a/src/main/runtime/rpc/methods/session-tabs-schemas.ts +++ b/src/main/runtime/rpc/methods/session-tabs-schemas.ts @@ -3,6 +3,7 @@ import { MAX_QUICK_COMMAND_AGENT_PROMPT_LENGTH } from '../../../../shared/termin import { isTuiAgent } from '../../../../shared/tui-agent-config' import type { TuiAgent } from '../../../../shared/types' import { sleepingAgentLaunchConfigSchema } from '../../../../shared/workspace-session-sleeping-agents' +import { RUNTIME_NAVIGATION_TARGETS } from '../../../../shared/runtime-navigation' import { OptionalBoolean } from '../schemas' export const WorktreeTabSelector = z.object({ @@ -22,7 +23,8 @@ export const ActivateTab = WorktreeTabSelector.extend({ .transform((v) => (typeof v === 'string' ? v : '')) .pipe(z.string().min(1, 'Missing tab id')), leafId: z.string().max(128).optional(), - notifyClients: OptionalBoolean + notifyClients: OptionalBoolean, + navigation: z.enum(RUNTIME_NAVIGATION_TARGETS).optional() }) export type TerminalPaneLayoutNodeInput = @@ -141,6 +143,8 @@ export const CreateTerminalTab = WorktreeTabSelector.extend({ .optional(), viewMode: z.enum(['terminal', 'chat']).optional(), activate: z.boolean().optional(), + select: z.boolean().optional(), + navigation: z.enum(RUNTIME_NAVIGATION_TARGETS).optional(), // Why: idempotency key so a retried create (double-tap, reconnect replay) // returns the in-flight operation instead of spawning a duplicate terminal. clientMutationId: z.string().min(1).max(128).optional() diff --git a/src/main/runtime/rpc/methods/session-tabs.test.ts b/src/main/runtime/rpc/methods/session-tabs.test.ts index 452680931ad..2fe42fcdc36 100644 --- a/src/main/runtime/rpc/methods/session-tabs.test.ts +++ b/src/main/runtime/rpc/methods/session-tabs.test.ts @@ -35,7 +35,35 @@ describe('session tab RPC methods', () => { expect(response.ok).toBe(true) expect(runtime.activateMobileSessionTab).toHaveBeenCalledWith('id:wt-1', 'tab-1', 'leaf-1', { - notifyClients: false + notifyClients: false, + clientNavigationId: undefined, + navigation: 'caller' + }) + }) + + it('defaults legacy paired activation to the authenticated caller identity', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + activateMobileSessionTab: vi.fn().mockResolvedValue({ tabs: [] }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + const replies: string[] = [] + + await dispatcher.dispatchStreaming( + makeRequest('session.tabs.activate', { + worktree: 'id:wt-1', + tabId: 'tab-1', + notifyClients: true + }), + (response) => replies.push(response), + { clientKind: 'runtime', pairedDeviceId: 'device-a' } + ) + + expect(replies).toHaveLength(1) + expect(runtime.activateMobileSessionTab).toHaveBeenCalledWith('id:wt-1', 'tab-1', undefined, { + notifyClients: true, + clientNavigationId: 'device-a', + navigation: 'caller' }) }) @@ -171,10 +199,45 @@ describe('session tab RPC methods', () => { }, launchAgent: 'codex', viewMode: 'chat', - activate: true + activate: true, + select: undefined, + clientNavigationId: undefined, + navigation: 'all' }) }) + it('defaults legacy paired terminal creation to caller-owned selection', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + createMobileSessionTerminal: vi.fn().mockResolvedValue({ + tab: { type: 'terminal', id: 'tab-1::leaf-1' }, + publicationEpoch: 'epoch-1', + snapshotVersion: 1 + }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + + await dispatcher.dispatchStreaming( + makeRequest('session.tabs.createTerminal', { + worktree: 'id:wt-1', + activate: true, + clientMutationId: 'create-1' + }), + () => {}, + { clientKind: 'runtime', pairedDeviceId: 'device-a' } + ) + + expect(runtime.createMobileSessionTerminal).toHaveBeenCalledWith( + 'id:wt-1', + expect.objectContaining({ + activate: true, + clientMutationId: 'create-1', + clientNavigationId: 'device-a', + navigation: 'caller' + }) + ) + }) + it('dispatches terminal creation with a requested agent preset', async () => { const runtime = { getRuntimeId: () => 'test-runtime', @@ -211,7 +274,10 @@ describe('session tab RPC methods', () => { startupCommandDelivery: undefined, agent: 'codex', agentPrompt: 'Review this diff', - activate: undefined + activate: undefined, + select: undefined, + clientNavigationId: undefined, + navigation: 'all' }) }) @@ -271,7 +337,10 @@ describe('session tab RPC methods', () => { command: "codex 'linked issue context'", startupCommandDelivery: 'shell-ready', agent: undefined, - activate: undefined + activate: undefined, + select: undefined, + clientNavigationId: undefined, + navigation: 'all' }) }) diff --git a/src/main/runtime/rpc/methods/session-tabs.ts b/src/main/runtime/rpc/methods/session-tabs.ts index 2f986f4d3ef..364f1b39301 100644 --- a/src/main/runtime/rpc/methods/session-tabs.ts +++ b/src/main/runtime/rpc/methods/session-tabs.ts @@ -1,4 +1,5 @@ import { z } from 'zod' +import { resolveRuntimeNavigationTarget } from '../../../../shared/runtime-navigation' import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core' import { ActivateTab, @@ -15,21 +16,28 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ defineMethod({ name: 'session.tabs.list', params: WorktreeTabSelector, - handler: async (params, { runtime }) => runtime.listMobileSessionTabs(params.worktree) + handler: async (params, { runtime, pairedDeviceId }) => + runtime.listMobileSessionTabs(params.worktree, pairedDeviceId) }), defineMethod({ name: 'session.tabs.listAll', params: null, - handler: async (_params, { runtime }) => ({ - snapshots: await runtime.listAllMobileSessionTabs() + handler: async (_params, { runtime, pairedDeviceId }) => ({ + snapshots: await runtime.listAllMobileSessionTabs(pairedDeviceId) }) }), defineMethod({ name: 'session.tabs.activate', params: ActivateTab, - handler: async (params, { runtime }) => + handler: async (params, { runtime, clientKind, pairedDeviceId }) => runtime.activateMobileSessionTab(params.worktree, params.tabId, params.leafId, { - notifyClients: params.notifyClients !== false + notifyClients: params.notifyClients !== false, + clientNavigationId: pairedDeviceId, + navigation: resolveRuntimeNavigationTarget({ + navigation: params.navigation, + notifyClients: params.notifyClients, + clientKind + }) }) }), defineMethod({ @@ -41,7 +49,7 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ defineMethod({ name: 'session.tabs.createTerminal', params: CreateTerminalTab, - handler: async (params, { runtime, signal }) => + handler: async (params, { runtime, signal, clientKind, pairedDeviceId }) => runtime.createMobileSessionTerminal(params.worktree, { afterTabId: params.afterTabId, targetGroupId: params.targetGroupId, @@ -57,6 +65,12 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ ...(params.launchAgent ? { launchAgent: params.launchAgent } : {}), ...(params.viewMode ? { viewMode: params.viewMode } : {}), activate: params.activate, + select: params.select, + clientNavigationId: pairedDeviceId, + navigation: resolveRuntimeNavigationTarget({ + navigation: params.navigation, + clientKind + }), clientMutationId: params.clientMutationId, // Why: a dead client connection must cancel the surface wait instead // of running down the timeout and rolling back a live tab (#7718). @@ -117,12 +131,12 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ defineStreamingMethod({ name: 'session.tabs.subscribe', params: WorktreeTabSelector, - handler: async (params, { runtime, connectionId, requestId }, emit) => { + handler: async (params, { runtime, connectionId, requestId, pairedDeviceId }, emit) => { let subscribedWorktree: string | null = null let unsubscribe = (): void => {} let closed = false let initialized = false - const initial = await runtime.listMobileSessionTabs(params.worktree) + const initial = await runtime.listMobileSessionTabs(params.worktree, pairedDeviceId) if (closed) { return } @@ -155,7 +169,7 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ if (snapshot.worktree === subscribedWorktree) { emit({ type: 'updated', ...snapshot }) } - }) + }, pairedDeviceId) if (closed) { unsubscribe() } @@ -164,8 +178,8 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ defineMethod({ name: 'session.tabs.unsubscribe', params: SessionTabsUnsubscribe, - handler: async (params, { runtime, connectionId }) => { - const snapshot = await runtime.listMobileSessionTabs(params.worktree) + handler: async (params, { runtime, connectionId, pairedDeviceId }) => { + const snapshot = await runtime.listMobileSessionTabs(params.worktree, pairedDeviceId) const connection = connectionId ?? 'local' if (params.subscriptionId) { runtime.cleanupSubscription( @@ -182,7 +196,7 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ defineStreamingMethod({ name: 'session.tabs.subscribeAll', params: null, - handler: async (_params, { runtime, connectionId, requestId }, emit) => { + handler: async (_params, { runtime, connectionId, requestId, pairedDeviceId }, emit) => { let unsubscribe = (): void => {} let closed = false // Why: initial listAll errors should return one RPC error, not a leaked @@ -207,7 +221,9 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ if (closed) { return } - const snapshots = await Promise.resolve(runtime.listAllMobileSessionTabs()).catch((error) => { + const snapshots = await Promise.resolve( + runtime.listAllMobileSessionTabs(pairedDeviceId) + ).catch((error) => { runtime.cleanupSubscription(subscriptionId) throw error }) @@ -222,7 +238,7 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ } unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => { emit({ type: 'updated', ...snapshot }) - }) + }, pairedDeviceId) } }), defineMethod({ diff --git a/src/main/runtime/rpc/methods/terminal-navigation.test.ts b/src/main/runtime/rpc/methods/terminal-navigation.test.ts new file mode 100644 index 00000000000..b0aa29828c6 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal-navigation.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it, vi } from 'vitest' +import type { OrcaRuntimeService } from '../../orca-runtime' +import type { RpcRequest } from '../core' +import { RpcDispatcher } from '../dispatcher' +import { TERMINAL_METHODS } from './terminal' + +function request(params: unknown): RpcRequest { + return { + id: 'focus-1', + authToken: 'token', + method: 'terminal.focus', + params + } +} + +describe('terminal focus navigation authority', () => { + it('denies implicit paired focus while preserving local and explicit host focus', async () => { + const runtime = { + getRuntimeId: () => 'runtime-1', + focusTerminal: vi.fn().mockResolvedValue({ + handle: 'term-1', + tabId: 'tab-1', + worktreeId: 'wt-1' + }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + await dispatcher.dispatchStreaming(request({ terminal: 'term-1' }), () => {}, { + clientKind: 'mobile', + pairedDeviceId: 'device-a' + }) + await dispatcher.dispatchStreaming( + request({ terminal: 'term-1', navigation: 'host' }), + () => {}, + { clientKind: 'runtime', pairedDeviceId: 'device-b' } + ) + await dispatcher.dispatchStreaming(request({ terminal: 'term-1' }), () => {}, { + clientKind: 'runtime', + pairedDeviceId: 'device-b' + }) + await dispatcher.dispatch(request({ terminal: 'term-1' })) + + expect(runtime.focusTerminal).toHaveBeenNthCalledWith(1, 'term-1', { + navigateHost: false + }) + expect(runtime.focusTerminal).toHaveBeenNthCalledWith(2, 'term-1', { + navigateHost: true + }) + expect(runtime.focusTerminal).toHaveBeenNthCalledWith(3, 'term-1', { + navigateHost: false + }) + expect(runtime.focusTerminal).toHaveBeenNthCalledWith(4, 'term-1', { + navigateHost: true + }) + }) + + it('rejects client fanout targets that terminal focus cannot honor', async () => { + const runtime = { + getRuntimeId: () => 'runtime-1', + focusTerminal: vi.fn() + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const response = await dispatcher.dispatch( + request({ terminal: 'term-1', navigation: 'clients' }) + ) + + expect(response).toMatchObject({ ok: false, error: { code: 'invalid_argument' } }) + expect(runtime.focusTerminal).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 83f550c75ef..7721ebc4d59 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -38,6 +38,10 @@ import { MOBILE_SUBSCRIBE_SCROLLBACK_ROWS } from '../../scrollback-limits' import { assertTerminalAgentSendable } from '../terminal-agent-send-guard' +import { + navigationTargetsHost, + resolveRuntimeNavigationTarget +} from '../../../../shared/runtime-navigation' const REQUESTED_SNAPSHOT_BYTE_BUDGET = 2 * 1024 * 1024 const TERMINAL_STREAM_CHUNK_BYTES = 48 * 1024 @@ -820,6 +824,10 @@ const TerminalHandle = z.object({ terminal: requiredString('Missing terminal handle') }) +const TerminalFocus = TerminalHandle.extend({ + navigation: z.enum(['caller', 'host']).optional() +}) + const TerminalListParams = z.object({ worktree: OptionalString, limit: OptionalFiniteNumber, @@ -1421,9 +1429,13 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ }), defineMethod({ name: 'terminal.focus', - params: TerminalHandle, - handler: async (params, { runtime }) => ({ - focus: await runtime.focusTerminal(params.terminal) + params: TerminalFocus, + handler: async (params, { runtime, clientKind }) => ({ + focus: await runtime.focusTerminal(params.terminal, { + navigateHost: navigationTargetsHost( + resolveRuntimeNavigationTarget({ navigation: params.navigation, clientKind }) + ) + }) }) }), defineMethod({ diff --git a/src/main/runtime/rpc/methods/worktree-schemas.test.ts b/src/main/runtime/rpc/methods/worktree-schemas.test.ts index 4f42e5fc4ce..975693bb7f7 100644 --- a/src/main/runtime/rpc/methods/worktree-schemas.test.ts +++ b/src/main/runtime/rpc/methods/worktree-schemas.test.ts @@ -1,7 +1,16 @@ import { describe, expect, it } from 'vitest' -import { WorktreeCreate } from './worktree-schemas' +import { WorktreeActivate, WorktreeCreate } from './worktree-schemas' describe('worktree RPC schemas', () => { + it('validates additive navigation intent', () => { + expect(WorktreeActivate.parse({ worktree: 'id:wt-1', navigation: 'clients' }).navigation).toBe( + 'clients' + ) + expect( + WorktreeActivate.safeParse({ worktree: 'id:wt-1', navigation: 'everyone' }).success + ).toBe(false) + }) + it('rejects invalid startup agent values', () => { const parsed = WorktreeCreate.safeParse({ repo: 'repo-1', diff --git a/src/main/runtime/rpc/methods/worktree-schemas.ts b/src/main/runtime/rpc/methods/worktree-schemas.ts index e271432e5f3..45848c92db9 100644 --- a/src/main/runtime/rpc/methods/worktree-schemas.ts +++ b/src/main/runtime/rpc/methods/worktree-schemas.ts @@ -3,6 +3,7 @@ import { isTuiAgent } from '../../../../shared/tui-agent-config' import type { TuiAgent } from '../../../../shared/types' import { workspaceSourceSchema } from '../../../../shared/telemetry-events' import { sleepingAgentLaunchConfigSchema } from '../../../../shared/workspace-session-sleeping-agents' +import { RUNTIME_NAVIGATION_TARGETS } from '../../../../shared/runtime-navigation' import { OptionalBoolean, OptionalFiniteNumber, @@ -56,7 +57,8 @@ export const WorktreeSelector = z.object({ }) export const WorktreeActivate = WorktreeSelector.extend({ - notifyClients: OptionalBoolean + notifyClients: OptionalBoolean, + navigation: z.enum(RUNTIME_NAVIGATION_TARGETS).optional() }) export const WorktreeCreate = z diff --git a/src/main/runtime/rpc/methods/worktree.test.ts b/src/main/runtime/rpc/methods/worktree.test.ts index ef96f0e1688..10271681dc4 100644 --- a/src/main/runtime/rpc/methods/worktree.test.ts +++ b/src/main/runtime/rpc/methods/worktree.test.ts @@ -45,7 +45,8 @@ describe('worktree RPC methods', () => { expect(response).toMatchObject({ ok: true }) expect(runtime.activateManagedWorktree).toHaveBeenCalledWith('id:wt-1', { notifyClients: false, - clientKind: undefined + clientKind: undefined, + navigation: 'caller' }) }) @@ -70,7 +71,8 @@ describe('worktree RPC methods', () => { expect(runtime.activateManagedWorktree).toHaveBeenCalledWith('id:wt-1', { notifyClients: false, - clientKind: 'mobile' + clientKind: 'mobile', + navigation: 'caller' }) }) diff --git a/src/main/runtime/rpc/methods/worktree.ts b/src/main/runtime/rpc/methods/worktree.ts index 4d3382f635d..05e7b0a08a3 100644 --- a/src/main/runtime/rpc/methods/worktree.ts +++ b/src/main/runtime/rpc/methods/worktree.ts @@ -4,6 +4,7 @@ import { resolveAutomationWorkspaceProvenance } from '../../../automations/workspace-provenance' import { defineMethod, type RpcMethod } from '../core' +import { resolveRuntimeNavigationTarget } from '../../../../shared/runtime-navigation' import { WorktreeCreate, WorktreeDetectedListParams, @@ -64,7 +65,12 @@ export const WORKTREE_METHODS: RpcMethod[] = [ // wake to phones so web/desktop activation behavior is unchanged. runtime.activateManagedWorktree(params.worktree, { notifyClients: params.notifyClients !== false, - clientKind + clientKind, + navigation: resolveRuntimeNavigationTarget({ + navigation: params.navigation, + notifyClients: params.notifyClients, + clientKind + }) }) }), defineMethod({ diff --git a/src/main/runtime/rpc/streaming.test.ts b/src/main/runtime/rpc/streaming.test.ts index 77deacc420c..382353de803 100644 --- a/src/main/runtime/rpc/streaming.test.ts +++ b/src/main/runtime/rpc/streaming.test.ts @@ -21,6 +21,30 @@ function makeRequest(method: string, params?: unknown): RpcRequest { } describe('RpcDispatcher streaming', () => { + it('passes pairing authority to streaming handlers', async () => { + const pairing = { + getEndpoints: vi.fn(), + provisionRelay: vi.fn() + } + let receivedPairing: unknown + const dispatcher = new RpcDispatcher({ + runtime: stubRuntime(), + methods: [ + defineStreamingMethod({ + name: 'test.pairing-stream', + params: null, + handler: async (_params, ctx) => { + receivedPairing = ctx.pairing + } + }) + ] + }) + + await dispatcher.dispatchStreaming(makeRequest('test.pairing-stream'), () => {}, { pairing }) + + expect(receivedPairing).toBe(pairing) + }) + it('sends initial scrollback via emit', async () => { const messages: string[] = [] const dispatcher = new RpcDispatcher({ diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index 0623366c316..f94ba79705c 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -540,6 +540,7 @@ export class OrcaRuntimeRpcServer { return false } this.mobileRelayPairingProvider?.onDemandStateChanged?.() + this.runtime.forgetClientNavigationState(deviceId) this.mobileSocketWiring?.terminateDeviceConnections(device.token) return true } @@ -549,6 +550,7 @@ export class OrcaRuntimeRpcServer { if (device?.scope !== 'runtime' || !this.deviceRegistry?.removeDevice(deviceId)) { return false } + this.runtime.forgetClientNavigationState(deviceId) this.mobileSocketWiring?.terminateDeviceConnections(device.token) return true } @@ -1094,6 +1096,7 @@ export class OrcaRuntimeRpcServer { await this.dispatcher.dispatchStreaming(request, replyForRequest, { connectionId, clientId: token, + pairedDeviceId: device.deviceId, // Why: gates the mobile-only payload diet so full-screen web/desktop clients aren't truncated. clientKind: device.scope, pairing: pairingContext, diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts index f5ef551723a..242b53921d2 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts @@ -1270,7 +1270,13 @@ describe('createRemoteRuntimePtyTransport', () => { expect(runtimeCall).toHaveBeenCalledWith( expect.objectContaining({ method: 'session.tabs.activate', - params: { worktree: 'id:wt-1', tabId: 'host-tab-1', leafId: 'leaf-1' } + params: { + worktree: 'id:wt-1', + tabId: 'host-tab-1', + leafId: 'leaf-1', + notifyClients: false, + navigation: 'caller' + } }) ) expect(runtimeCall).not.toHaveBeenCalledWith( @@ -1372,7 +1378,13 @@ describe('createRemoteRuntimePtyTransport', () => { expect(runtimeCall).toHaveBeenCalledWith( expect.objectContaining({ method: 'session.tabs.activate', - params: { worktree: 'id:wt-1', tabId: 'host-tab-1', leafId: 'leaf-2' } + params: { + worktree: 'id:wt-1', + tabId: 'host-tab-1', + leafId: 'leaf-2', + notifyClients: false, + navigation: 'caller' + } }) ) expect(runtimeCall).not.toHaveBeenCalledWith( diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts index 7c2f8d4d4e6..dae2338cd71 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts @@ -173,7 +173,9 @@ export function createRemoteRuntimePtyTransport( const activated = await callRuntime('session.tabs.activate', { worktree, tabId: hostTabId, - ...(leafId ? { leafId } : {}) + ...(leafId ? { leafId } : {}), + notifyClients: false, + navigation: 'caller' }) const immediate = findReadyHostSessionHandle(activated, hostTabId) if (immediate) { diff --git a/src/renderer/src/components/terminal-pane/terminal-handle-links.test.ts b/src/renderer/src/components/terminal-pane/terminal-handle-links.test.ts index f76733d6721..2fb6064f814 100644 --- a/src/renderer/src/components/terminal-pane/terminal-handle-links.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-handle-links.test.ts @@ -299,7 +299,7 @@ describe('createTerminalHandleLinkProvider', () => { expect(window.api.runtime.call).toHaveBeenCalledWith({ method: 'terminal.focus', - params: { terminal: 'term_worker' } + params: { terminal: 'term_worker', navigation: 'host' } }) }) @@ -361,7 +361,7 @@ describe('createTerminalHandleLinkProvider', () => { expect(window.api.runtimeEnvironments.call).toHaveBeenCalledWith({ selector: 'env-1', method: 'terminal.focus', - params: { terminal: 'term_worker' }, + params: { terminal: 'term_worker', navigation: 'host' }, timeoutMs: undefined }) }) @@ -389,7 +389,7 @@ describe('createTerminalHandleLinkProvider', () => { }) expect(window.api.runtime.call).toHaveBeenNthCalledWith(2, { method: 'terminal.focus', - params: { terminal: 'term_worker' } + params: { terminal: 'term_worker', navigation: 'host' } }) expect(preventDefault).toHaveBeenCalled() }) @@ -561,7 +561,7 @@ describe('createTerminalHandleLinkProvider', () => { expect(window.api.runtimeEnvironments.call).toHaveBeenNthCalledWith(2, { selector: 'env-1', method: 'terminal.focus', - params: { terminal: 'term_worker' }, + params: { terminal: 'term_worker', navigation: 'host' }, timeoutMs: undefined }) }) @@ -620,7 +620,7 @@ describe('createTerminalHandleLinkProvider', () => { }) expect(window.api.runtime.call).toHaveBeenNthCalledWith(2, { method: 'terminal.focus', - params: { terminal: 'term_worker' } + params: { terminal: 'term_worker', navigation: 'host' } }) }) @@ -662,7 +662,7 @@ describe('createTerminalHandleLinkProvider', () => { expect(window.api.runtimeEnvironments.call).toHaveBeenNthCalledWith(2, { selector: 'env-1', method: 'terminal.focus', - params: { terminal: 'term_remote' }, + params: { terminal: 'term_remote', navigation: 'host' }, timeoutMs: undefined }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-handle-links.ts b/src/renderer/src/components/terminal-pane/terminal-handle-links.ts index c11d595f611..baf2adbde90 100644 --- a/src/renderer/src/components/terminal-pane/terminal-handle-links.ts +++ b/src/renderer/src/components/terminal-pane/terminal-handle-links.ts @@ -275,5 +275,5 @@ async function focusRuntimeTerminalHandle( : ({ kind: 'local' } as const) // Why: main owns the `term_*` mapping. Defer to terminal.focus on click // instead of mirroring that state in renderer hover parsing. - await callRuntimeRpc(target, 'terminal.focus', { terminal: handle }) + await callRuntimeRpc(target, 'terminal.focus', { terminal: handle, navigation: 'host' }) } diff --git a/src/renderer/src/components/terminal-pane/terminal-orchestration-task-links.ts b/src/renderer/src/components/terminal-pane/terminal-orchestration-task-links.ts index 63b48c44e8b..a26245a4794 100644 --- a/src/renderer/src/components/terminal-pane/terminal-orchestration-task-links.ts +++ b/src/renderer/src/components/terminal-pane/terminal-orchestration-task-links.ts @@ -68,7 +68,7 @@ export async function focusRuntimeOrchestrationTask( } // Why: task IDs are stable orchestration DB records, but terminal.focus owns // the app-side navigation contract for local and SSH runtime terminals. - await callRuntimeRpc(target, 'terminal.focus', { terminal }) + await callRuntimeRpc(target, 'terminal.focus', { terminal, navigation: 'host' }) } function findOrchestrationTaskTokenEnd(lineText: string, startIndex: number): number { diff --git a/src/renderer/src/lib/worktree-activation-created-agent.test.ts b/src/renderer/src/lib/worktree-activation-created-agent.test.ts index dfa23851a6f..889243aeaec 100644 --- a/src/renderer/src/lib/worktree-activation-created-agent.test.ts +++ b/src/renderer/src/lib/worktree-activation-created-agent.test.ts @@ -365,7 +365,11 @@ describe('activateAndRevealWorktree created agent reopen', () => { expect(callRuntimeEnvironment).toHaveBeenCalledWith({ selector: 'web-runtime-1', method: 'worktree.activate', - params: { worktree: `id:${worktree.id}`, notifyClients: false }, + params: { + worktree: `id:${worktree.id}`, + notifyClients: false, + navigation: 'caller' + }, timeoutMs: 15_000 }) }) diff --git a/src/renderer/src/lib/worktree-activation-empty-remote.test.ts b/src/renderer/src/lib/worktree-activation-empty-remote.test.ts index c312299d253..2802ba7c734 100644 --- a/src/renderer/src/lib/worktree-activation-empty-remote.test.ts +++ b/src/renderer/src/lib/worktree-activation-empty-remote.test.ts @@ -107,7 +107,9 @@ describe('empty remote worktree activation', () => { method: 'session.tabs.createTerminal', params: expect.objectContaining({ worktree: `id:${worktree.id}`, - activate: true + activate: false, + select: true, + navigation: 'caller' }) }) ) diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index f1779144513..c23feaf74ca 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -324,8 +324,7 @@ export function activateAndRevealWorktree( // Why: paired web clients own only local selection, so the desktop host publishes session surfaces without treating it as a nav command. void activateWebRuntimeSessionWorktree({ worktreeId, - environmentId: ownerRuntimeEnvironmentId, - notifyDesktop: (globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ !== true + environmentId: ownerRuntimeEnvironmentId }) } diff --git a/src/renderer/src/runtime/web-runtime-session.test.ts b/src/renderer/src/runtime/web-runtime-session.test.ts index f4b312cf0b3..0411abe5d3a 100644 --- a/src/renderer/src/runtime/web-runtime-session.test.ts +++ b/src/renderer/src/runtime/web-runtime-session.test.ts @@ -80,7 +80,7 @@ describe('activateWebRuntimeSessionWorktree', () => { vi.clearAllMocks() }) - it('can ask the host to activate session surfaces without notifying desktop clients', async () => { + it('activates caller-owned session surfaces without steering host or clients', async () => { const runtimeCall = vi.fn().mockResolvedValueOnce({ id: 'activate', ok: true, @@ -97,8 +97,7 @@ describe('activateWebRuntimeSessionWorktree', () => { await expect( activateWebRuntimeSessionWorktree({ - worktreeId: WORKTREE_ID, - notifyDesktop: false + worktreeId: WORKTREE_ID }) ).resolves.toBe(true) @@ -107,7 +106,8 @@ describe('activateWebRuntimeSessionWorktree', () => { method: 'worktree.activate', params: { worktree: `id:${WORKTREE_ID}`, - notifyClients: false + notifyClients: false, + navigation: 'caller' }, timeoutMs: 15_000 }) @@ -514,7 +514,9 @@ describe('createWebRuntimeSessionTerminal', () => { }, launchAgent: 'codex', viewMode: 'chat', - activate: true + activate: false, + select: true, + navigation: 'caller' }, timeoutMs: 15_000 }) @@ -853,7 +855,9 @@ describe('web runtime session tab actions', () => { method: 'session.tabs.activate', params: { worktree: `id:${WORKTREE_ID}`, - tabId: 'host-browser-unified' + tabId: 'host-browser-unified', + notifyClients: false, + navigation: 'caller' }, timeoutMs: 15_000 }) diff --git a/src/renderer/src/runtime/web-runtime-session.ts b/src/renderer/src/runtime/web-runtime-session.ts index 826d2712ce6..b25138f7291 100644 --- a/src/renderer/src/runtime/web-runtime-session.ts +++ b/src/renderer/src/runtime/web-runtime-session.ts @@ -88,7 +88,10 @@ export async function createWebRuntimeSessionTerminal(args: { agent: args.agent, ...(args.launchAgent ? { launchAgent: args.launchAgent } : {}), ...(args.viewMode ? { viewMode: args.viewMode } : {}), - activate: args.activate !== false + // Why: old hosts understand activate:false; new hosts use select/navigation for caller-local focus. + activate: false, + select: args.activate !== false, + navigation: 'caller' }, timeoutMs: 15_000 }) @@ -277,7 +280,6 @@ async function refreshWebRuntimeSessionTabsSnapshot( export async function activateWebRuntimeSessionWorktree(args: { worktreeId: string environmentId?: string | null - notifyDesktop?: boolean }): Promise { const environmentId = args.environmentId?.trim() ?? @@ -293,7 +295,9 @@ export async function activateWebRuntimeSessionWorktree(args: { method: 'worktree.activate', params: { worktree: toRuntimeWorktreeSelector(args.worktreeId), - notifyClients: args.notifyDesktop !== false + // Why: notifyClients:false keeps navigation local when this client reaches an older host. + notifyClients: false, + navigation: 'caller' }, timeoutMs: 15_000 }) @@ -458,7 +462,14 @@ async function callWebRuntimeSessionTabMethod( method, params: { worktree: toRuntimeWorktreeSelector(args.worktreeId), - tabId: hostTabId + tabId: hostTabId, + ...(method === 'session.tabs.activate' + ? { + // Why: the additive intent protects new hosts while notifyClients:false protects old hosts. + notifyClients: false, + navigation: 'caller' as const + } + : {}) }, timeoutMs: 15_000 }) diff --git a/src/renderer/src/runtime/web-session-tabs-sync.test.ts b/src/renderer/src/runtime/web-session-tabs-sync.test.ts index 9d5f20f926f..66f4a1b70bd 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.test.ts @@ -2024,85 +2024,87 @@ describe('applyWebSessionTabsSnapshot', () => { isPinned: false } - const patch = applyWebSessionTabsSnapshot( - makeState({ - activeTabId: shellTabId, - activeTabIdByWorktree: { [WT]: shellTabId }, - activeTabType: 'terminal', - activeTabTypeByWorktree: { [WT]: 'terminal' }, - tabsByWorktree: { - [WT]: [ - { - id: agentTabId, - ptyId: 'remote:web-env-1@@terminal-1', - worktreeId: WT, - title: 'codex [working]', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: NOW - }, - { - id: shellTabId, - ptyId: 'remote:web-env-1@@terminal-2', - worktreeId: WT, - title: 'shell', - customTitle: null, - color: null, - sortOrder: 1, - createdAt: NOW + 1 - } - ] - }, - unifiedTabsByWorktree: { [WT]: [agentUnifiedTab, shellUnifiedTab] }, - tabBarOrderByWorktree: { [WT]: [agentTabId, shellTabId] }, - groupsByWorktree: { - [WT]: [ - { - id: 'host-group-1', - worktreeId: WT, - activeTabId: shellTabId, - tabOrder: [agentTabId, shellTabId], - recentTabIds: [agentTabId, shellTabId] - } - ] - } - }), - makeSnapshot( - [ + const state = makeState({ + activeTabId: shellTabId, + activeTabIdByWorktree: { [WT]: shellTabId }, + activeTabType: 'terminal', + activeTabTypeByWorktree: { [WT]: 'terminal' }, + tabsByWorktree: { + [WT]: [ { - type: 'terminal', - id: `host-tab-1::${LEAF_ID}`, - title: 'codex [thinking]', - parentTabId: 'host-tab-1', - leafId: LEAF_ID, - isActive: true, - status: 'ready', - terminal: 'terminal-1' + id: agentTabId, + ptyId: 'remote:web-env-1@@terminal-1', + worktreeId: WT, + title: 'codex [working]', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: NOW }, { - type: 'terminal', - id: `host-tab-2::${SECOND_LEAF_ID}`, + id: shellTabId, + ptyId: 'remote:web-env-1@@terminal-2', + worktreeId: WT, title: 'shell', - parentTabId: 'host-tab-2', - leafId: SECOND_LEAF_ID, - isActive: false, - status: 'ready', - terminal: 'terminal-2' + customTitle: null, + color: null, + sortOrder: 1, + createdAt: NOW + 1 } - ], + ] + }, + unifiedTabsByWorktree: { [WT]: [agentUnifiedTab, shellUnifiedTab] }, + tabBarOrderByWorktree: { [WT]: [agentTabId, shellTabId] }, + groupsByWorktree: { + [WT]: [ + { + id: 'host-group-1', + worktreeId: WT, + activeTabId: shellTabId, + tabOrder: [agentTabId, shellTabId], + recentTabIds: [agentTabId, shellTabId] + } + ] + } + }) + const remoteActiveSnapshot = makeSnapshot( + [ { - activeTabId: `host-tab-1::${LEAF_ID}`, - activeTabType: 'terminal', - tabGroups: [ - { - id: 'host-group-1', - activeTabId: 'host-tab-1', - tabOrder: ['host-tab-1', 'host-tab-2'] - } - ] + type: 'terminal', + id: `host-tab-1::${LEAF_ID}`, + title: 'codex [thinking]', + parentTabId: 'host-tab-1', + leafId: LEAF_ID, + isActive: true, + status: 'ready', + terminal: 'terminal-1' + }, + { + type: 'terminal', + id: `host-tab-2::${SECOND_LEAF_ID}`, + title: 'shell', + parentTabId: 'host-tab-2', + leafId: SECOND_LEAF_ID, + isActive: false, + status: 'ready', + terminal: 'terminal-2' } - ), + ], + { + activeTabId: `host-tab-1::${LEAF_ID}`, + activeTabType: 'terminal', + tabGroups: [ + { + id: 'host-group-1', + activeTabId: 'host-tab-1', + tabOrder: ['host-tab-1', 'host-tab-2'] + } + ] + } + ) + const patch = applyWebSessionTabsSnapshot( + state, + remoteActiveSnapshot, ENV, NOW + 10 ) as Partial @@ -2113,11 +2115,18 @@ describe('applyWebSessionTabsSnapshot', () => { activeTabId: shellTabId, tabOrder: [agentTabId, shellTabId] }) + + const followed = applyWebSessionTabsSnapshot( + state, + { ...remoteActiveSnapshot, navigationIntent: 'follow' }, + ENV, + NOW + 10 + ) as Partial + expect(followed.activeTabIdByWorktree?.[WT]).toBe(agentTabId) + expect(followed.groupsByWorktree?.[WT]?.[0]?.activeTabId).toBe(agentTabId) }) - it('focuses a brand-new remote terminal that the snapshot marks active', () => { - // Why: opening a new terminal must take focus. Distinct from the #5435 case - // above (existing tab echoed active = keep focus); here the active tab is new. + it('focuses a caller-created terminal even when an older host leaves it inactive', () => { const existingTabId = toWebTerminalSurfaceTabId('host-tab-1') const newTabId = toWebTerminalSurfaceTabId('host-tab-2') // Simulate createWebRuntimeSessionTerminal recording focus intent for the new tab. @@ -2179,7 +2188,7 @@ describe('applyWebSessionTabsSnapshot', () => { title: 'shell', parentTabId: 'host-tab-1', leafId: LEAF_ID, - isActive: false, + isActive: true, status: 'ready', terminal: 'terminal-1' }, @@ -2189,18 +2198,18 @@ describe('applyWebSessionTabsSnapshot', () => { title: 'new shell', parentTabId: 'host-tab-2', leafId: SECOND_LEAF_ID, - isActive: true, + isActive: false, status: 'ready', terminal: 'terminal-2' } ], { - activeTabId: `host-tab-2::${SECOND_LEAF_ID}`, + activeTabId: `host-tab-1::${LEAF_ID}`, activeTabType: 'terminal', tabGroups: [ { id: 'host-group-1', - activeTabId: 'host-tab-2', + activeTabId: 'host-tab-1', tabOrder: ['host-tab-1', 'host-tab-2'] } ] diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index 3dddb45fc0b..93481124df7 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -1643,19 +1643,23 @@ export function applyWebSessionTabsSnapshot( ) } : rawSnapshot - // Why: follow the snapshot's active tab over the user's focus only when this client initiated the activation; unsolicited server-active must not steal focus (#5435). + // Why: only a caller-recorded create intent may focus its arriving tab; unsolicited server-active must not steal focus (#5435). const focusIntentHostTabId = peekWebSessionFocusIntent(worktreeId) - const honorSnapshotActiveFocus = - focusIntentHostTabId !== null && - snapshot.activeTabId !== null && - (snapshot.activeTabId === focusIntentHostTabId || - snapshot.tabs.some( - (tab) => - tab.id === snapshot.activeTabId && - tab.type === 'browser' && - tab.browserPageId === focusIntentHostTabId - )) - if (honorSnapshotActiveFocus) { + const callerFocusIntentTab = + focusIntentHostTabId === null + ? null + : (snapshot.tabs.find( + (tab) => + tab.id === focusIntentHostTabId || + (tab.type === 'browser' && tab.browserPageId === focusIntentHostTabId) + ) ?? null) + const followIntentTab = + snapshot.navigationIntent === 'follow' + ? (snapshot.tabs.find((tab) => tab.id === snapshot.activeTabId) ?? null) + : null + const navigationIntentTab = callerFocusIntentTab ?? followIntentTab + const honorSnapshotActiveFocus = navigationIntentTab !== null + if (callerFocusIntentTab) { clearWebSessionFocusIntent(worktreeId) } const currentTerminalTabs = state.tabsByWorktree[worktreeId] ?? [] @@ -1860,15 +1864,31 @@ export function applyWebSessionTabsSnapshot( : null const activeMirroredEditorFileId = activeMirroredEditor?.file.id ?? null const activeMirroredEditorTabId = activeMirroredEditor?.unifiedTab.id ?? null + const intentMirroredTerminalId = + navigationIntentTab?.type === 'terminal' + ? toWebTerminalSurfaceTabId(navigationIntentTab.parentTabId) + : null + const intentMirroredBrowser = + navigationIntentTab?.type === 'browser' + ? (mirroredBrowserTabs.find( + (entry) => + entry.hostTabId === navigationIntentTab.id || + entry.remotePageId === navigationIntentTab.browserPageId + ) ?? null) + : null + const intentMirroredEditor = + navigationIntentTab?.type === 'markdown' || navigationIntentTab?.type === 'file' + ? (mirroredEditorTabs.find((entry) => entry.hostTabId === navigationIntentTab.id) ?? null) + : null const currentActiveTerminalStillExists = state.activeTabIdByWorktree[worktreeId] && (nextTerminalTabs ?? []).some((tab) => tab.id === state.activeTabIdByWorktree[worktreeId]) ? state.activeTabIdByWorktree[worktreeId] : null - // Why: on a client-initiated activation (honorSnapshotActiveFocus), the snapshot's active terminal wins over sticky current focus. + // Why: caller intent targets the requested tab even when an older host leaves its own active tab unchanged. const intentTerminalId = - honorSnapshotActiveFocus && snapshot.activeTabType === 'terminal' - ? activeMirroredTerminalId + honorSnapshotActiveFocus && navigationIntentTab?.type === 'terminal' + ? intentMirroredTerminalId : null const nextActiveTerminalId = intentTerminalId ?? @@ -1883,8 +1903,8 @@ export function applyWebSessionTabsSnapshot( ? state.activeBrowserTabIdByWorktree[worktreeId] : null const intentBrowserWorkspaceId = - honorSnapshotActiveFocus && snapshot.activeTabType === 'browser' - ? activeMirroredBrowserWorkspaceId + honorSnapshotActiveFocus && navigationIntentTab?.type === 'browser' + ? (intentMirroredBrowser?.workspace.id ?? null) : null const nextActiveBrowserWorkspaceId = intentBrowserWorkspaceId ?? @@ -1901,7 +1921,11 @@ export function applyWebSessionTabsSnapshot( ) ? state.activeFileIdByWorktree[worktreeId] : null + const intentEditorFileId = honorSnapshotActiveFocus + ? (intentMirroredEditor?.file.id ?? null) + : null const nextActiveEditorFileId = + intentEditorFileId ?? currentActiveEditorStillExists ?? (snapshot.activeTabType === 'markdown' || snapshot.activeTabType === 'file' ? (activeMirroredEditorFileId ?? mirroredEditorTabs[0]?.file.id) @@ -1914,12 +1938,12 @@ export function applyWebSessionTabsSnapshot( }) // Why: a client-initiated activation also drives the visible unified tab, overriding the sticky current-visible tab. const intentUnifiedTabId = honorSnapshotActiveFocus - ? snapshot.activeTabType === 'browser' - ? activeMirroredBrowserTabId - : snapshot.activeTabType === 'terminal' + ? navigationIntentTab?.type === 'browser' + ? (intentMirroredBrowser?.unifiedTab.id ?? null) + : navigationIntentTab?.type === 'terminal' ? intentTerminalId - : snapshot.activeTabType === 'markdown' || snapshot.activeTabType === 'file' - ? activeMirroredEditorTabId + : navigationIntentTab?.type === 'markdown' || navigationIntentTab?.type === 'file' + ? (intentMirroredEditor?.unifiedTab.id ?? null) : null : null const nextActiveUnifiedTabId = @@ -2255,6 +2279,14 @@ export function applyWebSessionTabsSnapshot( ? { ...state.activeFileIdByWorktree, [worktreeId]: nextActiveEditorFileId } : state.activeFileIdByWorktree const isActiveWorktree = state.activeWorktreeId === worktreeId + const focusIntentVisibleTabType = + navigationIntentTab?.type === 'browser' && intentBrowserWorkspaceId + ? ('browser' as const) + : navigationIntentTab?.type === 'terminal' && intentTerminalId + ? ('terminal' as const) + : intentEditorFileId + ? ('editor' as const) + : null const snapshotVisibleTabType = snapshot.activeTabType === 'browser' && nextActiveBrowserWorkspaceId ? ('browser' as const) @@ -2290,7 +2322,10 @@ export function applyWebSessionTabsSnapshot( : ('terminal' as const) // Why: don't keep pointing shortcuts at a removed browser/editor; a client-initiated activation lets the snapshot's type switch the visible pane. const nextVisibleTabType = honorSnapshotActiveFocus - ? (snapshotVisibleTabType ?? currentVisibleTabTypeStillValid ?? fallbackVisibleTabType) + ? (focusIntentVisibleTabType ?? + currentVisibleTabTypeStillValid ?? + snapshotVisibleTabType ?? + fallbackVisibleTabType) : (currentVisibleTabTypeStillValid ?? snapshotVisibleTabType ?? fallbackVisibleTabType) const currentActiveTerminalStillValid = state.activeTabId && (nextTerminalTabs ?? []).some((tab) => tab.id === state.activeTabId) diff --git a/src/shared/runtime-navigation.test.ts b/src/shared/runtime-navigation.test.ts new file mode 100644 index 00000000000..bb317c1e385 --- /dev/null +++ b/src/shared/runtime-navigation.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' +import { resolveRuntimeNavigationTarget } from './runtime-navigation' + +describe('runtime navigation authority', () => { + it('denies paired-client fanout for legacy payloads', () => { + expect(resolveRuntimeNavigationTarget({ clientKind: 'runtime', notifyClients: true })).toBe( + 'caller' + ) + expect(resolveRuntimeNavigationTarget({ clientKind: 'mobile', notifyClients: true })).toBe( + 'caller' + ) + }) + + it('preserves legacy in-process routing and explicit intent', () => { + expect(resolveRuntimeNavigationTarget({ notifyClients: true })).toBe('all') + expect(resolveRuntimeNavigationTarget({ notifyClients: false })).toBe('caller') + expect( + resolveRuntimeNavigationTarget({ + clientKind: 'runtime', + notifyClients: false, + navigation: 'host' + }) + ).toBe('host') + }) +}) diff --git a/src/shared/runtime-navigation.ts b/src/shared/runtime-navigation.ts new file mode 100644 index 00000000000..c6ba62a3234 --- /dev/null +++ b/src/shared/runtime-navigation.ts @@ -0,0 +1,26 @@ +export const RUNTIME_NAVIGATION_TARGETS = ['caller', 'host', 'clients', 'all'] as const + +export type RuntimeNavigationTarget = (typeof RUNTIME_NAVIGATION_TARGETS)[number] + +export function resolveRuntimeNavigationTarget(args: { + navigation?: RuntimeNavigationTarget + notifyClients?: boolean + clientKind?: 'mobile' | 'runtime' +}): RuntimeNavigationTarget { + if (args.navigation) { + return args.navigation + } + if (args.clientKind) { + // Why: legacy paired clients sent notifyClients:true; treating that as navigation lets one device steer every UI. + return 'caller' + } + return args.notifyClients === false ? 'caller' : 'all' +} + +export function navigationTargetsHost(target: RuntimeNavigationTarget): boolean { + return target === 'host' || target === 'all' +} + +export function navigationTargetsClients(target: RuntimeNavigationTarget): boolean { + return target === 'clients' || target === 'all' +} diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index 2223996ef06..ec626b47767 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -286,6 +286,8 @@ export type RuntimeMobileSessionTabsResult = { worktree: string publicationEpoch: string snapshotVersion: number + /** Live-only targeted command; omitted from durable/list snapshots so reconnect cannot replay navigation. */ + navigationIntent?: 'follow' activeGroupId: string | null activeTabId: string | null activeTabType: 'terminal' | 'markdown' | 'file' | 'browser' | null diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts index 6cddd6399ba..ff656a54bc0 100644 --- a/tests/e2e/global-setup.ts +++ b/tests/e2e/global-setup.ts @@ -19,10 +19,12 @@ import os from 'node:os' /** Temp file where the test repo path is stored for the fixture to read. */ export const TEST_REPO_PATH_FILE = path.join(os.tmpdir(), 'orca-e2e-test-repo-path.txt') const ELECTRON_E2E_BUILD_TIMEOUT_MS = 300_000 +const WEB_E2E_BUILD_TIMEOUT_MS = 300_000 export default function globalSetup(): void { const root = process.cwd() const outMain = path.join(root, 'out', 'main', 'index.js') + const outWeb = path.join(root, 'out', 'web', 'web-index.html') // ── 1. Build the Electron app ────────────────────────────────────── if (process.env.SKIP_BUILD && existsSync(outMain)) { @@ -41,6 +43,20 @@ export default function globalSetup(): void { }) console.error('[e2e] Build complete.') } + if (process.env.ORCA_E2E_WEB_CLIENT === '1') { + if (process.env.SKIP_BUILD && existsSync(outWeb)) { + console.error('[e2e] SKIP_BUILD set and web client exists — skipping web build') + } else { + // Why: paired-browser specs need the web bundle served by the runtime; ordinary Electron E2E does not. + console.error('[e2e] Building paired runtime web client...') + execSync('pnpm run build:web', { + cwd: root, + stdio: 'inherit', + timeout: WEB_E2E_BUILD_TIMEOUT_MS + }) + console.error('[e2e] Web client build complete.') + } + } if (process.env.ORCA_E2E_SSH_LOCALHOST === '1' || process.env.ORCA_E2E_SSH_DOCKER === '1') { // Why: the SSH specs deploy Orca's relay from out/relay. The // normal Electron E2E build does not produce that bundle, so build it only diff --git a/tests/e2e/multi-client-navigation-isolation.spec.ts b/tests/e2e/multi-client-navigation-isolation.spec.ts new file mode 100644 index 00000000000..399ae5d963f --- /dev/null +++ b/tests/e2e/multi-client-navigation-isolation.spec.ts @@ -0,0 +1,178 @@ +import { execFileSync } from 'node:child_process' +import { randomUUID } from 'node:crypto' +import path from 'node:path' +import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { worktreeRow, worktreeRowSurface } from './worktree-row-locators' + +type RuntimePairingOffer = { + deviceId: string + webClientUrl: string +} + +type TestWorktreeIds = { + host: string + clientA: string + clientB: string + clientA2: string +} + +const isPairedBrowserRun = process.env.ORCA_E2E_WEB_CLIENT === '1' + +test.skip( + !isPairedBrowserRun, + 'Run with pnpm test:e2e:multi-client-navigation so the paired web client is built' +) + +function addGitWorktree(repoPath: string, branchName: string): void { + const worktreePath = path.join(path.dirname(repoPath), `e2e-test-${branchName}`) + execFileSync('git', ['worktree', 'add', '-b', branchName, worktreePath], { + cwd: repoPath, + stdio: 'pipe' + }) +} + +async function loadTestWorktreeIds( + hostPage: Page, + branchA: string, + branchB: string +): Promise { + return hostPage.evaluate( + async ({ branchA, branchB }) => { + const store = window.__store + if (!store) { + return null + } + const repo = store.getState().repos[0] + if (!repo) { + return null + } + await store.getState().fetchWorktrees(repo.id) + const worktrees = store.getState().worktreesByRepo[repo.id] ?? [] + const host = worktrees.find((worktree) => worktree.branch === 'refs/heads/e2e-secondary') + const clientA = worktrees.find((worktree) => worktree.branch === `refs/heads/${branchA}`) + const clientB = worktrees.find((worktree) => worktree.branch === `refs/heads/${branchB}`) + const clientA2 = worktrees.find((worktree) => worktree.isMainWorktree) + if (!host || !clientA || !clientB || !clientA2) { + return null + } + return { + host: host.id, + clientA: clientA.id, + clientB: clientB.id, + clientA2: clientA2.id + } + }, + { branchA, branchB } + ) +} + +async function createPairingOffer(hostPage: Page): Promise { + return hostPage.evaluate(async () => { + const offer = await window.api.mobile.getRuntimePairingUrl({ + address: '127.0.0.1', + rotate: true + }) + if (!offer.available || !offer.webClientUrl) { + const reason = offer.available ? 'web client URL is missing' : 'runtime server is unavailable' + throw new Error(`Runtime web client pairing failed: ${reason}`) + } + return { deviceId: offer.deviceId, webClientUrl: offer.webClientUrl } + }) +} + +async function openPairedClient( + electronApp: ElectronApplication, + offer: RuntimePairingOffer, + visibleWorktreeId: string +): Promise { + const pagePromise = electronApp.waitForEvent('window') + await electronApp.evaluate( + async ({ BrowserWindow }, { partition, url }) => { + const clientWindow = new BrowserWindow({ + height: 1200, + show: false, + width: 1440, + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + partition, + sandbox: true + } + }) + await clientWindow.loadURL(url) + }, + { + partition: `e2e-paired-client-${randomUUID()}`, + url: offer.webClientUrl + } + ) + const page = await pagePromise + await expect(page.locator('[data-worktree-sidebar]')).toBeVisible({ timeout: 30_000 }) + await expect(worktreeRow(page, visibleWorktreeId)).toBeVisible({ timeout: 30_000 }) + return page +} + +async function selectWorktree(page: Page, worktreeId: string): Promise { + await worktreeRowSurface(page, worktreeId).click() + await expectActiveWorktree(page, worktreeId) +} + +async function expectActiveWorktree(page: Page, worktreeId: string): Promise { + await expect(page.locator('[data-rendered-active-worktree-id]')).toHaveAttribute( + 'data-rendered-active-worktree-id', + worktreeId + ) +} + +test('keeps two paired browser clients and the host on independent worktrees', async ({ + orcaPage, + electronApp, + testRepoPath +}) => { + const suffix = randomUUID().slice(0, 8) + const branchA = `e2e-client-a-${suffix}` + const branchB = `e2e-client-b-${suffix}` + addGitWorktree(testRepoPath, branchA) + addGitWorktree(testRepoPath, branchB) + + await expect + .poll(() => loadTestWorktreeIds(orcaPage, branchA, branchB), { + timeout: 30_000, + message: 'Expected host plus three client-selectable worktrees' + }) + .not.toBeNull() + + // Playwright's matcher does not narrow the polled value for TypeScript. + const ids = await loadTestWorktreeIds(orcaPage, branchA, branchB) + if (!ids) { + throw new Error('Test worktrees disappeared after discovery') + } + + await selectWorktree(orcaPage, ids.host) + + let clientA: Page | null = null + let clientB: Page | null = null + try { + const offerA = await createPairingOffer(orcaPage) + clientA = await openPairedClient(electronApp, offerA, ids.clientA) + await selectWorktree(clientA, ids.clientA) + + // Why: rotation preserves used grants, so B is issued only after A has completed pairing. + const offerB = await createPairingOffer(orcaPage) + expect(offerB.deviceId).not.toBe(offerA.deviceId) + clientB = await openPairedClient(electronApp, offerB, ids.clientB) + await selectWorktree(clientB, ids.clientB) + + await expectActiveWorktree(clientA, ids.clientA) + await expectActiveWorktree(orcaPage, ids.host) + + await selectWorktree(clientA, ids.clientA2) + + await expectActiveWorktree(clientB, ids.clientB) + await expectActiveWorktree(orcaPage, ids.host) + } finally { + await clientB?.close() + await clientA?.close() + } +}) From 98d2233d17fe10083e536b151d5c7251f941e182 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:56:09 -0700 Subject: [PATCH 41/58] fix(terminal): stop advertising the unimplemented Bold Text color override (#9661) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit xterm.js has no bold-color theme slot (upstream xtermjs/xterm.js#6032), so the Bold Text override was silently dropped — a false affordance. Makes the Settings copy and the Ghostty importer honest that it isn't applied yet, while preserving any persisted value for a future real implementation. No terminal-rendering change. Closes #8595 --- src/main/ghostty/mapper-extended.test.ts | 12 ++++++------ src/main/ghostty/mapper.ts | 14 +++++++------- .../settings/terminal-window-color-groups.tsx | 6 ++++-- src/renderer/src/i18n/locales/en.json | 4 ++-- src/renderer/src/i18n/locales/es.json | 4 ++-- src/renderer/src/i18n/locales/ja.json | 4 ++-- src/renderer/src/i18n/locales/ko.json | 4 ++-- src/renderer/src/i18n/locales/zh.json | 4 ++-- 8 files changed, 27 insertions(+), 25 deletions(-) diff --git a/src/main/ghostty/mapper-extended.test.ts b/src/main/ghostty/mapper-extended.test.ts index 777110630c1..402e9253f21 100644 --- a/src/main/ghostty/mapper-extended.test.ts +++ b/src/main/ghostty/mapper-extended.test.ts @@ -200,15 +200,15 @@ describe('mapGhosttyToOrca — cursor-text', () => { }) describe('mapGhosttyToOrca — bold-color', () => { - it('maps valid hex to terminalColorOverrides.bold', () => { + // Why: xterm.js ITheme has no bold color slot (xtermjs/xterm.js#6032), so bold-color can never + // render; the importer must list it as unsupported rather than claim it was applied (#8595). + it('reports valid bold-color as unsupported and does not apply it', () => { const result = mapGhosttyToOrca({ 'bold-color': '#ff0000' }) - expect(result.diff).toEqual({ - terminalColorOverrides: { bold: '#ff0000' } - }) - expect(result.unsupportedKeys).toEqual([]) + expect(result.diff).toEqual({}) + expect(result.unsupportedKeys).toEqual(['bold-color']) }) - it('rejects invalid bold-color', () => { + it('reports invalid bold-color as unsupported', () => { const result = mapGhosttyToOrca({ 'bold-color': 'red' }) expect(result.diff).toEqual({}) expect(result.unsupportedKeys).toEqual(['bold-color']) diff --git a/src/main/ghostty/mapper.ts b/src/main/ghostty/mapper.ts index 09ddec24c83..c3734cd04c5 100644 --- a/src/main/ghostty/mapper.ts +++ b/src/main/ghostty/mapper.ts @@ -199,13 +199,6 @@ export function mapGhosttyToOrca( return { colorOverrides: { cursorAccent: normalizeHex(v) } } }, - 'bold-color': (v) => { - if (!HEX_COLOR_RE.test(v)) { - return null - } - return { colorOverrides: { bold: normalizeHex(v) } } - }, - 'mouse-hide-while-typing': (v) => { if (v !== 'true' && v !== 'false') { return null @@ -289,6 +282,13 @@ export function mapGhosttyToOrca( continue } + // Why: xterm.js ITheme has no bold color slot (xtermjs/xterm.js#6032), so importing + // bold-color would render as a no-op; report it as unsupported instead of silently dropping. + if (key === 'bold-color') { + unsupportedKeys.push(key) + continue + } + if (!value.trim()) { unsupportedKeys.push(key) continue diff --git a/src/renderer/src/components/settings/terminal-window-color-groups.tsx b/src/renderer/src/components/settings/terminal-window-color-groups.tsx index 870349b5440..652d9ffc04c 100644 --- a/src/renderer/src/components/settings/terminal-window-color-groups.tsx +++ b/src/renderer/src/components/settings/terminal-window-color-groups.tsx @@ -103,9 +103,11 @@ export const COLOR_OVERRIDE_GROUPS: { return translate('auto.components.settings.TerminalWindowSection.862e463f7f', 'Bold Text') }, get description() { + // Why: xterm.js ITheme has no bold color slot (xtermjs/xterm.js#6032); the value is + // preserved but the terminal renderer does not apply it yet. Keep the copy honest. return translate( - 'auto.components.settings.TerminalWindowSection.fb8c6f1967', - 'Color for bold text. Falls back to the normal color if not set.' + 'auto.components.settings.TerminalWindowSection.605a35d600', + 'Not applied by the terminal renderer yet — xterm.js has no bold color slot. A saved value is preserved.' ) } } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 95a9c72f64c..75da8e7fe0d 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -7046,7 +7046,6 @@ "cf4437a2f7": "ANSI black color", "adfdee23cb": "Black", "68e9f07de0": "ANSI Normal", - "fb8c6f1967": "Color for bold text. Falls back to the normal color if not set.", "862e463f7f": "Bold Text", "b2c0857c49": "Text color of selected text", "8b450b5305": "Selection Foreground", @@ -7062,7 +7061,8 @@ "79f6bfb76e": "Foreground", "cf37ff69f6": "Base", "8abdab9f7c": "Restart now", - "907131d741": "Restarting…" + "907131d741": "Restarting…", + "605a35d600": "Not applied by the terminal renderer yet — xterm.js has no bold color slot. A saved value is preserved." }, "UIZoomControl": { "c2c64b24d0": "Reset" diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 2f0ded12e1c..56c09e447a0 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -6986,7 +6986,6 @@ "cf4437a2f7": "Color negro ANSI", "adfdee23cb": "Negro", "68e9f07de0": "ANSI normal", - "fb8c6f1967": "Color para texto en negrita. Vuelve al color normal si no se establece.", "862e463f7f": "Texto en negrita", "b2c0857c49": "Color del texto seleccionado", "8b450b5305": "Primer plano de selección", @@ -7002,7 +7001,8 @@ "79f6bfb76e": "Primer plano", "cf37ff69f6": "Base", "8abdab9f7c": "Reiniciar ahora", - "907131d741": "Reiniciando…" + "907131d741": "Reiniciando…", + "605a35d600": "Not applied by the terminal renderer yet — xterm.js has no bold color slot. A saved value is preserved." }, "UIZoomControl": { "c2c64b24d0": "Restablecer" diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 713a93f15f9..4bcf784d0ff 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -7008,7 +7008,6 @@ "cf4437a2f7": "ANSI黒色", "adfdee23cb": "黒", "68e9f07de0": "ANSI標準", - "fb8c6f1967": "太字のテキストの色。設定されていない場合は、通常の色に戻ります。", "862e463f7f": "太字のテキスト", "b2c0857c49": "選択したテキストの文字色", "8b450b5305": "選択範囲の前景", @@ -7024,7 +7023,8 @@ "79f6bfb76e": "前景", "cf37ff69f6": "ベース", "8abdab9f7c": "今すぐ再起動", - "907131d741": "再起動中…" + "907131d741": "再起動中…", + "605a35d600": "Not applied by the terminal renderer yet — xterm.js has no bold color slot. A saved value is preserved." }, "UIZoomControl": { "c2c64b24d0": "リセット" diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index d1a8a8ac854..d3c48681811 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -6971,7 +6971,6 @@ "cf4437a2f7": "ANSI 블랙 컬러", "adfdee23cb": "검은색", "68e9f07de0": "ANSI 일반", - "fb8c6f1967": "굵은 텍스트의 색상입니다. 설정하지 않으면 일반 색상으로 돌아갑니다.", "862e463f7f": "굵은 글씨", "b2c0857c49": "선택한 텍스트의 텍스트 색상", "8b450b5305": "선택 전경", @@ -6987,7 +6986,8 @@ "79f6bfb76e": "전경", "cf37ff69f6": "베이스", "8abdab9f7c": "지금 재시작", - "907131d741": "다시 시작하는 중…" + "907131d741": "다시 시작하는 중…", + "605a35d600": "Not applied by the terminal renderer yet — xterm.js has no bold color slot. A saved value is preserved." }, "UIZoomControl": { "c2c64b24d0": "재설정" diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 1a3caf2499a..0e45d3fcdb8 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -6971,7 +6971,6 @@ "cf4437a2f7": "ANSI 黑色", "adfdee23cb": "黑色的", "68e9f07de0": "ANSI 标准", - "fb8c6f1967": "粗体文本的颜色。如果未设置,则返回正常颜色。", "862e463f7f": "粗体文字", "b2c0857c49": "所选文本的文本颜色", "8b450b5305": "选择前景", @@ -6987,7 +6986,8 @@ "79f6bfb76e": "前景", "cf37ff69f6": "根据", "8abdab9f7c": "立即重启", - "907131d741": "正在重新启动..." + "907131d741": "正在重新启动...", + "605a35d600": "Not applied by the terminal renderer yet — xterm.js has no bold color slot. A saved value is preserved." }, "UIZoomControl": { "c2c64b24d0": "重置" From e62b7e8172982692a063e386a24948e0bfad1212 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 20 Jul 2026 22:40:33 -0700 Subject: [PATCH 42/58] perf(orchestration): skip per-terminal dispatch scan when no dispatches exist (#9694) Co-authored-by: Orca --- src/main/runtime/orca-runtime.ts | 8 ++ ...ty-dispatch-shortcircuit.benchmark.test.ts | 86 +++++++++++++++++++ src/main/runtime/orchestration/db.ts | 25 ++++++ 3 files changed, 119 insertions(+) create mode 100644 src/main/runtime/orchestration/db-empty-dispatch-shortcircuit.benchmark.test.ts diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 6c6cc553610..18600e15659 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -23187,6 +23187,14 @@ export class OrcaRuntimeService { if (!db) { return undefined } + // Why: this runs on every 16ms graph publish (title/status churn). With no + // dispatch rows — the overwhelming majority who never orchestrate — the + // per-terminal query fan-out below can only ever yield an empty result, so + // skip it wholesale via the DB's cached emptiness probe. Optional call so + // partial test-injected DBs without the probe fall through to the scan. + if (db.hasAnyDispatchContexts?.() === false) { + return undefined + } const contexts: Record = {} for (const leaf of this.leaves.values()) { if (!leaf.ptyId) { diff --git a/src/main/runtime/orchestration/db-empty-dispatch-shortcircuit.benchmark.test.ts b/src/main/runtime/orchestration/db-empty-dispatch-shortcircuit.benchmark.test.ts new file mode 100644 index 00000000000..991d9df4358 --- /dev/null +++ b/src/main/runtime/orchestration/db-empty-dispatch-shortcircuit.benchmark.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest' +import { OrchestrationDb } from './db' + +// Why: buildAgentOrchestrationByPaneKey issues 2 dispatch lookups per terminal +// on EVERY 16ms graph publish. For users who never orchestrate, every one of +// those queries returns nothing — pure main-thread SQLite churn. This bench +// proves the hasAnyDispatchContexts() short-circuit turns an N-terminal scan +// into a single cached probe, by counting the per-terminal +// getActiveDispatchForTerminal + getLatestDispatchForTerminal executions. + +// Simulate the buildAgentOrchestrationByPaneKey per-terminal fan-out. +function simulateGraphPublish(db: OrchestrationDb, terminalHandles: string[]): number { + if (db.hasAnyDispatchContexts() === false) { + return 0 + } + let contexts = 0 + for (const handle of terminalHandles) { + const active = db.getActiveDispatchForTerminal(handle) + const recent = active ?? db.getLatestDispatchForTerminal(handle) + if (recent) { + contexts++ + } + } + return contexts +} + +describe('orchestration empty-dispatch short-circuit (benchmark)', () => { + it('short-circuits the per-terminal query fan-out when no dispatch rows exist', () => { + const db = new OrchestrationDb(':memory:') + const handles = Array.from({ length: 100 }, (_, i) => `term_${i}`) + + // Instrument the real query methods to count executions. + let queries = 0 + const wrap = unknown>(fn: T): T => + ((...a: Parameters) => { + queries++ + return fn(...a) + }) as T + const original = { + active: db.getActiveDispatchForTerminal.bind(db), + latest: db.getLatestDispatchForTerminal.bind(db) + } + db.getActiveDispatchForTerminal = wrap(original.active) + db.getLatestDispatchForTerminal = wrap(original.latest) + + // 60 publishes/s * 5s = 300 ticks, 100 terminals each. + const TICKS = 300 + for (let t = 0; t < TICKS; t++) { + simulateGraphPublish(db, handles) + } + + // With the short-circuit, zero per-terminal dispatch queries execute. + expect(queries).toBe(0) + + // Sanity: without the short-circuit it would have been 2 * 100 * 300. + const wouldHaveBeen = 2 * handles.length * TICKS + // eslint-disable-next-line no-console + console.log( + `[bench] empty-dispatch short-circuit: ${queries} dispatch queries over ${TICKS} publishes x ${handles.length} terminals (naive path would run ${wouldHaveBeen})` + ) + expect(wouldHaveBeen).toBe(60000) + }) + + it('still runs the fan-out once a dispatch exists (correctness preserved)', () => { + const db = new OrchestrationDb(':memory:') + const task = db.createTask({ spec: 'work' }) + db.createDispatchContext(task.id, 'term_5') + const handles = Array.from({ length: 10 }, (_, i) => `term_${i}`) + + const contexts = simulateGraphPublish(db, handles) + // term_5 has an active dispatch → its context is produced. + expect(contexts).toBe(1) + }) + + it('predicate lifecycle: false when empty, true after dispatch (even completed), false after reset', () => { + const db = new OrchestrationDb(':memory:') + expect(db.hasAnyDispatchContexts()).toBe(false) + const ctx = db.createDispatchContext(db.createTask({ spec: 'work' }).id, 'term_worker') + expect(db.hasAnyDispatchContexts()).toBe(true) + // Completed rows still count — recent-completed lookups must stay valid. + db.completeDispatch(ctx.id) + expect(db.hasAnyDispatchContexts()).toBe(true) + db.resetTasks() + expect(db.hasAnyDispatchContexts()).toBe(false) + }) +}) diff --git a/src/main/runtime/orchestration/db.ts b/src/main/runtime/orchestration/db.ts index 1c4b68f9d10..b287cb0182c 100644 --- a/src/main/runtime/orchestration/db.ts +++ b/src/main/runtime/orchestration/db.ts @@ -89,6 +89,14 @@ const SCHEMA_VERSION = 6 export class OrchestrationDb { private db: Database.Database + // Why: the orchestration DB is created lazily for ALL users, but only the + // small minority who dispatch work ever have dispatch_contexts rows. The + // renderer graph publish rebuilds orchestration context on every 16ms tick + // (buildAgentOrchestrationByPaneKey), issuing 2 queries per terminal. Cache + // emptiness so the non-orchestration majority short-circuits the whole + // per-terminal fan-out. Only createDispatchContext flips this false→true. + private hasAnyDispatchContextsCache: boolean | undefined + constructor(dbPath: string | ':memory:') { this.db = new Database(dbPath) this.db.pragma('journal_mode = WAL') @@ -670,6 +678,7 @@ export class OrchestrationDb { VALUES (?, ?, ?, ?, 'dispatched', ?, datetime('now'))` ) .run(id, taskId, assigneeHandle, assigneePaneKey ?? null, priorFailures) + this.hasAnyDispatchContextsCache = true this.db.prepare("UPDATE tasks SET status = 'dispatched' WHERE id = ?").run(taskId) @@ -694,6 +703,20 @@ export class OrchestrationDb { return this.findActiveDispatchForAssignee(handle) } + /** + * Cheap "are there any dispatch rows at all" probe. When false, no terminal + * can have an active or recent-completed dispatch, so orchestration-context + * builders can skip their per-terminal query fan-out entirely. Cached after + * the first probe; createDispatchContext marks it true, resets clear it. + */ + hasAnyDispatchContexts(): boolean { + if (this.hasAnyDispatchContextsCache === undefined) { + const row = this.db.prepare('SELECT 1 FROM dispatch_contexts LIMIT 1').get() + this.hasAnyDispatchContextsCache = row !== undefined + } + return this.hasAnyDispatchContextsCache + } + private findActiveDispatchForAssignee( assigneeHandle: string, assigneePaneKey?: string @@ -956,6 +979,7 @@ export class OrchestrationDb { this.db.exec('DELETE FROM dispatch_contexts') this.db.exec('DELETE FROM tasks') this.db.exec('DELETE FROM messages') + this.hasAnyDispatchContextsCache = undefined } resetTasks(): void { @@ -963,6 +987,7 @@ export class OrchestrationDb { this.db.exec('DELETE FROM decision_gates') this.db.exec('DELETE FROM dispatch_contexts') this.db.exec('DELETE FROM tasks') + this.hasAnyDispatchContextsCache = undefined } resetMessages(): void { From d3f2a8c39a7fa1eebe73917b6f9eea52fce247f3 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 20 Jul 2026 22:41:39 -0700 Subject: [PATCH 43/58] perf(stats): move the debounced stats write off the main thread (#9697) Co-authored-by: Orca --- src/main/stats/collector-async-save.test.ts | 127 ++++++++++++++++++++ src/main/stats/collector.ts | 73 +++++++++-- 2 files changed, 188 insertions(+), 12 deletions(-) create mode 100644 src/main/stats/collector-async-save.test.ts diff --git a/src/main/stats/collector-async-save.test.ts b/src/main/stats/collector-async-save.test.ts new file mode 100644 index 00000000000..0fc34072b47 --- /dev/null +++ b/src/main/stats/collector-async-save.test.ts @@ -0,0 +1,127 @@ +import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs' +import type * as FsPromises from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +// Why: the debounced stats save must be async (off the main thread), while the +// shutdown flush() must stay synchronous. These tests pin both behaviors, prove +// the async path leaves no stray temp files, and — critically — prove an +// in-flight async write can never clobber the more-complete shutdown flush. + +let userDataDir: string +const statsPath = (): string => join(userDataDir, 'orca-stats.json') + +vi.mock('electron', () => ({ + app: { getPath: () => userDataDir } +})) + +// A controllable gate over node:fs/promises writeFile so a test can hold a +// debounced async write "in flight" while it drives a synchronous shutdown flush. +const gate = vi.hoisted(() => ({ + blocked: false, + waiters: [] as (() => void)[], + writeFileCalls: 0 +})) + +vi.mock('node:fs/promises', async () => { + const actual = await vi.importActual('node:fs/promises') + const writeFile = (async (...args: Parameters) => { + gate.writeFileCalls += 1 + if (gate.blocked) { + await new Promise((resolve) => gate.waiters.push(resolve)) + } + return actual.writeFile(...args) + }) as typeof actual.writeFile + return { ...actual, writeFile } +}) + +async function importCollector() { + return import('./collector') +} + +describe('StatsCollector async debounced save', () => { + beforeEach(() => { + userDataDir = mkdtempSync(join(tmpdir(), 'orca-stats-test-')) + gate.blocked = false + gate.waiters = [] + gate.writeFileCalls = 0 + vi.resetModules() + }) + + afterEach(() => { + rmSync(userDataDir, { recursive: true, force: true }) + vi.useRealTimers() + }) + + it('writes valid JSON via the async fs/promises path on the debounced timer, no stray temp files', async () => { + vi.useFakeTimers() + const { StatsCollector, initStatsPath } = await importCollector() + initStatsPath() + const collector = new StatsCollector() + + collector.onAgentStart('pty-1', Date.now(), 'repo-1', 'wt-1') + + await vi.advanceTimersByTimeAsync(5_000) + await vi.waitFor(() => { + expect(readdirSync(userDataDir)).toContain('orca-stats.json') + }) + + // Proves the debounced path went through async fs/promises writeFile (a + // revert to writeFileSync would leave this at 0). + expect(gate.writeFileCalls).toBeGreaterThan(0) + // No stray *.tmp files — the rename completed. + expect(readdirSync(userDataDir).filter((f) => f.endsWith('.tmp'))).toHaveLength(0) + + const parsed = JSON.parse(readFileSync(statsPath(), 'utf-8')) + expect(parsed.aggregates.totalAgentsSpawned).toBe(1) + expect(Array.isArray(parsed.events)).toBe(true) + }) + + it('flush() writes synchronously (no timer, immediately on disk)', async () => { + const { StatsCollector, initStatsPath } = await importCollector() + initStatsPath() + const collector = new StatsCollector() + + collector.onAgentStart('pty-2', Date.now()) + collector.flush() + + // Synchronous: the file exists immediately with no awaiting. + const parsed = JSON.parse(readFileSync(statsPath(), 'utf-8')) + expect(parsed.aggregates.totalAgentsSpawned).toBe(1) + }) + + it('an in-flight async write is vetoed by a shutdown flush (no data-loss clobber)', async () => { + const { StatsCollector, initStatsPath } = await importCollector() + initStatsPath() + const collector = new StatsCollector() + // White-box: drive the two writers directly so the race is deterministic + // (the debounce path calls writeToDiskAsync; flush() calls writeToDiskSync). + const internals = collector as unknown as { writeToDiskAsync: () => Promise } + + collector.onAgentStart('pty-a', 1_000) + collector.onAgentStart('pty-b', 1_000) + + // Start the async write; it parks inside the blocked writeFile, before rename. + gate.blocked = true + const inflight = internals.writeToDiskAsync() + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(gate.writeFileCalls).toBeGreaterThan(0) + + // App quits: close out both agents and flush the COMPLETE state synchronously + // (newer generation) while the async write is still parked. + collector.onAgentStop('pty-a', 5_000) + collector.onAgentStop('pty-b', 5_000) + collector.flush() + expect(JSON.parse(readFileSync(statsPath(), 'utf-8')).aggregates.totalAgentTimeMs).toBe(8_000) + + // Release the parked write; its older generation must be vetoed — the + // flushed data must survive and no temp file may leak. + gate.blocked = false + gate.waiters.splice(0).forEach((resolve) => resolve()) + await inflight + + expect(JSON.parse(readFileSync(statsPath(), 'utf-8')).aggregates.totalAgentTimeMs).toBe(8_000) + expect(readdirSync(userDataDir).filter((f) => f.endsWith('.tmp'))).toHaveLength(0) + }) +}) diff --git a/src/main/stats/collector.ts b/src/main/stats/collector.ts index 562e4de9681..508aa0a172a 100644 --- a/src/main/stats/collector.ts +++ b/src/main/stats/collector.ts @@ -1,5 +1,6 @@ import { app } from 'electron' import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync } from 'node:fs' +import { writeFile, mkdir, rm } from 'node:fs/promises' import { join, dirname } from 'node:path' import type { StatsSummary } from '../../shared/types' import type { StatsEvent, StatsAggregates, StatsFile } from './types' @@ -55,6 +56,10 @@ export class StatsCollector { private aggregates: StatsAggregates private liveAgents = new Map() // ptyId → startTimestamp private writeTimer: ReturnType | null = null + // Monotonic id stamped on each prepared payload; the highest committed one + // wins so a slow in-flight async write can't clobber a newer sync flush. + private writeGeneration = 0 + private lastCommittedGeneration = 0 // Why: star-nag lives in its own service but needs to observe the running // agent-spawned counter. A lightweight listener avoids cyclic imports and // keeps StatsCollector unaware of how the counter is consumed. @@ -226,11 +231,15 @@ export class StatsCollector { } this.writeTimer = setTimeout(() => { this.writeTimer = null - try { - this.writeToDiskSync() - } catch (err) { + // Why: the debounced save is fun-stats telemetry, not crash-critical + // state, so it uses the async writer to move the ~900KB tmp-file write + // off the main thread (the stringify stays sync — see prepareWritePayload). + // A chatty multi-agent session re-arms this every 5s; a fully-sync write + // is a recurring main-thread stall. Shutdown flush() stays synchronous; + // the generation guard keeps the two paths race-safe. + void this.writeToDiskAsync().catch((err) => { console.error('[stats] Failed to write stats:', err) - } + }) }, DEBOUNCE_MS) } @@ -241,12 +250,18 @@ export class StatsCollector { } } - private writeToDiskSync(): void { + // Serialize the current state and pick a unique temp path. Trimming mutates + // in-memory state and JSON.stringify must see a consistent snapshot, so both + // writers call this synchronously before any await to avoid a torn snapshot. + // The monotonic generation lets a later write veto an earlier, still-in-flight + // one so a stale rename can never win (see writeToDiskAsync). + private prepareWritePayload(): { + statsFile: string + tmpFile: string + json: string + generation: number + } { const statsFile = getStatsFile() - const dir = dirname(statsFile) - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }) - } // Trim events to bounded size before writing if (this.events.length > MAX_EVENTS) { @@ -259,10 +274,44 @@ export class StatsCollector { aggregates: this.aggregates } - // Why unique temp file: same race-safe pattern as persistence.ts:120 — - // synchronous flushes can race the debounced writer during shutdown. + const generation = ++this.writeGeneration + // Unique temp file so the async debounced writer and the sync shutdown + // flush never write the same temp path (same pattern as persistence.ts). const tmpFile = `${statsFile}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp` - writeFileSync(tmpFile, JSON.stringify(data), 'utf-8') + return { statsFile, tmpFile, json: JSON.stringify(data), generation } + } + + private writeToDiskSync(): void { + const dir = dirname(getStatsFile()) + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }) + } + const { statsFile, tmpFile, json, generation } = this.prepareWritePayload() + writeFileSync(tmpFile, json, 'utf-8') renameSync(tmpFile, statsFile) + this.lastCommittedGeneration = Math.max(this.lastCommittedGeneration, generation) + } + + private async writeToDiskAsync(): Promise { + const dir = dirname(getStatsFile()) + if (!existsSync(dir)) { + await mkdir(dir, { recursive: true }) + } + const { statsFile, tmpFile, json, generation } = this.prepareWritePayload() + // Only the ~900KB tmp write moves off the main thread; stringify stayed sync + // above (torn-snapshot constraint). The rename is a trivial metadata op done + // SYNCHRONOUSLY so it stays ordered with the shutdown flush's renameSync — + // an async rename could land after flush and clobber the more-complete + // shutdown data. The generation guard vetoes this write if a newer one (a + // later debounce OR the shutdown flush) already committed while we were + // writing; the check + rename run with no await between them, so the sync + // flush cannot interleave. + await writeFile(tmpFile, json, 'utf-8') + if (this.lastCommittedGeneration >= generation) { + await rm(tmpFile, { force: true }).catch(() => {}) + return + } + renameSync(tmpFile, statsFile) + this.lastCommittedGeneration = generation } } From 9b7d15362da5421e68355899eedb5f64626269a3 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 20 Jul 2026 22:41:59 -0700 Subject: [PATCH 44/58] perf(linear): lazy-load @linear/sdk off the launch parse path (#9698) Co-authored-by: Orca --- src/main/linear/client.test.ts | 10 +++++++--- src/main/linear/client.ts | 19 ++++++++++++------- src/main/linear/issues.ts | 7 +++++-- src/main/linear/linear-sdk.ts | 32 ++++++++++++++++++++++++++++++++ 4 files changed, 56 insertions(+), 12 deletions(-) create mode 100644 src/main/linear/linear-sdk.ts diff --git a/src/main/linear/client.test.ts b/src/main/linear/client.test.ts index 0f66860022e..7f518afe6e8 100644 --- a/src/main/linear/client.test.ts +++ b/src/main/linear/client.test.ts @@ -102,9 +102,13 @@ async function loadClientModule(options: SafeStorageMockOptions = {}) { return { ...actual, homedir: () => tempHome } }) class AuthenticationLinearError extends Error {} - vi.doMock('@linear/sdk', () => ({ - AuthenticationLinearError, - LinearClient: linearClientMock + // Why: client.ts loads @linear/sdk lazily via ./linear-sdk (createRequire) to + // keep it off the startup parse path; mock the loader, not the raw module. + vi.doMock('./linear-sdk', () => ({ + loadLinearSdk: () => ({ + AuthenticationLinearError, + LinearClient: linearClientMock + }) })) return import('./client') diff --git a/src/main/linear/client.ts b/src/main/linear/client.ts index bbd57fbd666..7058d130597 100644 --- a/src/main/linear/client.ts +++ b/src/main/linear/client.ts @@ -2,10 +2,11 @@ selection share one module so keychain-safe status reads and token mutation stay in one consistency boundary. */ import { safeStorage } from 'electron' -import { LinearClient, AuthenticationLinearError } from '@linear/sdk' +import type { LinearClient } from '@linear/sdk' import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' import { homedir } from 'node:os' import { join } from 'node:path' +import { loadLinearSdk } from './linear-sdk' import { CredentialDecryptionError, credentialFileHasContent, @@ -515,7 +516,7 @@ export function getClient(workspaceId?: string | null): LinearClient | null { if (!token) { return null } - return new LinearClient({ apiKey: token }) + return new (loadLinearSdk().LinearClient)({ apiKey: token }) } export function getClients( @@ -546,13 +547,17 @@ export function getClients( if (!token) { continue } - clients.push({ workspace, client: new LinearClient({ apiKey: token }), apiKey: token }) + clients.push({ + workspace, + client: new (loadLinearSdk().LinearClient)({ apiKey: token }), + apiKey: token + }) } return clients } export function getPublicFileUrlClient(entry: LinearClientForWorkspace): LinearClient { - return new LinearClient({ + return new (loadLinearSdk().LinearClient)({ apiKey: entry.apiKey, headers: { 'public-file-urls-expire-in': String(LINEAR_PUBLIC_FILE_URL_EXPIRY_SECONDS) @@ -565,7 +570,7 @@ export function getPublicFileUrlClient(entry: LinearClientForWorkspace): LinearC // renderer. All other errors are swallowed with console.warn to match GitHub // client's graceful degradation. export function isAuthError(error: unknown): boolean { - return error instanceof AuthenticationLinearError + return error instanceof loadLinearSdk().AuthenticationLinearError } // ── Connect / disconnect / status ──────────────────────────────────── @@ -575,7 +580,7 @@ export async function connect( { ok: true; viewer: LinearViewer; workspace: LinearWorkspace } | { ok: false; error: string } > { try { - const client = new LinearClient({ apiKey }) + const client = new (loadLinearSdk().LinearClient)({ apiKey }) const me = await client.viewer const org = await me.organization const workspace = workspaceFromLinearData(me, org) @@ -670,7 +675,7 @@ export async function testConnection( } try { - const client = new LinearClient({ apiKey: token }) + const client = new (loadLinearSdk().LinearClient)({ apiKey: token }) const me = await client.viewer const org = await me.organization const workspace = workspaceFromLinearData(me, org) diff --git a/src/main/linear/issues.ts b/src/main/linear/issues.ts index e4509c9e6a6..ac0f854c739 100644 --- a/src/main/linear/issues.ts +++ b/src/main/linear/issues.ts @@ -9,7 +9,8 @@ import type { LinearWorkspaceError, LinearWorkspaceSelection } from '../../shared/types' -import { LinearClient } from '@linear/sdk' +import type { LinearClient } from '@linear/sdk' +import { loadLinearSdk } from './linear-sdk' import { LINEAR_ISSUE_API_PAGE_SIZE_MAX, clampLinearIssueListLimit @@ -602,7 +603,9 @@ async function runLinearWrite( ): Promise { await acquire() try { - const client = signal ? new LinearClient({ apiKey: entry.apiKey, signal }) : entry.client + const client = signal + ? new (loadLinearSdk().LinearClient)({ apiKey: entry.apiKey, signal }) + : entry.client return await write(client) } catch (error) { if (error instanceof LinearWriteFailure) { diff --git a/src/main/linear/linear-sdk.ts b/src/main/linear/linear-sdk.ts new file mode 100644 index 00000000000..7a41b6bbfeb --- /dev/null +++ b/src/main/linear/linear-sdk.ts @@ -0,0 +1,32 @@ +import type { LinearClient } from '@linear/sdk' +import { createRequire } from 'node:module' + +// The subset of @linear/sdk that client.ts constructs / type-checks. Declared +// structurally (not `typeof import('@linear/sdk')`) so this loader never forces +// an eager module import and satisfies the no-`import()`-type lint rule. +export type LinearSdkModule = { + LinearClient: new (options: { + apiKey: string + headers?: Record + signal?: AbortSignal + }) => LinearClient + AuthenticationLinearError: new (...args: never[]) => Error +} + +// Why: @linear/sdk is a ~2.6MB CJS bundle (~33ms to parse). The Linear client +// module is imported on the main-process startup path (orca-runtime + ipc), but +// the SDK itself is only needed once the user actually acts on Linear. Load it +// lazily via createRequire so launch never parses it for the majority who never +// connect Linear, and cache it so repeat calls are free. The accessor stays +// synchronous so the client factories don't have to become async. +// +// Kept in its own module (rather than a bare createRequire in client.ts) so +// tests can mock the loader — a raw createRequire bypasses vitest's module +// registry, but `vi.doMock('./linear-sdk', …)` on this module works. +const requireFromMain = createRequire(__filename) +let cached: LinearSdkModule | null = null + +export function loadLinearSdk(): LinearSdkModule { + cached ??= requireFromMain('@linear/sdk') as LinearSdkModule + return cached +} From 6785f9d1a83906389837ba497e0e279b9cd50be6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 20 Jul 2026 22:55:22 -0700 Subject: [PATCH 45/58] feat(dashboard): open-worktree dialog control + finished-time parity (#9686) * feat(dashboard): open-worktree dialog control + finished-time parity Pop-out agent dashboard polish: - Terminal dialog: replace the footer Close button with the standard top-right X, rename the primary action from Focus worktree to Open worktree, and prefix it with an open-in-window icon. - Card time column: finished agents now read time-since-finish (matching the left worktree sidebar) instead of time-since-start. Extracts the sidebar's finished-timestamp logic into a shared helper so the two surfaces cannot drift, and threads finishedAt through the dashboard snapshot contract. * fix(dashboard): thread finishedAt through payload validator + test fixture CI typecheck caught a DashboardCard fixture in dashboard-payload-validation.test.ts missing the new finishedAt field; also validate finishedAt in the relay guard. * feat(dashboard): swap pending-question emoji for MessageCircleQuestion icon The amber pending-question chip used a raw hand emoji (renders differently per-OS and off the design system); use the lucide chat-with-question icon. --- .../ipc/dashboard-payload-validation.test.ts | 1 + src/main/ipc/dashboard-payload-validation.ts | 1 + .../AgentKanbanBoard.test.tsx | 1 + .../dashboard-popout/AgentKanbanCard.test.tsx | 1 + .../dashboard-popout/AgentKanbanCard.tsx | 20 +++++++++----- .../dashboard-popout/AgentTerminalDialog.tsx | 12 +++------ .../dashboard/agent-finished-timestamp.ts | 27 +++++++++++++++++++ .../dashboard/build-dashboard-snapshot.ts | 2 ++ .../worktree-card-compact-agent-row.tsx | 19 +------------ src/renderer/src/i18n/locales/en.json | 3 +-- src/renderer/src/i18n/locales/es.json | 1 - src/renderer/src/i18n/locales/ja.json | 1 - src/renderer/src/i18n/locales/ko.json | 1 - src/renderer/src/i18n/locales/zh.json | 1 - src/shared/dashboard-snapshot.ts | 4 +++ 15 files changed, 57 insertions(+), 38 deletions(-) create mode 100644 src/renderer/src/components/dashboard/agent-finished-timestamp.ts diff --git a/src/main/ipc/dashboard-payload-validation.test.ts b/src/main/ipc/dashboard-payload-validation.test.ts index 1a40ce22ec6..3ae3e85fdf1 100644 --- a/src/main/ipc/dashboard-payload-validation.test.ts +++ b/src/main/ipc/dashboard-payload-validation.test.ts @@ -21,6 +21,7 @@ const SNAPSHOT = { repoName: 'Orca', worktreeName: 'Dashboard', startedAt: 1_699_999_000_000, + finishedAt: null, stateChangedAt: 1_699_999_500_000, unseen: true, askSummary: '{"question":"Proceed?"}' diff --git a/src/main/ipc/dashboard-payload-validation.ts b/src/main/ipc/dashboard-payload-validation.ts index a729db97f4d..54168d1904a 100644 --- a/src/main/ipc/dashboard-payload-validation.ts +++ b/src/main/ipc/dashboard-payload-validation.ts @@ -77,6 +77,7 @@ function isDashboardCard(value: unknown): boolean { isBoundedString(card.repoName, MAX_LABEL_LENGTH, true) && isBoundedString(card.worktreeName, MAX_LABEL_LENGTH, true) && isFiniteNumber(card.startedAt) && + (card.finishedAt === null || isFiniteNumber(card.finishedAt)) && isFiniteNumber(card.stateChangedAt) && typeof card.unseen === 'boolean' && isOptionalBoundedString(card.askSummary, AGENT_STATUS_INTERACTIVE_PROMPT_MAX_LENGTH) diff --git a/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx b/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx index da866c17ac4..a9bd26804e0 100644 --- a/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx @@ -60,6 +60,7 @@ function card(overrides: Partial): DashboardCard { repoName: 'Repo', worktreeName: 'wt', startedAt: 0, + finishedAt: null, stateChangedAt: 0, unseen: false, ...overrides diff --git a/src/renderer/src/components/dashboard-popout/AgentKanbanCard.test.tsx b/src/renderer/src/components/dashboard-popout/AgentKanbanCard.test.tsx index 9e2712e2e6c..d338a21d381 100644 --- a/src/renderer/src/components/dashboard-popout/AgentKanbanCard.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentKanbanCard.test.tsx @@ -35,6 +35,7 @@ function card(overrides: Partial = {}): DashboardCard { repoName: 'Orca', worktreeName: 'dashboard-review', startedAt: 1_000, + finishedAt: null, stateChangedAt: 1_000, unseen: false, ...overrides diff --git a/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx b/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx index ef4d6ddec32..2ae818b5dbd 100644 --- a/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx @@ -1,5 +1,6 @@ import { memo } from 'react' import { useTranslation } from 'react-i18next' +import { MessageCircleQuestion } from 'lucide-react' import { AgentIcon } from '@/lib/agent-catalog' import { agentTypeToIconAgent, formatAgentTypeLabel } from '@/lib/agent-status' import { AgentStateDot } from '@/components/AgentStateDot' @@ -26,6 +27,12 @@ function formatStartedAgo(startedAt: number, now: number): string { }) } +/** The timestamp the card's time column counts from: since it finished when the + * agent has completed, else since it started — parity with the worktree sidebar. */ +function displayTimestamp(card: DashboardCard): number { + return card.finishedAt ?? card.startedAt +} + function sameCard(a: DashboardCard, b: DashboardCard): boolean { return ( a.paneKey === b.paneKey && @@ -43,6 +50,7 @@ function sameCard(a: DashboardCard, b: DashboardCard): boolean { a.repoName === b.repoName && a.worktreeName === b.worktreeName && a.startedAt === b.startedAt && + a.finishedAt === b.finishedAt && a.stateChangedAt === b.stateChangedAt && a.unseen === b.unseen && a.askSummary === b.askSummary @@ -118,16 +126,16 @@ export const AgentKanbanCard = memo( {card.askSummary ? (
- ✋ + {card.askSummary}
) : null}
{card.repoName} - {card.startedAt > 0 ? ( + {displayTimestamp(card) > 0 ? ( - {formatStartedAgo(card.startedAt, now)} + {formatStartedAgo(displayTimestamp(card), now)} ) : null}
@@ -137,7 +145,7 @@ export const AgentKanbanCard = memo( (previous, next) => previous.onOpenTerminal === next.onOpenTerminal && sameCard(previous.card, next.card) && - (previous.card.startedAt <= 0 || - formatStartedAgo(previous.card.startedAt, previous.now) === - formatStartedAgo(next.card.startedAt, next.now)) + (displayTimestamp(previous.card) <= 0 || + formatStartedAgo(displayTimestamp(previous.card), previous.now) === + formatStartedAgo(displayTimestamp(next.card), next.now)) ) diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.tsx index 76177fe955b..29d9ef9f310 100644 --- a/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.tsx @@ -1,4 +1,5 @@ import { useCallback } from 'react' +import { SquareArrowOutUpRight } from 'lucide-react' import { AgentIcon } from '@/lib/agent-catalog' import { agentTypeToIconAgent, formatAgentTypeLabel } from '@/lib/agent-status' import { agentStateLabel } from '@/components/AgentStateDot' @@ -41,7 +42,6 @@ export function AgentTerminalDialog({ {card ? ( )} -
- - - +
diff --git a/src/renderer/src/components/dashboard/agent-finished-timestamp.ts b/src/renderer/src/components/dashboard/agent-finished-timestamp.ts new file mode 100644 index 00000000000..2c81cec0be2 --- /dev/null +++ b/src/renderer/src/components/dashboard/agent-finished-timestamp.ts @@ -0,0 +1,27 @@ +import type { DashboardAgentRow } from './useDashboardData' + +/** + * The moment an agent last entered `done`, or null if it never finished (still + * working / idle without a prior completion). Shared by the left worktree + * sidebar and the pop-out dashboard so both time from the SAME event: a finished + * agent reads "N since it finished", an active one falls through to its start. + */ +export function lastEnteredDoneAt( + agent: Pick +): number | null { + // Why: idle subagent child rows are alive-but-idle (teammates persist + // between turns), not finished — fall through to the started-at timestamp. + if (agent.rowSource === 'subagent' && agent.state === 'idle') { + return null + } + const entry = agent.entry + if (entry.state === 'done') { + return entry.stateStartedAt + } + for (let i = (entry.stateHistory?.length ?? 0) - 1; i >= 0; i--) { + if (entry.stateHistory[i].state === 'done') { + return entry.stateHistory[i].startedAt + } + } + return null +} diff --git a/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts b/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts index 6a44e73c828..cce4a5cb062 100644 --- a/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts +++ b/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts @@ -8,6 +8,7 @@ import type { import { parsePaneKey } from '../../../../shared/stable-pane-id' import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry' import { applyAgentRowLineage } from './agent-row-lineage' +import { lastEnteredDoneAt } from './agent-finished-timestamp' import type { DashboardAgentRow } from './useDashboardData' import { buildWorktreeAgentRows } from '../sidebar/worktree-agent-rows' import { @@ -155,6 +156,7 @@ export function buildDashboardSnapshot( lastUserMessage: isTitleDerived ? undefined : nonEmpty(row.entry.prompt), lastAgentMessage: isTitleDerived ? undefined : nonEmpty(row.entry.lastAssistantMessage), startedAt: row.startedAt, + finishedAt: lastEnteredDoneAt(row), stateChangedAt: row.entry.stateStartedAt || row.startedAt, // Same derivation as WorktreeCardAgents' unvisitedByPaneKey, so the // board and the sidebar bold/mute the same agents at the same time. diff --git a/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx b/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx index 125c8528cc4..5282fe65422 100644 --- a/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx +++ b/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx @@ -8,6 +8,7 @@ import { cn } from '@/lib/utils' import { getAgentDotState } from './worktree-card-agent-summary' import { translate } from '@/i18n/i18n' import { getAgentRowPrimaryText } from '@/lib/agent-row-primary-text' +import { lastEnteredDoneAt } from '@/components/dashboard/agent-finished-timestamp' import CacheTimer, { usePromptCacheCountdownForPane } from './CacheTimer' function formatShortTimeAgo(ts: number, now: number): string { @@ -26,24 +27,6 @@ function formatShortTimeAgo(ts: number, now: number): string { return `${Math.floor(hours / 24)}d` } -function lastEnteredDoneAt(agent: DashboardAgentRowData): number | null { - // Why: idle subagent child rows are alive-but-idle (teammates persist - // between turns), not finished — fall through to the started-at timestamp. - if (agent.rowSource === 'subagent' && agent.state === 'idle') { - return null - } - const entry = agent.entry - if (entry.state === 'done') { - return entry.stateStartedAt - } - for (let i = (entry.stateHistory?.length ?? 0) - 1; i >= 0; i--) { - if (entry.stateHistory[i].state === 'done') { - return entry.stateHistory[i].startedAt - } - } - return null -} - function getCompactAgentPrimary(agent: DashboardAgentRowData): string { const prompt = getAgentRowPrimaryText(agent.entry) return prompt || agentStateLabel(getAgentDotState(agent)) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 75da8e7fe0d..6ebb791d9e6 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -13624,8 +13624,7 @@ }, "terminal": { "closed": "No live terminal — this agent's pane has closed.", - "close": "Close", - "focusWorktree": "Focus worktree" + "focusWorktree": "Open worktree" } }, "dashboard": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 56c09e447a0..92677acdd91 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -13601,7 +13601,6 @@ }, "terminal": { "closed": "No live terminal — this agent's pane has closed.", - "close": "Close", "focusWorktree": "Focus worktree" } }, diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 4bcf784d0ff..2181d64b8e6 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -13601,7 +13601,6 @@ }, "terminal": { "closed": "No live terminal — this agent's pane has closed.", - "close": "Close", "focusWorktree": "Focus worktree" } }, diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index d3c48681811..b5b1f87d739 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -13601,7 +13601,6 @@ }, "terminal": { "closed": "No live terminal — this agent's pane has closed.", - "close": "Close", "focusWorktree": "Focus worktree" } }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 0e45d3fcdb8..a62090c51d7 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -13601,7 +13601,6 @@ }, "terminal": { "closed": "No live terminal — this agent's pane has closed.", - "close": "Close", "focusWorktree": "Focus worktree" } }, diff --git a/src/shared/dashboard-snapshot.ts b/src/shared/dashboard-snapshot.ts index 2f63f451df4..0f3296c4e1d 100644 --- a/src/shared/dashboard-snapshot.ts +++ b/src/shared/dashboard-snapshot.ts @@ -45,6 +45,10 @@ export type DashboardCard = { worktreeName: string /** "Started … ago" display. */ startedAt: number + /** When the agent last entered `done`, or null if it never finished. Drives + * the card's time column: finished cards read time-since-finish (parity with + * the left worktree sidebar), active cards fall back to startedAt. */ + finishedAt: number | null /** When the agent entered its current state — column ordering key (cards * that moved into a bucket most recently sort first). 0 when unknown. */ stateChangedAt: number From 4877d0b65c71a4e4346940fe61766af24238c6ce Mon Sep 17 00:00:00 2001 From: Cal <99893432+adityachaudhary99@users.noreply.github.com> Date: Tue, 21 Jul 2026 11:34:37 +0530 Subject: [PATCH 46/58] Fix Windows Resource Manager memory and CPU attribution (#8845) Use a single CIM sample for Windows process memory and CPU attribution, with bounded Typeperf memory fallback and safe CPU delta recovery. --- src/main/memory/collector.test.ts | 486 ++++++++++++++++-- src/main/memory/collector.ts | 78 +-- .../windows-process-resource-collector.ts | 341 ++++++++++++ 3 files changed, 786 insertions(+), 119 deletions(-) create mode 100644 src/main/memory/windows-process-resource-collector.ts diff --git a/src/main/memory/collector.test.ts b/src/main/memory/collector.test.ts index 0b459cb5ed1..63f8026b870 100644 --- a/src/main/memory/collector.test.ts +++ b/src/main/memory/collector.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import os from 'node:os' import type { MemorySnapshotStore } from './collector' type AppMetricFixture = { @@ -8,8 +9,9 @@ type AppMetricFixture = { memory: { workingSetSize: number } } -const { appMetricsMock, execMock, listRegisteredPtysMock } = vi.hoisted(() => ({ +const { appMetricsMock, execFileMock, execMock, listRegisteredPtysMock } = vi.hoisted(() => ({ appMetricsMock: vi.fn<() => AppMetricFixture[]>(() => []), + execFileMock: vi.fn(), execMock: vi.fn(), listRegisteredPtysMock: vi.fn() })) @@ -22,7 +24,13 @@ vi.mock('electron', () => ({ vi.mock('child_process', () => ({ exec: (cmd: string, opts: unknown, cb: (err: Error | null, out: { stdout: string }) => void) => - execMock(cmd, opts, cb) + execMock(cmd, opts, cb), + execFile: ( + file: string, + args: string[], + opts: unknown, + cb: (err: Error | null, stdout: string, stderr: string) => void + ) => execFileMock(file, args, opts, cb) })) vi.mock('./pty-registry', () => ({ @@ -34,6 +42,11 @@ async function loadCollector() { return await import('./collector') } +async function loadWindowsProcessResourceCollector() { + vi.resetModules() + return await import('./windows-process-resource-collector') +} + const emptyStore = { getWorktreeMeta: () => undefined, getRepo: () => undefined @@ -102,48 +115,65 @@ describe('parsePsOutput', () => { }) }) -describe('parseWmicOutput', () => { - it('emits one row per blank-line-delimited stanza', async () => { - const { parseWmicOutput } = await loadCollector() - const stdout = [ - 'ParentProcessId=1', - 'ProcessId=100', - 'WorkingSetSize=2048', - '', - 'ParentProcessId=100', - 'ProcessId=200', - 'WorkingSetSize=1024', - '' - ].join('\r\n') +describe('parseWindowsProcessOutput', () => { + it('parses tab-delimited CIM process rows', async () => { + const { parseWindowsProcessOutput } = await loadWindowsProcessResourceCollector() - const rows = parseWmicOutput(stdout) - - expect(rows).toEqual([ + expect(parseWindowsProcessOutput('100\t1\t2048\r\n200\t100\t1024')).toEqual([ { pid: 100, ppid: 1, cpu: 0, memory: 2048 }, { pid: 200, ppid: 100, cpu: 0, memory: 1024 } ]) }) - it('flushes the final stanza even without a trailing blank line', async () => { - const { parseWmicOutput } = await loadCollector() - const rows = parseWmicOutput('ProcessId=100\nParentProcessId=1\nWorkingSetSize=512') - expect(rows).toEqual([{ pid: 100, ppid: 1, cpu: 0, memory: 512 }]) + it('skips malformed rows and clamps invalid memory to zero', async () => { + const { parseWindowsProcessOutput } = await loadWindowsProcessResourceCollector() + + expect( + parseWindowsProcessOutput( + [ + 'garbage', + 'abc\t1\t100', + '10\txyz\t100', + '0\t0\t100', + '-5\t0\t100', + '30\t-1\t100', + '20\t1\t-50' + ].join('\n') + ) + ).toEqual([{ pid: 20, ppid: 1, cpu: 0, memory: 0 }]) }) - it('skips stanzas missing pid or ppid', async () => { - const { parseWmicOutput } = await loadCollector() - // Why: wmic occasionally emits a stanza with only WorkingSetSize set - // (e.g. a process that exited mid-query). Dropping such rows avoids - // injecting ghost zero-pid entries into the index. - const stdout = ['WorkingSetSize=999', '', 'ProcessId=100', 'ParentProcessId=1'].join('\n') - const rows = parseWmicOutput(stdout) - expect(rows).toEqual([{ pid: 100, ppid: 1, cpu: 0, memory: 0 }]) + it('preserves empty CIM field positions instead of shifting CPU ticks into memory', async () => { + const { parseWindowsProcessOutput } = await loadWindowsProcessResourceCollector() + + expect(parseWindowsProcessOutput('100\t1\t\t200\t300\t638830000000000000')).toEqual([ + { pid: 100, ppid: 1, cpu: 0, memory: 0 } + ]) + }) +}) + +describe('parseTypeperfProcessOutput', () => { + it('joins PID, parent PID, and working-set counters by process instance', async () => { + const { parseTypeperfProcessOutput } = await loadWindowsProcessResourceCollector() + const stdout = [ + '"(PDH-CSV 4.0)","\\\\HOST\\Process(node)\\ID Process","\\\\HOST\\Process(node#1)\\ID Process","\\\\HOST\\Process(node)\\Creating Process ID","\\\\HOST\\Process(node#1)\\Creating Process ID","\\\\HOST\\Process(node)\\Working Set","\\\\HOST\\Process(node#1)\\Working Set"', + '"07/15/2026 01:44:54.514","100.000000","200.000000","1.000000","100.000000","2048.000000","4096.000000"' + ].join('\r\n') + + expect(parseTypeperfProcessOutput(stdout)).toEqual([ + { pid: 100, ppid: 1, cpu: 0, memory: 2048 }, + { pid: 200, ppid: 100, cpu: 0, memory: 4096 } + ]) }) - it('ignores lines without an equals separator', async () => { - const { parseWmicOutput } = await loadCollector() - const rows = parseWmicOutput(['garbage line', 'ProcessId=5', 'ParentProcessId=1'].join('\n')) - expect(rows).toEqual([{ pid: 5, ppid: 1, cpu: 0, memory: 0 }]) + it('ignores aggregate and incomplete rows and clamps invalid memory', async () => { + const { parseTypeperfProcessOutput } = await loadWindowsProcessResourceCollector() + const stdout = [ + '"(PDH-CSV 4.0)","\\\\HOST\\Process(_Total)\\ID Process","\\\\HOST\\Process(cmd)\\ID Process","\\\\HOST\\Process(orphan)\\ID Process","\\\\HOST\\Process(_Total)\\Creating Process ID","\\\\HOST\\Process(cmd)\\Creating Process ID","\\\\HOST\\Process(_Total)\\Working Set","\\\\HOST\\Process(cmd)\\Working Set"', + '"time","0.000000","100.000000","200.000000","0.000000","1.000000","999999.000000","-1.000000"' + ].join('\r\n') + + expect(parseTypeperfProcessOutput(stdout)).toEqual([{ pid: 100, ppid: 1, cpu: 0, memory: 0 }]) }) }) @@ -209,21 +239,27 @@ describe('collectSubtree', () => { describe('collectMemorySnapshot', () => { beforeEach(() => { + vi.restoreAllMocks() appMetricsMock.mockReset() appMetricsMock.mockReturnValue([]) + execFileMock.mockReset() execMock.mockReset() listRegisteredPtysMock.mockReset() listRegisteredPtysMock.mockReturnValue([]) }) function mockPsResponse(stdout: string) { - const processStdout = process.platform === 'win32' ? psFixtureToWmic(stdout) : stdout - execMock.mockImplementation((_cmd, _opts, cb) => - cb(null, { stdout: processStdout, stderr: '' }) - ) + execMock.mockImplementation((_cmd, _opts, cb) => cb(null, { stdout, stderr: '' })) + execFileMock.mockImplementation((file, _args, _opts, cb) => { + const output = + file === 'typeperf.exe' + ? psFixtureToTypeperfOutput(stdout) + : psFixtureToWindowsProcessOutput(stdout) + cb(null, output, '') + }) } - function psFixtureToWmic(stdout: string): string { + function psFixtureToWindowsProcessOutput(stdout: string): string { return stdout .split('\n') .map((line) => line.trim()) @@ -232,14 +268,361 @@ describe('collectMemorySnapshot', () => { const [pid, ppid, _cpu, rssKb] = line.split(/\s+/, 4) const memory = Number.parseInt(rssKb ?? '', 10) return [ - `ParentProcessId=${ppid ?? ''}`, - `ProcessId=${pid ?? ''}`, - `WorkingSetSize=${Number.isFinite(memory) && memory > 0 ? memory * 1024 : 0}` - ].join('\r\n') + pid ?? '', + ppid ?? '', + Number.isFinite(memory) && memory > 0 ? memory * 1024 : 0, + '0', + '0', + '1' + ].join('\t') }) - .join('\r\n\r\n') + .join('\r\n') } + function psFixtureToTypeperfOutput(stdout: string): string { + const rows = stdout + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .map((line, index) => { + const [pid, ppid, _cpu, rssKb] = line.split(/\s+/, 4) + const memoryKb = Number.parseInt(rssKb ?? '', 10) + return { + instance: `fixture${index}`, + pid: pid ?? '', + ppid: ppid ?? '', + memory: Number.isFinite(memoryKb) && memoryKb > 0 ? memoryKb * 1024 : 0 + } + }) + const counterColumns = (counter: string): string[] => + rows.map((row) => `"\\\\HOST\\Process(${row.instance})\\${counter}"`) + const valueColumns = (field: 'pid' | 'ppid' | 'memory'): string[] => + rows.map((row) => `"${row[field]}"`) + + return [ + [ + '"(PDH-CSV 4.0)"', + ...counterColumns('ID Process'), + ...counterColumns('Creating Process ID'), + ...counterColumns('Working Set') + ].join(','), + ['"time"', ...valueColumns('pid'), ...valueColumns('ppid'), ...valueColumns('memory')].join( + ',' + ) + ].join('\r\n') + } + + function expectProcessSweepCount(count: number): void { + if (os.platform() === 'win32') { + expect(execFileMock).toHaveBeenCalledTimes(count) + return + } + expect(execMock).toHaveBeenCalledTimes(count) + } + + it('uses one CIM process for Windows memory and CPU sampling', async () => { + vi.spyOn(os, 'platform').mockReturnValue('win32') + mockPsResponse('10 1 0 1024') + const { collectMemorySnapshot } = await loadCollector() + + await collectMemorySnapshot(emptyStore) + + expect(execMock).not.toHaveBeenCalled() + expect(execFileMock).toHaveBeenCalledTimes(1) + const [file, args] = execFileMock.mock.calls[0] + expect(file).toBe('powershell.exe') + expect(args.join(' ')).toContain('Get-CimInstance Win32_Process') + expect(args.join(' ')).toContain('KernelModeTime') + expect(args.join(' ')).toContain('UserModeTime') + expect(args.join(' ')).toContain('CreationDate') + expect(execFileMock.mock.calls[0][2]).toMatchObject({ + maxBuffer: 10 * 1024 * 1024, + timeout: 5_000, + windowsHide: true + }) + }) + + it('attributes Windows process CPU from cumulative time deltas between sweeps', async () => { + vi.spyOn(os, 'platform').mockReturnValue('win32') + vi.spyOn(performance, 'now').mockReturnValueOnce(1_000).mockReturnValueOnce(3_000) + const cpuOutputs = [ + '10\t1\t1048576\t10000000\t0\t638830000000000000', + '10\t1\t1048576\t30000000\t0\t638830000000000000' + ] + execFileMock.mockImplementation((_file, _args, _opts, cb) => + cb(null, cpuOutputs.shift() ?? '', '') + ) + listRegisteredPtysMock.mockReturnValue([ + { + ptyId: 'windows-cpu-pty', + worktreeId: 'repo-1::C:\\repo', + sessionId: 'session-1', + paneKey: 'pane-1', + pid: 10 + } + ]) + const { collectMemorySnapshot } = await loadCollector() + + const first = await collectMemorySnapshot(emptyStore) + const second = await collectMemorySnapshot(emptyStore) + + expect(first.worktrees[0].sessions[0].cpu).toBe(0) + expect(second.worktrees[0].sessions[0].cpu).toBe(100) + expect(execFileMock.mock.calls.map(([file]) => file)).toEqual([ + 'powershell.exe', + 'powershell.exe' + ]) + }) + + it('does not attribute prior CPU time after Windows reuses a process id', async () => { + vi.spyOn(os, 'platform').mockReturnValue('win32') + vi.spyOn(performance, 'now').mockReturnValueOnce(1_000).mockReturnValueOnce(3_000) + const cpuOutputs = [ + '10\t1\t1048576\t10000000\t0\t638830000000000000', + '10\t1\t1048576\t30000000\t0\t638830000000000001' + ] + execFileMock.mockImplementation((_file, _args, _opts, cb) => + cb(null, cpuOutputs.shift() ?? '', '') + ) + listRegisteredPtysMock.mockReturnValue([ + { + ptyId: 'reused-pid-pty', + worktreeId: 'repo-1::C:\\repo', + sessionId: 'session-1', + paneKey: 'pane-1', + pid: 10 + } + ]) + const { collectMemorySnapshot } = await loadCollector() + + await collectMemorySnapshot(emptyStore) + const second = await collectMemorySnapshot(emptyStore) + + expect(second.worktrees[0].sessions[0].cpu).toBe(0) + }) + + it('supports cumulative CPU counters above JavaScript safe integers', async () => { + vi.spyOn(os, 'platform').mockReturnValue('win32') + vi.spyOn(performance, 'now').mockReturnValueOnce(1_000).mockReturnValueOnce(3_000) + const cpuOutputs = [ + '10\t1\t1048576\t90071992547409920\t0\t638830000000000000', + '10\t1\t1048576\t90071992567409920\t0\t638830000000000000' + ] + execFileMock.mockImplementation((_file, _args, _opts, cb) => + cb(null, cpuOutputs.shift() ?? '', '') + ) + listRegisteredPtysMock.mockReturnValue([ + { + ptyId: 'large-counter-pty', + worktreeId: 'repo-1::C:\\repo', + sessionId: 'session-1', + paneKey: 'pane-1', + pid: 10 + } + ]) + const { collectMemorySnapshot } = await loadCollector() + + await collectMemorySnapshot(emptyStore) + const second = await collectMemorySnapshot(emptyStore) + + expect(second.worktrees[0].sessions[0].cpu).toBe(100) + }) + + it('keeps the older CPU baseline when forced snapshots are too close together', async () => { + vi.spyOn(os, 'platform').mockReturnValue('win32') + vi.spyOn(performance, 'now') + .mockReturnValueOnce(1_000) + .mockReturnValueOnce(1_100) + .mockReturnValueOnce(3_000) + const cpuOutputs = [ + '10\t1\t1048576\t0\t0\t638830000000000000', + '10\t1\t1048576\t1000000\t0\t638830000000000000', + '10\t1\t1048576\t20000000\t0\t638830000000000000' + ] + execFileMock.mockImplementation((_file, _args, _opts, cb) => + cb(null, cpuOutputs.shift() ?? '', '') + ) + listRegisteredPtysMock.mockReturnValue([ + { + ptyId: 'short-sample-pty', + worktreeId: 'repo-1::C:\\repo', + sessionId: 'session-1', + paneKey: 'pane-1', + pid: 10 + } + ]) + const { collectMemorySnapshot } = await loadCollector() + + await collectMemorySnapshot(emptyStore) + const tooSoon = await collectMemorySnapshot(emptyStore) + const normalPoll = await collectMemorySnapshot(emptyStore) + + expect(tooSoon.worktrees[0].sessions[0].cpu).toBe(0) + expect(normalPoll.worktrees[0].sessions[0].cpu).toBe(100) + }) + + it('caps impossible Windows CPU deltas at the host core capacity', async () => { + vi.spyOn(os, 'platform').mockReturnValue('win32') + vi.spyOn(os, 'cpus').mockReturnValue([{}, {}] as ReturnType) + vi.spyOn(performance, 'now').mockReturnValueOnce(1_000).mockReturnValueOnce(3_000) + const cpuOutputs = [ + '10\t1\t1048576\t0\t0\t638830000000000000', + '10\t1\t1048576\t1000000000\t0\t638830000000000000' + ] + execFileMock.mockImplementation((_file, _args, _opts, cb) => + cb(null, cpuOutputs.shift() ?? '', '') + ) + listRegisteredPtysMock.mockReturnValue([ + { + ptyId: 'impossible-cpu-pty', + worktreeId: 'repo-1::C:\\repo', + sessionId: 'session-1', + paneKey: 'pane-1', + pid: 10 + } + ]) + const { collectMemorySnapshot } = await loadCollector() + + await collectMemorySnapshot(emptyStore) + const capped = await collectMemorySnapshot(emptyStore) + + expect(capped.worktrees[0].sessions[0].cpu).toBe(200) + }) + + it('warms CPU sampling again after Resource Manager was closed', async () => { + vi.spyOn(os, 'platform').mockReturnValue('win32') + vi.spyOn(performance, 'now').mockReturnValueOnce(1_000).mockReturnValueOnce(12_000) + const cpuOutputs = [ + '10\t1\t1048576\t0\t0\t638830000000000000', + '10\t1\t1048576\t100000000\t0\t638830000000000000' + ] + execFileMock.mockImplementation((_file, _args, _opts, cb) => + cb(null, cpuOutputs.shift() ?? '', '') + ) + listRegisteredPtysMock.mockReturnValue([ + { + ptyId: 'stale-counter-pty', + worktreeId: 'repo-1::C:\\repo', + sessionId: 'session-1', + paneKey: 'pane-1', + pid: 10 + } + ]) + const { collectMemorySnapshot } = await loadCollector() + + await collectMemorySnapshot(emptyStore) + const reopened = await collectMemorySnapshot(emptyStore) + + expect(reopened.worktrees[0].sessions[0].cpu).toBe(0) + }) + + it('preserves Windows process memory when CPU counters are unavailable', async () => { + vi.spyOn(os, 'platform').mockReturnValue('win32') + execFileMock.mockImplementation((_file, _args, _opts, cb) => + cb(null, '10\t1\t1048576\t\t\t638830000000000000', '') + ) + listRegisteredPtysMock.mockReturnValue([ + { + ptyId: 'cpu-failure-pty', + worktreeId: 'repo-1::C:\\repo', + sessionId: 'session-1', + paneKey: 'pane-1', + pid: 10 + } + ]) + const { collectMemorySnapshot } = await loadCollector() + + const snapshot = await collectMemorySnapshot(emptyStore) + + expect(snapshot.worktrees[0].sessions[0]).toMatchObject({ cpu: 0, memory: 1024 * 1024 }) + }) + + it('uses Typeperf during the CIM retry cooldown', async () => { + vi.spyOn(os, 'platform').mockReturnValue('win32') + execFileMock.mockImplementation((file, _args, _opts, cb) => { + if (file === 'powershell.exe') { + cb(new Error('CIM unavailable'), '', '') + return + } + cb(null, psFixtureToTypeperfOutput('10 1 0 1024'), '') + }) + listRegisteredPtysMock.mockReturnValue([ + { + ptyId: 'cim-pty', + worktreeId: null, + sessionId: null, + paneKey: null, + pid: 10 + } + ]) + const { collectMemorySnapshot } = await loadCollector() + + const first = await collectMemorySnapshot(emptyStore) + const second = await collectMemorySnapshot(emptyStore) + + expect(execFileMock).toHaveBeenCalledTimes(3) + expect(execFileMock.mock.calls.map(([file]) => file)).toEqual([ + 'powershell.exe', + 'typeperf.exe', + 'typeperf.exe' + ]) + expect(execFileMock.mock.calls[1][2]).toMatchObject({ windowsHide: true, timeout: 5_000 }) + expect(first.worktrees[0].memory).toBe(1048576) + expect(second.worktrees[0].memory).toBe(1048576) + }) + + it('retries CIM after fallback and warms CPU sampling before restoring deltas', async () => { + vi.spyOn(os, 'platform').mockReturnValue('win32') + vi.spyOn(performance, 'now') + .mockReturnValueOnce(1_000) + .mockReturnValueOnce(2_000) + .mockReturnValueOnce(31_001) + .mockReturnValueOnce(32_000) + .mockReturnValueOnce(34_000) + const cimOutputs = [ + '10\t1\t1048576\t10000000\t0\t638830000000000000', + '10\t1\t1048576\t30000000\t0\t638830000000000000' + ] + let cimCalls = 0 + execFileMock.mockImplementation((file, _args, _opts, cb) => { + if (file === 'typeperf.exe') { + cb(null, psFixtureToTypeperfOutput('10 1 0 1024'), '') + return + } + cimCalls += 1 + if (cimCalls === 1) { + cb(new Error('transient CIM failure'), '', '') + return + } + cb(null, cimOutputs.shift() ?? '', '') + }) + listRegisteredPtysMock.mockReturnValue([ + { + ptyId: 'recovering-cim-pty', + worktreeId: 'repo-1::C:\\repo', + sessionId: 'session-1', + paneKey: 'pane-1', + pid: 10 + } + ]) + const { collectMemorySnapshot } = await loadCollector() + + await collectMemorySnapshot(emptyStore) + await collectMemorySnapshot(emptyStore) + const warming = await collectMemorySnapshot(emptyStore) + const recovered = await collectMemorySnapshot(emptyStore) + + expect(execFileMock.mock.calls.map(([file]) => file)).toEqual([ + 'powershell.exe', + 'typeperf.exe', + 'typeperf.exe', + 'powershell.exe', + 'powershell.exe' + ]) + expect(warming.worktrees[0].sessions[0].cpu).toBe(0) + expect(recovered.worktrees[0].sessions[0].cpu).toBe(100) + }) + it('coalesces concurrent callers onto a single in-flight sweep', async () => { // Why: the collector exists in part to prevent a burst of renderer // polls from spawning overlapping `ps` children. If a regression ever @@ -254,7 +637,7 @@ describe('collectMemorySnapshot', () => { collectMemorySnapshot(emptyStore) ]) - expect(execMock).toHaveBeenCalledTimes(1) + expectProcessSweepCount(1) // All three callers see the same snapshot object (same promise). expect(a).toBe(b) expect(b).toBe(c) @@ -267,7 +650,7 @@ describe('collectMemorySnapshot', () => { await collectMemorySnapshot(emptyStore) await collectMemorySnapshot(emptyStore) - expect(execMock).toHaveBeenCalledTimes(2) + expectProcessSweepCount(2) }) it('uses host process RSS for Electron app metrics when available', async () => { @@ -388,14 +771,19 @@ describe('collectMemorySnapshot', () => { expect(snap.worktrees[0].memory).toBe(2048 * 1024) }) - it('returns an empty snapshot when ps fails', async () => { - execMock.mockImplementation((_cmd, _opts, cb) => cb(new Error('ps blew up'), { stdout: '' })) + it('returns an empty snapshot when process enumeration fails', async () => { + execMock.mockImplementation((_cmd, _opts, cb) => + cb(new Error('process enumeration failed'), { stdout: '' }) + ) + execFileMock.mockImplementation((_file, _args, _opts, cb) => + cb(new Error('process enumeration failed'), '', '') + ) listRegisteredPtysMock.mockReturnValue([]) const { collectMemorySnapshot } = await loadCollector() const snap = await collectMemorySnapshot(emptyStore) - // ps failure should not surface as a rejected promise or crash the + // Enumeration failure should not surface as a rejected promise or crash the // renderer; the collector swallows and returns zeros so the UI can // render an empty state. expect(snap.worktrees).toEqual([]) diff --git a/src/main/memory/collector.ts b/src/main/memory/collector.ts index a22fceac481..171c07f175b 100644 --- a/src/main/memory/collector.ts +++ b/src/main/memory/collector.ts @@ -1,8 +1,6 @@ -/* eslint-disable max-lines -- Why: the collector's platform-specific - enumeration paths (`ps` on Unix, `wmic` on Windows) plus the history - ring buffer plus the Electron bucketing live together to keep one - snapshot's worth of code in one place. Splitting them produces extra - modules whose only consumer is this file. */ +/* eslint-disable max-lines -- Why: the collector's host-process index, + history ring buffer, Electron bucketing, and worktree attribution live + together to keep one snapshot's orchestration in one place. */ /** * Memory dashboard collector. * @@ -10,7 +8,7 @@ * - Orca's own Electron processes, via `app.getAppMetrics()`, bucketed * into main / renderer / other. * - Each registered PTY's process subtree, enumerated once from a host- - * wide `ps` sweep (`wmic` on Windows). + * wide process sweep (PowerShell CIM with a Typeperf fallback on Windows). * * Memory samples are held in a per-key ring (one key per worktree, plus * a reserved app-total key) so the UI can draw a trend sparkline. @@ -39,6 +37,7 @@ import type { import type { Store } from '../persistence' import { ORPHAN_WORKTREE_ID } from '../../shared/constants' import { listRegisteredPtys } from './pty-registry' +import { enumerateWindowsProcessResources } from './windows-process-resource-collector' export type MemorySnapshotStore = Pick @@ -77,13 +76,13 @@ const PS_MAX_BUFFER = 10 * 1024 * 1024 type ProcRow = { pid: number ppid: number - /** Percent of one core (may exceed 100 on multi-core). 0 on Windows. */ + /** Percent of one core (may exceed 100 on multi-core). */ cpu: number /** Resident memory in bytes. */ memory: number } -/** Indexed view of a single `ps`/`wmic` sweep. */ +/** Indexed view of a single host process sweep. */ type ProcIndex = { byPid: Map childrenOf: Map @@ -226,69 +225,8 @@ export function parsePsOutput(stdout: string): ProcRow[] { } async function enumerateWindows(): Promise { - // Why: `wmic` ships with stock Windows and emits a plain, tab-separated - // table without the CSV-quoting edge cases PowerShell's ConvertTo-Csv - // introduces. CPU% would require delta sampling between two queries; for - // v1 we report 0% on Windows — memory attribution is the primary signal. - try { - const { stdout } = await execAsync( - 'wmic process get ProcessId,ParentProcessId,WorkingSetSize /format:value', - { maxBuffer: PS_MAX_BUFFER, timeout: PS_EXEC_TIMEOUT_MS } - ) - return parseWmicOutput(stdout) - } catch (err) { - console.warn('[memory] wmic enumeration failed', err) - return [] - } + return enumerateWindowsProcessResources() } - -/** Exported for tests: parses `wmic /format:value` stanza output. */ -export function parseWmicOutput(stdout: string): ProcRow[] { - // wmic /format:value emits stanzas of `Key=Value` lines separated by blank - // lines. We accumulate a record until a blank line, then flush. - const rows: ProcRow[] = [] - let pid = Number.NaN - let ppid = Number.NaN - let ws = Number.NaN - - const flush = (): void => { - if (!Number.isNaN(pid) && !Number.isNaN(ppid)) { - rows.push({ - pid, - ppid, - cpu: 0, - memory: Number.isFinite(ws) && ws > 0 ? ws : 0 - }) - } - pid = Number.NaN - ppid = Number.NaN - ws = Number.NaN - } - - for (const raw of stdout.split(/\r?\n/)) { - const line = raw.trim() - if (line.length === 0) { - flush() - continue - } - const eq = line.indexOf('=') - if (eq < 0) { - continue - } - const key = line.slice(0, eq) - const value = line.slice(eq + 1) - if (key === 'ProcessId') { - pid = Number.parseInt(value, 10) - } else if (key === 'ParentProcessId') { - ppid = Number.parseInt(value, 10) - } else if (key === 'WorkingSetSize') { - ws = Number.parseInt(value, 10) - } - } - flush() - return rows -} - /** Walk every descendant PID of `root`, inclusive. Exported for tests. */ export function collectSubtree(index: ProcIndex, root: number): number[] { const result: number[] = [] diff --git a/src/main/memory/windows-process-resource-collector.ts b/src/main/memory/windows-process-resource-collector.ts new file mode 100644 index 00000000000..d73ae95b8c9 --- /dev/null +++ b/src/main/memory/windows-process-resource-collector.ts @@ -0,0 +1,341 @@ +import { execFile } from 'node:child_process' +import os from 'node:os' +import { performance } from 'node:perf_hooks' +import { + iterateProcessOutputLines, + PROCESS_OUTPUT_FIELD_SCAN_MAX_CHARS +} from '../../shared/process-output-field-scanner' + +const PROCESS_QUERY_TIMEOUT_MS = 5_000 +const PROCESS_QUERY_MAX_BUFFER = 10 * 1024 * 1024 +const TYPEPERF_COUNTERS = [ + '\\Process(*)\\ID Process', + '\\Process(*)\\Creating Process ID', + '\\Process(*)\\Working Set' +] as const +const TYPEPERF_MAX_FIELDS = 8_192 +const TYPEPERF_MAX_LINE_CHARS = 1024 * 1024 +const CPU_MIN_SAMPLE_MS = 250 +const CPU_STALE_AFTER_MS = 10_000 +const HUNDRED_NS_TICKS_PER_MS = 10_000 +const CIM_RETRY_AFTER_MS = 30_000 + +export type WindowsProcessResourceRow = { + pid: number + ppid: number + /** Percent of one core (may exceed 100 on multi-core). */ + cpu: number + /** Resident memory in bytes. */ + memory: number +} + +type WindowsCpuTimes = { + cpuTicks: bigint + startTimeId: string +} + +type WindowsProcessSample = { + sampledAtMs: number + rows: WindowsProcessResourceRow[] + cpuByPid: Map +} + +type ParsedWindowsProcessSample = Omit + +type TypeperfProcessFields = { + pid?: number + ppid?: number + memory?: number +} + +let processBackend: 'cim' | 'typeperf' = 'cim' +let previousCpuSample: WindowsProcessSample | null = null +let retryCimAtMs = 0 + +export async function enumerateWindowsProcessResources(): Promise { + // Why: one CIM sweep supplies both resource values and process identity, + // avoiding a second host-wide PowerShell process on every open-popover poll. + if (processBackend === 'typeperf') { + if (performance.now() < retryCimAtMs) { + return enumerateWindowsWithTypeperf() + } + processBackend = 'cim' + } + + const sample = await enumerateWindowsWithCim() + if (sample) { + return applyWindowsCpuSample(sample) + } + // Why: avoid repeating a blocked CIM timeout every two-second poll while + // still recovering CPU attribution after a transient PowerShell failure. + processBackend = 'typeperf' + retryCimAtMs = performance.now() + CIM_RETRY_AFTER_MS + previousCpuSample = null + return enumerateWindowsWithTypeperf() +} + +function applyWindowsCpuSample(sample: WindowsProcessSample): WindowsProcessResourceRow[] { + const previous = previousCpuSample + if (!previous) { + previousCpuSample = sample + return sample.rows + } + const elapsedMs = sample.sampledAtMs - previous.sampledAtMs + if (elapsedMs < CPU_MIN_SAMPLE_MS) { + // Why: forced snapshots can land too close together for a stable rate. + // Keep the older baseline so the next normal poll spans a useful interval. + return sample.rows + } + previousCpuSample = sample + if (elapsedMs > CPU_STALE_AFTER_MS) { + // Why: closing Resource Manager or sleeping the machine leaves a stale + // baseline whose long-term average is not the current CPU usage. + return sample.rows + } + + const maxProcessCpu = Math.max(1, os.cpus().length) * 100 + for (const row of sample.rows) { + const currentTimes = sample.cpuByPid.get(row.pid) + const previousTimes = previous.cpuByPid.get(row.pid) + // Why: process start time prevents a recycled PID from inheriting the old + // process's cumulative CPU time; counter resets likewise warm up again. + if ( + !currentTimes || + !previousTimes || + currentTimes.startTimeId !== previousTimes.startTimeId || + currentTimes.cpuTicks < previousTimes.cpuTicks + ) { + continue + } + const cpuMs = Number(currentTimes.cpuTicks - previousTimes.cpuTicks) / HUNDRED_NS_TICKS_PER_MS + row.cpu = Math.min(maxProcessCpu, nonNegativeNumber((cpuMs / elapsedMs) * 100)) + } + return sample.rows +} + +async function enumerateWindowsWithCim(): Promise { + const args = [ + '-NoLogo', + '-NoProfile', + '-NonInteractive', + '-Command', + "$ErrorActionPreference = 'Stop'; $ProgressPreference = 'SilentlyContinue'; " + + 'Get-CimInstance Win32_Process -Property ProcessId,ParentProcessId,WorkingSetSize,KernelModeTime,UserModeTime,CreationDate | ' + + 'ForEach-Object { try { [string]::Join([char]9, @($_.ProcessId, $_.ParentProcessId, $_.WorkingSetSize, [string]$_.KernelModeTime, [string]$_.UserModeTime, $_.CreationDate.ToUniversalTime().Ticks)) } catch {} }' + ] + try { + const stdout = await execFileText('powershell.exe', args) + const parsed = parseWindowsProcessSample(stdout) + return parsed.rows.length > 0 ? { ...parsed, sampledAtMs: performance.now() } : null + } catch (err) { + console.warn('[memory] PowerShell process enumeration failed; falling back to typeperf', err) + return null + } +} + +async function enumerateWindowsWithTypeperf(): Promise { + try { + // Why: an immediate sample keeps the memory-only fallback inside the + // Resource Manager's two-second poll interval without inventing CPU rates. + const stdout = await execFileText('typeperf.exe', [ + ...TYPEPERF_COUNTERS, + '-sc', + '1', + '-si', + '0' + ]) + return parseTypeperfProcessOutput(stdout) + } catch (err) { + console.warn('[memory] typeperf process enumeration failed', err) + return [] + } +} + +function execFileText(file: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + execFile( + file, + args, + { + encoding: 'utf8', + maxBuffer: PROCESS_QUERY_MAX_BUFFER, + timeout: PROCESS_QUERY_TIMEOUT_MS, + windowsHide: true + }, + (err, output) => { + if (err) { + reject(err) + return + } + resolve(String(output)) + } + ) + }) +} + +function parseWindowsProcessSample(stdout: string): ParsedWindowsProcessSample { + const rows: WindowsProcessResourceRow[] = [] + const cpuByPid = new Map() + for (const line of iterateProcessOutputLines(stdout)) { + const fields = parseCimTabFields(line) + if (fields.length < 3) { + continue + } + const pid = Number.parseInt(fields[0], 10) + const ppid = Number.parseInt(fields[1], 10) + const memory = Number.parseInt(fields[2], 10) + if (!Number.isSafeInteger(pid) || pid <= 0 || !Number.isSafeInteger(ppid) || ppid < 0) { + continue + } + rows.push({ + pid, + ppid, + cpu: 0, + memory: Number.isFinite(memory) && memory > 0 ? memory : 0 + }) + + const kernelTicks = parseUnsignedBigInt(fields[3]) + const userTicks = parseUnsignedBigInt(fields[4]) + const startTimeId = fields[5] ?? '' + if ( + kernelTicks !== null && + userTicks !== null && + /^\d+$/.test(startTimeId) && + !/^0+$/.test(startTimeId) + ) { + cpuByPid.set(pid, { cpuTicks: kernelTicks + userTicks, startTimeId }) + } + } + return { rows, cpuByPid } +} + +function parseCimTabFields(line: string): string[] { + // Why: CIM serializes null properties as empty tab fields; collapsing + // whitespace would shift CPU counters into the working-set column. + if (line.length > PROCESS_OUTPUT_FIELD_SCAN_MAX_CHARS) { + return [] + } + return line.split('\t', 6).map((field) => field.trim()) +} + +/** Parse tab-delimited PowerShell CIM process rows without deriving CPU deltas. */ +export function parseWindowsProcessOutput(stdout: string): WindowsProcessResourceRow[] { + return parseWindowsProcessSample(stdout).rows +} + +/** Parse one CSV sample from Windows Typeperf. */ +export function parseTypeperfProcessOutput(stdout: string): WindowsProcessResourceRow[] { + let headers: string[] | null = null + let values: string[] | null = null + + for (const line of iterateProcessOutputLines(stdout)) { + if (!line || line.length > TYPEPERF_MAX_LINE_CHARS) { + continue + } + const fields = parseTypeperfCsvLine(line) + if (!headers && fields[0]?.startsWith('(PDH-CSV')) { + headers = fields + continue + } + if (headers && fields.length === headers.length) { + values = fields + break + } + } + + if (!headers || !values) { + return [] + } + + const byInstance = new Map() + for (let index = 1; index < headers.length; index += 1) { + const path = parseTypeperfCounterPath(headers[index]) + if (!path || path.instance === '_Total') { + continue + } + const value = Number.parseFloat(values[index]) + if (!Number.isFinite(value)) { + continue + } + const row = byInstance.get(path.instance) ?? {} + if (path.counter === 'ID Process') { + row.pid = Math.trunc(value) + } else if (path.counter === 'Creating Process ID') { + row.ppid = Math.trunc(value) + } else if (path.counter === 'Working Set') { + row.memory = value + } + byInstance.set(path.instance, row) + } + + const rows: WindowsProcessResourceRow[] = [] + for (const row of byInstance.values()) { + if (row.pid === undefined || row.pid <= 0 || row.ppid === undefined || row.ppid < 0) { + continue + } + rows.push({ + pid: row.pid, + ppid: row.ppid, + cpu: 0, + memory: row.memory !== undefined && row.memory > 0 ? row.memory : 0 + }) + } + return rows +} + +function parseTypeperfCounterPath(path: string): { instance: string; counter: string } | null { + const processStart = path.lastIndexOf('\\Process(') + const counterStart = path.lastIndexOf(')\\') + if (processStart < 0 || counterStart <= processStart + 9) { + return null + } + return { + instance: path.slice(processStart + 9, counterStart), + counter: path.slice(counterStart + 2) + } +} + +function parseTypeperfCsvLine(line: string): string[] { + const fields: string[] = [] + let value = '' + let quoted = false + + for (let index = 0; index < line.length; index += 1) { + const char = line[index] + if (char === '"') { + if (quoted && line[index + 1] === '"') { + value += '"' + index += 1 + } else { + quoted = !quoted + } + continue + } + if (char === ',' && !quoted) { + fields.push(value) + value = '' + if (fields.length >= TYPEPERF_MAX_FIELDS) { + return [] + } + continue + } + value += char + } + fields.push(value) + return fields +} + +function parseUnsignedBigInt(value: string | undefined): bigint | null { + if (!value || !/^\d+$/.test(value)) { + return null + } + try { + return BigInt(value) + } catch { + return null + } +} + +function nonNegativeNumber(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) ? Math.max(0, value) : 0 +} From f0c6ca0bf57e6e046dacf19db7c850f381aff199 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 20 Jul 2026 23:11:12 -0700 Subject: [PATCH 47/58] perf(checks-panel): gate the terminal-cwd poll on window visibility (#9695) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Checks panel polls window.api.pty.getCwd every 4s to follow the active terminal's cwd. It was gated only on panel visibility (isPanelVisible), not window visibility — so a hidden or minimized window kept spawning lsof on macOS every 4s (the 4s cadence exceeds getCwd's 1.5s per-pid cache TTL, so every tick is a guaranteed miss and a fresh subprocess). Swap the raw window.setInterval for installWindowVisibilityInterval — the shared helper every sibling timer already uses — which pauses while the window is hidden and runs an immediate refresh on becoming visible so a cd made while hidden is picked up on return. A visible-but-unfocused window (second monitor) keeps polling, matching prior behavior. Co-authored-by: Orca --- ...use-checks-panel-terminal-worktree.test.ts | 30 +++++++++++++++++++ .../use-checks-panel-terminal-worktree.ts | 17 ++++++++--- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/components/right-sidebar/use-checks-panel-terminal-worktree.test.ts b/src/renderer/src/components/right-sidebar/use-checks-panel-terminal-worktree.test.ts index 6088c511be8..7d194d2bf00 100644 --- a/src/renderer/src/components/right-sidebar/use-checks-panel-terminal-worktree.test.ts +++ b/src/renderer/src/components/right-sidebar/use-checks-panel-terminal-worktree.test.ts @@ -129,6 +129,36 @@ describe('useChecksPanelTerminalWorktree', () => { expect(latest?.worktree).toBe(parentWorktree) }) + it('does not spawn getCwd while the window is hidden, then refreshes on becoming visible', async () => { + getCwdMock.mockResolvedValue(`${CHILD_PATH}/src`) + const setVisibility = (state: 'visible' | 'hidden'): void => { + Object.defineProperty(document, 'visibilityState', { + configurable: true, + get: () => state + }) + } + setVisibility('hidden') + try { + await renderHook(parentWorktree) + await flushMicrotasks() + // Hidden window: no lsof-backed cwd probe at all. + expect(getCwdMock).not.toHaveBeenCalled() + expect(latest?.worktree).toBe(parentWorktree) + + setVisibility('visible') + await act(async () => { + document.dispatchEvent(new Event('visibilitychange')) + }) + await flushMicrotasks() + // Becoming visible runs an immediate refresh (a `cd` made while hidden + // is picked up promptly on return). + expect(getCwdMock).toHaveBeenCalledWith('pty-1') + expect(latest?.worktree).toBe(childWorktree) + } finally { + setVisibility('visible') + } + }) + it('keeps the default worktree when the cwd is outside every worktree', async () => { getCwdMock.mockResolvedValue('/tmp/scratch') diff --git a/src/renderer/src/components/right-sidebar/use-checks-panel-terminal-worktree.ts b/src/renderer/src/components/right-sidebar/use-checks-panel-terminal-worktree.ts index 67b9416f488..eb902500547 100644 --- a/src/renderer/src/components/right-sidebar/use-checks-panel-terminal-worktree.ts +++ b/src/renderer/src/components/right-sidebar/use-checks-panel-terminal-worktree.ts @@ -1,6 +1,7 @@ import { useEffect, useMemo, useState } from 'react' import { useShallow } from 'zustand/react/shallow' import { useAppStore } from '@/store' +import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval' import { useAllWorktrees, useRepoMap } from '@/store/selectors' import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection' import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' @@ -31,7 +32,8 @@ type ChecksPanelTerminalWorktree = { * the *remote* shell's OSC 7 path, which must not resolve a local worktree. * getCwd reads the local shell pid, so it is correct for that case. Remote * runtime PTYs are skipped (their cwd lives on the relay host). Polling is - * gated on panel visibility, so a hidden panel does no background work and the + * gated on both panel visibility and window visibility, so a hidden panel or a + * hidden/minimized window does no background work (no `lsof` spawns) and the * caller's fallback worktree is used. * * Resolution is scoped to locally-executing worktrees: the cwd comes from a @@ -123,11 +125,18 @@ export function useChecksPanelTerminalWorktree(args: { } } - void refresh() - const interval = window.setInterval(refresh, TERMINAL_CWD_POLL_MS) + // Why: getCwd shells out to `lsof` on macOS every tick (poll cadence > + // the 1.5s per-pid cache TTL, so each tick is a guaranteed miss). Gate on + // window visibility so a hidden/minimized window spawns no subprocesses; + // the helper runs an immediate refresh on becoming visible so a `cd` made + // while hidden is picked up promptly on return. + const stopInterval = installWindowVisibilityInterval({ + run: () => void refresh(), + intervalMs: TERMINAL_CWD_POLL_MS + }) return () => { disposed = true - window.clearInterval(interval) + stopInterval() } }, [activeTerminalPtyId, shouldPollCwd]) From 63da9588136c1511c7270d1af2c0a2866d63a970 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BD=99=E8=BE=89?= <42717106+OnlyYu1996@users.noreply.github.com> Date: Tue, 21 Jul 2026 14:19:35 +0800 Subject: [PATCH 48/58] fix(github): keep work-item searches repo-scoped (#9668) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(github): prevent unscoped work item queries * fix(review): translate non-English rationale comment to English (client.ts:1151) Restores the English '// Why:' convention and re-states the allSettled partial-results rationale that was dropped when the comment was replaced. Flagged by Ce-code-review (maintainability/previous-comments) and a prior PR reviewer. No behavior change. Co-authored-by: Orca * fix(tasks): surface repos with no resolvable GitHub source Since #9660 an unresolvable GitHub source returns an empty null-source envelope instead of an unscoped search. That empty was indistinguishable from a genuine zero-result query — no log, no telemetry, no UI signal. Surface it in the Tasks list: a per-repo info row with Retry for each fetched repo that resolved neither an issue nor a PR source (told apart from genuine-zero and not-yet-fetched purely via the cached `sources`). Renderer-only — the signal was already threaded through the envelope. - selectTaskPageUnresolvedSourceRepos selector (+ unit tests) - per-repo row reusing the existing banner region + handleRetryIssuesFetch - suppress the generic empty state while those rows show - assert the null-source envelope contract in client-work-items test Co-authored-by: Orca --------- Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Co-authored-by: Orca --- .../client-work-items-query-paging.test.ts | 2 - src/main/github/client-work-items.test.ts | 42 ++-- src/main/github/client.ts | 229 +++++++----------- .../github/gh-cwd-repo-negative-cache.test.ts | 65 ----- src/main/github/gh-cwd-repo-negative-cache.ts | 83 ------- src/renderer/src/components/TaskPage.tsx | 45 ++++ .../task-page-cache-selectors.test.ts | 49 ++++ .../components/task-page-cache-selectors.ts | 34 +++ src/renderer/src/i18n/locales/en.json | 4 +- src/renderer/src/i18n/locales/es.json | 4 +- src/renderer/src/i18n/locales/ja.json | 4 +- src/renderer/src/i18n/locales/ko.json | 4 +- src/renderer/src/i18n/locales/zh.json | 4 +- 13 files changed, 257 insertions(+), 312 deletions(-) delete mode 100644 src/main/github/gh-cwd-repo-negative-cache.test.ts delete mode 100644 src/main/github/gh-cwd-repo-negative-cache.ts diff --git a/src/main/github/client-work-items-query-paging.test.ts b/src/main/github/client-work-items-query-paging.test.ts index b3f819c2143..f9cf934e24e 100644 --- a/src/main/github/client-work-items-query-paging.test.ts +++ b/src/main/github/client-work-items-query-paging.test.ts @@ -117,7 +117,6 @@ import { _resetMergeQueueCacheForTests, _resetOwnerRepoCache } from './client' -import { _resetGhCwdRepoNegativeCache } from './gh-cwd-repo-negative-cache' import { GITHUB_WORK_ITEMS_QUERY_MAX_BYTES } from '../../shared/github-work-items-query-bounds' import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository' @@ -158,7 +157,6 @@ describe('listWorkItems query paging', () => { getOwnerRepoForRemoteMock.mockResolvedValue(null) _resetOwnerRepoCache() _resetMergeQueueCacheForTests() - _resetGhCwdRepoNegativeCache() }) it('returns zero for oversized count queries before resolving repo sources', async () => { diff --git a/src/main/github/client-work-items.test.ts b/src/main/github/client-work-items.test.ts index 0ac756c9b54..0226b2af42f 100644 --- a/src/main/github/client-work-items.test.ts +++ b/src/main/github/client-work-items.test.ts @@ -111,7 +111,6 @@ import { _resetMergeQueueCacheForTests, _resetOwnerRepoCache } from './client' -import { _resetGhCwdRepoNegativeCache } from './gh-cwd-repo-negative-cache' import { GITHUB_WORK_ITEMS_QUERY_MAX_BYTES } from '../../shared/github-work-items-query-bounds' import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository' @@ -154,7 +153,6 @@ describe('listWorkItems', () => { ) _resetOwnerRepoCache() _resetMergeQueueCacheForTests() - _resetGhCwdRepoNegativeCache() }) it('routes GHES work-item listing through the Enterprise host', async () => { @@ -172,22 +170,36 @@ describe('listWorkItems', () => { expect(calls.every(([, options]) => options?.host === 'github.acme-corp.com')).toBe(true) }) - it('stops re-spawning gh for a repo whose cwd resolution already failed with no remotes', async () => { + it('does not run unscoped work-item queries when a local repository has no GitHub source', async () => { getIssueOwnerRepoMock.mockResolvedValue(null) getOwnerRepoMock.mockResolvedValue(null) - ghExecFileAsyncMock.mockRejectedValue( - Object.assign(new Error('Command failed: gh pr list\nno git remotes found'), { - stderr: 'no git remotes found' - }) + + // Why: unresolved sources must return empty null-source envelopes (not throw, not query) so the renderer can + // distinguish "no GitHub source detected" from genuine zero via `sources` (#9660 follow-up). + await expect(listWorkItems('/private-repo', 36)).resolves.toMatchObject({ + items: [], + sources: { issues: null, prs: null } + }) + await expect(listWorkItems('/private-repo', 36, 'is:open')).resolves.toMatchObject({ + items: [], + sources: { issues: null, prs: null } + }) + + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + }) + + it('queries only the resolved PR source when the issue source is unavailable', async () => { + getIssueOwnerRepoMock.mockResolvedValue(null) + getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock.mockResolvedValue({ stdout: '[]' }) + + await expect(listWorkItems('/private-repo', 36)).resolves.toMatchObject({ items: [] }) + + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + expect.arrayContaining(['pr', 'list', '--repo', 'acme/widgets']), + { cwd: '/private-repo' } ) - - await expect(listWorkItems('/no-remote-repo', 36)).rejects.toThrow('no git remotes found') - // The first refresh pays the two cwd-fallback spawns (issue + pr list). - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) - - await expect(listWorkItems('/no-remote-repo', 36)).rejects.toThrow('no git remotes found') - // The second refresh is served from the negative cache — zero new spawns. - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) }) it('runs both issue and PR GitHub searches for a mixed query and merges the results by recency', async () => { diff --git a/src/main/github/client.ts b/src/main/github/client.ts index 6c05d286c2f..5229c187f11 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -74,12 +74,6 @@ import { } from '../source-control/hosted-review-git-options' import { shouldHideNonOpenReviewOnDefaultBranch } from '../source-control/repo-default-branch' import { readLocalGitConfigSignature } from './local-git-config-signature' -import { - getRememberedGhCwdResolutionFailure, - isGhCwdRepoResolutionFailure, - rememberGhCwdResolutionFailure -} from './gh-cwd-repo-negative-cache' -import type { GitHubRepoContext } from './github-repository-identity' import { getGitHubApiRepositoryForRemote, getIssueGitHubApiRepository, @@ -1001,7 +995,7 @@ function normalizeWorkItemPage(page: number | undefined): number { function buildWorkItemListRequest(args: { kind: 'issue' | 'pr' - ownerRepo: OwnerRepo | null + ownerRepo: OwnerRepo limit: number query: ParsedTaskQuery page: number @@ -1009,7 +1003,7 @@ function buildWorkItemListRequest(args: { const { kind, ownerRepo, limit, query, page } = args const searchParts: string[] = [] - if (kind === 'issue' && ownerRepo) { + if (kind === 'issue') { searchParts.push(`repo:${ownerRepo.owner}/${ownerRepo.repo}`) } searchParts.push(kind === 'issue' ? 'is:issue' : 'is:pr') @@ -1076,9 +1070,7 @@ function buildWorkItemListRequest(args: { '--json', WORK_ITEM_PR_LIST_JSON_FIELDS ] - if (ownerRepo) { - out.push('--repo', `${ownerRepo.owner}/${ownerRepo.repo}`) - } + out.push('--repo', `${ownerRepo.owner}/${ownerRepo.repo}`) out.push('--search', searchParts.join(' ')) return { args: out, offset: (page - 1) * limit } } @@ -1122,30 +1114,6 @@ async function resolvePrWorkItemSource( return { source, originCandidate, upstreamCandidate } } -/** - * 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, - args: string[], - ghOptions: GhExecOptions -): Promise<{ stdout: string; stderr: string }> { - const remembered = await getRememberedGhCwdResolutionFailure(context) - if (remembered !== null) { - throw new Error(remembered) - } - try { - return await ghExecFileAsync(args, ghOptions) - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - if (isGhCwdRepoResolutionFailure(message)) { - await rememberGhCwdResolutionFailure(context, message) - } - throw err - } -} - async function listRecentWorkItems( repoPath: string, issueOwnerRepo: OwnerRepo | null, @@ -1156,104 +1124,87 @@ async function listRecentWorkItems( noCache?: boolean, localGitOptions: LocalGitExecOptions = {} ): Promise { - const repoContext = githubRepoContext(repoPath, connectionId, localGitOptions) - const ghOptions = ghRepoExecOptions(repoContext) - const requiresExplicitRepo = Boolean(connectionId) + const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) assertSshRepoHasResolvedGitHubSource({ connectionId, issueOwnerRepo, prOwnerRepo }) const recentQuery = parseTaskQuery('is:open') - const issueRequest = buildWorkItemListRequest({ - kind: 'issue', - ownerRepo: issueOwnerRepo, - limit, - query: recentQuery, - page - }) - const prRequest = buildWorkItemListRequest({ - kind: 'pr', - ownerRepo: prOwnerRepo, - limit, - query: recentQuery, - page - }) - if (noCache) { + const issueRequest = issueOwnerRepo + ? buildWorkItemListRequest({ + kind: 'issue', + ownerRepo: issueOwnerRepo, + limit, + query: recentQuery, + page + }) + : null + const prRequest = prOwnerRepo + ? buildWorkItemListRequest({ + kind: 'pr', + ownerRepo: prOwnerRepo, + limit, + query: recentQuery, + page + }) + : null + if (noCache && issueRequest) { issueRequest.args.splice(1, 2) } - if (issueOwnerRepo || prOwnerRepo || requiresExplicitRepo) { - // 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, - ...githubHostExecOptions(issueOwnerRepo) - }) - : requiresExplicitRepo - ? Promise.resolve({ stdout: '[]' }) - : ghCwdResolvedExec(repoContext, issueRequest.args, ghOptions), - prOwnerRepo - ? ghExecFileAsync(prRequest.args, { ...ghOptions, ...githubHostExecOptions(prOwnerRepo) }) - : requiresExplicitRepo - ? Promise.resolve({ stdout: '[]' }) - : ghCwdResolvedExec(repoContext, prRequest.args, ghOptions) - ]) - - let issues: MainWorkItem[] = [] - let issuesError: ClassifiedError | undefined - if (issuesSettled.status === 'fulfilled') { - issues = (JSON.parse(issuesSettled.value.stdout) as Record[]) - // 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 { - const stderr = - issuesSettled.reason instanceof Error - ? issuesSettled.reason.message - : String(issuesSettled.reason) - issuesError = classifyListIssuesError(stderr) - } - - let prs: MainWorkItem[] = [] - if (prsSettled.status === 'fulfilled') { - prs = (JSON.parse(prsSettled.value.stdout) as Record[]) - .slice(prRequest.offset, prRequest.offset + limit) - .map((item) => mapPullRequestWorkItem(item, prOwnerRepo)) - prs = await hydrateWorkItemRepositoryMergeMetadata(prs, prOwnerRepo, { - ...ghOptions, - ...githubHostExecOptions(prOwnerRepo) - }) - } else { - // 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:', - issuesError.type, - issuesError.message - ) - } - throw prsSettled.reason - } - - return { - items: sortWorkItemsByNumber([...issues, ...prs]).slice(0, limit), - issuesError - } - } - - // 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) + // Why: unresolved sources must stay empty — an unscoped Search API would return other public repos' issues (#9660). + // Why: allSettled so a 403 on the issue side doesn't zero the PR half (partial results + banner). + const [issuesSettled, prsSettled] = await Promise.allSettled([ + issueRequest && issueOwnerRepo + ? ghExecFileAsync(issueRequest.args, { + ...ghOptions, + ...githubHostExecOptions(issueOwnerRepo) + }) + : Promise.resolve({ stdout: '[]' }), + prRequest && prOwnerRepo + ? ghExecFileAsync(prRequest.args, { + ...ghOptions, + ...githubHostExecOptions(prOwnerRepo) + }) + : Promise.resolve({ stdout: '[]' }) ]) - const issues = (JSON.parse(issuesResult.stdout) as Record[]) - .filter((item) => !('pull_request' in item)) - .map(mapIssueWorkItem) - const prs = (JSON.parse(prsResult.stdout) as Record[]) - .slice(prRequest.offset, prRequest.offset + limit) - .map((item) => mapPullRequestWorkItem(item, null)) + let issues: MainWorkItem[] = [] + let issuesError: ClassifiedError | undefined + if (issuesSettled.status === 'fulfilled') { + issues = (JSON.parse(issuesSettled.value.stdout) as Record[]) + // 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 { + const stderr = + issuesSettled.reason instanceof Error + ? issuesSettled.reason.message + : String(issuesSettled.reason) + issuesError = classifyListIssuesError(stderr) + } + + let prs: MainWorkItem[] = [] + if (prsSettled.status === 'fulfilled') { + prs = (JSON.parse(prsSettled.value.stdout) as Record[]) + .slice(prRequest?.offset ?? 0, (prRequest?.offset ?? 0) + limit) + .map((item) => mapPullRequestWorkItem(item, prOwnerRepo)) + prs = await hydrateWorkItemRepositoryMergeMetadata(prs, prOwnerRepo, { + ...ghOptions, + ...githubHostExecOptions(prOwnerRepo) + }) + } else { + // 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:', + issuesError.type, + issuesError.message + ) + } + throw prsSettled.reason + } return { - items: sortWorkItemsByNumber([...issues, ...prs]).slice(0, limit) + items: sortWorkItemsByNumber([...issues, ...prs]).slice(0, limit), + issuesError } } @@ -1267,9 +1218,7 @@ async function listQueriedWorkItems( connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - const repoContext = githubRepoContext(repoPath, connectionId, localGitOptions) - const ghOptions = ghRepoExecOptions(repoContext) - const requiresExplicitRepo = Boolean(connectionId) + const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) assertSshRepoHasResolvedGitHubSource({ connectionId, issueOwnerRepo, prOwnerRepo }) const hasPrOnlyFilter = query.state === 'merged' || @@ -1287,7 +1236,7 @@ async function listQueriedWorkItems( if (!issueScope) { return { items: [] } } - if (requiresExplicitRepo && !issueOwnerRepo) { + if (!issueOwnerRepo) { return { items: [] } } const request = buildWorkItemListRequest({ @@ -1298,12 +1247,10 @@ async function listQueriedWorkItems( page: page ?? 1 }) try { - const { stdout } = issueOwnerRepo - ? await ghExecFileAsync(request.args, { - ...ghOptions, - ...githubHostExecOptions(issueOwnerRepo) - }) - : await ghCwdResolvedExec(repoContext, request.args, ghOptions) + const { stdout } = await ghExecFileAsync(request.args, { + ...ghOptions, + ...githubHostExecOptions(issueOwnerRepo) + }) const items = (JSON.parse(stdout) as Record[]) .filter((item) => !('pull_request' in item)) .map(mapIssueWorkItem) @@ -1324,7 +1271,7 @@ async function listQueriedWorkItems( if (!prScope) { return [] } - if (requiresExplicitRepo && !prOwnerRepo) { + if (!prOwnerRepo) { return [] } const request = buildWorkItemListRequest({ @@ -1335,12 +1282,10 @@ async function listQueriedWorkItems( page: page ?? 1 }) try { - const { stdout } = prOwnerRepo - ? await ghExecFileAsync(request.args, { - ...ghOptions, - ...githubHostExecOptions(prOwnerRepo) - }) - : await ghCwdResolvedExec(repoContext, request.args, ghOptions) + const { stdout } = await ghExecFileAsync(request.args, { + ...ghOptions, + ...githubHostExecOptions(prOwnerRepo) + }) const mapped = (JSON.parse(stdout) as Record[]) .slice(request.offset, request.offset + limit) .map((item) => mapPullRequestWorkItem(item, prOwnerRepo)) diff --git a/src/main/github/gh-cwd-repo-negative-cache.test.ts b/src/main/github/gh-cwd-repo-negative-cache.test.ts deleted file mode 100644 index 240711143f1..00000000000 --- a/src/main/github/gh-cwd-repo-negative-cache.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' - -const { readLocalGitConfigSignatureMock } = vi.hoisted(() => ({ - readLocalGitConfigSignatureMock: vi.fn() -})) - -vi.mock('./local-git-config-signature', () => ({ - readLocalGitConfigSignature: readLocalGitConfigSignatureMock -})) - -import { - _resetGhCwdRepoNegativeCache, - getRememberedGhCwdResolutionFailure, - isGhCwdRepoResolutionFailure, - rememberGhCwdResolutionFailure -} from './gh-cwd-repo-negative-cache' - -const context = { repoPath: '/repo', connectionId: null } - -afterEach(() => { - _resetGhCwdRepoNegativeCache() - readLocalGitConfigSignatureMock.mockReset() -}) - -describe('isGhCwdRepoResolutionFailure', () => { - it('matches gh cwd-resolution failures only', () => { - expect(isGhCwdRepoResolutionFailure('Command failed: gh\nno git remotes found')).toBe(true) - expect( - isGhCwdRepoResolutionFailure( - 'none of the git remotes configured for this repository point to a known GitHub host' - ) - ).toBe(true) - expect(isGhCwdRepoResolutionFailure('gh: API rate limit exceeded (HTTP 403)')).toBe(false) - expect(isGhCwdRepoResolutionFailure('gh: Not Found (HTTP 404)')).toBe(false) - }) -}) - -describe('remembered failures', () => { - it('serves the remembered failure while the config signature is unchanged', async () => { - readLocalGitConfigSignatureMock.mockResolvedValue('sig-1') - await rememberGhCwdResolutionFailure(context, 'no git remotes found') - await expect(getRememberedGhCwdResolutionFailure(context)).resolves.toBe('no git remotes found') - }) - - it('invalidates when the git config signature changes (remote added)', async () => { - readLocalGitConfigSignatureMock.mockResolvedValueOnce('sig-1') - await rememberGhCwdResolutionFailure(context, 'no git remotes found') - readLocalGitConfigSignatureMock.mockResolvedValue('sig-2') - await expect(getRememberedGhCwdResolutionFailure(context)).resolves.toBeNull() - // The stale entry is gone, not just skipped. - readLocalGitConfigSignatureMock.mockResolvedValue('sig-1') - await expect(getRememberedGhCwdResolutionFailure(context)).resolves.toBeNull() - }) - - it('separates repos and runtimes by cache key', async () => { - readLocalGitConfigSignatureMock.mockResolvedValue('sig') - await rememberGhCwdResolutionFailure(context, 'no git remotes found') - await expect( - getRememberedGhCwdResolutionFailure({ repoPath: '/other-repo', connectionId: null }) - ).resolves.toBeNull() - await expect( - getRememberedGhCwdResolutionFailure({ repoPath: '/repo', connectionId: 'ssh-1' }) - ).resolves.toBeNull() - }) -}) diff --git a/src/main/github/gh-cwd-repo-negative-cache.ts b/src/main/github/gh-cwd-repo-negative-cache.ts deleted file mode 100644 index 97a810f5e22..00000000000 --- a/src/main/github/gh-cwd-repo-negative-cache.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { readLocalGitConfigSignature } from './local-git-config-signature' -import type { GitHubRepoContext } from './github-repository-identity' - -/** - * Negative cache for repos where gh's cwd-based repo resolution fails - * ("no git remotes found" etc). Orca only falls back to cwd resolution when - * it could not resolve an owner/repo itself, so for a remote-less repo the - * failure is deterministic — yet the Tasks page re-spawned gh for every such - * repo on every refresh (×90 repos in the reported storm). Remember the - * failure and re-throw it without a subprocess until the repo's git config - * changes (signature check) or the TTL lapses. - */ -const NEGATIVE_CACHE_TTL_MS = 5 * 60_000 -const MAX_ENTRIES = 512 - -type NegativeCacheEntry = { - message: string - configSignature: string | undefined - expiresAt: number -} - -const entries = new Map() - -function negativeCacheKey(context: GitHubRepoContext): string { - const runtimeKey = context.connectionId ?? `local:${context.wslDistro ?? 'host'}` - return `${runtimeKey}\0${context.repoPath}` -} - -export function isGhCwdRepoResolutionFailure(text: string): boolean { - const s = text.toLowerCase() - return ( - s.includes('no git remotes found') || - s.includes('not a git repository') || - s.includes('unable to determine base repository') || - s.includes('none of the git remotes configured') - ) -} - -export async function getRememberedGhCwdResolutionFailure( - context: GitHubRepoContext -): Promise { - const key = negativeCacheKey(context) - const entry = entries.get(key) - if (!entry) { - return null - } - if (entry.expiresAt <= Date.now()) { - entries.delete(key) - return null - } - // Why: adding a remote is the way out of this failure; the config signature - // (mtime/size of the repo's git config chain) changes when that happens. - const signature = await readLocalGitConfigSignature(context) - if (signature !== entry.configSignature) { - entries.delete(key) - return null - } - return entry.message -} - -export async function rememberGhCwdResolutionFailure( - context: GitHubRepoContext, - message: string -): Promise { - const configSignature = await readLocalGitConfigSignature(context) - entries.set(negativeCacheKey(context), { - message, - configSignature, - expiresAt: Date.now() + NEGATIVE_CACHE_TTL_MS - }) - while (entries.size > MAX_ENTRIES) { - const oldest = entries.keys().next() - if (oldest.done) { - break - } - entries.delete(oldest.value) - } -} - -/** @internal — test-only */ -export function _resetGhCwdRepoNegativeCache(): void { - entries.clear() -} diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index 90146054f1d..2430505d6cc 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -199,6 +199,7 @@ import { reconcileTaskPagePagesAfterLandingRefresh, reconcileTaskPagePagesWithWorkItemsCache, shouldResetTaskPagePaginationAfterLandingRefresh, + selectTaskPageUnresolvedSourceRepos, selectTaskPageWorkItemsCacheEntries, shouldReplaceTaskPageItemsAfterRefresh, type TaskPageRepoSourceState @@ -3985,6 +3986,12 @@ export default function TaskPage(): React.JSX.Element { [selectedRepos, selectedWorkItemsCacheEntries] ) + // Why: repos that fetched but resolved no GitHub source (#9660) show empty like a genuine zero-result; surface them explicitly with Retry. + const unresolvedSourceRepos = useMemo( + () => selectTaskPageUnresolvedSourceRepos(selectedRepos, perRepoSourceState), + [selectedRepos, perRepoSourceState] + ) + useEffect(() => { if (taskSource !== 'github' || githubMode !== 'items') { return @@ -9138,6 +9145,43 @@ export default function TaskPage(): React.JSX.Element { ) })} + {unresolvedSourceRepos.map((r) => ( + // Why: null-source repos (#9660) render empty like genuine zero — name the repo and offer Retry so a transient resolve blip is recoverable. +
+ + {translate( + 'auto.components.TaskPage.noGithubSourceDetected', + 'No GitHub source detected for' + )}{' '} + {r.label} —{' '} + {translate( + 'auto.components.TaskPage.noGithubSourceDetectedHint', + 'it may have no GitHub remote, or the source could not be resolved.' + )} + + +
+ ))} + {showGitHubTaskSkeletons ? ( // Why: fill a typical viewport with shimmer rows so the table doesn't jump in height when results land.
@@ -9189,6 +9233,7 @@ export default function TaskPage(): React.JSX.Element { !tasksError && !githubUnavailable && failedCount === 0 && + unresolvedSourceRepos.length === 0 && perRepoSourceState.every((s) => !s.error) ? (

diff --git a/src/renderer/src/components/task-page-cache-selectors.test.ts b/src/renderer/src/components/task-page-cache-selectors.test.ts index 71b85b8adae..b1c9251251c 100644 --- a/src/renderer/src/components/task-page-cache-selectors.test.ts +++ b/src/renderer/src/components/task-page-cache-selectors.test.ts @@ -5,6 +5,7 @@ import { workItemsCacheKey, type CacheEntry } from '@/store/slices/github' import type { GitHubWorkItem, LinearCollectionResult, LinearIssue } from '../../../shared/types' import { buildTaskPageRepoSourceState, + selectTaskPageUnresolvedSourceRepos, deriveTaskPageGitHubWorkItemsFetchOptions, findTaskPageDialogWorkItem, findTaskPageLinearDrawerIssue, @@ -73,6 +74,54 @@ describe('task page cache selectors', () => { ]) }) + it('flags fetched repos that resolved neither an issue nor a PR GitHub source', () => { + const sourcesEntry = ( + sources: { issues: unknown; prs: unknown } | null + ): CacheEntry => + ({ + data: [], + fetchedAt: 1, + ...(sources + ? { sources: { originCandidate: null, upstreamCandidate: null, ...sources } } + : {}) + }) as CacheEntry + + const repos = [ + { id: 'unresolved', path: '/repos/unresolved', displayName: 'unresolved-repo' }, + { id: 'issues-ok', path: '/repos/issues-ok', displayName: 'issues-repo' }, + { id: 'prs-ok', path: '/repos/prs-ok', displayName: 'prs-repo' }, + { id: 'no-name', path: '/repos/no-name' }, + { id: 'not-fetched', path: '/repos/not-fetched', displayName: 'pending-repo' } + ] + const entries = [ + sourcesEntry({ issues: null, prs: null }), + sourcesEntry({ issues: { owner: 'acme', repo: 'issues-ok' }, prs: null }), + sourcesEntry({ issues: null, prs: { owner: 'acme', repo: 'prs-ok' } }), + sourcesEntry({ issues: null, prs: null }), + sourcesEntry(null) + ] + const sourceState = buildTaskPageRepoSourceState(repos, entries) + + // Only both-null fetched repos are flagged; label falls back to path when displayName is absent. + expect(selectTaskPageUnresolvedSourceRepos(repos, sourceState)).toEqual([ + { repoId: 'unresolved', sourceKey: 'unresolved::local', label: 'unresolved-repo' }, + { repoId: 'no-name', sourceKey: 'no-name::local', label: '/repos/no-name' } + ]) + }) + + it('does not flag an unresolved-source repo that already carries a per-repo error', () => { + const repos = [{ id: 'errored', path: '/repos/errored', displayName: 'errored-repo' }] + const erroredEntry = { + data: [], + fetchedAt: 1, + sources: { issues: null, prs: null, originCandidate: null, upstreamCandidate: null }, + error: { type: 'rate_limited', message: 'slow down', source: { owner: 'acme', repo: 'x' } } + } as unknown as CacheEntry + const sourceState = buildTaskPageRepoSourceState(repos, [erroredEntry]) + + expect(selectTaskPageUnresolvedSourceRepos(repos, sourceState)).toEqual([]) + }) + it('scopes repo source rows by source cache scope for retry ownership', () => { const localRepo = { id: 'repo-1', diff --git a/src/renderer/src/components/task-page-cache-selectors.ts b/src/renderer/src/components/task-page-cache-selectors.ts index 67b3a7c1c8c..37644ce76db 100644 --- a/src/renderer/src/components/task-page-cache-selectors.ts +++ b/src/renderer/src/components/task-page-cache-selectors.ts @@ -79,6 +79,40 @@ export function buildTaskPageRepoSourceState( }) } +export type TaskPageUnresolvedSourceRepo = { + repoId: string + sourceKey: string + label: string +} + +/** + * Select the fetched repos that resolved neither an issue nor a PR GitHub source. + * + * Why: since #9660 an unresolvable source returns an empty null-source envelope + * instead of an unscoped search. That empty is otherwise indistinguishable from a + * genuine zero-result query, so surface these repos to the user. The three states + * are told apart purely by `sources`: present-but-both-null = unresolved (this + * function), a non-null side = resolved (genuine zero), `null` = not yet fetched. + */ +export function selectTaskPageUnresolvedSourceRepos( + repos: readonly { id: string; displayName?: string; path: string }[], + sourceState: readonly TaskPageRepoSourceState[] +): TaskPageUnresolvedSourceRepo[] { + const stateByRepoId = new Map(sourceState.map((state) => [state.repoId, state])) + const unresolved: TaskPageUnresolvedSourceRepo[] = [] + for (const repo of repos) { + const state = stateByRepoId.get(repo.id) + if (state?.sources && !state.sources.issues && !state.sources.prs && !state.error) { + unresolved.push({ + repoId: repo.id, + sourceKey: state.sourceKey, + label: repo.displayName ?? repo.path + }) + } + } + return unresolved +} + function taskPageWorkItemCacheKey(item: GitHubWorkItem): string { return `${item.repoId}\u0000${item.id}` } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 6ebb791d9e6..9de3068eba4 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -1806,7 +1806,9 @@ "jiraSortDescending": "descending", "jiraSortBy": "Sort by", "jiraToggleSortDirection": "Sort {{value0}}", - "75a38d7df8": "GitHub data is temporarily unavailable. Its API may be down, rate-limited, or unreachable. Please try again shortly." + "75a38d7df8": "GitHub data is temporarily unavailable. Its API may be down, rate-limited, or unreachable. Please try again shortly.", + "noGithubSourceDetected": "No GitHub source detected for", + "noGithubSourceDetectedHint": "it may have no GitHub remote, or the source could not be resolved." }, "Terminal": { "73768427cf": "Close", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 92677acdd91..3e0090677fc 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -1783,7 +1783,9 @@ "jiraSortDescending": "descendente", "jiraSortBy": "Ordenar por", "jiraToggleSortDirection": "Ordenar de forma {{value0}}", - "75a38d7df8": "GitHub data is temporarily unavailable. Its API may be down, rate-limited, or unreachable. Please try again shortly." + "75a38d7df8": "GitHub data is temporarily unavailable. Its API may be down, rate-limited, or unreachable. Please try again shortly.", + "noGithubSourceDetected": "No GitHub source detected for", + "noGithubSourceDetectedHint": "it may have no GitHub remote, or the source could not be resolved." }, "Terminal": { "73768427cf": "Cerrar", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 2181d64b8e6..2258a9bdc71 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -1783,7 +1783,9 @@ "jiraSortDescending": "降順", "jiraSortBy": "並べ替え", "jiraToggleSortDirection": "{{value0}}に並べ替え", - "75a38d7df8": "GitHub data is temporarily unavailable. Its API may be down, rate-limited, or unreachable. Please try again shortly." + "75a38d7df8": "GitHub data is temporarily unavailable. Its API may be down, rate-limited, or unreachable. Please try again shortly.", + "noGithubSourceDetected": "No GitHub source detected for", + "noGithubSourceDetectedHint": "it may have no GitHub remote, or the source could not be resolved." }, "Terminal": { "73768427cf": "閉じる", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index b5b1f87d739..eda40a50d5a 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -1783,7 +1783,9 @@ "jiraSortDescending": "내림차순", "jiraSortBy": "정렬 기준", "jiraToggleSortDirection": "{{value0}}으로 정렬", - "75a38d7df8": "GitHub data is temporarily unavailable. Its API may be down, rate-limited, or unreachable. Please try again shortly." + "75a38d7df8": "GitHub data is temporarily unavailable. Its API may be down, rate-limited, or unreachable. Please try again shortly.", + "noGithubSourceDetected": "No GitHub source detected for", + "noGithubSourceDetectedHint": "it may have no GitHub remote, or the source could not be resolved." }, "Terminal": { "73768427cf": "닫기", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index a62090c51d7..35ec638a697 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -1783,7 +1783,9 @@ "jiraSortDescending": "降序", "jiraSortBy": "排序方式", "jiraToggleSortDirection": "按{{value0}}排序", - "75a38d7df8": "GitHub data is temporarily unavailable. Its API may be down, rate-limited, or unreachable. Please try again shortly." + "75a38d7df8": "GitHub data is temporarily unavailable. Its API may be down, rate-limited, or unreachable. Please try again shortly.", + "noGithubSourceDetected": "No GitHub source detected for", + "noGithubSourceDetectedHint": "it may have no GitHub remote, or the source could not be resolved." }, "Terminal": { "73768427cf": "关闭", From 22d9c8787285c54d4e40b6ad397440385ce47860 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 20 Jul 2026 23:51:11 -0700 Subject: [PATCH 49/58] Fix Codex automatic resume across account switches (#9696) * fix(codex): preserve session home on automatic resume * fix(codex): keep automatic resume homes launch-authoritative * fix(codex): resume compressed rollout representations * fix(codex): keep resume migration best effort * fix(codex): reject stale automatic resume provenance * fix(codex): enforce real home on automatic resume * fix(codex): keep automatic resume home authoritative --- .../codex/codex-legacy-session-resume.test.ts | 21 +- src/main/codex/codex-legacy-session-resume.ts | 2 +- src/main/codex/codex-session-file-listing.ts | 30 ++- .../codex/codex-session-resume-home.test.ts | 226 ++++++++++++++++++ src/main/codex/codex-session-resume-home.ts | 135 +++++++++++ src/main/index.ts | 86 ++++++- src/main/ipc/pty.test.ts | 202 ++++++++++++++++ src/main/ipc/pty.ts | 111 +++++++-- src/main/providers/local-pty-provider.ts | 2 + src/main/providers/types.ts | 2 + src/main/runtime/orca-runtime.ts | 11 +- src/main/runtime/rpc/methods/terminal.ts | 10 + .../window/attach-main-window-services.ts | 9 +- src/preload/api-types.ts | 7 +- src/preload/index.ts | 8 +- .../terminal-pane/pty-connection-types.ts | 6 +- .../terminal-pane/pty-connection.test.ts | 11 +- .../terminal-pane/pty-connection.ts | 15 ++ .../terminal-pane/pty-transport-types.ts | 7 +- .../terminal-pane/pty-transport.test.ts | 15 ++ .../components/terminal-pane/pty-transport.ts | 6 + .../remote-runtime-pty-transport.test.ts | 12 +- .../remote-runtime-pty-transport.ts | 5 + .../use-terminal-pane-lifecycle.ts | 7 +- src/renderer/src/hooks/useIpcEvents.ts | 5 + src/shared/runtime-types.ts | 6 +- 26 files changed, 919 insertions(+), 38 deletions(-) create mode 100644 src/main/codex/codex-session-resume-home.test.ts create mode 100644 src/main/codex/codex-session-resume-home.ts diff --git a/src/main/codex/codex-legacy-session-resume.test.ts b/src/main/codex/codex-legacy-session-resume.test.ts index c69d4e4ed39..852da6b2c91 100644 --- a/src/main/codex/codex-legacy-session-resume.test.ts +++ b/src/main/codex/codex-legacy-session-resume.test.ts @@ -9,7 +9,7 @@ import { writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { basename, join } from 'node:path' import { prepareLegacySharedCodexSessionResume } from './codex-legacy-session-resume' describe('prepareLegacySharedCodexSessionResume', () => { @@ -47,6 +47,16 @@ describe('prepareLegacySharedCodexSessionResume', () => { expect(statSync(targetPath).ino).toBe(statSync(rolloutPath).ino) }) + it('materializes a compressed legacy rollout without changing its representation', async () => { + const compressedPath = `${rolloutPath}.zst` + rmSync(rolloutPath) + rolloutPath = compressedPath + writeFileSync(rolloutPath, 'compressed-rollout', 'utf-8') + + await expect(prepare()).resolves.toEqual({ useRealCodexHome: true }) + expect(readFileSync(targetRolloutPath(), 'utf-8')).toBe('compressed-rollout') + }) + it('coalesces concurrent resumes of the same legacy rollout', async () => { const [first, second] = await Promise.all([prepare(), prepare()]) @@ -129,14 +139,7 @@ describe('prepareLegacySharedCodexSessionResume', () => { } function targetRolloutPath(): string { - return join( - systemHome, - 'sessions', - '2026', - '07', - '20', - 'rollout-2026-07-20T12-00-00-session.jsonl' - ) + return join(systemHome, 'sessions', '2026', '07', '20', basename(rolloutPath)) } }) diff --git a/src/main/codex/codex-legacy-session-resume.ts b/src/main/codex/codex-legacy-session-resume.ts index c2febd6d772..3976b4a0d8d 100644 --- a/src/main/codex/codex-legacy-session-resume.ts +++ b/src/main/codex/codex-legacy-session-resume.ts @@ -162,7 +162,7 @@ function isLegacyRolloutRelativePath(relativePath: string): boolean { /^\d{4}$/.test(parts[0] ?? '') && /^\d{2}$/.test(parts[1] ?? '') && /^\d{2}$/.test(parts[2] ?? '') && - /^rollout-.+\.jsonl$/.test(parts[3] ?? '') + /^rollout-.+\.jsonl(?:\.zst)?$/.test(parts[3] ?? '') ) } diff --git a/src/main/codex/codex-session-file-listing.ts b/src/main/codex/codex-session-file-listing.ts index 6532f6ee5c9..8b0a8dd6f2e 100644 --- a/src/main/codex/codex-session-file-listing.ts +++ b/src/main/codex/codex-session-file-listing.ts @@ -58,6 +58,34 @@ export async function* listCodexSessionJsonlFilesIncrementally( rootPath: string, options: CodexSessionBridgeIncrementalOptions, onDirectoryError?: (directoryPath: string, error: unknown) => void | Promise +): AsyncGenerator { + yield* listCodexSessionFilesIncrementally( + rootPath, + options, + (fileName) => fileName.endsWith('.jsonl'), + onDirectoryError + ) +} + +/** Yields both physical representations that current Codex can resume. */ +export async function* listCodexSessionRolloutFilesIncrementally( + rootPath: string, + options: CodexSessionBridgeIncrementalOptions, + onDirectoryError?: (directoryPath: string, error: unknown) => void | Promise +): AsyncGenerator { + yield* listCodexSessionFilesIncrementally( + rootPath, + options, + (fileName) => fileName.endsWith('.jsonl') || fileName.endsWith('.jsonl.zst'), + onDirectoryError + ) +} + +async function* listCodexSessionFilesIncrementally( + rootPath: string, + options: CodexSessionBridgeIncrementalOptions, + isSessionFile: (fileName: string) => boolean, + onDirectoryError?: (directoryPath: string, error: unknown) => void | Promise ): AsyncGenerator { const batchSize = Math.max(1, options.batchSize ?? INCREMENTAL_BRIDGE_BATCH_SIZE) const yieldMs = Math.max(0, options.yieldMs ?? INCREMENTAL_BRIDGE_YIELD_MS) @@ -75,7 +103,7 @@ export async function* listCodexSessionJsonlFilesIncrementally( const childPath = join(currentDirectory, entry.name) if (entry.isDirectory()) { pendingDirectories.push(childPath) - } else if (entry.isFile() && entry.name.endsWith('.jsonl')) { + } else if (entry.isFile() && isSessionFile(entry.name)) { yield childPath } entriesSinceYield += 1 diff --git a/src/main/codex/codex-session-resume-home.test.ts b/src/main/codex/codex-session-resume-home.test.ts new file mode 100644 index 00000000000..4f55dec236d --- /dev/null +++ b/src/main/codex/codex-session-resume-home.test.ts @@ -0,0 +1,226 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + findTrustedCodexSessionResume, + resolveTrustedCodexSessionResumeHome +} from './codex-session-resume-home' + +const tempRoots: string[] = [] + +afterEach(() => { + for (const root of tempRoots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +describe('resolveTrustedCodexSessionResumeHome', () => { + it('returns the trusted home containing a persisted rollout', () => { + expect( + resolveTrustedCodexSessionResumeHome({ + transcriptPath: '/Users/example/.codex/sessions/2026/07/20/rollout-session.jsonl', + trustedCodexHomes: ['/managed/account/home', '/Users/example/.codex'], + fileIsRegular: () => true + }) + ).toBe('/Users/example/.codex') + }) + + it('accepts Windows paths case-insensitively', () => { + expect( + resolveTrustedCodexSessionResumeHome({ + transcriptPath: 'C:\\Users\\Example\\.codex\\sessions\\2026\\07\\20\\rollout-a.jsonl', + trustedCodexHomes: ['c:\\users\\example\\.codex'], + fileIsRegular: () => true + }) + ).toBe('c:\\users\\example\\.codex') + }) + + it('rejects paths outside trusted homes or outside the rollout layout', () => { + const fileIsRegular = vi.fn((): boolean => true) + expect( + resolveTrustedCodexSessionResumeHome({ + transcriptPath: '/tmp/sessions/2026/07/20/rollout-a.jsonl', + trustedCodexHomes: ['/Users/example/.codex'], + fileIsRegular + }) + ).toBeNull() + expect( + resolveTrustedCodexSessionResumeHome({ + transcriptPath: '/Users/example/.codex/sessions/index.jsonl', + trustedCodexHomes: ['/Users/example/.codex'], + fileIsRegular + }) + ).toBeNull() + expect( + resolveTrustedCodexSessionResumeHome({ + transcriptPath: + '/Users/example/.codex/sessions/2026/07/20/rollout-a/../../../../outside.jsonl', + trustedCodexHomes: ['/Users/example/.codex'], + fileIsRegular + }) + ).toBeNull() + expect(fileIsRegular).not.toHaveBeenCalled() + }) + + it('rejects a trusted-looking path when the rollout no longer exists', () => { + expect( + resolveTrustedCodexSessionResumeHome({ + transcriptPath: '/Users/example/.codex/sessions/2026/07/20/rollout-a.jsonl', + trustedCodexHomes: ['/Users/example/.codex'], + fileIsRegular: () => false + }) + ).toBeNull() + }) + + it('requires the transcript provenance to name a regular rollout file', () => { + const homePath = mkdtempSync(join(tmpdir(), 'orca-codex-resume-home-')) + tempRoots.push(homePath) + const rolloutDirectory = join(homePath, 'sessions', '2026', '07', '20', 'rollout-a.jsonl') + mkdirSync(rolloutDirectory, { recursive: true }) + + expect( + resolveTrustedCodexSessionResumeHome({ + transcriptPath: rolloutDirectory, + trustedCodexHomes: [homePath] + }) + ).toBeNull() + + const rolloutFile = join(homePath, 'sessions', '2026', '07', '20', 'rollout-b.jsonl') + writeFileSync(rolloutFile, '{}\n') + expect( + resolveTrustedCodexSessionResumeHome({ + transcriptPath: rolloutFile, + trustedCodexHomes: [homePath] + }) + ).toBe(homePath) + }) + + it('follows Codex when a persisted plain rollout was compressed in place', async () => { + const homePath = mkdtempSync(join(tmpdir(), 'orca-codex-resume-home-')) + tempRoots.push(homePath) + const plainPath = join( + homePath, + 'sessions', + '2026', + '07', + '20', + 'rollout-2026-07-20T12-00-00-session.jsonl' + ) + const compressedPath = `${plainPath}.zst` + mkdirSync(join(plainPath, '..'), { recursive: true }) + writeFileSync(compressedPath, 'compressed-rollout') + + await expect( + findTrustedCodexSessionResume({ + sessionId: 'session-a', + transcriptPath: plainPath, + trustedCodexHomes: [homePath] + }) + ).resolves.toEqual({ homePath, transcriptPath: compressedPath }) + + writeFileSync(plainPath, 'active-rollout') + await expect( + findTrustedCodexSessionResume({ + sessionId: 'session-a', + transcriptPath: compressedPath, + trustedCodexHomes: [homePath] + }) + ).resolves.toEqual({ homePath, transcriptPath: plainPath }) + }) + + it('finds compressed rollouts for legacy records without transcript provenance', async () => { + const homePath = mkdtempSync(join(tmpdir(), 'orca-codex-resume-home-')) + tempRoots.push(homePath) + const sessionId = '019f81b9-19a9-7651-a8d1-352d9420bd11' + const compressedPath = join( + homePath, + 'sessions', + '2026', + '07', + '20', + `rollout-2026-07-20T12-00-00-${sessionId}.jsonl.zst` + ) + mkdirSync(join(compressedPath, '..'), { recursive: true }) + writeFileSync(compressedPath, 'compressed-rollout') + + await expect( + findTrustedCodexSessionResume({ + sessionId, + transcriptPath: undefined, + trustedCodexHomes: [homePath] + }) + ).resolves.toEqual({ homePath, transcriptPath: compressedPath }) + }) + + it('finds older saved sessions by id when transcript provenance is absent', async () => { + const sessionId = '019f81b9-19a9-7651-a8d1-352d9420bd11' + const rolloutPath = `/managed/account/home/sessions/2026/07/20/rollout-2026-07-20T15-50-19-${sessionId}.jsonl` + const listSessionFiles = async function* (sessionsRoot: string): AsyncIterable { + if (sessionsRoot === '/managed/account/home/sessions') { + yield `/managed/account/home/sessions/misplaced-${sessionId}.jsonl` + yield rolloutPath + } + } + + await expect( + findTrustedCodexSessionResume({ + sessionId, + transcriptPath: undefined, + trustedCodexHomes: ['/Users/example/.codex', '/managed/account/home'], + listSessionFiles + }) + ).resolves.toEqual({ homePath: '/managed/account/home', transcriptPath: rolloutPath }) + }) + + it('does not scan session trees when exact transcript provenance is valid', async () => { + const transcriptPath = + '/managed/account/home/sessions/2026/07/20/rollout-2026-07-20-session.jsonl' + const listSessionFiles = vi.fn((): AsyncIterable => { + throw new Error('must not scan') + }) + + await expect( + findTrustedCodexSessionResume({ + sessionId: 'session-a', + transcriptPath, + trustedCodexHomes: ['/managed/account/home'], + fileIsRegular: () => true, + listSessionFiles + }) + ).resolves.toEqual({ homePath: '/managed/account/home', transcriptPath }) + expect(listSessionFiles).not.toHaveBeenCalled() + }) + + it('does not replace rejected transcript provenance with a same-id rollout from another home', async () => { + const sessionId = '019f81b9-19a9-7651-a8d1-352d9420bd11' + const listSessionFiles = vi.fn((): AsyncIterable => { + throw new Error('must not scan') + }) + + await expect( + findTrustedCodexSessionResume({ + sessionId, + transcriptPath: `/managed/origin/home/sessions/2026/07/20/rollout-${sessionId}.jsonl`, + trustedCodexHomes: ['/managed/origin/home', '/managed/other/home'], + fileIsRegular: () => false, + listSessionFiles + }) + ).resolves.toBeNull() + expect(listSessionFiles).not.toHaveBeenCalled() + }) + + it('does not scan homes for an untrusted legacy session id shape', async () => { + const listSessionFiles = (): AsyncIterable => { + throw new Error('must not scan') + } + await expect( + findTrustedCodexSessionResume({ + sessionId: '../session', + transcriptPath: undefined, + trustedCodexHomes: ['/Users/example/.codex'], + listSessionFiles + }) + ).resolves.toBeNull() + }) +}) diff --git a/src/main/codex/codex-session-resume-home.ts b/src/main/codex/codex-session-resume-home.ts new file mode 100644 index 00000000000..aebaf6a157c --- /dev/null +++ b/src/main/codex/codex-session-resume-home.ts @@ -0,0 +1,135 @@ +import { existsSync, lstatSync } from 'node:fs' +import { join } from 'node:path' +import { + getRuntimePathBasename, + normalizeRuntimePathForComparison, + relativePathInsideRoot +} from '../../shared/cross-platform-path' +import { listCodexSessionRolloutFilesIncrementally } from './codex-session-file-listing' + +// Why: only Codex's dated rollout layout may establish account-home provenance; nested/misplaced JSONL must not select credentials. +const ROLLOUT_RELATIVE_PATH = /^\d{4}\/\d{2}\/\d{2}\/rollout-[^/]+\.jsonl(?:\.zst)?$/ + +function isCodexRolloutInsideSessionsRoot(sessionsRoot: string, filePath: string): boolean { + const relativePath = relativePathInsideRoot(sessionsRoot, filePath) + return Boolean(relativePath && ROLLOUT_RELATIVE_PATH.test(relativePath.replace(/\\/g, '/'))) +} + +function isRegularFile(filePath: string): boolean { + try { + return lstatSync(filePath).isFile() + } catch { + return false + } +} + +function resolveExistingRolloutPath( + transcriptPath: string, + fileIsRegular: (filePath: string) => boolean +): string | null { + const plainPath = transcriptPath.endsWith('.jsonl.zst') + ? transcriptPath.slice(0, -'.zst'.length) + : transcriptPath.endsWith('.jsonl') + ? transcriptPath + : null + if (!plainPath) { + return fileIsRegular(transcriptPath) ? transcriptPath : null + } + if (fileIsRegular(plainPath)) { + return plainPath + } + const compressedPath = `${plainPath}.zst` + return fileIsRegular(compressedPath) ? compressedPath : null +} + +function resolveTrustedCodexSessionResume(args: { + transcriptPath: string | undefined + trustedCodexHomes: readonly string[] + fileIsRegular?: (filePath: string) => boolean +}): { homePath: string; transcriptPath: string } | null { + const persistedPath = args.transcriptPath?.trim() + if (!persistedPath) { + return null + } + + for (const homePath of args.trustedCodexHomes) { + const sessionsRoot = join(homePath, 'sessions') + if (!isCodexRolloutInsideSessionsRoot(sessionsRoot, persistedPath)) { + continue + } + const transcriptPath = resolveExistingRolloutPath( + persistedPath, + args.fileIsRegular ?? isRegularFile + ) + if (transcriptPath) { + return { homePath, transcriptPath } + } + } + return null +} + +export function resolveTrustedCodexSessionResumeHome(args: { + transcriptPath: string | undefined + trustedCodexHomes: readonly string[] + fileIsRegular?: (filePath: string) => boolean +}): string | null { + return resolveTrustedCodexSessionResume(args)?.homePath ?? null +} + +export async function findTrustedCodexSessionResume(args: { + sessionId: string + transcriptPath: string | undefined + trustedCodexHomes: readonly string[] + fileIsRegular?: (filePath: string) => boolean + listSessionFiles?: (sessionsRoot: string) => AsyncIterable +}): Promise<{ homePath: string; transcriptPath: string } | null> { + const directSession = resolveTrustedCodexSessionResume(args) + if (directSession) { + return directSession + } + if (args.transcriptPath?.trim()) { + // Why: stale/rejected provenance must not select a same-id rollout under different account credentials; scanning is legacy-only. + return null + } + if (!/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(args.sessionId)) { + return null + } + + const listSessionFiles = + args.listSessionFiles ?? + ((sessionsRoot: string) => + listCodexSessionRolloutFilesIncrementally(sessionsRoot, { batchSize: 64, yieldMs: 0 })) + const expectedSuffix = `-${args.sessionId}.jsonl`.toLowerCase() + const seenHomes = new Set() + for (const homePath of args.trustedCodexHomes) { + const comparisonHome = normalizeRuntimePathForComparison(homePath) + if (seenHomes.has(comparisonHome)) { + continue + } + seenHomes.add(comparisonHome) + const sessionsRoot = join(homePath, 'sessions') + if (!args.listSessionFiles && !existsSync(sessionsRoot)) { + continue + } + for await (const filePath of listSessionFiles(sessionsRoot)) { + const plainFilePath = filePath.endsWith('.jsonl.zst') + ? filePath.slice(0, -'.zst'.length) + : filePath + // Why: the directory entry already proves the compressed file exists; only probe its preferred plain sibling. + const preferredFilePath = + plainFilePath !== filePath && (args.fileIsRegular ?? isRegularFile)(plainFilePath) + ? plainFilePath + : filePath + const plainFileName = getRuntimePathBasename(preferredFilePath) + .toLowerCase() + .replace(/\.zst$/, '') + if ( + isCodexRolloutInsideSessionsRoot(sessionsRoot, preferredFilePath) && + plainFileName.endsWith(expectedSuffix) + ) { + return { homePath, transcriptPath: preferredFilePath } + } + } + } + return null +} diff --git a/src/main/index.ts b/src/main/index.ts index 6af71bd5f85..314c9d4ce31 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -147,6 +147,10 @@ import { startCodexSessionIndexHealInBackground } from './codex/codex-session-in import { createCodexSessionMigrationScheduler } from './codex/codex-session-migration-scheduler' import { prepareLegacySharedCodexSessionResume } from './codex/codex-legacy-session-resume' import { resolveHostCodexSessionSourceHome } from './codex/codex-session-source-home' +import { findTrustedCodexSessionResume } from './codex/codex-session-resume-home' +import { getSystemCodexHomePath } from './codex/codex-home-paths' +import { normalizeRuntimePathForComparison } from '../shared/cross-platform-path' +import type { AgentProviderSessionMetadata } from '../shared/agent-session-resume' import { getDefaultWslDistro } from './wsl' import { ClaudeAccountService } from './claude-accounts/service' import { ClaudeRuntimeAuthService } from './claude-accounts/runtime-auth-service' @@ -784,6 +788,84 @@ function prepareCodexRuntimeHomeForLaunch( return runtimeHomePath } +async function prepareCodexSessionResumeForLaunch(args: { + providerSession: AgentProviderSessionMetadata + target: CodexAccountSelectionTarget + launchEnv?: NodeJS.ProcessEnv + workspacePath?: string +}): Promise<{ codexHomePath: string | null } | null> { + if (args.target.runtime === 'wsl' || !codexRuntimeHome || !store) { + return null + } + const systemHomePath = getSystemCodexHomePath() + // Why: codexSessionSourceHome is import-only; treating it as CODEX_HOME would mutate history sources and bypass account auth. + const trustedHomes = [ + systemHomePath, + ...codexRuntimeHome.getHostCodexHomePathsForSessionDiscovery() + ] + const sessionSource = await findTrustedCodexSessionResume({ + sessionId: args.providerSession.id, + transcriptPath: args.providerSession.transcriptPath, + trustedCodexHomes: trustedHomes + }) + if (!sessionSource) { + if (args.providerSession.transcriptPath) { + throw new Error( + 'Orca could not verify the originating Codex session file, so automatic resume was stopped to avoid using a different account.' + ) + } + return null + } + + let migrated = { useRealCodexHome: false } + try { + migrated = await prepareLegacySharedCodexSessionResume( + { + agent: 'codex', + executionHostId: 'local', + filePath: sessionSource.transcriptPath, + codexHome: sessionSource.homePath + }, + { + isHostSystemDefaultRealHome: () => codexRuntimeHome!.isHostSystemDefaultRealHome(), + systemCodexHomePath: systemHomePath + } + ) + } catch (error) { + // Why: migration is a compatibility repair; its failure must not prevent the PTY from resuming from its trusted origin home. + console.warn( + '[codex-session-resume] Legacy rollout migration failed; using origin home:', + error + ) + } + const resumeHome = migrated.useRealCodexHome ? systemHomePath : sessionSource.homePath + + if (args.workspacePath) { + try { + markCodexProjectTrusted(args.workspacePath) + } catch (error) { + console.warn('[codex-project-trust] failed to pre-mark resumed workspace:', error) + } + } + const isSystemHome = + normalizeRuntimePathForComparison(resumeHome) === + normalizeRuntimePathForComparison(systemHomePath) + const hooksEnabled = isAgentStatusHooksEnabled(store.getSettings()) + try { + if (isSystemHome) { + ensureRealHomeCodexHookState({ hooksEnabled, userDataPath: app.getPath('userData') }) + } else if (hooksEnabled) { + codexHookService.install(resumeHome) + } else { + codexHookService.refreshRuntimeUserHooks(resumeHome) + } + } catch (error) { + // Why: hook repair is best-effort; session provenance must still win over the currently selected home. + console.warn('[codex-hook-service] failed to prepare automatic resume home:', error) + } + return { codexHomePath: resumeHome } +} + // Why: restore the window the close handler may have hidden to tray, or reopen it (dock-reactivation style) if fully torn down. function showMainWindowFromTray(): void { if (mainWindow && !mainWindow.isDestroyed()) { @@ -1050,6 +1132,7 @@ function openMainWindow(): BrowserWindow { prepareCodexRuntimeHomeForLaunch, (target) => claudeRuntimeAuth!.prepareForClaudeLaunch(target), { + prepareCodexSessionResume: prepareCodexSessionResumeForLaunch, awaitLocalPtyStartup: () => localPtyStartupReady, awaitLocalPtyProviderStartup: () => localPtyProviderStartupReady, onBeforeRendererReload: ({ ignoreCache, webContentsId }) => { @@ -2200,7 +2283,8 @@ app.whenReady().then(async () => { prepareCodexRuntimeHomeForLaunch, () => store!.getSettings(), (target) => claudeRuntimeAuth!.prepareForClaudeLaunch(target), - store + store, + prepareCodexSessionResumeForLaunch ) // Why: headless servers can't mount panes; use offscreen WebContents, gated on a real display so browser.headless.v1 stays honest. if (headlessBrowserDisplayAvailable) { diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index bafc64347c7..cc7428dac3b 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -931,6 +931,111 @@ describe('registerPtyHandlers', () => { expect(env.ORCA_CODEX_HOME).toBe(TEST_CODEX_HOME) }) + it('resumes an automatic Codex session from its prepared originating home', async () => { + const selectedHome = vi.fn(() => '/managed/current/home') + const prepareResume = vi.fn(async () => ({ codexHomePath: '/managed/origin/home' })) + registerPtyHandlers( + mainWindow as never, + undefined, + selectedHome, + undefined, + undefined, + undefined, + { prepareCodexSessionResume: prepareResume } + ) + + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + command: 'codex resume session-a', + launchAgent: 'codex', + resumeProviderSession: { + key: 'session_id', + id: 'session-a', + transcriptPath: '/managed/origin/home/sessions/2026/07/20/rollout-a.jsonl' + } + }) + + const env = spawnMock.mock.calls.at(-1)![2].env as Record + expect(prepareResume).toHaveBeenCalledWith( + expect.objectContaining({ + providerSession: expect.objectContaining({ id: 'session-a' }), + target: { runtime: 'host' } + }) + ) + expect(selectedHome).not.toHaveBeenCalled() + expect(env.CODEX_HOME).toBe('/managed/origin/home') + expect(env.ORCA_CODEX_HOME).toBe('/managed/origin/home') + }) + + it('overrides an unmarked custom home when the resumed session originated in real home', async () => { + const selectedHome = vi.fn(() => '/managed/current/home') + const systemHome = '/Users/example/.codex' + registerPtyHandlers( + mainWindow as never, + undefined, + selectedHome, + undefined, + undefined, + undefined, + { prepareCodexSessionResume: async () => ({ codexHomePath: systemHome }) } + ) + + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + command: 'codex resume session-a', + env: { CODEX_HOME: '/custom/codex', REMOVE_ME: 'stale' }, + envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME', 'REMOVE_ME'], + launchAgent: 'codex', + resumeProviderSession: { + key: 'session_id', + id: 'session-a', + transcriptPath: '/Users/example/.codex/sessions/2026/07/20/rollout-a.jsonl' + } + }) + + const env = spawnMock.mock.calls.at(-1)![2].env as Record + expect(selectedHome).not.toHaveBeenCalled() + expect(env.CODEX_HOME).toBe(systemHome) + expect(env.ORCA_CODEX_HOME).toBe(systemHome) + expect(env.REMOVE_ME).toBeUndefined() + }) + + it('does not fall back to the selected account when automatic resume provenance is rejected', async () => { + const selectedHome = vi.fn(() => '/managed/current/home') + registerPtyHandlers( + mainWindow as never, + undefined, + selectedHome, + undefined, + undefined, + undefined, + { + prepareCodexSessionResume: async () => { + throw new Error('origin unavailable') + } + } + ) + + await expect( + handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + command: 'codex resume session-a', + launchAgent: 'codex', + resumeProviderSession: { + key: 'session_id', + id: 'session-a', + transcriptPath: '/managed/origin/home/sessions/2026/07/20/rollout-a.jsonl' + } + }) + ).rejects.toThrow('origin unavailable') + + expect(selectedHome).not.toHaveBeenCalled() + expect(spawnMock).not.toHaveBeenCalled() + }) + it('prepares Codex launch state for the workspace before spawning an interactive tab', async () => { const workspacePath = '/repo/worktrees/new-feature' const resolveHome = vi.fn( @@ -1729,6 +1834,103 @@ describe('registerPtyHandlers', () => { expect(env.ORCA_CODEX_HOME).toBe(TEST_CODEX_HOME) }) + it('overrides an unmarked custom home for an authoritative daemon resume', async () => { + const daemonSpawn = setupDaemonAdapter() + const selectedHome = vi.fn(() => '/managed/current/home') + const systemHome = '/Users/example/.codex' + handlers.clear() + registerPtyHandlers( + mainWindow as never, + undefined, + selectedHome, + undefined, + undefined, + undefined, + { prepareCodexSessionResume: async () => ({ codexHomePath: systemHome }) } + ) + + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + command: 'codex resume session-a', + env: { CODEX_HOME: '/custom/codex', REMOVE_ME: 'stale' }, + envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME', 'REMOVE_ME'], + launchAgent: 'codex', + resumeProviderSession: { + key: 'session_id', + id: 'session-a', + transcriptPath: `${systemHome}/sessions/2026/07/20/rollout-a.jsonl` + } + }) + + const env = daemonSpawn.mock.calls.at(-1)![0].env + expect(selectedHome).not.toHaveBeenCalled() + expect(env.CODEX_HOME).toBe(systemHome) + expect(env.ORCA_CODEX_HOME).toBe(systemHome) + expect(env.REMOVE_ME).toBeUndefined() + }) + + it('keeps the authoritative home for runtime-created daemon resumes', async () => { + type RuntimeSpawnController = { + spawn(args: { + cols: number + rows: number + command: string + env: Record + envToDelete: string[] + launchAgent: 'codex' + resumeProviderSession: { + key: 'session_id' + id: string + transcriptPath: string + } + }): Promise<{ id: string }> + } + const daemonSpawn = setupDaemonAdapter() + const runtime = { + setPtyController: vi.fn(), + registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn() + } + const systemHome = '/Users/example/.codex' + handlers.clear() + registerPtyHandlers( + mainWindow as never, + runtime as never, + undefined, + undefined, + undefined, + undefined, + { prepareCodexSessionResume: async () => ({ codexHomePath: systemHome }) } + ) + const controller = runtime.setPtyController.mock.calls[0]?.[0] as RuntimeSpawnController + + await controller.spawn({ + cols: 80, + rows: 24, + command: 'codex resume session-a', + env: { CODEX_HOME: '/custom/codex', REMOVE_ME: 'stale' }, + envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME', 'REMOVE_ME'], + launchAgent: 'codex', + resumeProviderSession: { + key: 'session_id', + id: 'session-a', + transcriptPath: `${systemHome}/sessions/2026/07/20/rollout-a.jsonl` + } + }) + + const spawnOptions = daemonSpawn.mock.calls.at(-1)?.[0] as DaemonSpawnCall + expect(spawnOptions.env.CODEX_HOME).toBe(systemHome) + expect(spawnOptions.env.ORCA_CODEX_HOME).toBe(systemHome) + expect(spawnOptions.env.REMOVE_ME).toBeUndefined() + expect(spawnOptions.envToDelete ?? []).not.toContain('CODEX_HOME') + expect(spawnOptions.envToDelete ?? []).not.toContain('ORCA_CODEX_HOME') + expect(spawnOptions.envToDelete).toContain('REMOVE_ME') + }) + it('prepares Codex project trust before a daemon-backed interactive launch', async () => { const workspacePath = '/repo/worktrees/new-feature' const resolveHome = vi.fn( diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 407ae52503f..5738485dfda 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -32,7 +32,11 @@ import { } from '../../shared/pty-delivery-diagnostics' import { recordCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store' import { isTuiAgent } from '../../shared/tui-agent-config' -import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume' +import { + normalizeAgentProviderSession, + type AgentProviderSessionMetadata, + type SleepingAgentLaunchConfig +} from '../../shared/agent-session-resume' import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime' import { isWslShellName, @@ -624,6 +628,12 @@ export type GetSelectedCodexHomePath = ( launchEnv?: NodeJS.ProcessEnv, launchContext?: { workspacePath?: string; launchAgent?: TuiAgent } ) => string | null +export type PrepareCodexSessionResume = (args: { + providerSession: AgentProviderSessionMetadata + target: CodexAccountSelectionTarget + launchEnv?: NodeJS.ProcessEnv + workspacePath?: string +}) => Promise<{ codexHomePath: string | null } | null> type PrepareClaudeAuth = ( target?: ClaudeAccountSelectionTarget ) => Promise @@ -747,6 +757,12 @@ function mergePtyEnvDeletions( return Array.from(new Set([...(existingKeys ?? []), ...additionalKeys])) } +function removeCodexHomeDeletionRequests(keys: string[] | undefined): string[] | undefined { + // Why: resume provenance is launch-authoritative; late deletions must not fall back to the current account. + const filtered = keys?.filter((key) => key !== 'CODEX_HOME' && key !== 'ORCA_CODEX_HOME') + return filtered?.length ? filtered : undefined +} + function getInheritedAgentHookEnvKeysToDelete( spawnEnv: Record | undefined ): string[] { @@ -1381,6 +1397,7 @@ export function registerPtyHandlers( prepareClaudeAuth?: PrepareClaudeAuth, store?: Store, options?: { + prepareCodexSessionResume?: PrepareCodexSessionResume awaitLocalPtyStartup?: () => Promise awaitLocalPtyProviderStartup?: () => Promise // Why: returns true once for the crash-recovery reload so its did-finish-load skips the orphan sweep and keeps live PTYs (#5787). @@ -1453,10 +1470,12 @@ export function registerPtyHandlers( : { runtime: 'host' } const selectedCodexHomePath = getCompatibleSelectedCodexHomePath( codexSelectionTarget, - getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv, { - workspacePath: ctx?.cwd, - launchAgent: ctx?.launchAgent - }) ?? null + ctx?.codexHomePathOverride + ? ctx.codexHomePathOverride.value + : (getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv, { + workspacePath: ctx?.cwd, + launchAgent: ctx?.launchAgent + }) ?? null) ) const skipCodexHomeEnv = ctx?.isWsl === true && !selectedCodexHomePath const env = buildPtyHostEnv(id, baseEnv, { @@ -2699,6 +2718,29 @@ export function registerPtyHandlers( } } + const prepareCodexResumeHome = (args: { + connectionId?: string | null + launchAgent?: TuiAgent + providerSession?: AgentProviderSessionMetadata + target: CodexAccountSelectionTarget + launchEnv?: NodeJS.ProcessEnv + workspacePath?: string + }): Promise<{ codexHomePath: string | null } | null> | null => { + if (args.connectionId || args.launchAgent !== 'codex' || !options?.prepareCodexSessionResume) { + return null + } + const providerSession = normalizeAgentProviderSession(args.providerSession) + if (!providerSession) { + return null + } + return options.prepareCodexSessionResume({ + providerSession, + target: args.target, + launchEnv: args.launchEnv, + workspacePath: args.workspacePath + }) + } + // Why: route through getProviderForPty() so CLI commands work for remote PTYs too; localProvider would silently fail for them. runtime?.setPtyController({ spawn: async (args) => { @@ -2729,6 +2771,15 @@ export function registerPtyHandlers( cwd, terminalRuntimeOptions.terminalWindowsWslDistro ?? null ) + const codexResumePreparation = prepareCodexResumeHome({ + connectionId: args.connectionId, + launchAgent: args.launchAgent, + providerSession: args.resumeProviderSession, + target: codexSelectionTarget, + launchEnv: args.env, + workspacePath: cwd + }) + const codexResumeHome = codexResumePreparation ? await codexResumePreparation : null const claudeAuth = isClaudeLaunch && prepareClaudeAuth ? await prepareClaudeAuth(codexSelectionTarget) : null if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) { @@ -2798,10 +2849,12 @@ export function registerPtyHandlers( const selectedCodexHomePath = isDaemonHostSpawn ? getCompatibleSelectedCodexHomePath( codexSelectionTarget, - getSelectedCodexHomePath?.(codexSelectionTarget, env, { - workspacePath: cwd, - launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined - }) ?? null + codexResumeHome + ? codexResumeHome.codexHomePath + : (getSelectedCodexHomePath?.(codexSelectionTarget, env, { + workspacePath: cwd, + launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined + }) ?? null) ) : null const skipCodexHomeEnv = @@ -2849,6 +2902,9 @@ export function registerPtyHandlers( env, ...(isMintedSessionId ? { isNewSession: true } : {}) } + if (!isDaemonHostSpawn && codexResumeHome) { + spawnOptions.codexHomePathOverride = { value: codexResumeHome.codexHomePath } + } const startupTerminalColorQueryReplyColors = getStartupTerminalColorQueryReplyColors(args) if (startupTerminalColorQueryReplyColors) { spawnOptions.startupIngress = { @@ -2872,6 +2928,9 @@ export function registerPtyHandlers( 'ORCA_CODEX_HOME' ]) } + if (codexResumeHome?.codexHomePath) { + spawnOptions.envToDelete = removeCodexHomeDeletionRequests(spawnOptions.envToDelete) + } deleteRequestedEnvKeys(env, spawnOptions.envToDelete) promoteAgentTeamsShimPath(env, requestedAgentTeamsPath) if (args.command !== undefined) { @@ -3480,6 +3539,7 @@ export function registerPtyHandlers( command?: string commandDelivery?: 'renderer' | 'provider' launchConfig?: SleepingAgentLaunchConfig + resumeProviderSession?: AgentProviderSessionMetadata launchAgent?: TuiAgent startupCommandDelivery?: StartupCommandDelivery connectionId?: string | null @@ -3700,13 +3760,24 @@ export function registerPtyHandlers( cwd, terminalRuntimeOptions.terminalWindowsWslDistro ?? null ) + const codexResumePreparation = prepareCodexResumeHome({ + connectionId: args.connectionId, + launchAgent: args.launchAgent, + providerSession: args.resumeProviderSession, + target: codexSelectionTarget, + launchEnv: baseEnv, + workspacePath: cwd + }) + const codexResumeHome = codexResumePreparation ? await codexResumePreparation : null const selectedCodexHomePath = isDaemonHostSpawn ? getCompatibleSelectedCodexHomePath( codexSelectionTarget, - getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv, { - workspacePath: cwd, - launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined - }) ?? null + codexResumeHome + ? codexResumeHome.codexHomePath + : (getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv, { + workspacePath: cwd, + launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined + }) ?? null) ) : null const skipCodexHomeEnv = @@ -3768,7 +3839,7 @@ export function registerPtyHandlers( const envToDelete = claudeAuth?.stripAuthEnv ? [...CLAUDE_AUTH_ENV_VARS, 'ANTHROPIC_CUSTOM_HEADERS'] : undefined - const combinedEnvToDelete = mergePtyEnvDeletions( + let combinedEnvToDelete = mergePtyEnvDeletions( mergePtyEnvDeletions( mergePtyEnvDeletions( mergePtyEnvDeletions( @@ -3783,6 +3854,9 @@ export function registerPtyHandlers( // main cannot safely decide ownership for a process it may not parent. stripInheritedOrcaCodexHome ? ['ORCA_CODEX_HOME'] : [] ) + if (codexResumeHome?.codexHomePath) { + combinedEnvToDelete = removeCodexHomeDeletionRequests(combinedEnvToDelete) + } deleteRequestedEnvKeys(spawnEnv, combinedEnvToDelete) promoteAgentTeamsShimPath(spawnEnv, requestedAgentTeamsPath) const spawnOptions: PtySpawnOptions = { @@ -3792,6 +3866,9 @@ export function registerPtyHandlers( env: spawnEnv, ...(isMintedSessionId ? { isNewSession: true } : {}) } + if (!isDaemonHostSpawn && codexResumeHome) { + spawnOptions.codexHomePathOverride = { value: codexResumeHome.codexHomePath } + } if (combinedEnvToDelete) { spawnOptions.envToDelete = combinedEnvToDelete } @@ -4899,7 +4976,8 @@ export function registerHeadlessPtyRuntime( getSelectedCodexHomePath?: GetSelectedCodexHomePath, getSettings?: () => GlobalSettings, prepareClaudeAuth?: PrepareClaudeAuth, - store?: Store + store?: Store, + prepareCodexSessionResume?: PrepareCodexSessionResume ): void { // Why: headless `orca serve` has no renderer window but still needs the same PTY handlers so remote clients can drive terminals. const headlessWindow = { @@ -4916,7 +4994,8 @@ export function registerHeadlessPtyRuntime( getSelectedCodexHomePath, getSettings, prepareClaudeAuth, - store + store, + { prepareCodexSessionResume } ) } diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index e4a30c8af45..41d8708e36a 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -473,6 +473,7 @@ export type LocalPtyProviderOptions = { ctx?: { command?: string launchAgent?: PtySpawnOptions['launchAgent'] + codexHomePathOverride?: PtySpawnOptions['codexHomePathOverride'] cwd?: string shellPath?: string isWsl?: boolean @@ -675,6 +676,7 @@ export class LocalPtyProvider implements IPtyProvider { ? this.opts.buildSpawnEnv(id, spawnEnv, { command: args.command, launchAgent: args.launchAgent, + codexHomePathOverride: args.codexHomePathOverride, cwd, shellPath, isWsl: isWslShell, diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index 6523736fd2b..fca74501c10 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -56,6 +56,8 @@ export type PtySpawnOptions = { cwd?: string env?: Record envToDelete?: string[] + /** Main-validated home provenance for an automatic Codex session resume. */ + codexHomePathOverride?: { value: string | null } command?: string commandDelivery?: 'renderer' | 'provider' startupCommandDelivery?: StartupCommandDelivery diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 18600e15659..316d89f1efd 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -152,7 +152,10 @@ import { parseExecutionHostId, type ExecutionHostId } from '../../shared/execution-host' -import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume' +import type { + AgentProviderSessionMetadata, + SleepingAgentLaunchConfig +} from '../../shared/agent-session-resume' import type { RuntimeClientEvent } from '../../shared/runtime-client-events' import { toRuntimeActivateWorktreeEvent } from '../../shared/runtime-client-events' import { @@ -1089,6 +1092,7 @@ type TerminalCreateOptions = { env?: Record envToDelete?: string[] launchConfig?: WorktreeStartupLaunch['launchConfig'] + resumeProviderSession?: AgentProviderSessionMetadata launchToken?: string launchAgent?: TuiAgent terminalColorQueryReplies?: TerminalOscColorQueryReplyColors @@ -1283,6 +1287,7 @@ type RuntimePtyController = { startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] env?: Record envToDelete?: string[] + resumeProviderSession?: AgentProviderSessionMetadata telemetry?: WorktreeStartupLaunch['telemetry'] connectionId?: string | null worktreeId?: string @@ -19317,6 +19322,7 @@ export class OrcaRuntimeService { launchOpts.envToDelete, agentTeamsPlan?.envToDelete ), + resumeProviderSession: launchOpts.resumeProviderSession, telemetry: launchOpts.telemetry, connectionId: workspace.connectionId, worktreeId: workspace.id, @@ -19461,6 +19467,9 @@ export class OrcaRuntimeService { cwd, ...(launchOpts.env ? { env: launchOpts.env } : {}), ...(launchOpts.launchConfig ? { launchConfig: launchOpts.launchConfig } : {}), + ...(launchOpts.resumeProviderSession + ? { resumeProviderSession: launchOpts.resumeProviderSession } + : {}), ...(launchOpts.launchToken ? { launchToken: launchOpts.launchToken } : {}), ...(launchOpts.launchAgent ? { launchAgent: launchOpts.launchAgent } : {}), ...(launchOpts.viewMode ? { viewMode: launchOpts.viewMode } : {}), diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 7721ebc4d59..48458ea0d07 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -921,6 +921,13 @@ const TerminalCreateParams = z.object({ agentEnv: z.record(z.string(), z.string()) }) .optional(), + resumeProviderSession: z + .object({ + key: z.enum(['session_id', 'conversation_id']), + id: z.string().min(1).max(512), + transcriptPath: z.string().min(1).max(32_768).optional() + }) + .optional(), launchToken: OptionalString, launchAgent: z.string().refine(isTuiAgent).optional(), terminalColorQueryReplies: z @@ -1362,6 +1369,9 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ env: params.env, envToDelete: params.envToDelete, ...(params.launchConfig ? { launchConfig: params.launchConfig } : {}), + ...(params.resumeProviderSession + ? { resumeProviderSession: params.resumeProviderSession } + : {}), ...(params.launchToken ? { launchToken: params.launchToken } : {}), ...(params.launchAgent ? { launchAgent: params.launchAgent } : {}), ...(params.terminalColorQueryReplies diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index 55f1265ea48..39ea7ff2a6a 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -12,7 +12,12 @@ import type { import { registerRepoHandlers } from '../ipc/repos' import { registerWorktreeHandlers } from '../ipc/worktrees' import { registerWorkspaceCleanupHandlers } from '../ipc/workspace-cleanup' -import { getLocalPtyProvider, registerPtyHandlers, type GetSelectedCodexHomePath } from '../ipc/pty' +import { + getLocalPtyProvider, + registerPtyHandlers, + type GetSelectedCodexHomePath, + type PrepareCodexSessionResume +} from '../ipc/pty' import { registerDaemonManagementHandlers } from '../ipc/pty-management' import { registerSshHandlers } from '../ipc/ssh' import { registerRemoteWorkspaceHandlers } from '../ipc/remote-workspace' @@ -70,6 +75,7 @@ export function attachMainWindowServices( target?: ClaudeAccountSelectionTarget ) => Promise, options?: { + prepareCodexSessionResume?: PrepareCodexSessionResume awaitLocalPtyStartup?: () => Promise awaitLocalPtyProviderStartup?: () => Promise onBeforeRendererReload?: (args: { webContentsId: number; ignoreCache: boolean }) => void @@ -93,6 +99,7 @@ export function attachMainWindowServices( prepareClaudeAuth, store, { + prepareCodexSessionResume: options?.prepareCodexSessionResume, awaitLocalPtyStartup: options?.awaitLocalPtyStartup, awaitLocalPtyProviderStartup: options?.awaitLocalPtyProviderStartup, isRecoveryReloadInFlight: options?.isRecoveryReloadInFlight diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index d85797fdc1d..b031ff3aff4 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -63,7 +63,10 @@ import type { TaskSourceContext } from '../shared/task-source-context' import type { LinearIssueAttributeFilter } from '../shared/linear-issue-attribute-filter' import type { ProjectExecutionRuntimeResolution } from '../shared/project-execution-runtime' import type { StartupCommandDelivery } from '../shared/codex-startup-delivery' -import type { SleepingAgentLaunchConfig } from '../shared/agent-session-resume' +import type { + AgentProviderSessionMetadata, + SleepingAgentLaunchConfig +} from '../shared/agent-session-resume' import type { LocalhostWorktreeLabelResult, LocalhostWorktreeLabelRoute @@ -1290,6 +1293,7 @@ export type PreloadApi = { envToDelete?: string[] command?: string launchConfig?: SleepingAgentLaunchConfig + resumeProviderSession?: AgentProviderSessionMetadata launchToken?: string launchAgent?: TuiAgent startupCommandDelivery?: StartupCommandDelivery @@ -2878,6 +2882,7 @@ export type PreloadApi = { cwd?: string env?: Record launchConfig?: SleepingAgentLaunchConfig + resumeProviderSession?: AgentProviderSessionMetadata launchToken?: string launchAgent?: TuiAgent viewMode?: 'terminal' | 'chat' diff --git a/src/preload/index.ts b/src/preload/index.ts index 04181c3b812..eed0de1c6d5 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -14,7 +14,10 @@ import type { AgentHookInstallStatus } from '../shared/agent-hook-types' import type { TerminalPaneSplitSource } from '../shared/feature-education-telemetry' import type { ProjectExecutionRuntimeResolution } from '../shared/project-execution-runtime' import type { StartupCommandDelivery } from '../shared/codex-startup-delivery' -import type { SleepingAgentLaunchConfig } from '../shared/agent-session-resume' +import type { + AgentProviderSessionMetadata, + SleepingAgentLaunchConfig +} from '../shared/agent-session-resume' import type { MobileRelayStatus } from '../shared/mobile-relay-status' import type { MobilePairingConnectionMode } from '../shared/mobile-pairing-connection-mode' import type { @@ -797,6 +800,7 @@ const api = { envToDelete?: string[] command?: string launchConfig?: SleepingAgentLaunchConfig + resumeProviderSession?: AgentProviderSessionMetadata launchToken?: string launchAgent?: TuiAgent startupCommandDelivery?: StartupCommandDelivery @@ -3487,6 +3491,7 @@ const api = { cwd?: string env?: Record launchConfig?: SleepingAgentLaunchConfig + resumeProviderSession?: AgentProviderSessionMetadata launchToken?: string launchAgent?: TuiAgent viewMode?: 'terminal' | 'chat' @@ -3510,6 +3515,7 @@ const api = { cwd?: string env?: Record launchConfig?: SleepingAgentLaunchConfig + resumeProviderSession?: AgentProviderSessionMetadata launchToken?: string launchAgent?: TuiAgent viewMode?: 'terminal' | 'chat' diff --git a/src/renderer/src/components/terminal-pane/pty-connection-types.ts b/src/renderer/src/components/terminal-pane/pty-connection-types.ts index e783514977a..f03f009b56d 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-types.ts @@ -6,7 +6,10 @@ import type { EventProps } from '../../../../shared/telemetry-events' import type { TerminalColorSchemeMode } from '../../../../shared/terminal-color-scheme-protocol' import type { StartupCommandDelivery } from '../../../../shared/codex-startup-delivery' import type { SetupSplitDirection, TuiAgent } from '../../../../shared/types' -import type { SleepingAgentLaunchConfig } from '../../../../shared/agent-session-resume' +import type { + AgentProviderSessionMetadata, + SleepingAgentLaunchConfig +} from '../../../../shared/agent-session-resume' import type { TerminalKittyKeyboardModeTracker } from '../../../../shared/terminal-kitty-keyboard-mode-tracker' export type PtyConnectionDeps = { @@ -22,6 +25,7 @@ export type PtyConnectionDeps = { env?: Record envToDelete?: string[] launchConfig?: SleepingAgentLaunchConfig + resumeProviderSession?: AgentProviderSessionMetadata launchToken?: string launchAgent?: TuiAgent draftPrompt?: string diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 7900adf3fe5..b3b5383ee28 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -7444,7 +7444,11 @@ describe('connectPanePty', () => { updatedAt: 1, stateStartedAt: 1, stateHistory: [], - providerSession: { key: 'session_id', id: 'codex-session-1' } + providerSession: { + key: 'session_id', + id: 'codex-session-1', + transcriptPath: '/Users/example/.codex/sessions/2026/07/20/rollout-session.jsonl' + } } } } as StoreState @@ -7470,6 +7474,11 @@ describe('connectPanePty', () => { expect.objectContaining({ sessionId: 'lost-pty', command: "codex '--dangerously-bypass-approvals-and-sandbox' 'resume' 'codex-session-1'", + resumeProviderSession: { + key: 'session_id', + id: 'codex-session-1', + transcriptPath: '/Users/example/.codex/sessions/2026/07/20/rollout-session.jsonl' + }, env: expect.objectContaining({ ORCA_PANE_KEY: paneKey, ORCA_TAB_ID: 'tab-1', diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 78b96cdafc9..4889b090b3d 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -235,6 +235,7 @@ import { agentProviderSessionsEqual, isResumableTuiAgent, normalizeAgentProviderSession, + type AgentProviderSessionMetadata, type ResumableTuiAgent, type SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume' @@ -481,6 +482,7 @@ type FreshSpawnOptions = { type ColdRestoreAgentResumeStartup = PendingStartupCommand & { agent: ResumableTuiAgent + resumeProviderSession: AgentProviderSessionMetadata launchConfig: NonNullable>['launchConfig'] launchToken: string useLiveEntry: boolean @@ -3348,6 +3350,9 @@ export function connectPanePty( ...(projectRuntime ? { projectRuntime } : {}), ...(terminalColorQueryReplies ? { terminalColorQueryReplies } : {}), ...(paneStartup?.launchConfig ? { launchConfig: paneStartup.launchConfig } : {}), + ...(paneStartup?.resumeProviderSession + ? { resumeProviderSession: paneStartup.resumeProviderSession } + : {}), ...(launchToken ? { launchToken } : {}), ...(paneStartup?.launchAgent ? { launchAgent: paneStartup.launchAgent } : {}), ...(paneStartup?.telemetry ? { telemetry: paneStartup.telemetry } : {}), @@ -4487,6 +4492,7 @@ export function connectPanePty( ORCA_AGENT_LAUNCH_TOKEN: coldRestoreLaunchToken }, launchConfig: startupPlan.launchConfig, + resumeProviderSession: providerSession, launchToken: coldRestoreLaunchToken, useLiveEntry: Boolean(useLiveEntry), hasSleepingRecord: Boolean(sleepingRecord), @@ -4700,6 +4706,9 @@ export function connectPanePty( ? { env: mergeStartupEnvWithPaneIdentity(startupOverride.env) } : {}), ...(coldRestoreOverride ? { launchConfig: coldRestoreOverride.launchConfig } : {}), + ...(coldRestoreOverride + ? { resumeProviderSession: coldRestoreOverride.resumeProviderSession } + : {}), ...(coldRestoreOverride ? { launchToken: coldRestoreOverride.launchToken } : {}), ...(coldRestoreOverride ? { launchAgent: coldRestoreOverride.agent } : {}), ...(shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}), @@ -7552,6 +7561,9 @@ export function connectPanePty( ...(coldRestoreStartup?.launchConfig ? { launchConfig: coldRestoreStartup.launchConfig } : {}), + ...(coldRestoreStartup?.resumeProviderSession + ? { resumeProviderSession: coldRestoreStartup.resumeProviderSession } + : {}), ...(coldRestoreStartup?.launchToken ? { launchToken: coldRestoreStartup.launchToken } : {}), @@ -7763,6 +7775,9 @@ export function connectPanePty( ...(coldRestoreStartup?.launchConfig ? { launchConfig: coldRestoreStartup.launchConfig } : {}), + ...(coldRestoreStartup?.resumeProviderSession + ? { resumeProviderSession: coldRestoreStartup.resumeProviderSession } + : {}), ...(coldRestoreStartup?.launchToken ? { launchToken: coldRestoreStartup.launchToken } : {}), ...(coldRestoreStartup?.agent ? { launchAgent: coldRestoreStartup.agent } : {}), ...(shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}), diff --git a/src/renderer/src/components/terminal-pane/pty-transport-types.ts b/src/renderer/src/components/terminal-pane/pty-transport-types.ts index b1cda244784..4507b91e185 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -1,5 +1,8 @@ import type { ParsedAgentStatusPayload } from '../../../../shared/agent-status-types' -import type { SleepingAgentLaunchConfig } from '../../../../shared/agent-session-resume' +import type { + AgentProviderSessionMetadata, + SleepingAgentLaunchConfig +} from '../../../../shared/agent-session-resume' import type { StartupCommandDelivery } from '../../../../shared/codex-startup-delivery' import type { ProjectExecutionRuntimeResolution } from '../../../../shared/project-execution-runtime' import type { EventProps } from '../../../../shared/telemetry-events' @@ -86,6 +89,7 @@ export type PtyTransport = { env?: Record envToDelete?: string[] launchConfig?: SleepingAgentLaunchConfig + resumeProviderSession?: AgentProviderSessionMetadata launchToken?: string launchAgent?: TuiAgent startupCommandDelivery?: StartupCommandDelivery @@ -144,6 +148,7 @@ export type IpcPtyTransportOptions = { envToDelete?: string[] command?: string launchConfig?: SleepingAgentLaunchConfig + resumeProviderSession?: AgentProviderSessionMetadata launchToken?: string launchAgent?: TuiAgent startupCommandDelivery?: StartupCommandDelivery diff --git a/src/renderer/src/components/terminal-pane/pty-transport.test.ts b/src/renderer/src/components/terminal-pane/pty-transport.test.ts index 48f4f919ed8..e2c89d57da0 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.test.ts @@ -104,6 +104,21 @@ describe('createIpcPtyTransport', () => { ) }) + it('forwards automatic resume provenance to the PTY spawn', async () => { + const { createIpcPtyTransport } = await import('./pty-transport') + const spawn = window.api.pty.spawn as unknown as ReturnType + const resumeProviderSession = { + key: 'session_id' as const, + id: 'session-a', + transcriptPath: '/Users/example/.codex/sessions/2026/07/20/rollout-a.jsonl' + } + const transport = createIpcPtyTransport({ resumeProviderSession }) + + await transport.connect({ url: '', callbacks: {} }) + + expect(spawn).toHaveBeenCalledWith(expect.objectContaining({ resumeProviderSession })) + }) + it('leaves the transport silently unbound after a failed connect — sendInput drops with no write IPC (frozen-terminal repro)', async () => { const { createIpcPtyTransport } = await import('./pty-transport') const spawn = window.api.pty.spawn as unknown as ReturnType diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index e40c5d9103c..8e1a0380a58 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -468,6 +468,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra envToDelete, command, launchConfig, + resumeProviderSession, launchToken, launchAgent, startupCommandDelivery, @@ -686,6 +687,11 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra ...((options.launchConfig ?? launchConfig) ? { launchConfig: options.launchConfig ?? launchConfig } : {}), + ...((options.resumeProviderSession ?? resumeProviderSession) + ? { + resumeProviderSession: options.resumeProviderSession ?? resumeProviderSession + } + : {}), ...((options.launchToken ?? launchToken) ? { launchToken: options.launchToken ?? launchToken } : {}), diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts index 242b53921d2..916bffa7372 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts @@ -1182,6 +1182,11 @@ describe('createRemoteRuntimePtyTransport', () => { }, launchToken: 'fresh-token', launchAgent: 'codex', + resumeProviderSession: { + key: 'session_id', + id: 'session-1', + transcriptPath: '/home/example/.codex/sessions/2026/07/20/rollout-a.jsonl' + }, callbacks: {} }) @@ -1197,7 +1202,12 @@ describe('createRemoteRuntimePtyTransport', () => { agentEnv: { CODEX_PROFILE: 'captured' } }, launchToken: 'fresh-token', - launchAgent: 'codex' + launchAgent: 'codex', + resumeProviderSession: { + key: 'session_id', + id: 'session-1', + transcriptPath: '/home/example/.codex/sessions/2026/07/20/rollout-a.jsonl' + } }) }) ) diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts index dae2338cd71..204be73898f 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts @@ -68,6 +68,7 @@ export function createRemoteRuntimePtyTransport( env, envToDelete, launchConfig, + resumeProviderSession, launchToken, launchAgent, terminalColorQueryReplies, @@ -781,6 +782,7 @@ export function createRemoteRuntimePtyTransport( const envToSend = options.env ?? env const envToDeleteToSend = options.envToDelete ?? envToDelete const launchConfigToSend = options.launchConfig ?? launchConfig + const resumeProviderSessionToSend = options.resumeProviderSession ?? resumeProviderSession const launchTokenToSend = options.launchToken ?? launchToken const launchAgentToSend = options.launchAgent ?? launchAgent const created = await callRuntime<{ terminal: RuntimeTerminalCreate }>('terminal.create', { @@ -792,6 +794,9 @@ export function createRemoteRuntimePtyTransport( ...(envToSend !== undefined ? { env: envToSend } : {}), ...(envToDeleteToSend !== undefined ? { envToDelete: envToDeleteToSend } : {}), ...(launchConfigToSend !== undefined ? { launchConfig: launchConfigToSend } : {}), + ...(resumeProviderSessionToSend !== undefined + ? { resumeProviderSession: resumeProviderSessionToSend } + : {}), ...(launchTokenToSend !== undefined ? { launchToken: launchTokenToSend } : {}), ...(launchAgentToSend !== undefined ? { launchAgent: launchAgentToSend } : {}), ...(terminalColorQueryReplies ? { terminalColorQueryReplies } : {}), diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index 05ddb589fc1..453cb1d23dd 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -50,7 +50,10 @@ import type { import type { TerminalPaneSplitSource } from '../../../../shared/feature-education-telemetry' import type { EventProps } from '../../../../shared/telemetry-events' import type { StartupCommandDelivery } from '../../../../shared/codex-startup-delivery' -import type { SleepingAgentLaunchConfig } from '../../../../shared/agent-session-resume' +import type { + AgentProviderSessionMetadata, + SleepingAgentLaunchConfig +} from '../../../../shared/agent-session-resume' import { resolveTerminalFontWeights } from '../../../../shared/terminal-fonts' import { buildFontFamily, @@ -209,7 +212,9 @@ type UseTerminalPaneLifecycleDeps = { delivery?: 'terminal-paste' startupCommandDelivery?: StartupCommandDelivery env?: Record + envToDelete?: string[] launchConfig?: SleepingAgentLaunchConfig + resumeProviderSession?: AgentProviderSessionMetadata launchToken?: string launchAgent?: TuiAgent draftPrompt?: string diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 5d02b630ce6..7ad315bbbfe 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -1387,6 +1387,7 @@ export function useIpcEvents(): void { cwd, env, launchConfig, + resumeProviderSession, launchToken, launchAgent, viewMode, @@ -1572,6 +1573,7 @@ export function useIpcEvents(): void { command, ...(env ? { env } : {}), ...(launchConfig ? { launchConfig } : {}), + ...(resumeProviderSession ? { resumeProviderSession } : {}), ...(launchToken ? { launchToken } : {}), ...(launchAgent ? { launchAgent } : {}) }) @@ -1723,6 +1725,9 @@ export function useIpcEvents(): void { ...(data.env ? { env: data.env } : {}), ...(data.envToDelete ? { envToDelete: data.envToDelete } : {}), ...(data.launchConfig ? { launchConfig: data.launchConfig } : {}), + ...(data.resumeProviderSession + ? { resumeProviderSession: data.resumeProviderSession } + : {}), ...(data.launchToken ? { launchToken: data.launchToken } : {}), ...(data.launchAgent ? { launchAgent: data.launchAgent } : {}), ...(data.startupCommandDelivery diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index ec626b47767..f03ab399090 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -32,7 +32,10 @@ import type { } from './mobile-markdown-document' import type { RuntimeCapability } from './protocol-version' import type { RemoteRuntimeSharedConnectionDiagnostics } from './remote-runtime-shared-control-types' -import type { SleepingAgentLaunchConfig } from './agent-session-resume' +import type { + AgentProviderSessionMetadata, + SleepingAgentLaunchConfig +} from './agent-session-resume' import type { StartupCommandDelivery } from './codex-startup-delivery' export type { RuntimeMarkdownReadTabResult, RuntimeMarkdownSaveTabResult } @@ -506,6 +509,7 @@ type RuntimeTerminalCreateBaseRequestPayload = { env?: Record envToDelete?: string[] launchConfig?: SleepingAgentLaunchConfig + resumeProviderSession?: AgentProviderSessionMetadata launchToken?: string launchAgent?: TuiAgent viewMode?: 'terminal' | 'chat' From 42bff5e5980ebd68eb6c629087f4418b801c9a19 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 20 Jul 2026 23:57:05 -0700 Subject: [PATCH 50/58] fix(native-chat): add reconciliation to catch missed transcript updates (#9701) * fix(native-chat): add reconciliation to catch missed transcript updates Periodic reconciliation catches file changes that fs.watch may miss on remote filesystems. Event-driven drains use a bounded max-wait timer to ensure responsiveness. Extracts timer logic into a scheduler module for testability and maintainability. * fix(native-chat): add reconciliation to catch missed transcript updates Detects same-size file rewrites and native watcher failures by reconciling transcript state even when fs.watch events don't fire. Extracts watcher binding into a dedicated module that gracefully degrades to reconciliation-only mode when native watch is unavailable. Adds test coverage for watcher rebind failures, parent-directory replacements, and unsubscribe races. * Document transcript watcher reconciliation contract Clarify that bind() is best-effort with reconciliation fallback, invalidate() detaches from stale identities, and the caller's polling loop owns transcript liveness. Supports resilience on remote filesystems. --- .../native-chat/transcript-file-version.ts | 30 ++ .../native-chat/transcript-native-watcher.ts | 87 ++++++ .../native-chat/transcript-watch-contract.ts | 2 + .../native-chat/transcript-watch-engine.ts | 170 +++++------ .../transcript-watch-liveness.test.ts | 273 ++++++++++++++++++ .../native-chat/transcript-watch-scheduler.ts | 101 +++++++ .../transcript-watch-unsubscribe-race.test.ts | 68 +++++ src/main/native-chat/transcript-watch.ts | 5 +- 8 files changed, 651 insertions(+), 85 deletions(-) create mode 100644 src/main/native-chat/transcript-file-version.ts create mode 100644 src/main/native-chat/transcript-native-watcher.ts create mode 100644 src/main/native-chat/transcript-watch-liveness.test.ts create mode 100644 src/main/native-chat/transcript-watch-scheduler.ts create mode 100644 src/main/native-chat/transcript-watch-unsubscribe-race.test.ts diff --git a/src/main/native-chat/transcript-file-version.ts b/src/main/native-chat/transcript-file-version.ts new file mode 100644 index 00000000000..69634c0a957 --- /dev/null +++ b/src/main/native-chat/transcript-file-version.ts @@ -0,0 +1,30 @@ +import { stat } from 'node:fs/promises' + +export type TranscriptFileVersion = { + identity: string + size: number + mtimeMs: number + ctimeMs: number +} + +export async function readTranscriptFileVersion(filePath: string): Promise { + const value = await stat(filePath) + return { + identity: `${value.dev}:${value.ino}`, + size: value.size, + mtimeMs: value.mtimeMs, + ctimeMs: value.ctimeMs + } +} + +export function transcriptFileVersionChanged( + current: TranscriptFileVersion, + previous: TranscriptFileVersion +): boolean { + return ( + current.identity !== previous.identity || + current.size !== previous.size || + current.mtimeMs !== previous.mtimeMs || + current.ctimeMs !== previous.ctimeMs + ) +} diff --git a/src/main/native-chat/transcript-native-watcher.ts b/src/main/native-chat/transcript-native-watcher.ts new file mode 100644 index 00000000000..8cccd022c57 --- /dev/null +++ b/src/main/native-chat/transcript-native-watcher.ts @@ -0,0 +1,87 @@ +import { watch, type FSWatcher } from 'node:fs' +import { basename, dirname } from 'node:path' + +export type TranscriptNativeWatcher = { + /** Best-effort bind; false keeps the caller in reconciliation-only mode. */ + bind: () => boolean + /** Detach from an identity that may no longer represent the watched path. */ + invalidate: () => void + needsRebind: () => boolean + dispose: () => void +} + +/** + * Optional fs.watch acceleration for transcript reconciliation. Native watches + * can fail on otherwise-readable remote filesystems, so binding is retryable + * and never owns transcript liveness; the caller's polling loop does. + */ +export function createTranscriptNativeWatcher( + filePath: string, + onEvent: () => void, + onError: () => void +): TranscriptNativeWatcher { + const watchedName = basename(filePath) + let disposed = false + let watcher: FSWatcher | null = null + let rebindNeeded = true + + function invalidateCandidate(candidate: FSWatcher): void { + if (watcher !== candidate) { + return + } + watcher = null + rebindNeeded = true + candidate.close() + } + + return { + bind(): boolean { + if (disposed || watcher) { + return watcher !== null + } + let nextWatcher: FSWatcher + try { + // Why: watching the parent survives target-file replacement on macOS. + nextWatcher = watch(dirname(filePath), (event, changedName) => { + if (changedName !== null && changedName.toString() !== watchedName) { + return + } + // Why: a parent replacement may emit rename without a watcher error. + if (event === 'rename') { + invalidateCandidate(nextWatcher) + } + onEvent() + }) + } catch { + rebindNeeded = true + return false + } + // Why: an active tail should not keep a headless runtime alive during shutdown. + nextWatcher.unref?.() + nextWatcher.on('error', () => { + if (disposed || watcher !== nextWatcher) { + return + } + invalidateCandidate(nextWatcher) + onError() + }) + watcher = nextWatcher + rebindNeeded = false + return true + }, + invalidate(): void { + if (watcher) { + invalidateCandidate(watcher) + } else { + rebindNeeded = true + } + }, + needsRebind: () => rebindNeeded, + dispose(): void { + disposed = true + watcher?.close() + watcher = null + rebindNeeded = false + } + } +} diff --git a/src/main/native-chat/transcript-watch-contract.ts b/src/main/native-chat/transcript-watch-contract.ts index 2942594b370..f2e46f3581b 100644 --- a/src/main/native-chat/transcript-watch-contract.ts +++ b/src/main/native-chat/transcript-watch-contract.ts @@ -28,6 +28,8 @@ export type SubscribeNativeChatTranscriptArgs = ResolveSessionFileOptions & { debounceMs?: number /** Test-only override for the production resolve-poll backoff. */ resolvePollIntervalMs?: number + /** Test-only override for the host-side watcher reconciliation interval. */ + reconciliationIntervalMs?: number } export type NativeChatTranscriptSubscription = { diff --git a/src/main/native-chat/transcript-watch-engine.ts b/src/main/native-chat/transcript-watch-engine.ts index 6ce52de4787..ff1df71ee0f 100644 --- a/src/main/native-chat/transcript-watch-engine.ts +++ b/src/main/native-chat/transcript-watch-engine.ts @@ -1,20 +1,24 @@ -import { watch, type FSWatcher } from 'node:fs' import { open, stat } from 'node:fs/promises' -import { basename, dirname } from 'node:path' import type { NativeChatMessage, NativeChatTurnLifecycle } from '../../shared/native-chat-types' +import { + readTranscriptFileVersion, + transcriptFileVersionChanged, + type TranscriptFileVersion +} from './transcript-file-version' import { readIncrementalTranscriptMessages, resetIncrementalTranscriptState, type IncrementalTranscriptState } from './transcript-incremental-reader' +import { createTranscriptNativeWatcher } from './transcript-native-watcher' import { readNativeChatTranscriptTailFile } from './transcript-tail-reader' import { nativeChatTurnLifecycleDecoderForAgent } from './transcript-turn-lifecycle' import type { NativeChatTranscriptSubscription, SubscribeNativeChatTranscriptArgs } from './transcript-watch-contract' +import { createTranscriptWatchScheduler } from './transcript-watch-scheduler' -const DEFAULT_DEBOUNCE_MS = 40 const ROTATION_RETRY_MS = 25 const MAX_ROTATION_RETRY_MS = 2_000 let activeWatcherCount = 0 @@ -23,11 +27,6 @@ export function getActiveNativeChatWatcherCount(): number { return activeWatcherCount } -async function fileVersion(filePath: string): Promise<{ identity: string; size: number }> { - const value = await stat(filePath) - return { identity: `${value.dev}:${value.ino}`, size: value.size } -} - async function boundaryFingerprint(filePath: string, offset: number): Promise { if (offset <= 0) { return '' @@ -45,12 +44,9 @@ async function boundaryFingerprint(filePath: string, offset: number): Promise | null = null - let watcher: FSWatcher | null = null - let watcherNeedsRebind = false - - function scheduleDrain(): void { - if (closed) { - return - } - if (debounceTimer) { - clearTimeout(debounceTimer) - } - debounceTimer = setTimeout(() => { - debounceTimer = null - void drain() - }, debounceMs ?? DEFAULT_DEBOUNCE_MS) - } function scheduleRotationRetry(): void { - if (closed || debounceTimer) { + if (closed) { return } const retryDelay = Math.min( ROTATION_RETRY_MS * 2 ** Math.min(rotationRetryCount, 7), MAX_ROTATION_RETRY_MS ) - rotationRetryCount += 1 - debounceTimer = setTimeout(() => { - debounceTimer = null - void drain() - }, retryDelay) + if (scheduler.scheduleRetry(retryDelay)) { + rotationRetryCount += 1 + } } async function readAndEmitAppends(): Promise { @@ -135,24 +113,50 @@ export async function installTranscriptWatcher( } } + async function finishSuccessfulDrain(startVersion: TranscriptFileVersion): Promise { + watchedBoundary = await boundaryFingerprint(filePath, state.offset) + const completedVersion = await readTranscriptFileVersion(filePath) + if (transcriptFileVersionChanged(completedVersion, startVersion)) { + // Why: a write racing this drain needs another pass even when the reader + // happened to reach its new EOF; timestamp-only rewrites may need replace. + watchedVersion = startVersion + pendingReadRequested = true + } else { + watchedVersion = completedVersion + } + if (closed) { + return + } + if (!nativeWatcher.needsRebind() || nativeWatcher.bind()) { + rotationRetryCount = 0 + return + } + scheduleRotationRetry() + } + async function drainOnce(): Promise { - const current = await fileVersion(filePath) + const current = await readTranscriptFileVersion(filePath) const currentBoundary = await boundaryFingerprint(filePath, state.offset) if (closed) { return } - if (watcherNeedsRebind) { - bindWatcher() - } - const identityChanged = watchedIdentity !== null && current.identity !== watchedIdentity + const identityChanged = watchedVersion !== null && current.identity !== watchedVersion.identity + const sameSizeVersionChanged = + watchedVersion !== null && + current.identity === watchedVersion.identity && + current.size === watchedVersion.size && + transcriptFileVersionChanged(current, watchedVersion) const contentReplaced = identityChanged || + sameSizeVersionChanged || current.size < state.offset || (state.offset > 0 && watchedBoundary !== currentBoundary) + if (identityChanged) { + nativeWatcher.invalidate() + } if (contentReplaced) { resetIncrementalTranscriptState(state) } - watchedIdentity = current.identity const replacementSnapshot = // Why: 0 is a valid window — an explicit undefined check keeps an empty @@ -180,8 +184,7 @@ export async function installTranscriptWatcher( replacementSnapshot.lifecycle ) await readAndEmitAppends() - watchedBoundary = await boundaryFingerprint(filePath, state.offset) - rotationRetryCount = 0 + await finishSuccessfulDrain(current) return } @@ -224,14 +227,16 @@ export async function installTranscriptWatcher( lifecycle = nextLifecycle } ) + if (closed) { + return + } onInitialSnapshot(messages, false, 0, undefined, lifecycle) } } else { initialDrain = false await readAndEmitAppends() } - watchedBoundary = await boundaryFingerprint(filePath, state.offset) - rotationRetryCount = 0 + await finishSuccessfulDrain(current) } async function drain(): Promise { @@ -254,7 +259,7 @@ export async function installTranscriptWatcher( // A still-pending initial drain also surfaces one error snapshot so a // watching client isn't stranded at 'loading' when the read keeps // throwing; initialDrain stays true so a recovered read can still win. - if (initialDrain && onInitialSnapshot && !initialErrorEmitted) { + if (!closed && initialDrain && onInitialSnapshot && !initialErrorEmitted) { initialErrorEmitted = true onInitialSnapshot([], false, 0, 'Transcript unavailable') } @@ -267,38 +272,43 @@ export async function installTranscriptWatcher( } } - function bindWatcher(): void { - const watchedName = basename(filePath) - // Why: file watchers stay bound to an unlinked inode on macOS. Watching - // the parent keeps observing a successor even after a long recreate gap. - const nextWatcher = watch(dirname(filePath), (_event, changedName) => { - if (changedName === null || changedName.toString() === watchedName) { - scheduleDrain() - } - }) - nextWatcher.on('error', () => { - // Why: Windows can emit EPERM when a watched directory disappears. An - // error listener prevents a process crash and retries against its successor. - if (closed || watcher !== nextWatcher) { + async function reconcile(): Promise { + if (closed) { + return + } + try { + const current = await readTranscriptFileVersion(filePath) + if (closed) { return } - watcherNeedsRebind = true - nextWatcher.close() - watcher = null - scheduleRotationRetry() - }) - watcher = nextWatcher - watcherNeedsRebind = false + const versionChanged = + watchedVersion === null || transcriptFileVersionChanged(current, watchedVersion) + if (versionChanged || current.size !== state.offset || nativeWatcher.needsRebind()) { + await drain() + } + } catch { + // Why: a missing/replaced path needs the existing capped rotation retry, + // even when fs.watch stayed silent about the transition. + await drain() + } } - try { - bindWatcher() - } catch { - // File vanished between resolve and watch. - return null - } + const scheduler = createTranscriptWatchScheduler({ + debounceMs: args.debounceMs, + reconciliationIntervalMs: args.reconciliationIntervalMs, + drain: () => void drain(), + reconcile + }) + const nativeWatcher = createTranscriptNativeWatcher( + filePath, + () => scheduler.scheduleEventDrain(), + scheduleRotationRetry + ) + + nativeWatcher.bind() activeWatcherCount++ - scheduleDrain() + scheduler.startReconciliation() + scheduler.scheduleEventDrain() return { watching: true, @@ -307,12 +317,8 @@ export async function installTranscriptWatcher( return } closed = true - if (debounceTimer) { - clearTimeout(debounceTimer) - debounceTimer = null - } - watcher?.close() - watcher = null + scheduler.dispose() + nativeWatcher.dispose() activeWatcherCount-- } } diff --git a/src/main/native-chat/transcript-watch-liveness.test.ts b/src/main/native-chat/transcript-watch-liveness.test.ts new file mode 100644 index 00000000000..e5d67a3e121 --- /dev/null +++ b/src/main/native-chat/transcript-watch-liveness.test.ts @@ -0,0 +1,273 @@ +import { EventEmitter } from 'node:events' +import type { FSWatcher } from 'node:fs' +import type * as NodeFs from 'node:fs' +import { appendFile, mkdir, mkdtemp, rename, rm, utimes, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const { watchers, watchCallbacks, watchMock, watchState } = vi.hoisted(() => ({ + watchers: [] as (EventEmitter & { + close: ReturnType + unref: ReturnType + })[], + watchCallbacks: [] as ((event: string, filename: string | Buffer | null) => void)[], + watchMock: vi.fn(), + watchState: { error: null as Error | null } +})) + +vi.mock('node:fs', async () => { + const actual = await vi.importActual('node:fs') + watchMock.mockImplementation((_path, callback) => { + if (watchState.error) { + throw watchState.error + } + const watcher = Object.assign(new EventEmitter(), { close: vi.fn(), unref: vi.fn() }) + watchers.push(watcher) + watchCallbacks.push(callback) + return watcher as unknown as FSWatcher + }) + return { ...actual, watch: watchMock } +}) + +import { subscribeNativeChatTranscript } from './transcript-watch' + +const roots: string[] = [] + +afterEach(async () => { + vi.useRealTimers() + watchers.length = 0 + watchCallbacks.length = 0 + watchMock.mockClear() + watchState.error = null + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +async function tempFile(initial: string): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-watch-liveness-')) + roots.push(root) + const filePath = join(root, 'transcript.jsonl') + await writeFile(filePath, initial) + return filePath +} + +function claudeLine(uuid: string, role: 'user' | 'assistant', text: string): string { + return `${JSON.stringify({ + type: role, + uuid, + timestamp: '2026-06-01T10:00:00.000Z', + message: { role, content: role === 'user' ? text : [{ type: 'text', text }] } + })}\n` +} + +async function waitFor(predicate: () => boolean, timeoutMs = 2_000): Promise { + const start = Date.now() + while (!predicate()) { + if (Date.now() - start > timeoutMs) { + throw new Error('timed out waiting for condition') + } + await new Promise((resolve) => setTimeout(resolve, 10)) + } +} + +describe('native chat transcript watcher liveness', () => { + it('reconciles without a native watcher when fs.watch cannot bind', async () => { + watchState.error = new Error('ENOSYS') + const filePath = await tempFile(claudeLine('seed', 'user', 'hello')) + const snapshots = vi.fn() + const appends = vi.fn() + const subscription = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'session', + filePath, + onInitialSnapshot: snapshots, + onAppend: appends, + debounceMs: 0, + reconciliationIntervalMs: 20 + }) + await waitFor(() => snapshots.mock.calls.length === 1) + + await appendFile(filePath, claudeLine('poll-only', 'assistant', 'recovered')) + await waitFor(() => appends.mock.calls.flat(2).some((message) => message.id === 'poll-only')) + + subscription.unsubscribe() + expect(subscription.watching).toBe(true) + expect(watchers).toHaveLength(0) + expect(watchMock.mock.calls.length).toBeGreaterThan(1) + }) + + it('keeps polling readable transcripts while native watcher rebind fails', async () => { + const filePath = await tempFile(claudeLine('seed', 'user', 'hello')) + const snapshots = vi.fn() + const appends = vi.fn() + const subscription = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'session', + filePath, + onInitialSnapshot: snapshots, + onAppend: appends, + debounceMs: 0, + reconciliationIntervalMs: 20 + }) + await waitFor(() => snapshots.mock.calls.length === 1) + + watchState.error = new Error('EPERM') + watchers[0]!.emit('error', watchState.error) + await appendFile(filePath, claudeLine('failed-rebind', 'assistant', 'still tailed')) + await waitFor( + () => + appends.mock.calls.flat(2).some((message) => message.id === 'failed-rebind') && + watchMock.mock.calls.length > 1 + ) + + subscription.unsubscribe() + expect(watchers[0]!.close).toHaveBeenCalledOnce() + expect(watchMock.mock.calls.length).toBeGreaterThan(1) + }) + + it('reconciles an append when fs.watch omits the callback', async () => { + const filePath = await tempFile(claudeLine('seed', 'user', 'hello')) + const snapshots = vi.fn() + const appends = vi.fn() + const subscription = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'session', + filePath, + onInitialSnapshot: snapshots, + onAppend: appends, + debounceMs: 0, + reconciliationIntervalMs: 20 + }) + await waitFor(() => snapshots.mock.calls.length === 1) + + await appendFile(filePath, claudeLine('missed-event', 'assistant', 'recovered')) + // The mocked watcher never invokes watchCallbacks[0], reproducing a silent + // missed/coalesced host event while the file itself remains valid. + await waitFor(() => appends.mock.calls.flat(2).some((message) => message.id === 'missed-event')) + + subscription.unsubscribe() + expect(watchCallbacks).toHaveLength(1) + }) + + it('replaces a same-size prefix rewrite with an unchanged trailing boundary', async () => { + const prefixBefore = claudeLine('prefix-old', 'user', 'before') + const prefixAfter = claudeLine('prefix-new', 'user', 'after!') + const stableTail = claudeLine('stable-tail', 'assistant', 'x'.repeat(200)) + expect(Buffer.byteLength(prefixAfter)).toBe(Buffer.byteLength(prefixBefore)) + const filePath = await tempFile(prefixBefore + stableTail) + const snapshots = vi.fn() + const replacements = vi.fn() + const subscription = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'session', + filePath, + initialLimit: 40, + onInitialSnapshot: snapshots, + onReplace: replacements, + onAppend: () => {}, + debounceMs: 0, + reconciliationIntervalMs: 20 + }) + await waitFor(() => snapshots.mock.calls.length === 1) + + await writeFile(filePath, prefixAfter + stableTail) + const future = new Date(Date.now() + 10_000) + await utimes(filePath, future, future) + await waitFor(() => + replacements.mock.calls.flat(2).some((message) => message.id === 'prefix-new') + ) + + subscription.unsubscribe() + expect(replacements).toHaveBeenCalledOnce() + }) + + it('rebinds the native watcher after silent parent-directory replacement', async () => { + const filePath = await tempFile(claudeLine('old-file', 'user', 'before')) + const root = dirname(filePath) + const oldRoot = `${root}.old` + roots.push(oldRoot) + const snapshots = vi.fn() + const replacements = vi.fn() + const appends = vi.fn() + const subscription = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'session', + filePath, + initialLimit: 40, + onInitialSnapshot: snapshots, + onReplace: replacements, + onAppend: appends, + debounceMs: 0, + reconciliationIntervalMs: 20 + }) + await waitFor(() => snapshots.mock.calls.length === 1) + + await rename(root, oldRoot) + await mkdir(root) + await writeFile(filePath, claudeLine('new-file', 'user', 'after')) + await waitFor(() => replacements.mock.calls.length === 1 && watchers.length === 2) + await appendFile(filePath, claudeLine('new-followup', 'assistant', 'event-driven')) + watchCallbacks[1]!('change', 'transcript.jsonl') + await waitFor(() => appends.mock.calls.flat(2).some((message) => message.id === 'new-followup')) + + subscription.unsubscribe() + expect(watchers[0]!.close).toHaveBeenCalledOnce() + expect(watchers[1]!.close).toHaveBeenCalledOnce() + }) + + it('drains within the max wait while matching events remain sustained', async () => { + const filePath = await tempFile(claudeLine('seed', 'user', 'hello')) + const snapshots = vi.fn() + const appends = vi.fn() + const subscription = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'session', + filePath, + onInitialSnapshot: snapshots, + onAppend: appends, + debounceMs: 40, + reconciliationIntervalMs: 10_000 + }) + await waitFor(() => snapshots.mock.calls.length === 1) + await appendFile(filePath, claudeLine('sustained-events', 'assistant', 'bounded')) + + for (let index = 0; index < 20; index += 1) { + watchCallbacks[0]!('change', 'transcript.jsonl') + await new Promise((resolve) => setTimeout(resolve, 20)) + } + + expect(appends.mock.calls.flat(2).some((message) => message.id === 'sustained-events')).toBe( + true + ) + subscription.unsubscribe() + }) + + it('clears every timer and cannot rebind after unsubscribe', async () => { + vi.useFakeTimers() + const filePath = await tempFile('') + const snapshots = vi.fn() + const appends = vi.fn() + const subscription = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'session', + filePath, + onInitialSnapshot: snapshots, + onAppend: appends, + reconciliationIntervalMs: 20 + }) + + subscription.unsubscribe() + expect(watchers[0]!.unref).toHaveBeenCalledOnce() + expect(watchers[0]!.close).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(0) + + watchCallbacks[0]!('change', 'transcript.jsonl') + watchers[0]!.emit('error', new Error('late watcher error')) + await vi.advanceTimersByTimeAsync(10_000) + + expect(watchMock).toHaveBeenCalledOnce() + expect(snapshots).not.toHaveBeenCalled() + expect(appends).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + }) +}) diff --git a/src/main/native-chat/transcript-watch-scheduler.ts b/src/main/native-chat/transcript-watch-scheduler.ts new file mode 100644 index 00000000000..983b1802166 --- /dev/null +++ b/src/main/native-chat/transcript-watch-scheduler.ts @@ -0,0 +1,101 @@ +const DEFAULT_DEBOUNCE_MS = 40 +const EVENT_DRAIN_MAX_WAIT_MS = 250 +const DEFAULT_RECONCILIATION_MS = 1_000 + +type TranscriptWatchSchedulerOptions = { + debounceMs?: number + reconciliationIntervalMs?: number + drain: () => void + reconcile: () => Promise +} + +export type TranscriptWatchScheduler = { + scheduleEventDrain: () => void + scheduleRetry: (delayMs: number) => boolean + startReconciliation: () => void + dispose: () => void +} + +function unrefTimer(timer: ReturnType): void { + timer.unref?.() +} + +export function createTranscriptWatchScheduler( + options: TranscriptWatchSchedulerOptions +): TranscriptWatchScheduler { + const debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS + const maxEventWaitMs = Math.max(debounceMs, EVENT_DRAIN_MAX_WAIT_MS) + const reconciliationIntervalMs = options.reconciliationIntervalMs ?? DEFAULT_RECONCILIATION_MS + let disposed = false + let drainTimer: ReturnType | null = null + let firstEventAt: number | null = null + let reconciliationTimer: ReturnType | null = null + + function clearDrainTimer(): void { + if (!drainTimer) { + return + } + clearTimeout(drainTimer) + drainTimer = null + } + + function fireDrain(): void { + clearDrainTimer() + firstEventAt = null + options.drain() + } + + function armDrain(delayMs: number): void { + drainTimer = setTimeout(fireDrain, delayMs) + unrefTimer(drainTimer) + } + + function armReconciliation(): void { + if (disposed || reconciliationTimer) { + return + } + reconciliationTimer = setTimeout(() => { + reconciliationTimer = null + // Why: wait for the host-side stat/drain check before rearming so a slow + // remote filesystem cannot accumulate overlapping reconciliation work. + void options.reconcile().then(armReconciliation, armReconciliation) + }, reconciliationIntervalMs) + unrefTimer(reconciliationTimer) + } + + return { + scheduleEventDrain(): void { + if (disposed) { + return + } + const now = Date.now() + firstEventAt ??= now + const remainingMaxWait = Math.max(0, maxEventWaitMs - (now - firstEventAt)) + clearDrainTimer() + armDrain(Math.min(debounceMs, remainingMaxWait)) + }, + scheduleRetry(delayMs: number): boolean { + if (disposed || drainTimer) { + return false + } + firstEventAt = null + armDrain(delayMs) + return true + }, + startReconciliation(): void { + armReconciliation() + }, + dispose(): void { + if (disposed) { + return + } + disposed = true + clearDrainTimer() + if (reconciliationTimer) { + clearTimeout(reconciliationTimer) + reconciliationTimer = null + } + firstEventAt = null + } + } +} diff --git a/src/main/native-chat/transcript-watch-unsubscribe-race.test.ts b/src/main/native-chat/transcript-watch-unsubscribe-race.test.ts new file mode 100644 index 00000000000..1e6ef3c1bf9 --- /dev/null +++ b/src/main/native-chat/transcript-watch-unsubscribe-race.test.ts @@ -0,0 +1,68 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type * as TranscriptTailReader from './transcript-tail-reader' + +const { tailRead, tailReadStarted, rejectTailRead } = vi.hoisted(() => { + let markStarted = (): void => {} + let reject = (_error: Error): void => {} + const started = new Promise((resolve) => { + markStarted = resolve + }) + const read = new Promise((_resolve, rejectPromise) => { + reject = rejectPromise + }) + return { + tailRead: vi.fn(() => { + markStarted() + return read + }), + tailReadStarted: started, + rejectTailRead: reject + } +}) + +vi.mock('./transcript-tail-reader', async () => { + const actual = await vi.importActual('./transcript-tail-reader') + return { ...actual, readNativeChatTranscriptTailFile: tailRead } +}) + +import { getActiveNativeChatWatcherCount, subscribeNativeChatTranscript } from './transcript-watch' + +let root: string | null = null + +afterEach(async () => { + if (root) { + await rm(root, { recursive: true, force: true }) + root = null + } +}) + +describe('native chat transcript watcher unsubscribe race', () => { + it('does not emit an initial read error after unsubscribe', async () => { + root = await mkdtemp(join(tmpdir(), 'orca-native-chat-unsubscribe-race-')) + const filePath = join(root, 'transcript.jsonl') + await writeFile(filePath, '{}\n') + const snapshots = vi.fn() + const activeBefore = getActiveNativeChatWatcherCount() + const subscription = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'session', + filePath, + initialLimit: 40, + onInitialSnapshot: snapshots, + onAppend: () => {}, + debounceMs: 0, + reconciliationIntervalMs: 10_000 + }) + await tailReadStarted + + subscription.unsubscribe() + rejectTailRead(new Error('late read failure')) + await new Promise((resolve) => setImmediate(resolve)) + + expect(snapshots).not.toHaveBeenCalled() + expect(getActiveNativeChatWatcherCount()).toBe(activeBefore) + }) +}) diff --git a/src/main/native-chat/transcript-watch.ts b/src/main/native-chat/transcript-watch.ts index 1d92afe2974..72c609b02a2 100644 --- a/src/main/native-chat/transcript-watch.ts +++ b/src/main/native-chat/transcript-watch.ts @@ -15,9 +15,8 @@ export type { SubscribeNativeChatTranscriptArgs } from './transcript-watch-contract' -/** One resolve+install attempt. Returns null when the file isn't resolvable - * yet, or vanished between resolve and `watch()` — either case is retried by - * the resolve-poll rather than treated as a hard failure. */ +/** One resolve+install attempt. Returns null while the transcript file itself + * is unresolved; native-watch failure degrades to reconciliation-only mode. */ async function attemptInstall( args: SubscribeNativeChatTranscriptArgs, decode: (line: string, fallbackId: string) => NativeChatMessage | null From 4a7bdf9177fe151e98fcc34edbb4bcc1c272e00e Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 20 Jul 2026 23:59:05 -0700 Subject: [PATCH 51/58] perf(mobile): route graph-sync snapshot fanout through the notify coalescer (#9421) * perf(mobile): route graph-sync snapshot fanout through the notify coalescer syncWindowGraph no longer unconditionally re-serializes+fans every worktree's mobile snapshot to every client. Serve-hydrate fast path skips the build-then-drop loop when no serve-owned pty exists (desktop case); changed-worktree gating (strict superset of snapshot inputs) routes only real changes through the existing coalescer. No-op syncs 720->0 emits; single-worktree churn 720->120 (~83% fewer bytes to phone). Real changes still propagate. 11-round GPT-5.6-Sol xHIGH review converged 2-consecutive-clean; independently verified tsc+830 tests. Co-authored-by: Orca * fix(mobile): bump snapshotVersion on preserved prune frames + carry split-group layout Pre-merge adversarial review found two stale-phone regressions: (1) preserved-headless snapshots did not bump snapshotVersion, so a pruned-but-serve-preserved worktree change was dropped by clients' same-epoch freshness gate (orca-runtime.ts:22316); (2) serve-only no-op hydrate dropped the phone's split-group layout (orca-runtime.ts:3731). Both fixed + mutation-verified regression tests. 3 typechecks + suites green. Co-authored-by: Orca --------- Co-authored-by: Orca --- .../graph-sync-mobile-snapshot-gating.test.ts | 861 ++++++++++++++++++ src/main/runtime/orca-runtime.ts | 469 ++++++++-- .../sync-runtime-graph-key-reuse.test.ts | 65 ++ .../src/runtime/sync-runtime-graph.ts | 72 +- 4 files changed, 1407 insertions(+), 60 deletions(-) create mode 100644 src/main/runtime/graph-sync-mobile-snapshot-gating.test.ts diff --git a/src/main/runtime/graph-sync-mobile-snapshot-gating.test.ts b/src/main/runtime/graph-sync-mobile-snapshot-gating.test.ts new file mode 100644 index 00000000000..3efcbcc8010 --- /dev/null +++ b/src/main/runtime/graph-sync-mobile-snapshot-gating.test.ts @@ -0,0 +1,861 @@ +/** + * Graph-sync mobile snapshot gating: the serve-only hydrate fast-path must + * never hide a serve-owned terminal or a headless browser tab, and the + * changed-worktree coalesced fanout must emit every real change while + * suppressing no-op syncs entirely. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + RuntimeMobileSessionTabsResult, + RuntimeMobileSessionTabsSnapshot +} from '../../shared/runtime-types' +import type { WorkspaceSessionState } from '../../shared/types' +import { OrcaRuntimeService } from './orca-runtime' + +// Freshness predicate of shouldApplyWebSessionTabsSnapshot in +// src/renderer/src/runtime/web-session-tabs-sync.ts, copied as a literal +// because a main-process test must not import a renderer module: a same-epoch +// frame whose version is not strictly newer is dropped by web clients. +function makeWebClientFreshnessGate(): (frame: RuntimeMobileSessionTabsResult) => boolean { + const latestByWorktree = new Map() + return (frame) => { + const current = latestByWorktree.get(frame.worktree) + if ( + current && + current.publicationEpoch === frame.publicationEpoch && + frame.snapshotVersion <= current.snapshotVersion + ) { + return false + } + latestByWorktree.set(frame.worktree, { + publicationEpoch: frame.publicationEpoch, + snapshotVersion: frame.snapshotVersion + }) + return true + } +} + +const WT = 'repo-1::/tmp/worktree-a' +const WT_B = 'repo-1::/tmp/worktree-b' + +const storeBase = { + getRepo: () => ({ + id: 'repo-1', + path: '/tmp/repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1 + }), + getRepos: () => [storeBase.getRepo()], + addRepo: () => {}, + updateRepo: () => undefined as never, + getAllWorktreeMeta: () => ({}), + getWorktreeMeta: () => undefined, + getGitHubCache: () => ({ pr: {}, issue: {} }), + setWorktreeMeta: () => undefined as never, + removeWorktreeMeta: () => {}, + getSettings: () => ({ + workspaceDir: '/tmp/workspaces', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: false, + branchPrefix: 'none', + branchPrefixCustom: '' + }) +} + +function makeSession(overrides: Partial = {}): WorkspaceSessionState { + return { + activeRepoId: 'repo-1', + activeWorktreeId: WT, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + ...overrides + } +} + +function makeTerminalTab(id: string, ptyId: string | null) { + return { + id, + ptyId, + worktreeId: WT, + title: `Terminal ${id}`, + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } +} + +function createRuntime(initialSession: WorkspaceSessionState) { + let session = initialSession + const runtime = new OrcaRuntimeService({ + ...storeBase, + getWorkspaceSession: () => session, + setWorkspaceSession: (next: WorkspaceSessionState) => { + session = next + } + }) + const events: RuntimeMobileSessionTabsResult[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot)) + const setSession = (next: WorkspaceSessionState): void => { + session = next + } + const sync = ( + mobileSessionTabs?: RuntimeMobileSessionTabsSnapshot[], + graph: { tabs?: unknown[]; leaves?: unknown[] } = {} + ): void => { + runtime.syncWindowGraph(1, { + tabs: (graph.tabs ?? []) as never, + leaves: (graph.leaves ?? []) as never, + mobileSessionTabs + }) + } + return { runtime, events, sync, setSession, unsubscribe } +} + +function makeRendererSnapshot(args: { + worktree?: string + version: number + epoch?: string + title?: string + ptyId?: string +}): RuntimeMobileSessionTabsSnapshot { + const worktree = args.worktree ?? WT + return { + worktree, + publicationEpoch: args.epoch ?? 'renderer:test-epoch', + snapshotVersion: args.version, + activeGroupId: 'group-1', + activeTabId: 'tab-1::leaf-1', + activeTabType: 'terminal', + tabs: [ + { + type: 'terminal', + id: 'tab-1::leaf-1', + parentTabId: 'tab-1', + leafId: 'leaf-1', + title: args.title ?? 'Terminal 1', + ...(args.ptyId ? { ptyId: args.ptyId } : {}), + isActive: true + } + ] + } +} + +type RuntimeInternals = { + buildHeadlessMobileSessionTerminalTabs: (...args: unknown[]) => unknown[] + offscreenBrowserBackend: unknown + agentBrowserBridge: unknown + mobileSessionTabsByWorktree: Map +} + +describe('graph-sync mobile snapshot gating', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('hydrates a serve-owned terminal bound via tab.ptyId and emits it', () => { + const { events, sync } = createRuntime( + makeSession({ + tabsByWorktree: { [WT]: [makeTerminalTab('serve-tab', 'serve-pty-1')] } + }) + ) + + sync([]) + vi.advanceTimersByTime(60) + + expect(events).toHaveLength(1) + expect(events[0]?.worktree).toBe(WT) + expect(events[0]?.tabs).toEqual([ + expect.objectContaining({ type: 'terminal', parentTabId: 'serve-tab' }) + ]) + }) + + it('hydrates a serve-owned terminal bound ONLY via the layout leaf map (superset proof)', () => { + const { events, sync } = createRuntime( + makeSession({ + tabsByWorktree: { [WT]: [makeTerminalTab('split-tab', null)] }, + terminalLayoutsByTabId: { + 'split-tab': { + root: { type: 'leaf', leafId: 'leaf-a' }, + activeLeafId: 'leaf-a', + expandedLeafId: null, + ptyIdsByLeafId: { 'leaf-a': 'serve-pty-2' } + } + } + }) + ) + + sync([]) + vi.advanceTimersByTime(60) + + expect(events).toHaveLength(1) + expect(events[0]?.tabs).toEqual([ + expect.objectContaining({ + type: 'terminal', + parentTabId: 'split-tab', + parentLayout: expect.objectContaining({ + ptyIdsByLeafId: { 'leaf-a': 'serve-pty-2' } + }) + }) + ]) + }) + + it('skips the serve-only hydrate rebuild and emits nothing when no serve ptys and no browser backend', () => { + const { runtime, events, sync } = createRuntime( + makeSession({ + tabsByWorktree: { [WT]: [makeTerminalTab('plain-tab', 'repo-1::wt@@abc')] } + }) + ) + const buildSpy = vi.spyOn( + runtime as unknown as RuntimeInternals, + 'buildHeadlessMobileSessionTerminalTabs' + ) + + const snapshot = makeRendererSnapshot({ version: 1 }) + sync([structuredClone(snapshot)]) + vi.advanceTimersByTime(300) + expect(events).toHaveLength(1) + expect(buildSpy).not.toHaveBeenCalled() + + // Re-sync with fresh IPC-style structured clones of the same publication + // (same epoch + version = the renderer's unchanged-content resend): zero + // emits, even though object identity never survives IPC. + events.length = 0 + for (let i = 0; i < 5; i++) { + sync([structuredClone(snapshot)]) + } + vi.advanceTimersByTime(500) + expect(events).toHaveLength(0) + expect(buildSpy).not.toHaveBeenCalled() + }) + + it('still emits when the renderer publishes a new version with identical content', () => { + const { events, sync } = createRuntime(makeSession()) + + sync([makeRendererSnapshot({ version: 1 })]) + vi.advanceTimersByTime(300) + events.length = 0 + + // Byte-identical content, but a fresh version: a higher version is the + // renderer saying "this worktree changed", so it must emit. + sync([makeRendererSnapshot({ version: 2 })]) + vi.advanceTimersByTime(60) + expect(events).toHaveLength(1) + }) + + it('accepts a renderer-reload epoch with a reset snapshotVersion and emits', () => { + const { events, sync } = createRuntime(makeSession()) + + sync([makeRendererSnapshot({ version: 5, epoch: 'renderer:epoch-1' })]) + vi.advanceTimersByTime(300) + events.length = 0 + + sync([makeRendererSnapshot({ version: 1, epoch: 'renderer:epoch-2', title: 'Renamed' })]) + vi.advanceTimersByTime(60) + expect(events).toHaveLength(1) + expect(events[0]?.publicationEpoch).toBe('renderer:epoch-2') + }) + + it('hydrates headless browser tabs when an offscreen backend exists despite zero serve terminals', () => { + const { runtime, events, sync } = createRuntime( + makeSession({ + tabsByWorktree: { [WT]: [makeTerminalTab('plain-tab', 'repo-1::wt@@abc')] } + }) + ) + const internals = runtime as unknown as RuntimeInternals + internals.offscreenBrowserBackend = { closeTab: vi.fn() } + internals.agentBrowserBridge = { + tabList: vi.fn(() => ({ + tabs: [ + { browserPageId: 'page-1', index: 0, url: 'https://x.test', title: 'X', active: true } + ] + })), + getRegisteredTabs: vi.fn(() => new Map([['page-1', 100]])) + } + + sync([]) + vi.advanceTimersByTime(60) + + expect(events).toHaveLength(1) + expect(events[0]?.tabs).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: 'browser', browserPageId: 'page-1' }) + ]) + ) + }) + + it('fans out only the changed worktree, never the unchanged sibling (across IPC clones)', () => { + const { events, sync } = createRuntime(makeSession()) + const snapshotB = makeRendererSnapshot({ worktree: WT_B, version: 1 }) + + sync([makeRendererSnapshot({ version: 1 }), structuredClone(snapshotB)]) + vi.advanceTimersByTime(300) + events.length = 0 + + // Worktree A republishes with a new version; unchanged B arrives as a fresh + // structured clone with the same (epoch, version) pair, exactly as the + // renderer's per-worktree snapshot cache produces over IPC. + sync([makeRendererSnapshot({ version: 3, title: 'Renamed' }), structuredClone(snapshotB)]) + vi.advanceTimersByTime(60) + + expect(events).toHaveLength(1) + expect(events[0]?.worktree).toBe(WT) + }) + + it('emits a removed frame immediately and cancels the pending coalesced notify', () => { + const { events, sync } = createRuntime(makeSession()) + + sync([makeRendererSnapshot({ version: 1 })]) + // Pending coalesced notify exists (no timer advance yet). Removing the + // worktree must emit the removed frame NOW and cancel the pending notify. + sync([]) + + const removed = events.filter((event) => 'removed' in event && event.removed === true) + expect(removed).toHaveLength(1) + expect(removed[0]?.worktree).toBe(WT) + + events.length = 0 + vi.advanceTimersByTime(500) + expect(events).toHaveLength(0) + }) + + it('forces an emit by the starvation cap under sustained sync churn', () => { + const { events, sync } = createRuntime(makeSession()) + + sync([makeRendererSnapshot({ version: 1 })]) + vi.advanceTimersByTime(300) + events.length = 0 + + // Re-schedule every 20ms: the 50ms trailing edge never settles, so the + // 250ms cap must force the emit (the schedule at t>=250 fires inline). + let version = 2 + for (let elapsed = 0; elapsed <= 300; elapsed += 20) { + sync([makeRendererSnapshot({ version: version++, title: `spin-${version}` })]) + vi.advanceTimersByTime(20) + } + expect(events.length).toBeGreaterThanOrEqual(1) + }) + + it('emits ready when a graph-only sync binds a restored leaf under an unchanged snapshot pair', () => { + const { events, sync } = createRuntime(makeSession()) + const webClientAccepts = makeWebClientFreshnessGate() + // Renderer restore: the saved layout ptyId is in the snapshot, but the + // graph leaf has not re-bound yet (null ptyId) — client sees pending-handle. + const snapshot = makeRendererSnapshot({ version: 1, ptyId: 'pty-restored' }) + const nullLeaf = { + tabId: 'tab-1', + worktreeId: WT, + leafId: 'leaf-1', + paneRuntimeId: 1, + ptyId: null + } + sync([structuredClone(snapshot)], { leaves: [nullLeaf] }) + vi.advanceTimersByTime(300) + expect(events).toHaveLength(1) + expect(events[0]?.tabs[0]).toMatchObject({ status: 'pending-handle', terminal: null }) + // The web client applies the pending frame and records its (epoch, version). + expect(webClientAccepts(events[0]!)).toBe(true) + + // A later graph-only sync binds the leaf while the renderer resends the + // exact same (epoch, version) pair. The payload is a function of leaf state + // too, so the subscriber must receive ready + a terminal handle. + events.length = 0 + sync([structuredClone(snapshot)], { leaves: [{ ...nullLeaf, ptyId: 'pty-restored' }] }) + vi.advanceTimersByTime(60) + expect(events).toHaveLength(1) + expect(events[0]?.tabs[0]).toMatchObject({ + status: 'ready', + terminal: expect.stringMatching(/^term_/) + }) + // Why: the raw listener receiving the frame is not enough — the web + // freshness gate drops same-epoch versions that aren't strictly newer, so + // the ready frame must carry a bumped version to actually reach the client. + expect(webClientAccepts(events[0]!)).toBe(true) + + // Once bound, further byte-identical no-op syncs stay fully suppressed. + events.length = 0 + sync([structuredClone(snapshot)], { leaves: [{ ...nullLeaf, ptyId: 'pty-restored' }] }) + vi.advanceTimersByTime(500) + expect(events).toHaveLength(0) + }) + + it('accepts a newer renderer revision after main-local touches raised the stored version', () => { + const { runtime, events, sync } = createRuntime(makeSession()) + sync([makeRendererSnapshot({ version: 1, ptyId: 'pty-live' })]) + vi.advanceTimersByTime(300) + events.length = 0 + + // Several OSC/status chunks touch the stored snapshot: version 1 → 6, + // while the renderer's counter is still at 1. + const internals = runtime as unknown as RuntimeInternals & { + touchMobileSessionSnapshotsForPty: (ptyId: string) => void + } + for (let i = 0; i < 5; i++) { + internals.touchMobileSessionSnapshotsForPty('pty-live') + } + vi.advanceTimersByTime(300) + expect(internals.mobileSessionTabsByWorktree.get(WT)?.snapshotVersion).toBe(6) + events.length = 0 + + // The renderer's next real change publishes version 2 — lower than main's 6. + // It must merge (renderer ordering, not stored-version ordering) and the + // emitted version must stay strictly monotonic so clients don't drop it. + sync([makeRendererSnapshot({ version: 2, ptyId: 'pty-live', title: 'Renamed tab' })]) + vi.advanceTimersByTime(60) + expect(events).toHaveLength(1) + expect(events[0]?.tabs[0]).toMatchObject({ title: 'Renamed tab' }) + expect(events[0]?.snapshotVersion).toBeGreaterThan(6) + + // Byte-identical graph-sync resends of that revision are no-op suppressed + // and never resurrect the pre-rename content. + events.length = 0 + const emittedVersion = internals.mobileSessionTabsByWorktree.get(WT)?.snapshotVersion + for (let i = 0; i < 3; i++) { + sync([makeRendererSnapshot({ version: 2, ptyId: 'pty-live', title: 'Renamed tab' })]) + } + vi.advanceTimersByTime(500) + expect(events).toHaveLength(0) + expect(internals.mobileSessionTabsByWorktree.get(WT)?.snapshotVersion).toBe(emittedVersion) + expect(internals.mobileSessionTabsByWorktree.get(WT)?.tabs[0]?.title).toBe('Renamed tab') + }) + + it('suppresses repeated unchanged syncs with a serve-owned terminal present', () => { + const { runtime, events, sync } = createRuntime( + makeSession({ + tabsByWorktree: { [WT]: [makeTerminalTab('serve-tab', 'serve-pty-1')] } + }) + ) + sync([]) + vi.advanceTimersByTime(300) + expect(events).toHaveLength(1) + const internals = runtime as unknown as RuntimeInternals + const stored = internals.mobileSessionTabsByWorktree.get(WT) + + // Byte-identical re-syncs: the serve-only hydrate rebuilds the projection + // but must retain the existing snapshot object/epoch/version, so the + // identity-based no-op gating emits nothing. + events.length = 0 + for (let i = 0; i < 5; i++) { + sync([]) + } + vi.advanceTimersByTime(500) + expect(events).toHaveLength(0) + expect(internals.mobileSessionTabsByWorktree.get(WT)).toBe(stored) + }) + + it('suppresses repeated unchanged syncs with an offscreen browser backend enabled', () => { + const { runtime, events, sync } = createRuntime( + makeSession({ + tabsByWorktree: { [WT]: [makeTerminalTab('plain-tab', 'repo-1::wt@@abc')] } + }) + ) + const internals = runtime as unknown as RuntimeInternals + internals.offscreenBrowserBackend = { closeTab: vi.fn() } + internals.agentBrowserBridge = { + tabList: vi.fn(() => ({ + tabs: [ + { browserPageId: 'page-1', index: 0, url: 'https://x.test', title: 'X', active: true } + ] + })), + getRegisteredTabs: vi.fn(() => new Map([['page-1', 100]])) + } + sync([]) + vi.advanceTimersByTime(300) + expect(events).toHaveLength(1) + const stored = internals.mobileSessionTabsByWorktree.get(WT) + + events.length = 0 + for (let i = 0; i < 5; i++) { + sync([]) + } + vi.advanceTimersByTime(500) + expect(events).toHaveLength(0) + expect(internals.mobileSessionTabsByWorktree.get(WT)).toBe(stored) + + // A real browser change (a newly opened page) must still emit. Same-page + // navigation is delivered via the browser-bridge notify path, not graph + // sync, so a new tab is the graph-sync-visible browser change. + internals.agentBrowserBridge = { + tabList: vi.fn(() => ({ + tabs: [ + { browserPageId: 'page-1', index: 0, url: 'https://x.test', title: 'X', active: false }, + { browserPageId: 'page-2', index: 1, url: 'https://y.test', title: 'Y', active: true } + ] + })), + getRegisteredTabs: vi.fn( + () => + new Map([ + ['page-1', 100], + ['page-2', 101] + ]) + ) + } + sync([]) + vi.advanceTimersByTime(60) + expect(events).toHaveLength(1) + expect(events[0]?.tabs).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: 'browser', browserPageId: 'page-2' }) + ]) + ) + }) + + it('drops a closed renderer tab from a mixed renderer+serve snapshot while keeping the serve tab', () => { + const { runtime, events, sync, setSession } = createRuntime(makeSession()) + const internals = runtime as unknown as RuntimeInternals + + // Renderer publishes desktop tab A with no serve terminals anywhere. + sync([makeRendererSnapshot({ version: 1 })]) + vi.advanceTimersByTime(300) + expect(events).toHaveLength(1) + + // A serve-owned terminal appears in the persisted session; the serve-only + // hydrate merges it into the stored RENDERER publication (mixed snapshot). + setSession( + makeSession({ + tabsByWorktree: { [WT]: [makeTerminalTab('serve-tab', 'serve-pty-1')] } + }) + ) + events.length = 0 + sync([makeRendererSnapshot({ version: 1 })]) + vi.advanceTimersByTime(60) + expect(events).toHaveLength(1) + const mixed = internals.mobileSessionTabsByWorktree.get(WT) + expect(mixed?.tabs.map((tab) => (tab.type === 'terminal' ? tab.parentTabId : tab.id))).toEqual([ + 'tab-1', + 'serve-tab' + ]) + + // Desktop closes tab A: the next renderer publication omits it. The merge + // must NOT resurrect A from the mixed snapshot, but the serve tab (not + // renderer-owned) must survive. + events.length = 0 + sync([ + { + worktree: WT, + publicationEpoch: 'renderer:test-epoch', + snapshotVersion: 2, + activeGroupId: 'group-1', + activeTabId: null, + activeTabType: null, + tabs: [] + } + ]) + vi.advanceTimersByTime(60) + expect(events).toHaveLength(1) + expect(events[0]?.tabs).toEqual([ + expect.objectContaining({ type: 'terminal', parentTabId: 'serve-tab' }) + ]) + + // The serve binding itself disappears from persistence (and no live PTY + // backs it): the next renderer publication must stop preserving it too. + setSession(makeSession()) + events.length = 0 + sync([ + { + worktree: WT, + publicationEpoch: 'renderer:test-epoch', + snapshotVersion: 3, + activeGroupId: 'group-1', + activeTabId: null, + activeTabType: null, + tabs: [] + } + ]) + vi.advanceTimersByTime(60) + expect(internals.mobileSessionTabsByWorktree.get(WT)?.tabs).toEqual([]) + }) + + it('delivers the prune frame when the renderer omits a worktree whose serve tab is preserved', () => { + const { events, sync } = createRuntime( + makeSession({ + tabsByWorktree: { [WT]: [makeTerminalTab('serve-tab', 'serve-pty-1')] } + }) + ) + const webClientAccepts = makeWebClientFreshnessGate() + + // Phone accepts the renderer+serve merged frame. + sync([makeRendererSnapshot({ version: 1 })]) + vi.advanceTimersByTime(300) + expect(events).toHaveLength(1) + expect(webClientAccepts(events[0]!)).toBe(true) + expect(events[0]?.tabs.map((tab) => ('parentTabId' in tab ? tab.parentTabId : tab.id))).toEqual( + ['tab-1', 'serve-tab'] + ) + + // Desktop closes the renderer tab, so the next graph omits the worktree + // while the serve binding persists. Preservation prunes the renderer tab, + // but the preserved epoch hashes only the unchanged serve tab — without a + // fresh snapshotVersion the phone's same-epoch freshness gate drops the + // prune frame and keeps the closed tab forever. + events.length = 0 + sync([]) + vi.advanceTimersByTime(60) + expect(events).toHaveLength(1) + expect(events[0]?.tabs).toEqual([ + expect.objectContaining({ type: 'terminal', parentTabId: 'serve-tab' }) + ]) + expect(webClientAccepts(events[0]!)).toBe(true) + + // Recomputing the preservation on further omitted syncs is a genuine + // no-op: the preservedIsNoOp identity gate keeps the entry, zero fanout. + events.length = 0 + for (let i = 0; i < 3; i++) { + sync([]) + } + vi.advanceTimersByTime(500) + expect(events).toHaveLength(0) + }) + + it('drops a de-persisted serve tab when the renderer resends the unchanged accepted revision', () => { + const { runtime, events, sync, setSession } = createRuntime(makeSession()) + const internals = runtime as unknown as RuntimeInternals + const webClientAccepts = makeWebClientFreshnessGate() + + // Renderer publishes (epoch, version 1) while a serve binding exists; the + // serve-only hydrate merges the serve tab and version 1 becomes accepted. + setSession( + makeSession({ + tabsByWorktree: { [WT]: [makeTerminalTab('serve-tab', 'serve-pty-1')] } + }) + ) + sync([makeRendererSnapshot({ version: 1 })]) + vi.advanceTimersByTime(300) + expect(events).toHaveLength(1) + expect(webClientAccepts(events[0]!)).toBe(true) + expect( + internals.mobileSessionTabsByWorktree + .get(WT) + ?.tabs.some((tab) => tab.type === 'terminal' && tab.parentTabId === 'serve-tab') + ).toBe(true) + + // The serve binding disappears from persistence (no live PTY backs it) + // while desktop tabs are unchanged, so the renderer correctly resends the + // SAME (epoch, version 1). The accepted-revision no-op gate must not keep + // the stale preserved serve tab published: the resend must re-merge, drop + // the tab, and reach clients past their same-epoch freshness gate. + setSession(makeSession()) + events.length = 0 + sync([makeRendererSnapshot({ version: 1 })]) + vi.advanceTimersByTime(60) + expect(events).toHaveLength(1) + expect( + events[0]?.tabs.some((tab) => 'parentTabId' in tab && tab.parentTabId === 'serve-tab') + ).toBe(false) + expect(webClientAccepts(events[0]!)).toBe(true) + expect( + internals.mobileSessionTabsByWorktree + .get(WT) + ?.tabs.some((tab) => tab.type === 'terminal' && tab.parentTabId === 'serve-tab') + ).toBe(false) + + // Further byte-identical resends of the accepted revision stay suppressed. + events.length = 0 + for (let i = 0; i < 3; i++) { + sync([makeRendererSnapshot({ version: 1 })]) + } + vi.advanceTimersByTime(500) + expect(events).toHaveLength(0) + }) + + it('drops a de-persisted SSH tab when the renderer resends the unchanged accepted revision', () => { + const sshPtyId = 'ssh:conn-1@@pty-7' + const { runtime, sync, setSession } = createRuntime( + makeSession({ + tabsByWorktree: { [WT]: [makeTerminalTab('ssh-tab', sshPtyId)] } + }) + ) + const internals = runtime as unknown as RuntimeInternals & { + hydrateHeadlessMobileSessionTabsFromWorkspaceSession: (worktreeId?: string) => Set + } + internals.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(WT) + + // Renderer attaches: the SSH tab merges into the accepted renderer revision. + sync([makeRendererSnapshot({ version: 1 })]) + vi.advanceTimersByTime(300) + expect( + internals.mobileSessionTabsByWorktree + .get(WT) + ?.tabs.some((tab) => tab.type === 'terminal' && tab.parentTabId === 'ssh-tab') + ).toBe(true) + + // The persisted SSH binding is removed with no renderer-visible change, so + // the renderer resends the unchanged version 1 — the tab must still drop. + setSession(makeSession()) + sync([makeRendererSnapshot({ version: 1 })]) + vi.advanceTimersByTime(60) + expect( + internals.mobileSessionTabsByWorktree + .get(WT) + ?.tabs.some((tab) => tab.type === 'terminal' && tab.parentTabId === 'ssh-tab') + ).toBe(false) + }) + + it('still preserves every tab across a renderer publication when the base snapshot is headless-built', () => { + const { runtime, sync } = createRuntime( + makeSession({ + tabsByWorktree: { [WT]: [makeTerminalTab('serve-tab', 'serve-pty-1')] } + }) + ) + const internals = runtime as unknown as RuntimeInternals + + // Headless-built snapshot (serve hydrate, no renderer publication yet). + sync([]) + vi.advanceTimersByTime(300) + expect( + internals.mobileSessionTabsByWorktree.get(WT)?.publicationEpoch.startsWith('headless') + ).toBe(true) + + // A renderer attaches and publishes an unrelated tab: the headless-built + // tab must be preserved into the merged renderer snapshot (broad rule). + sync([makeRendererSnapshot({ version: 1 })]) + vi.advanceTimersByTime(60) + const merged = internals.mobileSessionTabsByWorktree.get(WT) + expect( + merged?.tabs.some((tab) => tab.type === 'terminal' && tab.parentTabId === 'serve-tab') + ).toBe(true) + expect(merged?.tabs.some((tab) => tab.type === 'terminal' && tab.parentTabId === 'tab-1')).toBe( + true + ) + }) + + it('preserves a runtime-owned SSH terminal across successive renderer revisions', () => { + const sshPtyId = 'ssh:conn-1@@pty-7' + const { runtime, sync, setSession } = createRuntime( + makeSession({ + tabsByWorktree: { [WT]: [makeTerminalTab('ssh-tab', sshPtyId)] } + }) + ) + const internals = runtime as unknown as RuntimeInternals & { + hydrateHeadlessMobileSessionTabsFromWorkspaceSession: (worktreeId?: string) => Set + } + // Full headless hydrate (SSH tabs never come from the serve-only path) + // builds the SSH tab into a headless-built snapshot. + internals.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(WT) + expect(internals.mobileSessionTabsByWorktree.get(WT)?.tabs).toEqual([ + expect.objectContaining({ type: 'terminal', parentTabId: 'ssh-tab' }) + ]) + + // A renderer attaches and publishes an unrelated tab: the SSH tab is merged + // into the renderer epoch (broad headless-built preservation). + sync([makeRendererSnapshot({ version: 1 })]) + vi.advanceTimersByTime(300) + const merged = internals.mobileSessionTabsByWorktree.get(WT) + expect( + merged?.tabs.some((tab) => tab.type === 'terminal' && tab.parentTabId === 'ssh-tab') + ).toBe(true) + + // A newer renderer revision still omits the still-runtime-owned SSH tab. + // Its binding remains persisted, so it must remain published — this is the + // regression: serve-only preservation dropped app-scoped ssh:@@ bindings. + sync([makeRendererSnapshot({ version: 2, title: 'Renamed' })]) + vi.advanceTimersByTime(60) + expect( + internals.mobileSessionTabsByWorktree + .get(WT) + ?.tabs.some((tab) => tab.type === 'terminal' && tab.parentTabId === 'ssh-tab') + ).toBe(true) + + // Once the SSH binding disappears from persistence (and no live PTY backs + // it), the next renderer revision must stop preserving it. + setSession(makeSession()) + sync([makeRendererSnapshot({ version: 3, title: 'Renamed again' })]) + vi.advanceTimersByTime(60) + expect( + internals.mobileSessionTabsByWorktree + .get(WT) + ?.tabs.some((tab) => tab.type === 'terminal' && tab.parentTabId === 'ssh-tab') + ).toBe(false) + }) + + it('retains the split-group layout across no-op syncs with a serve-owned terminal present', () => { + const { runtime, events, sync } = createRuntime( + makeSession({ + tabsByWorktree: { [WT]: [makeTerminalTab('serve-tab', 'serve-pty-1')] } + }) + ) + const internals = runtime as unknown as RuntimeInternals + const splitLayout = { + type: 'split' as const, + direction: 'horizontal' as const, + first: { type: 'leaf' as const, groupId: 'group-1' }, + second: { type: 'leaf' as const, groupId: 'group-2' }, + ratio: 0.4 + } + const makeSplitRendererSnapshot = (): RuntimeMobileSessionTabsSnapshot => ({ + worktree: WT, + publicationEpoch: 'renderer:test-epoch', + snapshotVersion: 1, + activeGroupId: 'group-1', + activeTabId: 'tab-1::leaf-1', + activeTabType: 'terminal', + tabGroups: [ + { id: 'group-1', activeTabId: 'tab-1', tabOrder: ['tab-1'] }, + { id: 'group-2', activeTabId: 'tab-2', tabOrder: ['tab-2'] } + ], + tabGroupLayout: structuredClone(splitLayout), + tabs: [ + { + type: 'terminal', + id: 'tab-1::leaf-1', + parentTabId: 'tab-1', + leafId: 'leaf-1', + title: 'Terminal 1', + isActive: true + }, + { + type: 'terminal', + id: 'tab-2::leaf-1', + parentTabId: 'tab-2', + leafId: 'leaf-1', + title: 'Terminal 2', + isActive: false + } + ] + }) + + // Renderer publishes a two-group split while a serve binding exists; the + // merged snapshot must carry the renderer's split layout. + sync([makeSplitRendererSnapshot()]) + vi.advanceTimersByTime(300) + expect(events).toHaveLength(1) + expect(events[0]?.tabGroupLayout).toEqual(splitLayout) + expect(internals.mobileSessionTabsByWorktree.get(WT)?.tabGroupLayout).toEqual(splitLayout) + + // A second identical renderer pair: the serve-only hydrate rebuild runs + // (serve PTY present) and must carry the stored split layout forward, so + // the sync is a full no-op — stored layout unchanged, zero fanout. + events.length = 0 + sync([makeSplitRendererSnapshot()]) + vi.advanceTimersByTime(500) + expect(events).toHaveLength(0) + expect(internals.mobileSessionTabsByWorktree.get(WT)?.tabGroupLayout).toEqual(splitLayout) + }) + + it('hydrates a serve terminal that appears in the session after suppressed syncs', () => { + const { events, sync, setSession } = createRuntime(makeSession()) + + sync([]) + vi.advanceTimersByTime(300) + expect(events).toHaveLength(0) + + setSession( + makeSession({ + tabsByWorktree: { [WT]: [makeTerminalTab('late-serve-tab', 'serve-pty-9')] } + }) + ) + sync([]) + vi.advanceTimersByTime(60) + + expect(events).toHaveLength(1) + expect(events[0]?.tabs).toEqual([ + expect.objectContaining({ type: 'terminal', parentTabId: 'late-serve-tab' }) + ]) + }) +}) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 316d89f1efd..587fd80b7f3 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -2270,6 +2270,16 @@ export class OrcaRuntimeService { private authoritativeWindowId: number | null = null private tabs = new Map() private mobileSessionTabsByWorktree = new Map() + // Why: renderer publication ordering must be judged against the renderer's + // own last-accepted (epoch, version) — never against the stored snapshot's + // version, which main-local touches bump independently and can push + // permanently ahead of the renderer's counter. The renderer reuses one pair + // for byte-identical content, so a same-epoch version <= this one is a no-op + // resend (or stale) and is skipped without touching the stored entry. + private acceptedRendererMobileSnapshotByWorktree = new Map< + string, + { publicationEpoch: string; rendererVersion: number } + >() private clientSessionTabSelections = new ClientSessionTabSelectionStore() // Why: idempotency map for mobile terminal creation — a retried create with the // same clientMutationId returns the in-flight operation instead of duplicating. @@ -3455,8 +3465,10 @@ export class OrcaRuntimeService { throw new Error('Runtime graph publisher does not match the authoritative window') } + const previousTabs = this.tabs + const previousLeaves = this.leaves this.tabs = new Map(graph.tabs.map((tab) => [tab.tabId, tab])) - this.syncMobileSessionTabs(graph.mobileSessionTabs) + const changedMobileWorktrees = this.syncMobileSessionTabs(graph.mobileSessionTabs) const nextLeaves = new Map() const graphSyncedAt = this.nextTitleObservationSequence() @@ -3577,7 +3589,40 @@ export class OrcaRuntimeService { this.leaves = nextLeaves this.rebuildLeafPtyIndex() - this.notifyMobileSessionTabSnapshots() + // Why: the emitted client payload is a function of the stored snapshot AND + // the tab/leaf graph (handles/titles/connected resolve from leaf state), so + // a graph-only change — e.g. a restored leaf binding its ptyId while the + // snapshot pair is unchanged — must also fan out, or a paired client stays + // on pending-handle forever. Schedule the union on the same 50ms trailing + // edge as the OSC-title path; the coalescer emit reads the latest state at + // fire time so no final version is ever lost. + for (const worktreeId of this.collectMobileVisibleGraphChangedWorktrees( + previousTabs, + previousLeaves + )) { + if (changedMobileWorktrees.has(worktreeId)) { + continue + } + const stored = this.mobileSessionTabsByWorktree.get(worktreeId) + if (!stored) { + continue + } + // Why: web clients drop same-epoch frames whose version isn't strictly + // newer, so a graph-only change must mint a fresh stored version (like + // the PTY touch path does) or the re-emitted payload — e.g. the + // pending-handle → ready flip — is discarded and the client stays stale. + // The accepted-renderer tracking is untouched: this is a main-local bump. + this.mobileSessionTabsByWorktree.set(worktreeId, { + ...stored, + snapshotVersion: stored.snapshotVersion + 1 + }) + changedMobileWorktrees.add(worktreeId) + } + for (const worktreeId of changedMobileWorktrees) { + if (this.mobileSessionTabsByWorktree.has(worktreeId)) { + this.mobileSessionTabsNotifyCoalescer.schedule(worktreeId) + } + } this.graphStatus = 'ready' this.setTerminalSideEffectConsumerAvailable(windowId !== HEADLESS_RUNTIME_WINDOW_ID) this.refreshWritableFlags() @@ -3598,6 +3643,46 @@ export class OrcaRuntimeService { } } + // Why: toMobileSessionTabsResult resolves handles/titles from this.tabs and + // this.leaves, so any tab/leaf delta a graph sync installs can flip the + // client payload (pending-handle → ready, tab title) with zero change to the + // stored snapshot. Compare exactly the projection-relevant fields and report + // the affected worktrees; false positives only cost a coalesced no-op emit. + private collectMobileVisibleGraphChangedWorktrees( + previousTabs: Map, + previousLeaves: Map + ): Set { + const changed = new Set() + for (const [tabId, tab] of this.tabs) { + const prev = previousTabs.get(tabId) + if (!prev || prev.title !== tab.title) { + changed.add(tab.worktreeId) + } + } + for (const [tabId, tab] of previousTabs) { + if (!this.tabs.has(tabId)) { + changed.add(tab.worktreeId) + } + } + for (const [leafKey, leaf] of this.leaves) { + const prev = previousLeaves.get(leafKey) + if ( + !prev || + prev.ptyId !== leaf.ptyId || + prev.connected !== leaf.connected || + prev.paneTitle !== leaf.paneTitle + ) { + changed.add(leaf.worktreeId) + } + } + for (const [leafKey, leaf] of previousLeaves) { + if (!this.leaves.has(leafKey)) { + changed.add(leaf.worktreeId) + } + } + return changed + } + async listMobileSessionTabs( worktreeSelector: string, clientNavigationId?: string @@ -3645,6 +3730,18 @@ export class OrcaRuntimeService { if (!session) { return reconciledWorktreeIds } + // Why: with no serve-owned ptyId anywhere in the session and no offscreen + // browser backend, the serve-only hydrate provably builds zero tabs for + // every worktree — skip the per-worktree rebuild entirely (hot on every + // graph sync). Scoped to onlyServeOwnedTerminals so full hydrates are + // untouched. + if ( + options.onlyServeOwnedTerminals === true && + !this.offscreenBrowserBackend && + !this.workspaceSessionHasServeOwnedPty(session) + ) { + return reconciledWorktreeIds + } const entries = worktreeId !== undefined ? ([[worktreeId, session.tabsByWorktree[worktreeId] ?? []]] as const) @@ -3714,57 +3811,146 @@ export class OrcaRuntimeService { ? mergedActiveTab.parentTabId : mergedActiveTab.id : null - this.mobileSessionTabsByWorktree.set(entryWorktreeId, { + const nextTabGroups: RuntimeMobileSessionTabGroup[] = hasPersistedSplit + ? this.appendBrowserTabOrder( + this.distributeHeadlessTabsAcrossGroups( + persistedGroups.map((group) => ({ + id: group.id, + activeTabId: group.activeTabId, + tabOrder: [...group.tabOrder], + ...(group.recentTabIds ? { recentTabIds: [...group.recentTabIds] } : {}) + })), + this.collectHeadlessParentTabOrder(mergedTerminalTabs), + activeTopLevelId + ), + mergedBrowserOrder, + undefined, + // Why: distribute drops browser ids (terminal-only), so carry each + // browser's persisted group forward instead of coalescing left. + this.collectBrowserGroupAssignment(persistedGroups, mergedBrowserOrder) + ) + : options.onlyServeOwnedTerminals === true && existing?.tabGroups + ? this.appendBrowserTabOrder( + this.mergeMobileSessionTabGroups( + entryWorktreeId, + existing.tabGroups, + mergedTerminalTabs, + mergedActiveTab?.type === 'terminal' ? mergedActiveTab : null + ), + mergedBrowserOrder + ) + : [ + { + id: groupId, + activeTabId: mergedActiveTab?.id + ? (activeTab?.parentTabId ?? mergedActiveTab.id) + : (tabOrder[0] ?? null), + tabOrder + } + ] + // Why: merging runtime tabs INTO a renderer publication must not reclass + // the snapshot as headless-built — the preservation predicate would then + // treat the renderer's own tabs as runtime-owned and resurrect tabs the + // renderer later closes. Keep the renderer base epoch with a merge suffix + // (idempotent) so ownership stays derivable from the epoch. + const mergedIntoRendererPublication = + options.onlyServeOwnedTerminals === true && + existing !== undefined && + !this.isHeadlessBuiltMobileSessionPublicationBase(existing.publicationEpoch) + const nextSnapshot: RuntimeMobileSessionTabsSnapshot = { worktree: existing?.worktree ?? entryWorktreeId, - publicationEpoch: `headless-hydrated:${Date.now().toString(36)}`, + publicationEpoch: mergedIntoRendererPublication + ? this.getMergedMobileSessionPublicationEpoch(existing, tabs) + : `headless-hydrated:${Date.now().toString(36)}`, snapshotVersion: (existing?.snapshotVersion ?? 0) + 1, activeGroupId: existing?.activeGroupId ?? groupId, activeTabId: mergedActiveTab?.id ?? null, activeTabType: mergedActiveTab?.type ?? null, - tabGroups: hasPersistedSplit - ? this.appendBrowserTabOrder( - this.distributeHeadlessTabsAcrossGroups( - persistedGroups.map((group) => ({ - id: group.id, - activeTabId: group.activeTabId, - tabOrder: [...group.tabOrder], - ...(group.recentTabIds ? { recentTabIds: [...group.recentTabIds] } : {}) - })), - this.collectHeadlessParentTabOrder(mergedTerminalTabs), - activeTopLevelId - ), - mergedBrowserOrder, - undefined, - // Why: distribute drops browser ids (terminal-only), so carry each - // browser's persisted group forward instead of coalescing left. - this.collectBrowserGroupAssignment(persistedGroups, mergedBrowserOrder) - ) - : options.onlyServeOwnedTerminals === true && existing?.tabGroups - ? this.appendBrowserTabOrder( - this.mergeMobileSessionTabGroups( - entryWorktreeId, - existing.tabGroups, - mergedTerminalTabs, - mergedActiveTab?.type === 'terminal' ? mergedActiveTab : null - ), - mergedBrowserOrder - ) - : [ - { - id: groupId, - activeTabId: mergedActiveTab?.id - ? (activeTab?.parentTabId ?? mergedActiveTab.id) - : (tabOrder[0] ?? null), - tabOrder - } - ], - ...(hasPersistedSplit && persistedLayout ? { tabGroupLayout: persistedLayout } : {}), + tabGroups: nextTabGroups, + // Why: the serve-only rebuild runs on every graph sync — carry the + // existing split layout forward or each sync drops it and fans out. + ...(hasPersistedSplit && persistedLayout + ? { tabGroupLayout: persistedLayout } + : options.onlyServeOwnedTerminals === true && existing?.tabGroupLayout + ? { tabGroupLayout: existing.tabGroupLayout } + : {}), tabs: mergedTabs - }) + } + // Why: the serve-only hydrate runs on EVERY graph sync; when the rebuilt + // projection matches the existing snapshot, keep the existing object and + // (epoch, version) untouched so identity-based change detection stays a + // pure no-op and unchanged serve/browser worktrees never fan out. + if (existing && this.headlessMobileSnapshotContentUnchanged(existing, nextSnapshot)) { + continue + } + this.mobileSessionTabsByWorktree.set(entryWorktreeId, nextSnapshot) } return reconciledWorktreeIds } + // Why: content equality for the hydrate's idempotence check — compares every + // client-visible field EXCEPT publicationEpoch/snapshotVersion (both are + // freshly minted on each rebuild and would defeat the comparison). Tab and + // group objects are rebuilt each hydrate, so compare by value, not identity. + private headlessMobileSnapshotContentUnchanged( + existing: RuntimeMobileSessionTabsSnapshot, + next: RuntimeMobileSessionTabsSnapshot + ): boolean { + if ( + existing.worktree !== next.worktree || + existing.activeGroupId !== next.activeGroupId || + existing.activeTabId !== next.activeTabId || + existing.activeTabType !== next.activeTabType + ) { + return false + } + // Why: this runs per persisted worktree on EVERY graph sync whenever a + // serve PTY exists, so compare structurally instead of stable-stringifying + // both sides (which allocated six full serialized trees per worktree). + return ( + this.mobileSnapshotValueEqual(existing.tabs, next.tabs) && + this.mobileSnapshotValueEqual(existing.tabGroups ?? null, next.tabGroups ?? null) && + this.mobileSnapshotValueEqual(existing.tabGroupLayout ?? null, next.tabGroupLayout ?? null) + ) + } + + // Deep structural equality over plain snapshot JSON (objects/arrays/scalars). + // Key order is irrelevant; a mismatch only costs a coalesced no-op emit. + private mobileSnapshotValueEqual(a: unknown, b: unknown): boolean { + if (a === b) { + return true + } + if (Array.isArray(a) || Array.isArray(b)) { + if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) { + return false + } + for (let index = 0; index < a.length; index++) { + if (!this.mobileSnapshotValueEqual(a[index], b[index])) { + return false + } + } + return true + } + if (a !== null && b !== null && typeof a === 'object' && typeof b === 'object') { + const aRecord = a as Record + const bRecord = b as Record + const aKeys = Object.keys(aRecord) + if (aKeys.length !== Object.keys(bRecord).length) { + return false + } + for (const key of aKeys) { + if ( + !Object.hasOwn(bRecord, key) || + !this.mobileSnapshotValueEqual(aRecord[key], bRecord[key]) + ) { + return false + } + } + return true + } + return false + } + // Why: keep an existing snapshot's browser tabs in sync with the live bridge // without rebuilding stable terminal state. Replaces browser entries with the // current live set and rewrites the browser portion of the primary group order. @@ -3882,6 +4068,27 @@ export class OrcaRuntimeService { return typeof ptyId === 'string' && ptyId.startsWith('serve-') } + // Why: strict superset of hasServeOwnedPtyBinding's inputs (tab.ptyId + + // layout leaf ptyIds are what the built tabs' bindings are derived from), so + // the serve-only hydrate fast-path can never hide a serve terminal. + private workspaceSessionHasServeOwnedPty(session: WorkspaceSessionState): boolean { + for (const tabs of Object.values(session.tabsByWorktree ?? {})) { + for (const tab of tabs) { + if (this.isServeOwnedPtyId(tab.ptyId)) { + return true + } + const leafPtyIds = session.terminalLayoutsByTabId?.[tab.id]?.ptyIdsByLeafId + if ( + leafPtyIds && + Object.values(leafPtyIds).some((ptyId) => this.isServeOwnedPtyId(ptyId)) + ) { + return true + } + } + } + return false + } + private hasServeOwnedPtyBinding(tab: RuntimeMobileSessionTerminalTab): boolean { if (this.isServeOwnedPtyId(tab.ptyId)) { return true @@ -3913,6 +4120,47 @@ export class OrcaRuntimeService { ) } + // Why: a snapshot tab can keep a serve/SSH-owned ptyId after the runtime + // terminal died and was de-persisted, so id shape alone must not preserve it + // against a renderer publication. Require the binding to be backed by a live + // PTY or by the persisted workspace session (a dormant persisted serve/SSH + // binding is still re-hydratable, so it stays preserved). + private hasLiveOrPersistedServeOrSshOwnedPtyBinding( + worktreeId: string, + tab: RuntimeMobileSessionTerminalTab + ): boolean { + const boundPtyIds = [ + tab.ptyId, + ...Object.values(tab.parentLayout?.ptyIdsByLeafId ?? {}) + ].filter((ptyId): ptyId is string => this.isServeOrSshOwnedPtyId(ptyId)) + if (boundPtyIds.length === 0) { + return false + } + // Why: exited PTY records are archived in ptysById, so require a connected + // record — a dead serve shell whose persisted binding is also gone must + // stop being preserved. + if (boundPtyIds.some((ptyId) => this.ptysById.get(ptyId)?.connected === true)) { + return true + } + const session = this.store?.getWorkspaceSession?.() + if (!session) { + return false + } + const persistedTab = (session.tabsByWorktree?.[worktreeId] ?? []).find( + (candidate) => candidate.id === tab.parentTabId + ) + if (!persistedTab) { + return false + } + const persistedPtyIds = new Set( + [ + persistedTab.ptyId, + ...Object.values(session.terminalLayoutsByTabId?.[persistedTab.id]?.ptyIdsByLeafId ?? {}) + ].filter((ptyId): ptyId is string => typeof ptyId === 'string') + ) + return boundPtyIds.some((ptyId) => persistedPtyIds.has(ptyId)) + } + // Why: a tab needs authoritative runtime teardown (kill + de-persist + prune) // only when the renderer can't durably tear it down: either it's serve/SSH // (preserved + re-hydrated, would resurrect) or the renderer graph never @@ -22407,10 +22655,20 @@ export class OrcaRuntimeService { } } - private syncMobileSessionTabs(snapshots: RuntimeMobileSessionTabsSnapshot[] | undefined): void { + // Returns the worktrees whose stored snapshot object changed during this + // sync, so the caller can fan out only actually-changed worktrees. + private syncMobileSessionTabs( + snapshots: RuntimeMobileSessionTabsSnapshot[] | undefined + ): Set { + const changedWorktreeIds = new Set() if (snapshots === undefined) { - return + return changedWorktreeIds } + // Why: snapshots are immutable — every writer replaces the map entry with a + // new object, and the accept gate below drops semantically-unchanged + // renderer resends before they replace an entry — so reference identity + // before/after detects exactly the entries that actually changed. + const before = new Map(this.mobileSessionTabsByWorktree) // Why: renderer graphs own renderer tabs, but headless serve terminals never enter that graph unless we preserve their bindings. this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(undefined, { allowAttachedWindow: true, @@ -22420,29 +22678,85 @@ export class OrcaRuntimeService { for (const snapshot of snapshots) { nextWorktrees.add(snapshot.worktree) const existing = this.mobileSessionTabsByWorktree.get(snapshot.worktree) - const nextSnapshot = this.mergePreservedHeadlessMobileSessionTabs(snapshot, existing) + // Why: judge renderer publication ordering against the renderer's own + // last-accepted (epoch, version) — the renderer reuses one pair for + // byte-identical content, so a same-epoch version <= the accepted one is + // a no-op resend (or a stale frame) and must be skipped. Never compare + // against the stored snapshot's version: main-local touches bump it + // independently and would reject genuinely newer renderer revisions. + const accepted = this.acceptedRendererMobileSnapshotByWorktree.get(snapshot.worktree) if ( - !existing || - nextSnapshot.publicationEpoch !== existing.publicationEpoch || - nextSnapshot.snapshotVersion >= existing.snapshotVersion + accepted && + accepted.publicationEpoch === snapshot.publicationEpoch && + snapshot.snapshotVersion <= accepted.rendererVersion && + // Why: preservation is main-only state — a serve/SSH binding (or live + // browser page) can disappear without the renderer bumping its version, + // so a resend of the EXACT accepted revision (content-identical to the + // accepted publication, safe to re-merge) must still fall through to + // the merge, which prunes stale preserved tabs. Strictly-older frames + // stay skipped: their content is outdated, and the next accepted-pair + // resend performs the prune. + !( + existing && + snapshot.snapshotVersion === accepted.rendererVersion && + this.storedMobileSnapshotHasStalePreservedTab(existing, snapshot) + ) ) { - this.mobileSessionTabsByWorktree.set(snapshot.worktree, nextSnapshot) + continue } + const nextSnapshot = this.mergePreservedHeadlessMobileSessionTabs(snapshot, existing) + // Why: clients drop same-epoch frames whose version isn't strictly newer, + // and main-local touches may already have emitted a higher version than + // the renderer's counter — keep the stored version strictly monotonic so + // the accepted content is never discarded as stale downstream. + const storedVersion = existing + ? Math.max(nextSnapshot.snapshotVersion, existing.snapshotVersion + 1) + : nextSnapshot.snapshotVersion + this.mobileSessionTabsByWorktree.set( + snapshot.worktree, + storedVersion === nextSnapshot.snapshotVersion + ? nextSnapshot + : { ...nextSnapshot, snapshotVersion: storedVersion } + ) + this.acceptedRendererMobileSnapshotByWorktree.set(snapshot.worktree, { + publicationEpoch: snapshot.publicationEpoch, + rendererVersion: snapshot.snapshotVersion + }) } for (const [worktreeId, existing] of [...this.mobileSessionTabsByWorktree.entries()]) { if (!nextWorktrees.has(worktreeId)) { const preserved = this.buildPreservedHeadlessMobileSessionSnapshot(existing) if (preserved) { - this.mobileSessionTabsByWorktree.set(worktreeId, preserved) + // Why: preservation filters existing.tabs in place (same objects) and + // the merge epoch hashes the preserved identities idempotently, so an + // equal epoch with every tab object retained means the recomputation + // was a no-op — keep the entry so no-op syncs don't fan out. + const preservedIsNoOp = + preserved.publicationEpoch === existing.publicationEpoch && + preserved.tabs.length === existing.tabs.length && + preserved.tabs.every((tab, index) => tab === existing.tabs[index]) + if (!preservedIsNoOp) { + this.mobileSessionTabsByWorktree.set(worktreeId, preserved) + } + // Why: the stored entry is no longer the renderer's publication, so a + // future renderer frame must be re-merged even if it reuses the pair. + this.acceptedRendererMobileSnapshotByWorktree.delete(worktreeId) nextWorktrees.add(worktreeId) } else { this.mobileSessionTabsByWorktree.delete(worktreeId) + this.acceptedRendererMobileSnapshotByWorktree.delete(worktreeId) // Why: drop any pending coalesced notify so a stale snapshot can't land after the removed frame. this.mobileSessionTabsNotifyCoalescer.cancel(worktreeId) this.notifyMobileSessionTabsRemoved(worktreeId) } } } + for (const [worktreeId, snapshot] of this.mobileSessionTabsByWorktree) { + if (before.get(worktreeId) !== snapshot) { + changedWorktreeIds.add(worktreeId) + } + } + return changedWorktreeIds } private mergePreservedHeadlessMobileSessionTabs( @@ -22511,6 +22825,8 @@ export class OrcaRuntimeService { return { ...existing, publicationEpoch: this.getMergedMobileSessionPublicationEpoch(existing, tabs), + // Why: mint a fresh version or clients' same-epoch gate drops the prune frame. + snapshotVersion: existing.snapshotVersion + 1, activeGroupId: existing.activeGroupId ?? this.getHeadlessMobileSessionGroupId(existing.worktree), activeTabId: activeTab?.id ?? null, @@ -22525,6 +22841,26 @@ export class OrcaRuntimeService { } } + // Why: the accepted-revision no-op gate must not fossilize preserved runtime + // tabs. A stored merged snapshot's tabs that are absent from the incoming + // renderer publication exist only via preservation; if any such tab no longer + // passes the preservation predicate (binding removed from the live PTY table + // and persisted session, or browser page closed), the stored snapshot is + // stale even though the renderer revision is unchanged. + private storedMobileSnapshotHasStalePreservedTab( + existing: RuntimeMobileSessionTabsSnapshot, + incoming: RuntimeMobileSessionTabsSnapshot + ): boolean { + const incomingIds = new Set( + incoming.tabs.flatMap((tab) => this.getMobileSessionSnapshotTabIdentityKeys(tab)) + ) + return existing.tabs.some( + (tab) => + !this.getMobileSessionSnapshotTabIdentityKeys(tab).some((id) => incomingIds.has(id)) && + !this.shouldPreserveHeadlessMobileSessionTab(existing, tab) + ) + } + private collectPreservedHeadlessMobileSessionTabs( existing: RuntimeMobileSessionTabsSnapshot, incoming?: RuntimeMobileSessionTabsSnapshot @@ -22546,17 +22882,31 @@ export class OrcaRuntimeService { ): boolean { // Why: headless offscreen browser tabs exist only server-side, so a renderer-graph merge must keep them, not prune as "not in the graph". if (tab.type === 'browser') { + if (!this.offscreenBrowserBackend) { + return false + } + // Why: in a renderer-based merged snapshot the browser entries can also + // be renderer-owned, so only pages the offscreen bridge still lists are + // runtime-owned and preservable; a pure renderer epoch preserves none. return ( - Boolean(this.offscreenBrowserBackend) && - this.isHeadlessMobileSessionPublication(snapshot.publicationEpoch) + this.isHeadlessBuiltMobileSessionPublicationBase(snapshot.publicationEpoch) || + (snapshot.publicationEpoch.includes(':headless-merge:') && + typeof tab.browserPageId === 'string' && + this.getLiveBrowserTabsByPageId(snapshot.worktree).has(tab.browserPageId)) ) } if (tab.type !== 'terminal') { return false } + // Why: a merged renderer snapshot carries BOTH renderer-owned and + // runtime-owned tabs, so the epoch alone must not preserve every terminal — + // that resurrects renderer tabs the renderer already closed. Broad + // preservation applies only to genuinely headless-built snapshots; in a + // renderer-based one, only tabs with a live-or-persisted serve/SSH binding + // are runtime-owned and preservable. return ( - this.isHeadlessMobileSessionPublication(snapshot.publicationEpoch) || - this.hasServeOwnedPtyBinding(tab) + this.isHeadlessBuiltMobileSessionPublicationBase(snapshot.publicationEpoch) || + this.hasLiveOrPersistedServeOrSshOwnedPtyBinding(snapshot.worktree, tab) ) } @@ -22568,6 +22918,15 @@ export class OrcaRuntimeService { ) } + // Why: `:headless-merge:` only marks that runtime tabs were merged in — the + // BASE epoch still says who published the snapshot. A renderer-based merged + // snapshot must not be classified as headless-built, or its renderer tabs + // read as runtime-owned. + private isHeadlessBuiltMobileSessionPublicationBase(publicationEpoch: string): boolean { + const base = publicationEpoch.split(':headless-merge:')[0] + return base.startsWith('headless:') || base.startsWith('headless-hydrated:') + } + private getMergedMobileSessionPublicationEpoch( snapshot: RuntimeMobileSessionTabsSnapshot, preservedTabs: readonly RuntimeMobileSessionSnapshotTab[] diff --git a/src/renderer/src/runtime/sync-runtime-graph-key-reuse.test.ts b/src/renderer/src/runtime/sync-runtime-graph-key-reuse.test.ts index 388ea40eab7..2f895784d17 100644 --- a/src/renderer/src/runtime/sync-runtime-graph-key-reuse.test.ts +++ b/src/renderer/src/runtime/sync-runtime-graph-key-reuse.test.ts @@ -189,4 +189,69 @@ describe('mobile session snapshot reuse', () => { expect(draftSpy).not.toHaveBeenCalled() }) + + it('keeps (publicationEpoch, snapshotVersion) stable for byte-identical worktree content', () => { + const state = makeState({ + tabsByWorktree: { + 'wt-stable': [{ id: 'term-1', title: 'Codex working', customTitle: null }] + } as unknown as AppState['tabsByWorktree'], + terminalLayoutsByTabId: { + 'term-1': { + root: { type: 'leaf', leafId: '11111111-1111-4111-8111-111111111111' }, + activeLeafId: '11111111-1111-4111-8111-111111111111', + expandedLeafId: null + } + } as unknown as AppState['terminalLayoutsByTabId'] + }) + + const first = buildMobileSessionTabSnapshots(state)[0] + const second = buildMobileSessionTabSnapshots(state)[0] + + // Why: main gates per-worktree mobile fanout on this pair; a no-op rebuild + // must not mint a fresh version or every graph sync fans out every worktree. + expect(second?.publicationEpoch).toBe(first?.publicationEpoch) + expect(second?.snapshotVersion).toBe(first?.snapshotVersion) + }) + + it('bumps only the changed worktree version when a sibling stays byte-identical', () => { + const makeTwoWorktreeState = (title: string): AppState => + makeState({ + tabsByWorktree: { + 'wt-changed': [{ id: 'term-a', title, customTitle: null }], + 'wt-stable-sibling': [{ id: 'term-b', title: 'Idle agent', customTitle: null }] + } as unknown as AppState['tabsByWorktree'], + terminalLayoutsByTabId: { + 'term-a': { + root: { type: 'leaf', leafId: '22222222-2222-4222-8222-222222222222' }, + activeLeafId: '22222222-2222-4222-8222-222222222222', + expandedLeafId: null + }, + 'term-b': { + root: { type: 'leaf', leafId: '33333333-3333-4333-8333-333333333333' }, + activeLeafId: '33333333-3333-4333-8333-333333333333', + expandedLeafId: null + } + } as unknown as AppState['terminalLayoutsByTabId'] + }) + + const before = new Map( + buildMobileSessionTabSnapshots(makeTwoWorktreeState('Codex working')).map((snapshot) => [ + snapshot.worktree, + snapshot + ]) + ) + const after = new Map( + buildMobileSessionTabSnapshots(makeTwoWorktreeState('Codex done')).map((snapshot) => [ + snapshot.worktree, + snapshot + ]) + ) + + expect(after.get('wt-changed')?.snapshotVersion).toBeGreaterThan( + before.get('wt-changed')?.snapshotVersion ?? Number.POSITIVE_INFINITY + ) + expect(after.get('wt-stable-sibling')?.snapshotVersion).toBe( + before.get('wt-stable-sibling')?.snapshotVersion + ) + }) }) diff --git a/src/renderer/src/runtime/sync-runtime-graph.ts b/src/renderer/src/runtime/sync-runtime-graph.ts index 4a4e12fddc5..340af3c3d59 100644 --- a/src/renderer/src/runtime/sync-runtime-graph.ts +++ b/src/renderer/src/runtime/sync-runtime-graph.ts @@ -87,6 +87,47 @@ let syncEnabled = false let syncTimer: ReturnType | null = null let getStoreState: (() => AppState) | null = null let mobileSessionSnapshotVersion = 0 +// Why: main gates per-worktree mobile fanout on (publicationEpoch, +// snapshotVersion), so that pair must be a semantic revision: reuse the cached +// snapshot (same version) whenever a worktree's mobile-visible content is +// unchanged, and bump the version only for worktrees that actually changed. +const mobileSessionSnapshotCacheByWorktree = new Map< + string, + { content: unknown; snapshot: RuntimeMobileSessionTabsSnapshot } +>() + +// Structural equality under JSON-serialization semantics (undefined-valued +// keys are absent), so version reuse matches a JSON fingerprint exactly +// without allocating a serialized copy of the payload on every graph sync. +// Any value strict-equality can't prove equal (e.g. NaN) reads as changed, +// which only costs a redundant fanout — never a suppressed one. +function jsonContentEquals(a: unknown, b: unknown): boolean { + if (a === b) { + return true + } + if (Array.isArray(a) || Array.isArray(b)) { + if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) { + return false + } + return a.every((item, index) => jsonContentEquals(item, b[index])) + } + if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) { + return false + } + const aRecord = a as Record + const bRecord = b as Record + for (const key of Object.keys(aRecord)) { + if (!jsonContentEquals(aRecord[key], bRecord[key])) { + return false + } + } + for (const key of Object.keys(bRecord)) { + if (bRecord[key] !== undefined && aRecord[key] === undefined) { + return false + } + } + return true +} let cachedTabsProjection: TabsProjectionCache | null = null let cachedOpenFileIndexesSource: AppState['openFiles'] | null = null let cachedOpenFileIndexes: OpenFileIndexes | null = null @@ -821,17 +862,38 @@ export function buildMobileSessionTabSnapshots( new Set(tabGroups.map((group) => group.id)) ) : groupProjection.tabGroupLayout - snapshots.push({ - worktree: worktreeId, - publicationEpoch: mobileSessionPublicationEpoch, - snapshotVersion: ++mobileSessionSnapshotVersion, + const content = { activeGroupId, activeTabId: active?.id ?? null, activeTabType: active?.type ?? null, ...(tabGroups && tabGroups.length > 0 ? { tabGroups } : {}), ...(tabGroupLayout ? { tabGroupLayout } : {}), tabs - }) + } + // Why: main suppresses per-worktree fanout on an unchanged (epoch, version) + // pair, so reuse the cached version for structurally-identical content. The + // global counter still advances per worktree per build (as before caching) + // so a changed worktree's fresh version stays ahead of main's +1 bumps. + const candidateVersion = ++mobileSessionSnapshotVersion + const cached = mobileSessionSnapshotCacheByWorktree.get(worktreeId) + if (cached && jsonContentEquals(cached.content, content)) { + snapshots.push(cached.snapshot) + continue + } + const snapshot: RuntimeMobileSessionTabsSnapshot = { + worktree: worktreeId, + publicationEpoch: mobileSessionPublicationEpoch, + snapshotVersion: candidateVersion, + ...content + } + mobileSessionSnapshotCacheByWorktree.set(worktreeId, { content, snapshot }) + snapshots.push(snapshot) + } + + for (const worktreeId of mobileSessionSnapshotCacheByWorktree.keys()) { + if (!worktreeIds.has(worktreeId)) { + mobileSessionSnapshotCacheByWorktree.delete(worktreeId) + } } return snapshots From 58bcfe2a33bff0a136e796f8750045b3fb7b5ad8 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 20 Jul 2026 23:59:09 -0700 Subject: [PATCH 52/58] perf(runtime): gate PTY path-candidate extraction on mobile-connected (#9422) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(runtime): gate PTY path-candidate extraction on mobile-connected The 3-regex path extractor ran on every PTY chunk of every session for a mobile-only consumer. Now gated behind a sticky mobile-connected flag; first onReady synchronously backfills candidates from the retained 64KB window per original chunk boundary (lazy safety-net fallback). ~28.5us/chunk (~99.8%) saved on the desktop-only hot path; mobile parity preserved (Tier-1 raw-window read unchanged + backfill before any file RPC). 5-round review converged 2-consecutive-clean; independently verified tsc+902 tests. Co-authored-by: Orca * fix(runtime): guard path-candidate activation on the remote runtime proxy MobileSocketWiring.onReady called this.runtime.activateRecentPtyPathCandidateTracking() unconditionally, throwing on the E2EE/remote proxy (which lacks the method) and breaking the remote runtime connection (CI verify failure). Guarded with optional chaining (runtime-rpc.ts:900) — activation is a local-host concern. Mutation-verified E2EE-proxy regression test. Integration test + 3 typechecks green. Co-authored-by: Orca --------- Co-authored-by: Orca --- src/main/runtime/orca-runtime.test.ts | 175 ++++++++++++++++++ src/main/runtime/orca-runtime.ts | 67 ++++++- .../runtime/recent-pty-output-buffer.test.ts | 77 ++++++++ src/main/runtime/recent-pty-output-buffer.ts | 61 +++++- src/main/runtime/runtime-rpc.test.ts | 157 ++++++++++++++++ src/main/runtime/runtime-rpc.ts | 10 +- 6 files changed, 535 insertions(+), 12 deletions(-) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index da5644ec0ad..d6cdb4d08d0 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -8580,6 +8580,181 @@ describe('OrcaRuntimeService', () => { ) }) + it('resolves paths without candidate activation via the lazy safety net', async () => { + const runtime = createRuntime() + syncSinglePty(runtime, 'pty-1') + const [terminal] = (await runtime.listTerminals()).terminals + const artifactPath = '/tmp/lazy-activation-artifact.json' + + runtime.onPtyData('pty-1', `wrote ${artifactPath}\n`, 100) + + // No mobile connect ever happened; the query itself must activate+backfill. + expect(runtime.hasRecentTerminalOutputPath(terminal.handle, artifactPath, artifactPath)).toBe( + true + ) + }) + + it('backfills candidates on activation so scrolled-off paths still resolve', async () => { + const runtime = createRuntime() + syncSinglePty(runtime, 'pty-1') + const [terminal] = (await runtime.listTerminals()).terminals + const artifactPath = '/tmp/backfilled-artifact.json' + + // Path arrives while tracking is inactive (desktop-only phase). + runtime.onPtyData('pty-1', `wrote ${artifactPath}\n`, 100) + // First mobile connect: backfill from the retained raw window. + runtime.activateRecentPtyPathCandidateTracking() + // Scroll the raw 64KB window past the path with pathless output. + runtime.onPtyData('pty-1', 'x'.repeat(70 * 1024), 200) + + // Only the backfilled candidate tier can answer now. + expect(runtime.hasRecentTerminalOutputPath(terminal.handle, artifactPath, artifactPath)).toBe( + true + ) + }) + + it('backfills per retained chunk so chunk boundaries match the eager extractor', async () => { + const runtime = createRuntime() + syncSinglePty(runtime, 'pty-1') + const [terminal] = (await runtime.listTerminals()).terminals + const artifactPath = '/tmp/a.json' + + // Two chunks whose join would parse as one different candidate + // (/tmp/a.jsonsuffix.txt). The eager per-chunk extractor kept /tmp/a.json. + runtime.onPtyData('pty-1', `wrote ${artifactPath}`, 100) + runtime.onPtyData('pty-1', 'suffix.txt', 150) + runtime.activateRecentPtyPathCandidateTracking() + // Scroll the raw 64KB window so only the backfilled candidates can answer. + runtime.onPtyData('pty-1', 'x'.repeat(70 * 1024), 200) + + expect(runtime.hasRecentTerminalOutputPath(terminal.handle, artifactPath, artifactPath)).toBe( + true + ) + }) + + it('extracts candidates per chunk after activation for scrolled-off paths', async () => { + const runtime = createRuntime() + syncSinglePty(runtime, 'pty-1') + const [terminal] = (await runtime.listTerminals()).terminals + const artifactPath = '/tmp/post-activation-artifact.json' + + runtime.activateRecentPtyPathCandidateTracking() + // Idempotent: a second activation must not disturb live tracking. + runtime.activateRecentPtyPathCandidateTracking() + runtime.onPtyData('pty-1', `wrote ${artifactPath}\n`, 100) + runtime.onPtyData('pty-1', 'x'.repeat(70 * 1024), 200) + + expect(runtime.hasRecentTerminalOutputPath(terminal.handle, artifactPath, artifactPath)).toBe( + true + ) + }) + + it('does not retain pre-activation paths that scrolled past the raw window', async () => { + const runtime = createRuntime() + syncSinglePty(runtime, 'pty-1') + const [terminal] = (await runtime.listTerminals()).terminals + const artifactPath = '/tmp/pre-activation-scrolled-artifact.json' + + runtime.onPtyData('pty-1', `wrote ${artifactPath}\n`, 100) + runtime.onPtyData('pty-1', 'x'.repeat(70 * 1024), 200) + + // Documented accepted loss: output that scrolled past the raw window + // before the first-ever mobile connect yields no candidates. + expect(runtime.hasRecentTerminalOutputPath(terminal.handle, artifactPath, artifactPath)).toBe( + false + ) + }) + + it('backfill does not mint candidates from an over-limit line shortened by the window trim', async () => { + const runtime = createRuntime() + syncSinglePty(runtime, 'pty-1') + const [terminal] = (await runtime.listTerminals()).terminals + const artifactPath = '/tmp/result.json' + + // One chunk with a >4KiB line whose tail is the path: the eager + // extractor skipped it under the line-length guard. + runtime.onPtyData('pty-1', `${'a'.repeat(5000)} ${artifactPath}\n`, 100) + // Newline-free filler trims the window to ~1KiB before the path, so a + // trimmed-head replay would see an under-limit line ending in the path. + runtime.onPtyData('pty-1', 'y'.repeat(64 * 1024 - 1000), 150) + runtime.activateRecentPtyPathCandidateTracking() + // Scroll the raw window so only backfilled candidates can answer. + runtime.onPtyData('pty-1', 'x'.repeat(70 * 1024), 200) + + // Parity with eager extraction: the over-limit line never yielded a + // candidate, so the grant must stay denied after the raw window scrolls. + expect(runtime.hasRecentTerminalOutputPath(terminal.handle, artifactPath, artifactPath)).toBe( + false + ) + }) + + it('backfill replays the full head chunk including its window-trimmed prefix', async () => { + const runtime = createRuntime() + syncSinglePty(runtime, 'pty-1') + const [terminal] = (await runtime.listTerminals()).terminals + const artifactPath = '/tmp/trimmed-prefix-artifact.json' + + // Path sits in the head chunk's prefix, which the window trim drops from + // read() but the eager extractor saw at append time. + runtime.onPtyData('pty-1', `wrote ${artifactPath}\n${'b'.repeat(3000)}\n`, 100) + runtime.onPtyData('pty-1', 'y'.repeat(64 * 1024 - 1000), 150) + runtime.activateRecentPtyPathCandidateTracking() + runtime.onPtyData('pty-1', 'x'.repeat(70 * 1024), 200) + + // Parity with eager extraction: the append-time candidate outlived the + // raw window, so backfill must recover it from the intact head chunk. + expect(runtime.hasRecentTerminalOutputPath(terminal.handle, artifactPath, artifactPath)).toBe( + true + ) + }) + + it('matches eager extraction exactly for a pre-sliced oversized chunk', async () => { + const runtime = createRuntime() + syncSinglePty(runtime, 'pty-1') + const [terminal] = (await runtime.listTerminals()).terminals + const cutLinePath = '/tmp/cut-line.json' + const keptPath = '/tmp/kept-after-cut.json' + + // Single >64KiB append is stored pre-sliced, so its original text is + // unrecoverable at activation time. Extraction runs eagerly at append + // instead: cutLinePath sat on an over-4KiB line the extractor's line + // guard rejects (and the slice leaves an under-4KiB tail of it that must + // NOT mint a candidate later), while keptPath sat on a short line and + // must survive the raw window scrolling. + const keptLine = `wrote ${keptPath}\n` + const afterFirstLine = `${keptLine}${'z'.repeat(62 * 1024 - keptLine.length)}` + const oversized = `${'a'.repeat(5 * 1024)} ${cutLinePath}\n${afterFirstLine}` + runtime.onPtyData('pty-1', oversized, 100) + runtime.activateRecentPtyPathCandidateTracking() + runtime.onPtyData('pty-1', 'x'.repeat(70 * 1024), 200) + + expect(runtime.hasRecentTerminalOutputPath(terminal.handle, cutLinePath, cutLinePath)).toBe( + false + ) + expect(runtime.hasRecentTerminalOutputPath(terminal.handle, keptPath, keptPath)).toBe(true) + }) + + it('keeps a candidate from the short first line of an oversized chunk after the window scrolls', async () => { + const runtime = createRuntime() + syncSinglePty(runtime, 'pty-1') + const [terminal] = (await runtime.listTerminals()).terminals + const artifactPath = '/tmp/result.json' + + // A short first line of a >64KiB chunk loses only its `wrote ` prefix to + // the pre-slice; the path itself stays in the retained window. The old + // eager extractor recorded it from the intact original chunk, so it must + // stay authorized after the raw window scrolls — parity requires the + // append-time extraction for oversized chunks, not backfill replay. + const firstLine = `wrote ${artifactPath}\n` + runtime.onPtyData('pty-1', `${firstLine}${'f'.repeat(64 * 1024 + 6 - firstLine.length)}`, 100) + runtime.activateRecentPtyPathCandidateTracking() + runtime.onPtyData('pty-1', 'x'.repeat(70 * 1024), 200) + + expect(runtime.hasRecentTerminalOutputPath(terminal.handle, artifactPath, artifactPath)).toBe( + true + ) + }) + it('replaces suffix-only headless state with the recovered renderer snapshot', async () => { const runtime = createRuntime() syncSinglePty(runtime, 'pty-1') diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 587fd80b7f3..1784cede32d 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -370,7 +370,7 @@ import type { import type { AutomationService } from '../automations/service' import { RuntimeBrowserCommands } from './orca-runtime-browser' import { buildHeadlessTerminalSplitLayout } from './headless-terminal-split-layout' -import { RecentPtyOutputBuffer } from './recent-pty-output-buffer' +import { RECENT_PTY_OUTPUT_LIMIT, RecentPtyOutputBuffer } from './recent-pty-output-buffer' import { buildHeadlessTabGroupMove, buildHeadlessTabGroupSplit @@ -2417,6 +2417,10 @@ export class OrcaRuntimeService { private ptyLifecycleGenerationById = new Map() private nextPtyLifecycleGeneration = 1 private recentPtyPathCandidatesById = new Map() + // Why: candidates only feed mobile file-tap provenance; desktop-only + // sessions skip the 3-regex extraction on every PTY chunk until a + // mobile/remote client authenticates (sticky, backfilled on activation). + private recentPtyPathCandidateTrackingActive = false // Why: OSC 9999 status can span PTY chunks. Keeping parser state in the // runtime lets hidden/model-owned terminals observe agent state without a // mounted xterm view. @@ -8707,14 +8711,60 @@ export class OrcaRuntimeService { private recordRecentPtyOutputForPathProvenance(ptyId: string, data: string): void { let recentOutputBuffer = this.recentPtyOutputById.get(ptyId) if (!recentOutputBuffer) { - recentOutputBuffer = new RecentPtyOutputBuffer() + // Boundaries are only owed to the one-time activation backfill; once + // tracking is live, new buffers keep the read-collapsing hot path. + recentOutputBuffer = new RecentPtyOutputBuffer({ + preserveChunkBoundaries: !this.recentPtyPathCandidateTrackingActive + }) this.recentPtyOutputById.set(ptyId, recentOutputBuffer) } recentOutputBuffer.append(data) - this.recentPtyPathCandidatesById.set( - ptyId, - appendRecentPtyPathCandidates(this.recentPtyPathCandidatesById.get(ptyId), data) - ) + if ( + this.recentPtyPathCandidateTrackingActive || + // Why: an over-window chunk is stored pre-sliced, so activation backfill + // could never replay its original text. Extract while intact; oversized + // chunks are rare, so the desktop-only gate still skips the hot path. + data.length > RECENT_PTY_OUTPUT_LIMIT + ) { + this.recentPtyPathCandidatesById.set( + ptyId, + appendRecentPtyPathCandidates(this.recentPtyPathCandidatesById.get(ptyId), data) + ) + } + } + + activateRecentPtyPathCandidateTracking(): void { + if (this.recentPtyPathCandidateTrackingActive) { + return + } + this.recentPtyPathCandidateTrackingActive = true + // Why: synchronous backfill from the retained raw windows so a file tap + // right after first mobile connect resolves exactly as before the gate. + // Replay each retained chunk in its original full form: joining or + // trimming chunks would change the candidate set (e.g. a window cut can + // shorten an over-4KiB line under the extractor's line guard, minting + // candidates the eager extractor rejected). + // Accepted best-effort loss: output that scrolled past the raw window + // before the first-ever connect no longer yields candidates. + for (const [ptyId, buffer] of this.recentPtyOutputById) { + let candidates = this.recentPtyPathCandidatesById.get(ptyId) + const { chunks, headChunkIsPartial } = buffer.retainedChunks() + for (let index = 0; index < chunks.length; index += 1) { + if (index === 0 && headChunkIsPartial) { + // A pre-sliced over-window chunk was already extracted eagerly at + // append time (while its original text was intact); replaying its + // truncated remainder would mint or drop candidates spuriously. + continue + } + candidates = appendRecentPtyPathCandidates(candidates, chunks[index]!) + } + if (candidates) { + this.recentPtyPathCandidatesById.set(ptyId, candidates) + } + // Chunk boundaries were owed only to this one-time backfill; return + // the buffer to the compact read-collapsing steady state. + buffer.compact() + } } resolveTerminalContext( @@ -8757,6 +8807,11 @@ export class OrcaRuntimeService { } hasRecentTerminalOutputPath(handle: string, pathText: string, absolutePath: string): boolean { + // Why: safety net for any query path that never saw a mobile onReady — + // lazily backfill so the answer matches pre-gate behavior. + if (!this.recentPtyPathCandidateTrackingActive) { + this.activateRecentPtyPathCandidateTracking() + } const ptyId = this.resolveLeafForHandle(handle)?.ptyId const recentOutput = ptyId ? this.recentPtyOutputById.get(ptyId)?.read() : null if (recentOutput && recentTerminalOutputIncludesPath(recentOutput, pathText, absolutePath)) { diff --git a/src/main/runtime/recent-pty-output-buffer.test.ts b/src/main/runtime/recent-pty-output-buffer.test.ts index 1778c7aee42..6c21284e163 100644 --- a/src/main/runtime/recent-pty-output-buffer.test.ts +++ b/src/main/runtime/recent-pty-output-buffer.test.ts @@ -165,6 +165,83 @@ describe('RecentPtyOutputBuffer', () => { expect(buffer.read()).toBe(reference ?? '') }) + it('retainedChunks preserves original chunk boundaries within the window', () => { + const buffer = new RecentPtyOutputBuffer() + buffer.append('wrote /tmp/a.json') + buffer.append('suffix.txt') + expect(buffer.retainedChunks().chunks).toEqual(['wrote /tmp/a.json', 'suffix.txt']) + expect(buffer.read()).toBe('wrote /tmp/a.jsonsuffix.txt') + // read() must not collapse the chunk array before compact(): boundaries + // survive a read so the backfill can still replay original chunks. + expect(buffer.retainedChunks().chunks).toEqual(['wrote /tmp/a.json', 'suffix.txt']) + }) + + it('retainedChunks keeps the full original head chunk when the window trims mid-chunk', () => { + const buffer = new RecentPtyOutputBuffer() + const head = 'a'.repeat(RECENT_PTY_OUTPUT_LIMIT - 5) + buffer.append(head) + buffer.append('bbbbbbbbbb') + buffer.append('cc') + const { chunks, headChunkIsPartial } = buffer.retainedChunks() + // The head chunk is intact (its original text, trimmed prefix included) + // so candidate backfill sees the same input the eager extractor saw. + expect(chunks).toEqual([head, 'bbbbbbbbbb', 'cc']) + expect(headChunkIsPartial).toBe(false) + expect(buffer.read()).toBe(`${'a'.repeat(RECENT_PTY_OUTPUT_LIMIT - 12)}bbbbbbbbbbcc`) + }) + + it('retainedChunks flags a pre-sliced oversized head chunk as partial', () => { + const buffer = new RecentPtyOutputBuffer() + buffer.append('z'.repeat(RECENT_PTY_OUTPUT_LIMIT + 100)) + expect(buffer.retainedChunks().headChunkIsPartial).toBe(true) + buffer.append('tail') + expect(buffer.retainedChunks().headChunkIsPartial).toBe(true) + // Once the partial head chunk is fully evicted the flag clears. + buffer.append('w'.repeat(RECENT_PTY_OUTPUT_LIMIT - 1)) + const { chunks, headChunkIsPartial } = buffer.retainedChunks() + expect(headChunkIsPartial).toBe(false) + expect(chunks).toEqual(['tail', 'w'.repeat(RECENT_PTY_OUTPUT_LIMIT - 1)]) + // An exactly cap-sized append is stored whole, not partial. + const exact = new RecentPtyOutputBuffer() + exact.append('q'.repeat(RECENT_PTY_OUTPUT_LIMIT)) + expect(exact.retainedChunks().headChunkIsPartial).toBe(false) + }) + + it('compact collapses to a single chunk and restores read-time defragmentation', () => { + const buffer = new RecentPtyOutputBuffer() + let reference: string | undefined + for (let i = 0; i < 200; i++) { + const chunk = `${i}:${'p'.repeat(700)}\n` + buffer.append(chunk) + reference = referenceAppend(reference, chunk) + } + buffer.compact() + expect(buffer.retainedChunks().chunks).toEqual([reference]) + expect(buffer.read()).toBe(reference) + // After compact, a read re-collapses fragmentation from later appends. + buffer.append('after') + reference = referenceAppend(reference, 'after') + expect(buffer.read()).toBe(reference) + expect(buffer.retainedChunks().chunks).toEqual([reference]) + }) + + it('retainedChunks window-trimmed join always equals read()', () => { + const rng = mulberry32(0xfeed) + const buffer = new RecentPtyOutputBuffer() + const joinRetained = (): string => { + const { chunks } = buffer.retainedChunks() + const joined = chunks.join('') + return joined.slice(Math.max(0, joined.length - RECENT_PTY_OUTPUT_LIMIT)) + } + for (let i = 0; i < 300; i++) { + buffer.append(String.fromCharCode(33 + (i % 90)).repeat(Math.floor(rng() * 1500))) + if (i % 11 === 0) { + expect(joinRetained()).toBe(buffer.read()) + } + } + expect(joinRetained()).toBe(buffer.read()) + }) + it('stays equivalent under randomized chunk sizes straddling the cap', () => { const rng = mulberry32(0xc0ffee) for (let round = 0; round < 5; round++) { diff --git a/src/main/runtime/recent-pty-output-buffer.ts b/src/main/runtime/recent-pty-output-buffer.ts index 29af04f1daf..a10559b75c9 100644 --- a/src/main/runtime/recent-pty-output-buffer.ts +++ b/src/main/runtime/recent-pty-output-buffer.ts @@ -14,20 +14,33 @@ const DROPPED_HEAD_COMPACT_THRESHOLD = 1024 export class RecentPtyOutputBuffer { private chunks: string[] = [] private headIndex = 0 - // Code units already trimmed off the front of the head chunk. Deferred to - // read() so repeated small trims never allocate a substring per append. + // Code units already trimmed off the front of the head chunk. Deferred so + // repeated small trims never allocate a substring per append, and so the + // head chunk's original text stays available for candidate backfill. private headOffset = 0 private totalLen = 0 + // True when the stored head chunk is not the full original PTY chunk (a + // single over-limit append is stored pre-sliced), so backfill replay knows + // the original line context of its leading text is gone. + private headChunkIsPartial = false + // Original chunk boundaries are owed only to the one-time path-candidate + // backfill; compact() ends that obligation and lets read() collapse. + private preserveChunkBoundaries: boolean + + constructor(options?: { preserveChunkBoundaries?: boolean }) { + this.preserveChunkBoundaries = options?.preserveChunkBoundaries ?? true + } append(data: string): void { + if (data.length === 0) { + return + } if (data.length >= RECENT_PTY_OUTPUT_LIMIT) { this.chunks = [data.slice(-RECENT_PTY_OUTPUT_LIMIT)] this.headIndex = 0 this.headOffset = 0 this.totalLen = RECENT_PTY_OUTPUT_LIMIT - return - } - if (data.length === 0) { + this.headChunkIsPartial = data.length > RECENT_PTY_OUTPUT_LIMIT return } this.chunks.push(data) @@ -40,6 +53,7 @@ export class RecentPtyOutputBuffer { this.chunks[this.headIndex] = '' this.headIndex += 1 this.headOffset = 0 + this.headChunkIsPartial = false this.totalLen -= headRemaining } else { this.headOffset += excess @@ -53,6 +67,19 @@ export class RecentPtyOutputBuffer { } read(): string { + if (this.preserveChunkBoundaries) { + // Join without mutating: boundaries and the original head chunk are + // still owed to retainedChunks(); reads are rare before compact(). + if (this.chunks.length - this.headIndex > 1) { + const retained = this.chunks.slice(this.headIndex) + if (this.headOffset > 0) { + retained[0] = retained[0].slice(this.headOffset) + } + return retained.join('') + } + const head = this.chunks[this.headIndex] ?? '' + return this.headOffset > 0 ? head.slice(this.headOffset) : head + } if (this.chunks.length - this.headIndex > 1) { // Collapse to the joined tail so repeated reads stay O(1). const retained = this.chunks.slice(this.headIndex) @@ -69,4 +96,28 @@ export class RecentPtyOutputBuffer { } return this.chunks[this.headIndex] ?? '' } + + /** + * Retained chunks with original PTY boundaries. The head chunk is its full + * original text (any window-trimmed prefix included) unless + * headChunkIsPartial. Why: path-candidate backfill must replay the eager + * per-chunk extraction exactly — trimming or joining chunks changes the + * candidate set. Only meaningful before compact(). + */ + retainedChunks(): { chunks: string[]; headChunkIsPartial: boolean } { + return { + chunks: this.chunks.slice(this.headIndex), + headChunkIsPartial: this.headChunkIsPartial + } + } + + /** + * Ends the chunk-boundary obligation after the one-time backfill and + * collapses immediately, so the append/read hot path returns to the + * compact single-chunk steady state. + */ + compact(): void { + this.preserveChunkBoundaries = false + this.read() + } } diff --git a/src/main/runtime/runtime-rpc.test.ts b/src/main/runtime/runtime-rpc.test.ts index 2c5d069b941..54b775b3178 100644 --- a/src/main/runtime/runtime-rpc.test.ts +++ b/src/main/runtime/runtime-rpc.test.ts @@ -1,5 +1,6 @@ /* eslint-disable max-lines -- Why: this integration-style RPC test keeps the request/response contract together so regressions in the external CLI surface are easier to spot. */ import { existsSync, mkdtempSync } from 'node:fs' +import { rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { createConnection, type Socket } from 'node:net' @@ -3273,6 +3274,162 @@ describe('OrcaRuntimeRpcServer', () => { } }) + it('authorizes a mobile artifact tap after first-connect backfill even once the raw window scrolls', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const runtime = new OrcaRuntimeService(makeStore() as never) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-1' }), + write: () => true, + kill: () => true, + getCwd: async () => '/tmp/worktree-a', + getForegroundProcess: async () => null + }) + const server = new OrcaRuntimeRpcServer({ + runtime, + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + // Real artifact under the temp root so the grant path stats it. + const artifactPath = join(tmpdir(), `orca-artifact-${process.pid}-${Date.now()}.json`) + await writeFile(artifactPath, '{"ok":true}') + + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-1', + worktreeId: 'repo-1::/tmp/worktree-a', + title: 'Agent', + activeLeafId: 'pane:1', + layout: null + } + ], + leaves: [ + { + tabId: 'tab-1', + worktreeId: 'repo-1::/tmp/worktree-a', + leafId: 'pane:1', + paneRuntimeId: 1, + ptyId: 'pty-1' + } + ] + }) + // Path printed before any mobile client exists: tracking is inactive, so + // only the retained raw window knows it at connect time. + runtime.onPtyData('pty-1', `wrote ${artifactPath}\n`, 100) + + await server.start() + const offer = server.createPairingOffer({ + address: '127.0.0.1', + name: 'phone', + scope: 'mobile' + }) + expect(offer.available).toBe(true) + if (!offer.available) { + throw new Error('WebSocket pairing unavailable') + } + // Full direct E2EE authentication drives MobileSocketWiring.onReady (the + // relay transport attaches through the same wiring), which must backfill + // candidates from the raw window without any direct activation call. + const phone = await authenticateMobileWsSession(offer.pairingUrl) + const phoneResponses = createEncryptedWsResponseReader(phone) + try { + // Post-connect pathless output scrolls the artifact out of the raw + // 64KiB window; only the connect-time backfilled candidate can answer. + runtime.onPtyData('pty-1', 'x'.repeat(70 * 1024), 200) + + sendEncryptedWsRequest(phone, { + id: 'phone_terminals', + method: 'terminal.list', + params: { worktree: 'id:repo-1::/tmp/worktree-a' } + }) + const listResponse = await phoneResponses.next('phone_terminals') + const handle = (listResponse.result as { terminals: { handle: string }[] }).terminals[0]! + .handle + expect(handle).toBeTruthy() + + sendEncryptedWsRequest(phone, { + id: 'phone_tap', + method: 'files.resolveTerminalPath', + params: { + worktree: 'id:repo-1::/tmp/worktree-a', + pathText: artifactPath, + terminal: handle + } + }) + await expect(phoneResponses.next('phone_tap')).resolves.toMatchObject({ + ok: true, + result: { + exists: true, + isDirectory: false, + openTarget: { + kind: 'absolute-file', + provider: 'local', + grantId: expect.any(String) + } + } + }) + } finally { + phoneResponses.dispose() + phone.ws.close() + await server.stop() + await rm(artifactPath, { force: true }) + } + }) + + it('completes remote E2EE authentication against a runtime proxy without activateRecentPtyPathCandidateTracking', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + // Why: a remote-host runtime proxy only implements RPC-forwarded methods; + // activation is a local-host concern, so the proxy legitimately lacks + // activateRecentPtyPathCandidateTracking and onReady must not throw. + const runtimeProxy = { + getRuntimeId: () => 'proxy-runtime-test', + getStartedAt: () => 1, + getStatus: () => ({ graphStatus: 'unavailable' }), + cleanupSubscriptionsForConnection: () => {}, + cancelMobileDictationForConnection: () => {}, + onClientDisconnected: () => {} + } as unknown as OrcaRuntimeService + expect( + (runtimeProxy as { activateRecentPtyPathCandidateTracking?: unknown }) + .activateRecentPtyPathCandidateTracking + ).toBeUndefined() + const server = new OrcaRuntimeRpcServer({ + runtime: runtimeProxy, + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + + await server.start() + const offer = server.createPairingOffer({ + address: '127.0.0.1', + name: 'remote', + scope: 'runtime' + }) + expect(offer.available).toBe(true) + if (!offer.available) { + throw new Error('WebSocket pairing unavailable') + } + // Real E2EE pairing + authentication drives MobileSocketWiring.onReady + // before e2ee_authenticated is sent; a throwing onReady never authenticates. + const session = await authenticateMobileWsSession(offer.pairingUrl) + const responses = createEncryptedWsResponseReader(session) + try { + sendEncryptedWsRequest(session, { id: 'proxy_status', method: 'status.get' }) + await expect(responses.next('proxy_status')).resolves.toMatchObject({ + id: 'proxy_status', + ok: true, + result: { graphStatus: 'unavailable' } + }) + } finally { + responses.dispose() + session.ws.close() + await server.stop() + } + }) + it('keeps active runtime multiplex streams responsive while a background stream is ACK-limited over WebSocket', async () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) const writes: { terminal: string; text: string }[] = [] diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index f94ba79705c..020eec5183c 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -851,7 +851,15 @@ export class OrcaRuntimeRpcServer { ) }, onBinary: (socket, bytes) => this.handleWebSocketBinaryMessage(bytes, socket.ws), - onReady: () => this.mobileRelayPairingProvider?.onDemandStateChanged?.(), + onReady: () => { + // Why: first authenticated mobile/remote client (direct WS and + // cloud relay both attach here) starts path-candidate tracking. + // Activation is a local-host concern: candidate buffers live on the + // buffer-owning host's runtime, so a remote runtime proxy may + // legitimately lack this method (its own server activates it). + this.runtime.activateRecentPtyPathCandidateTracking?.() + this.mobileRelayPairingProvider?.onDemandStateChanged?.() + }, onClose: (socket, hasOtherConnections) => { if (!socket) { return From 643cbe43585e950955a36bb655ca779473761e51 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 21 Jul 2026 00:04:44 -0700 Subject: [PATCH 53/58] fix(editor): fail safe when rich-markdown reconcile throws (#9685) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(editor): fail safe when rich-markdown reconcile throws commitRichMarkdownSerialization called reconcileSerializedMarkdown with no guard. Its three callers all assumed the only failure was editor teardown — which commit already handles internally (didSerialize:false) — so their empty `catch {}` (debounced onUpdate, flush) and missing catch (Cmd+S shortcut) actually only ever swallowed, or crashed on, a genuine reconcile throw. When reconcile threw, the Cmd+S path crashed uncaught, and the debounced/flush paths dropped the draft update silently — leaving editorDrafts stale and stalling auto-save with no UI signal (the auto-save half of #9158/STA-2027, diagnosed by @klay7w). Make the shared choke point degrade to canonical `edited` on any reconcile throw — the same fallback reconcile already uses internally — so no path can crash or silently lose content; worst case is a canonical-style save (#6080). The two remaining catch blocks now log instead of swallowing so an unexpected serialize failure can't be invisible. The multi-byte crash that was the known trigger is already fixed (#9642); this hardens the shared path against any future reconcile failure. * fix(editor): preserve source EOL on reconcile fallback --- .../components/editor/RichMarkdownEditor.tsx | 6 ++--- .../editor/rich-markdown-editor-config.ts | 6 ++--- ...rich-markdown-serialization-commit.test.ts | 27 +++++++++++++++++++ .../rich-markdown-serialization-commit.ts | 24 ++++++++++++----- .../editor/rich-markdown-source-reconcile.ts | 4 +++ 5 files changed, 54 insertions(+), 13 deletions(-) diff --git a/src/renderer/src/components/editor/RichMarkdownEditor.tsx b/src/renderer/src/components/editor/RichMarkdownEditor.tsx index 2f499ba6b65..dc2b9885ad2 100644 --- a/src/renderer/src/components/editor/RichMarkdownEditor.tsx +++ b/src/renderer/src/components/editor/RichMarkdownEditor.tsx @@ -178,9 +178,9 @@ export default function RichMarkdownEditor({ if (didSerialize) { onContentChangeRef.current(markdown) } - } catch { - // Why: save/restart flows should never crash the UI just because the - // editor was torn down between scheduling and flushing a debounced sync. + } catch (error) { + // Why: teardown and reconcile failures are handled above; other failures must stay observable. + console.error('[editor] rich markdown serialize (flush) failed', error) } }, [reconcileRoundTripRef]) diff --git a/src/renderer/src/components/editor/rich-markdown-editor-config.ts b/src/renderer/src/components/editor/rich-markdown-editor-config.ts index 5877d9eb31e..09f1479e9ae 100644 --- a/src/renderer/src/components/editor/rich-markdown-editor-config.ts +++ b/src/renderer/src/components/editor/rich-markdown-editor-config.ts @@ -254,9 +254,9 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE if (didSerialize) { onContentChangeRef.current(markdown) } - } catch { - // Why: save/restart flows should never crash the UI just because - // the editor was torn down between scheduling and serializing. + } catch (error) { + // Why: teardown and reconcile failures are handled above; other failures must stay observable. + console.error('[editor] rich markdown serialize (debounced) failed', error) } }, 300) }, diff --git a/src/renderer/src/components/editor/rich-markdown-serialization-commit.test.ts b/src/renderer/src/components/editor/rich-markdown-serialization-commit.test.ts index e08b7a08bfb..008f751b513 100644 --- a/src/renderer/src/components/editor/rich-markdown-serialization-commit.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-serialization-commit.test.ts @@ -78,6 +78,33 @@ describe('commitRichMarkdownSerialization (shared disk-bound serialize chokepoin expect(didSerialize).toBe(false) expect(markdown).toBe('safe') }) + + it('preserves source EOL and advances refs when reconciliation throws', () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + const r = refs('# 이전\r\n\r\n_강조_\r\n', '# 이전\n\n*강조*') + const editor = fakeEditor(() => '# 변경\n\n*강조*') + const throwingRoundTrip = vi.fn(() => { + throw new Error('round-trip failed') + }) + + try { + const { markdown, didSerialize } = commitRichMarkdownSerialization( + editor, + r, + throwingRoundTrip + ) + + expect(markdown).toBe('# 변경\r\n\r\n*강조*') + expect(didSerialize).toBe(true) + expect(r.originalSourceRef.current).toBe(markdown) + expect(r.baseCanonicalRef.current).toBe('# 변경\n\n*강조*') + expect(r.lastCommittedMarkdownRef.current).toBe(markdown) + expect(throwingRoundTrip).toHaveBeenCalledTimes(1) + expect(consoleError).toHaveBeenCalledTimes(1) + } finally { + consoleError.mockRestore() + } + }) }) describe('handleRichMarkdownSaveShortcut (Cmd/Ctrl+S persistence site)', () => { diff --git a/src/renderer/src/components/editor/rich-markdown-serialization-commit.ts b/src/renderer/src/components/editor/rich-markdown-serialization-commit.ts index d35e9bde42b..dcebdf60dbd 100644 --- a/src/renderer/src/components/editor/rich-markdown-serialization-commit.ts +++ b/src/renderer/src/components/editor/rich-markdown-serialization-commit.ts @@ -1,6 +1,9 @@ import type { MutableRefObject } from 'react' import type { Editor } from '@tiptap/react' -import { reconcileSerializedMarkdown } from './rich-markdown-source-reconcile' +import { + reconcileSerializedMarkdown, + restoreMarkdownSourceEol +} from './rich-markdown-source-reconcile' export type RichMarkdownReconcileRefs = { /** Current on-disk source bytes; updated to the reconciled output each commit. */ @@ -41,12 +44,19 @@ export function commitRichMarkdownSerialization( return { markdown: refs.lastCommittedMarkdownRef.current, didSerialize: false } } - const reconciled = reconcileSerializedMarkdown({ - originalSource: refs.originalSourceRef.current, - baseCanonical: refs.baseCanonicalRef.current, - edited, - roundTrip - }) + let reconciled: string + try { + reconciled = reconcileSerializedMarkdown({ + originalSource: refs.originalSourceRef.current, + baseCanonical: refs.baseCanonicalRef.current, + edited, + roundTrip + }) + } catch (error) { + // Why: style reconciliation is best-effort; preserve content and source EOL when it fails. + console.error('[editor] markdown reconcile failed; falling back to canonical output', error) + reconciled = restoreMarkdownSourceEol(edited, refs.originalSourceRef.current) + } refs.originalSourceRef.current = reconciled // Why: reconciled ≡ edited semantically, so its canonical form is `edited` diff --git a/src/renderer/src/components/editor/rich-markdown-source-reconcile.ts b/src/renderer/src/components/editor/rich-markdown-source-reconcile.ts index 9a047992b0f..f04134100d3 100644 --- a/src/renderer/src/components/editor/rich-markdown-source-reconcile.ts +++ b/src/renderer/src/components/editor/rich-markdown-source-reconcile.ts @@ -26,6 +26,10 @@ export type ReconcileSerializedMarkdownParams = { roundTrip: (markdown: string) => string | null } +export function restoreMarkdownSourceEol(markdown: string, source: string): string { + return restoreEol(toLf(markdown), detectDominantEol(source)) +} + /** * Carries the user's edit into the original source style so untouched regions keep their non-canonical bytes. * Falls back to canonical `edited` when the transform can't be proven render-equivalent, so it never corrupts or relocates content. From 18b7aa301e1043b7fdd533cb5bc441516525d96f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 07:10:43 +0000 Subject: [PATCH 54/58] Update README downloads badge --- docs/assets/readme-downloads.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index c516026baa6..37447a0883f 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 6.8m + + downloads: 7.0m @@ -15,7 +15,7 @@ downloads downloads - 6.8m - 6.8m + 7.0m + 7.0m From c24ebcade518c7c27152507a759843828704c702 Mon Sep 17 00:00:00 2001 From: Dzmitry Bachko Date: Tue, 21 Jul 2026 00:36:42 -0700 Subject: [PATCH 55/58] =?UTF-8?q?fix(rate-limits):=20unstick=20Claude=20"L?= =?UTF-8?q?imited"=20usage=20=E2=80=94=20respect=20Retry-After=20and=20fee?= =?UTF-8?q?d=20live=20usage=20from=20session=20statuslines=20(#9617)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(rate-limits): unstick Claude "Limited" usage and feed live usage from session statuslines The OAuth usage endpoint's 429 Retry-After (~50 min) was ignored, so the 30s-15min automated retry lanes kept landing inside the throttle window and the status bar stayed on a bare "Limited" indefinitely while Claude itself worked fine. - Respect Retry-After on 429: carry it through usageMetadata.retryAtMs and gate automated refetches (activation lane, poll cycles) until it expires; user-directed refreshes still bypass. - Keep the last-known usage snapshot visible through rate-limited windows (24h) instead of dropping it after the generic 30-minute stale threshold. - Add a managed Claude statusLine command that forwards each session's rate_limits (Claude Code >=2.1.80) to a new /statusline/claude loopback route, feeding live usage windows with zero usage-endpoint calls; OAuth polling pauses while the live feed is fresh. User-owned statusLine settings are never overwritten. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(rate-limits): keep last-known window when a statusline post carries only one Statusline payloads may report five_hour and seven_day independently; a partial post must not wipe the other bar to null. Also document the seconds-vs-ms epoch heuristic. Addresses CodeRabbit review on #9617. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(rate-limits): unstick Claude usage with live statusline feed The OAuth polling endpoint is rate-limited; Claude's status often shows "Limited" until the next poll cycle, even when quota remains. Live posts from the statusline command update usage within 100ms, eliminating false "Limited" displays during active sessions. Manages install lifecycle via marker to respect user deletions. Handles Windows payload buffering and guards before curl spawn. Protects against live-post/OAuth-fetch races and cross-attribution during account switches. Gracefully tolerates schema drift in statusline parsing. * test(rate-limits): assert stale outgoing post doesn't affect incoming Capture usedPercent before ingesting and assert it remains unchanged, rather than checking for a specific value. This is more precise and less brittle when testing session switch isolation. --------- Co-authored-by: Dzmitry Bachko Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> --- config/tsconfig.cli.json | 1 + src/main/agent-hooks/hook-stdin-contract.ts | 13 +- .../managed-hook-stdin-lifecycle.test.ts | 24 +- .../agent-hooks/managed-hook-timeout.test.ts | 11 +- .../server-claude-statusline.test.ts | 77 ++++ src/main/agent-hooks/server.ts | 22 + src/main/claude/hook-service.test.ts | 132 ++++++ src/main/claude/hook-service.ts | 60 ++- src/main/claude/hook-settings.ts | 71 ++++ src/main/claude/statusline-script.test.ts | 92 +++++ src/main/claude/statusline-script.ts | 85 ++++ src/main/index.ts | 4 + src/main/rate-limits/claude-fetcher.test.ts | 46 ++- src/main/rate-limits/claude-fetcher.ts | 95 +---- .../claude-oauth-usage-error.test.ts | 54 +++ .../rate-limits/claude-oauth-usage-error.ts | 26 +- src/main/rate-limits/claude-usage-window.ts | 76 ++++ src/main/rate-limits/service.test.ts | 384 ++++++++++++++++++ src/main/rate-limits/service.ts | 223 +++++++++- .../claude-statusline-rate-limits.test.ts | 86 ++++ src/shared/claude-statusline-rate-limits.ts | 83 ++++ src/shared/rate-limit-types.ts | 4 +- 22 files changed, 1536 insertions(+), 133 deletions(-) create mode 100644 src/main/agent-hooks/server-claude-statusline.test.ts create mode 100644 src/main/claude/statusline-script.test.ts create mode 100644 src/main/claude/statusline-script.ts create mode 100644 src/main/rate-limits/claude-oauth-usage-error.test.ts create mode 100644 src/main/rate-limits/claude-usage-window.ts create mode 100644 src/shared/claude-statusline-rate-limits.test.ts create mode 100644 src/shared/claude-statusline-rate-limits.ts diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 11020ab0206..e8a1f036c10 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -13,6 +13,7 @@ "../src/main/antigravity/hook-service.ts", "../src/main/claude/hook-settings.ts", "../src/main/claude/hook-service.ts", + "../src/main/claude/statusline-script.ts", "../src/main/codex/codex-app-server-capability-cache.ts", "../src/main/codex/codex-app-server-capability-signal.ts", "../src/main/codex/codex-app-server-client.ts", diff --git a/src/main/agent-hooks/hook-stdin-contract.ts b/src/main/agent-hooks/hook-stdin-contract.ts index a54a8e55f85..902ec543287 100644 --- a/src/main/agent-hooks/hook-stdin-contract.ts +++ b/src/main/agent-hooks/hook-stdin-contract.ts @@ -13,7 +13,10 @@ export function buildPosixHookPayloadCapture( } export const WINDOWS_HOOK_STDIN_DRAIN_LABEL = 'orca_agent_hook_drain_stdin' -export const WINDOWS_HOOK_STDIN_DRAIN_COMMAND = '"%SystemRoot%\\System32\\more.com" >nul 2>nul' +// Why: qualify the stdin reader because Windows searches the worktree for +// executables before PATH and hook payloads must not reach repo-local code. +export const WINDOWS_HOOK_STDIN_READER = '"%SystemRoot%\\System32\\more.com"' +export const WINDOWS_HOOK_STDIN_DRAIN_COMMAND = `${WINDOWS_HOOK_STDIN_READER} >nul 2>nul` // Why: batch payloads stream directly to curl and cannot be buffered safely in // environment variables, so guard failures share one EOF-draining epilogue. @@ -27,11 +30,5 @@ export function buildWindowsHookEnvironmentGuardLines(): string[] { } export function buildWindowsHookStdinDrainEpilogue(): string[] { - return [ - `:${WINDOWS_HOOK_STDIN_DRAIN_LABEL}`, - // Why: qualify the inbox reader because Windows searches the worktree for - // executables before PATH and hook payloads must not reach repo-local code. - WINDOWS_HOOK_STDIN_DRAIN_COMMAND, - 'exit /b 0' - ] + return [`:${WINDOWS_HOOK_STDIN_DRAIN_LABEL}`, WINDOWS_HOOK_STDIN_DRAIN_COMMAND, 'exit /b 0'] } diff --git a/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts b/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts index 92ea4d01572..0645de342e5 100644 --- a/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts +++ b/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts @@ -207,8 +207,11 @@ async function generatePosixScripts(): Promise> { const generated = [...memory.fs.files.entries()].filter( ([path]) => path.includes('/.orca/agent-hooks/') && path.endsWith('.sh') ) - expect(generated, `${entry.agent} generated scripts`).toHaveLength(1) - scripts.set(entry.agent, generated[0][1]) + // Why: Claude ships a second managed script (the statusline usage feed); the stdin lifecycle contract applies to every generated script. + expect(generated.length, `${entry.agent} generated scripts`).toBeGreaterThan(0) + for (const [path, script] of generated) { + scripts.set(`${entry.agent} ${path.split('/').pop()}`, script) + } } return scripts } @@ -377,14 +380,13 @@ describe.skipIf(process.platform === 'win32')('managed hook stdin lifecycle', () it('accepts a large payload without Orca environment or a broken writer', async () => { const scripts = await generatePosixScripts() for (const [agent, script] of scripts) { - const extraEnv = - agent === 'command-code' - ? { - ORCA_AGENT_HOOK_PORT: '1', - ORCA_AGENT_HOOK_TOKEN: 'test-token', - ORCA_PANE_KEY: 'test-pane' - } - : {} + const extraEnv = agent.startsWith('command-code') + ? { + ORCA_AGENT_HOOK_PORT: '1', + ORCA_AGENT_HOOK_TOKEN: 'test-token', + ORCA_PANE_KEY: 'test-pane' + } + : {} const result = await runPosixHook(script, extraEnv) expect(result.exitCode, `${agent} exit code`).toBe(0) expect(result.stdinErrors, `${agent} stdin errors`).toHaveLength(0) @@ -392,7 +394,7 @@ describe.skipIf(process.platform === 'win32')('managed hook stdin lifecycle', () }) it('drains before Claude skips hooks imported by Devin', async () => { - const script = (await generatePosixScripts()).get('claude') + const script = (await generatePosixScripts()).get('claude claude-hook.sh') expect(script).toBeDefined() const result = await runPosixHook(script!, { DEVIN_PROJECT_DIR: '/tmp/devin-project' }) expect(result.exitCode).toBe(0) diff --git a/src/main/agent-hooks/managed-hook-timeout.test.ts b/src/main/agent-hooks/managed-hook-timeout.test.ts index c7d374573ff..599d9a93b31 100644 --- a/src/main/agent-hooks/managed-hook-timeout.test.ts +++ b/src/main/agent-hooks/managed-hook-timeout.test.ts @@ -112,6 +112,8 @@ const JSON_INSTALLERS = [ ] as const const MANAGED_HOOKS_DIR_NEEDLE = '/.orca/agent-hooks/' +// Why: statusLine is not a hook — Claude's schema has no timeout field (type/command/padding/refreshInterval), and a slow statusline can't block agent turns. +const STATUSLINE_SCRIPT_NEEDLE = '-statusline.' // Walk the parsed config and assert every Orca-managed command carrier (a node // with a `command`/`bash`/`powershell` string pointing at the managed script @@ -121,8 +123,13 @@ const MANAGED_HOOKS_DIR_NEEDLE = '/.orca/agent-hooks/' function countManagedCarriersWithTimeout( node: unknown, expectedTimeout: number, - isManagedCarrier = (value: string): boolean => - value.replaceAll('\\', '/').includes(MANAGED_HOOKS_DIR_NEEDLE) + isManagedCarrier = (value: string): boolean => { + const normalized = value.replaceAll('\\', '/') + return ( + normalized.includes(MANAGED_HOOKS_DIR_NEEDLE) && + !normalized.includes(STATUSLINE_SCRIPT_NEEDLE) + ) + } ): number { if (Array.isArray(node)) { return node.reduce( diff --git a/src/main/agent-hooks/server-claude-statusline.test.ts b/src/main/agent-hooks/server-claude-statusline.test.ts new file mode 100644 index 00000000000..b40edb397d2 --- /dev/null +++ b/src/main/agent-hooks/server-claude-statusline.test.ts @@ -0,0 +1,77 @@ +// Why: locks the /statusline/claude loopback contract — form-encoded posts from the +// managed statusline script must reach the listener, and junk must fail open (204). +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { AgentHookServer } from './server' +import type { ClaudeStatusLineRateLimits } from '../../shared/claude-statusline-rate-limits' + +describe('AgentHookServer /statusline/claude', () => { + let server: AgentHookServer + + beforeEach(async () => { + server = new AgentHookServer() + await server.start({ env: 'production' }) + }) + + afterEach(() => { + server.stop() + }) + + function post(body: string, token?: string): Promise { + const env = server.buildPtyEnv() + return fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/statusline/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-Orca-Agent-Hook-Token': token ?? env.ORCA_AGENT_HOOK_TOKEN + }, + body + }) + } + + it('forwards parsed rate limits to the statusline listener', async () => { + const events: ClaudeStatusLineRateLimits[] = [] + server.setClaudeStatusLineListener((event) => { + events.push(event) + }) + + const payload = JSON.stringify({ + rate_limits: { + five_hour: { used_percentage: 12.5, resets_at: 1738425600 }, + seven_day: { used_percentage: 40, resets_at: 1712059200 } + } + }) + const body = new URLSearchParams({ + paneKey: 'pane-1', + configDir: '/home/dev/managed', + payload + }).toString() + + await expect(post(body)).resolves.toMatchObject({ status: 204 }) + expect(events).toEqual([ + { + configDir: '/home/dev/managed', + fiveHour: { used_percentage: 12.5, resets_at: 1738425600 }, + sevenDay: { used_percentage: 40, resets_at: 1712059200 } + } + ]) + }) + + it('rejects posts with a bad token and ignores payloads without rate limits', async () => { + const events: ClaudeStatusLineRateLimits[] = [] + server.setClaudeStatusLineListener((event) => { + events.push(event) + }) + + await expect(post('payload={}', 'wrong-token')).resolves.toMatchObject({ status: 403 }) + + const noLimits = new URLSearchParams({ + paneKey: 'pane-1', + payload: JSON.stringify({ context_window: { used_percentage: 8 } }) + }).toString() + await expect(post(noLimits)).resolves.toMatchObject({ status: 204 }) + + await expect(post('payload=not-json')).resolves.toMatchObject({ status: 204 }) + + expect(events).toEqual([]) + }) +}) diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index d02642b1111..62d588d5df5 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -32,6 +32,11 @@ import { type HookListenerState } from '../../shared/agent-hook-listener' import type { AgentHookSource } from '../../shared/agent-hook-relay' +import { + CLAUDE_STATUSLINE_PATHNAME, + parseClaudeStatusLineBody, + type ClaudeStatusLineRateLimits +} from '../../shared/claude-statusline-rate-limits' import { AGENT_STATUS_STALE_AFTER_MS, type AgentStatusClearIpcPayload, @@ -445,6 +450,7 @@ export class AgentHookServer { // Why: identifies this Orca instance so the server can detect dev vs. prod cross-talk; set at start() from packaged-build knowledge. private env = 'production' private onAgentStatus: ((payload: EnrichedAgentHookEventPayload) => void) | null = null + private onClaudeStatusLine: ((event: ClaudeStatusLineRateLimits) => void) | null = null private onPaneStatusCleared: PaneStatusClearListener | null = null private statusChangeListeners = new Set() // Why: set via start()'s userDataPath so the class has no direct Electron dependency (mockable in vitest node env). @@ -486,6 +492,13 @@ export class AgentHookServer { } } + // Why: statusline posts carry live Claude usage windows, not agent status; they feed RateLimitService directly. + setClaudeStatusLineListener( + listener: ((event: ClaudeStatusLineRateLimits) => void) | null + ): void { + this.onClaudeStatusLine = listener + } + subscribeStatusChanges(listener: StatusChangeListener): () => void { this.statusChangeListeners.add(listener) return () => { @@ -1472,6 +1485,15 @@ export class AgentHookServer { try { const body = await readRequestBody(req) const pathname = new URL(req.url ?? '/', 'http://127.0.0.1').pathname + if (pathname === CLAUDE_STATUSLINE_PATHNAME) { + const statusLineEvent = parseClaudeStatusLineBody(body) + if (statusLineEvent) { + this.onClaudeStatusLine?.(statusLineEvent) + } + res.writeHead(204) + res.end() + return + } const source = resolveHookSource(pathname) if (!source) { res.writeHead(404) diff --git a/src/main/claude/hook-service.test.ts b/src/main/claude/hook-service.test.ts index 1c07cf38942..cd31d5577f5 100644 --- a/src/main/claude/hook-service.test.ts +++ b/src/main/claude/hook-service.test.ts @@ -20,6 +20,8 @@ import { ClaudeHookService } from './hook-service' import { OPENCLAUDE_HOOK_SETTINGS } from './hook-settings' const CLAUDE_SCRIPT_FILE_NAME = process.platform === 'win32' ? 'claude-hook.cmd' : 'claude-hook.sh' +const STATUSLINE_SCRIPT_FILE_NAME = + process.platform === 'win32' ? 'claude-statusline.cmd' : 'claude-statusline.sh' const OPENCLAUDE_SCRIPT_FILE_NAME = process.platform === 'win32' ? 'openclaude-hook.cmd' : 'openclaude-hook.sh' const WINDOWS_POWERSHELL_LAUNCHER = @@ -181,6 +183,130 @@ describe('ClaudeHookService.install', () => { } }) + it('installs the managed statusLine command and forwards rate_limits posts', () => { + const tmpHome = mkdtempSync(join(tmpdir(), 'orca-claude-statusline-')) + vi.stubEnv('HOME', tmpHome) + vi.stubEnv('USERPROFILE', tmpHome) + try { + expect(new ClaudeHookService().install().state).toBe('installed') + + const settings = JSON.parse( + readFileSync(join(tmpHome, '.claude', 'settings.json'), 'utf-8') + ) as { statusLine?: { type: string; command: string } } + expect(settings.statusLine?.type).toBe('command') + expect(settings.statusLine?.command).toContain('claude-statusline') + + const script = readFileSync( + join(tmpHome, '.orca', 'agent-hooks', STATUSLINE_SCRIPT_FILE_NAME), + 'utf-8' + ) + expect(script).toContain('/statusline/claude') + // Why: non-subscriber sessions never carry rate_limits; both branches must guard before spawning curl. + if (process.platform === 'win32') { + expect(script).toContain('findstr.exe" /c:\\"rate_limits\\"') + expect(script).toContain('--data-urlencode "payload@%ORCA_STATUSLINE_PAYLOAD_FILE%"') + } else { + expect(script).toContain('"rate_limits"') + expect(script).toContain('--data-urlencode "payload@-"') + } + } finally { + vi.unstubAllEnvs() + rmSync(tmpHome, { recursive: true, force: true }) + } + }) + + it('never overwrites a user-owned statusLine command', () => { + const tmpHome = mkdtempSync(join(tmpdir(), 'orca-claude-user-statusline-')) + vi.stubEnv('HOME', tmpHome) + vi.stubEnv('USERPROFILE', tmpHome) + try { + const settingsPath = join(tmpHome, '.claude', 'settings.json') + mkdirSync(join(tmpHome, '.claude'), { recursive: true }) + writeFileSync( + settingsPath, + JSON.stringify({ statusLine: { type: 'command', command: '/usr/local/bin/my-statusline' } }) + ) + + expect(new ClaudeHookService().install().state).toBe('installed') + + const settings = JSON.parse(readFileSync(settingsPath, 'utf-8')) + expect(settings.statusLine).toEqual({ + type: 'command', + command: '/usr/local/bin/my-statusline' + }) + + // remove() must also leave the user's statusLine untouched. + new ClaudeHookService().remove() + const afterRemove = JSON.parse(readFileSync(settingsPath, 'utf-8')) + expect(afterRemove.statusLine).toEqual({ + type: 'command', + command: '/usr/local/bin/my-statusline' + }) + } finally { + vi.unstubAllEnvs() + rmSync(tmpHome, { recursive: true, force: true }) + } + }) + + it('removes the managed statusLine on remove()', () => { + const tmpHome = mkdtempSync(join(tmpdir(), 'orca-claude-statusline-remove-')) + vi.stubEnv('HOME', tmpHome) + vi.stubEnv('USERPROFILE', tmpHome) + try { + new ClaudeHookService().install() + new ClaudeHookService().remove() + const settings = JSON.parse(readFileSync(join(tmpHome, '.claude', 'settings.json'), 'utf-8')) + expect(settings.statusLine).toBeUndefined() + } finally { + vi.unstubAllEnvs() + rmSync(tmpHome, { recursive: true, force: true }) + } + }) + + it('does not re-install a managed statusLine the user deleted, until remove() resets the opt-out', () => { + const tmpHome = mkdtempSync(join(tmpdir(), 'orca-claude-statusline-optout-')) + vi.stubEnv('HOME', tmpHome) + vi.stubEnv('USERPROFILE', tmpHome) + try { + const settingsPath = join(tmpHome, '.claude', 'settings.json') + new ClaudeHookService().install() + expect(JSON.parse(readFileSync(settingsPath, 'utf-8')).statusLine).toBeTruthy() + + // The user deletes the managed statusLine from settings.json (e.g. via /statusline or an editor). + const settings = JSON.parse(readFileSync(settingsPath, 'utf-8')) + delete settings.statusLine + writeFileSync(settingsPath, JSON.stringify(settings)) + + // A later install (app restart) must respect the deletion — statusLine is opportunistic, not required. + new ClaudeHookService().install() + expect(JSON.parse(readFileSync(settingsPath, 'utf-8')).statusLine).toBeUndefined() + + // An Orca-level remove() resets the opt-out memory, so a fresh install re-adds it. + new ClaudeHookService().remove() + new ClaudeHookService().install() + expect(JSON.parse(readFileSync(settingsPath, 'utf-8')).statusLine).toBeTruthy() + } finally { + vi.unstubAllEnvs() + rmSync(tmpHome, { recursive: true, force: true }) + } + }) + + it('keeps refreshing a still-managed statusLine across installs', () => { + const tmpHome = mkdtempSync(join(tmpdir(), 'orca-claude-statusline-refresh-')) + vi.stubEnv('HOME', tmpHome) + vi.stubEnv('USERPROFILE', tmpHome) + try { + const settingsPath = join(tmpHome, '.claude', 'settings.json') + new ClaudeHookService().install() + new ClaudeHookService().install() + const settings = JSON.parse(readFileSync(settingsPath, 'utf-8')) + expect(settings.statusLine?.command).toContain('claude-statusline') + } finally { + vi.unstubAllEnvs() + rmSync(tmpHome, { recursive: true, force: true }) + } + }) + // Why: #6078 — Claude Code runs hooks through Git Bash, and an unquoted path // with a space (e.g. `C:/Users/Jane Doe`) splits at the space. The managed // command must use an encoded launcher so Git Bash/cmd.exe never splits or @@ -279,6 +405,10 @@ describe('ClaudeHookService.installRemote', () => { expect(script).toContain('--data-urlencode "payload@-"') expect(script).not.toContain('--data-urlencode "payload=${payload}"') expect(fs.modes.get('/home/dev/.orca/agent-hooks/claude-hook.sh')).toBe(0o755) + // Why: no remote statusLine — this path serves SSH remotes and WSL guests, whose relay + // listener doesn't route /statusline/claude and whose accounts aren't attributable locally. + expect(parsed.statusLine).toBeUndefined() + expect(fs.files.get('/home/dev/.orca/agent-hooks/claude-statusline.sh')).toBeUndefined() }) it('reports parse error when remote settings.json cannot be parsed', async () => { @@ -364,6 +494,8 @@ describe('OpenClaudeHookService-compatible install', () => { expect( readFileSync(join(tmpHome, '.orca', 'agent-hooks', OPENCLAUDE_SCRIPT_FILE_NAME), 'utf-8') ).not.toContain('DEVIN_PROJECT_DIR') + // Why: the statusline usage feed is Claude-only; OpenClaude installs must not set statusLine. + expect(parsed.statusLine).toBeUndefined() expect(existsSync(join(tmpHome, '.claude', 'settings.json'))).toBe(false) } finally { vi.unstubAllEnvs() diff --git a/src/main/claude/hook-service.ts b/src/main/claude/hook-service.ts index 95304f19fc6..96cbe301dcf 100644 --- a/src/main/claude/hook-service.ts +++ b/src/main/claude/hook-service.ts @@ -1,10 +1,12 @@ +import { existsSync, rmSync, writeFileSync } from 'node:fs' import type { SFTPWrapper } from 'ssh2' import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types' import { buildWindowsAgentHookCurlPostCommand, readHooksJson, writeHooksJson, - writeManagedScript + writeManagedScript, + type HooksConfig } from '../agent-hooks/installer-utils' import { readHooksJsonRemote, @@ -17,8 +19,10 @@ import { buildWindowsHookStdinDrainEpilogue, WINDOWS_HOOK_STDIN_DRAIN_LABEL } from '../agent-hooks/hook-stdin-contract' +import { getManagedStatusLineScript } from './statusline-script' import { applyManagedHooks, + applyManagedStatusLine, CLAUDE_EVENTS, CLAUDE_HOOK_SETTINGS, getManagedScriptFileName, @@ -28,7 +32,12 @@ import { getPosixManagedScriptFileName, getRemoteConfigPath, getRemoteManagedCommand, + getStatusLineInstallMarkerPath, + getStatusLineScriptFileName, + getStatusLineScriptPath, + getStatusLineSlotState, removeManagedHooks, + removeManagedStatusLine, type ClaudeCompatibleHookSettings } from './hook-settings' @@ -175,7 +184,7 @@ export class ClaudeHookService { } const command = getManagedCommand(scriptPath) - const nextConfig = applyManagedHooks( + let nextConfig = applyManagedHooks( config, command, getManagedScriptFileName(this.options.settings) @@ -184,10 +193,38 @@ export class ClaudeHookService { scriptPath, getManagedScript('local', { skipWhenDevinImportsClaude: this.options.agent === 'claude' }) ) + // Why: the statusline usage feed is Claude-only — OpenClaude data would be misattributed to the Claude provider. + if (this.options.agent === 'claude') { + nextConfig = this.installManagedStatusLine(nextConfig) + } writeHooksJson(configPath, nextConfig) return this.getStatus() } + // Why: the statusline feed is opportunistic (usage display, not agent status); a user who deleted the + // managed entry has opted out, and the marker distinguishes that deletion from a first install. + private installManagedStatusLine(config: HooksConfig): HooksConfig { + const scriptFileName = getStatusLineScriptFileName(this.options.settings) + const markerPath = getStatusLineInstallMarkerPath(this.options.settings) + const slot = getStatusLineSlotState(config, scriptFileName) + if (slot === 'user' || (slot === 'empty' && existsSync(markerPath))) { + return config + } + const statusLineScriptPath = getStatusLineScriptPath(this.options.settings) + writeManagedScript(statusLineScriptPath, getManagedStatusLineScript('local')) + const next = applyManagedStatusLine( + config, + getManagedCommand(statusLineScriptPath), + scriptFileName + ) + try { + writeFileSync(markerPath, '') + } catch { + // Best-effort: a missing marker only means one future user deletion gets re-installed once. + } + return next + } + // 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; ship POSIX paths. process.platform here is the local box, not the remote, so it can't gate this. @@ -218,6 +255,9 @@ export class ClaudeHookService { remoteScriptPath, getManagedScript('posix', { skipWhenDevinImportsClaude: this.options.agent === 'claude' }) ) + // Why: no statusline install here — this path serves SSH remotes and WSL guests, whose relay hook + // listener doesn't route /statusline/claude, and an SSH box's Claude login can be a different + // account than the locally selected one, so its usage must not feed the local bar (live feed is host-local only). await writeHooksJsonRemote(sftp, remoteConfigPath, nextConfig) return { @@ -250,13 +290,25 @@ export class ClaudeHookService { detail: `Could not parse ${this.options.displayName} settings.json` } } - const { config: nextConfig, changed } = removeManagedHooks( + const { config: hooksRemoved, changed: hooksChanged } = removeManagedHooks( config, getManagedScriptFileName(this.options.settings) ) - if (changed) { + const { config: nextConfig, changed: statusLineChanged } = removeManagedStatusLine( + hooksRemoved, + getStatusLineScriptFileName(this.options.settings) + ) + if (hooksChanged || statusLineChanged) { writeHooksJson(configPath, nextConfig) } + if (this.options.agent === 'claude') { + try { + // Why: an Orca-level uninstall resets the opt-out memory so a later re-enable installs the statusline again. + rmSync(getStatusLineInstallMarkerPath(this.options.settings), { force: true }) + } catch { + // ignore — marker cleanup is best-effort + } + } return this.getStatus() } } diff --git a/src/main/claude/hook-settings.ts b/src/main/claude/hook-settings.ts index 8392d07f1b9..1059a9a0945 100644 --- a/src/main/claude/hook-settings.ts +++ b/src/main/claude/hook-settings.ts @@ -4,6 +4,7 @@ import { buildManagedCommandHook, createManagedCommandMatcher, getSharedManagedScriptPath, + isPlainObject, removeManagedCommands, wrapPosixHookCommand, wrapWindowsGitBashHookCommand, @@ -64,6 +65,24 @@ export function getConfigPath(settings = CLAUDE_HOOK_SETTINGS): string { return join(homedir(), settings.configDirName, 'settings.json') } +export function getStatusLineScriptBaseName(settings = CLAUDE_HOOK_SETTINGS): string { + return settings.scriptBaseName.replace(/-hook$/, '-statusline') +} + +export function getStatusLineScriptFileName(settings = CLAUDE_HOOK_SETTINGS): string { + return process.platform === 'win32' + ? `${getStatusLineScriptBaseName(settings)}.cmd` + : getPosixStatusLineScriptFileName(settings) +} + +export function getPosixStatusLineScriptFileName(settings = CLAUDE_HOOK_SETTINGS): string { + return `${getStatusLineScriptBaseName(settings)}.sh` +} + +export function getStatusLineScriptPath(settings = CLAUDE_HOOK_SETTINGS): string { + return getSharedManagedScriptPath(getStatusLineScriptFileName(settings)) +} + export function getManagedScriptFileName(settings = CLAUDE_HOOK_SETTINGS): string { return process.platform === 'win32' ? `${settings.scriptBaseName}.cmd` @@ -113,6 +132,58 @@ export function applyManagedHooks( return { ...config, hooks: nextHooks } } +export type StatusLineSlotState = 'managed' | 'user' | 'empty' + +// Why: install policy needs "user owns the slot" vs "slot is empty" vs "ours" — an empty slot +// after a prior install means the user deleted the managed entry, which install must respect. +export function getStatusLineSlotState( + config: HooksConfig, + scriptFileName = getStatusLineScriptFileName() +): StatusLineSlotState { + const isManagedCommand = createManagedCommandMatcher(scriptFileName) + const current = config.statusLine + const currentCommand = + isPlainObject(current) && typeof current.command === 'string' ? current.command : null + if (!currentCommand) { + return 'empty' + } + return isManagedCommand(currentCommand) ? 'managed' : 'user' +} + +// Why: records that the managed statusline was installed once, so a later empty slot reads as user opt-out. +export function getStatusLineInstallMarkerPath(settings = CLAUDE_HOOK_SETTINGS): string { + return getSharedManagedScriptPath(`${getStatusLineScriptBaseName(settings)}.installed`) +} + +// Why: statusLine is a single settings slot, not a hooks array — never overwrite a +// user-owned status line; the usage feed then simply falls back to the OAuth poll. +export function applyManagedStatusLine( + config: HooksConfig, + command: string, + scriptFileName = getStatusLineScriptFileName() +): HooksConfig { + if (getStatusLineSlotState(config, scriptFileName) === 'user') { + return config + } + return { ...config, statusLine: { type: 'command', command } } +} + +export function removeManagedStatusLine( + config: HooksConfig, + scriptFileName = getStatusLineScriptFileName() +): { config: HooksConfig; changed: boolean } { + const isManagedCommand = createManagedCommandMatcher(scriptFileName) + const current = config.statusLine + const currentCommand = + isPlainObject(current) && typeof current.command === 'string' ? current.command : null + if (!currentCommand || !isManagedCommand(currentCommand)) { + return { config, changed: false } + } + const next = { ...config } + delete next.statusLine + return { config: next, changed: true } +} + export function removeManagedHooks( config: HooksConfig, scriptFileName = getManagedScriptFileName() diff --git a/src/main/claude/statusline-script.test.ts b/src/main/claude/statusline-script.test.ts new file mode 100644 index 00000000000..36261adfc48 --- /dev/null +++ b/src/main/claude/statusline-script.test.ts @@ -0,0 +1,92 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { getManagedStatusLineScript } from './statusline-script' + +const ORIGINAL_PLATFORM = process.platform + +function stubPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { configurable: true, value: platform }) +} + +afterEach(() => { + Object.defineProperty(process, 'platform', { configurable: true, value: ORIGINAL_PLATFORM }) +}) + +describe('getManagedStatusLineScript (posix)', () => { + it('guards on rate_limits before sourcing the endpoint or spawning curl', () => { + stubPlatform('darwin') + const script = getManagedStatusLineScript('local') + expect(script).toBe(getManagedStatusLineScript('posix')) + const guardIndex = script.indexOf('*\'"rate_limits"\'*') + const endpointIndex = script.indexOf('ORCA_AGENT_HOOK_ENDPOINT') + const curlIndex = script.indexOf('curl -sS') + expect(guardIndex).toBeGreaterThan(-1) + expect(guardIndex).toBeLessThan(endpointIndex) + expect(endpointIndex).toBeLessThan(curlIndex) + expect(script).toContain('/statusline/claude') + expect(script).toContain('--data-urlencode "payload@-"') + }) + + it('returns the posix script even on win32 when targeting a remote', () => { + stubPlatform('win32') + const script = getManagedStatusLineScript('posix') + expect(script).toContain('#!/bin/sh') + expect(script).not.toContain('curl.exe') + }) +}) + +describe('getManagedStatusLineScript (win32 local)', () => { + it('guards on rate_limits via findstr before the endpoint call and curl spawn', () => { + stubPlatform('win32') + const script = getManagedStatusLineScript('local') + const captureIndex = script.indexOf('more.com') + // Why: the \"-escaped needle makes findstr match the quoted JSON key, not any path containing rate_limits. + const guardIndex = script.indexOf('findstr.exe" /c:\\"rate_limits\\"') + const endpointIndex = script.indexOf('call "%ORCA_AGENT_HOOK_ENDPOINT%"') + const curlIndex = script.indexOf('curl.exe') + expect(captureIndex).toBeGreaterThan(-1) + expect(guardIndex).toBeGreaterThan(captureIndex) + expect(guardIndex).toBeLessThan(endpointIndex) + expect(endpointIndex).toBeLessThan(curlIndex) + expect(script).toContain('if errorlevel 1 goto :orca_statusline_cleanup') + }) + + it('posts the buffered payload file and deletes it afterwards', () => { + stubPlatform('win32') + const script = getManagedStatusLineScript('local') + // Why: the temp file is per-pane (pane key with ":" mapped to "_") because %RANDOM% + // collides across cmd instances spawned in the same second. + expect(script).toContain( + 'set "ORCA_STATUSLINE_PAYLOAD_FILE=%TEMP%\\orca-claude-statusline-%ORCA_PANE_KEY::=_%.tmp"' + ) + expect(script).toContain('--data-urlencode "payload@%ORCA_STATUSLINE_PAYLOAD_FILE%"') + expect(script).not.toContain('payload@-') + const curlIndex = script.indexOf('curl.exe') + const delIndex = script.indexOf('del "%ORCA_STATUSLINE_PAYLOAD_FILE%"') + expect(delIndex).toBeGreaterThan(curlIndex) + }) + + it('never posts a literal %CLAUDE_CONFIG_DIR% token when the var is unset', () => { + stubPlatform('win32') + const script = getManagedStatusLineScript('local') + // Why: the posted field comes from an always-defined variable so an unset + // CLAUDE_CONFIG_DIR yields "configDir=" (matching POSIX + the null snapshot). + expect(script).toContain('set "ORCA_STATUSLINE_CONFIG_DIR_FIELD=configDir="') + expect(script).toContain( + 'if defined CLAUDE_CONFIG_DIR set "ORCA_STATUSLINE_CONFIG_DIR_FIELD=configDir=%CLAUDE_CONFIG_DIR%"' + ) + expect(script).toContain('--data-urlencode "%ORCA_STATUSLINE_CONFIG_DIR_FIELD%"') + expect(script).not.toContain('"configDir=%CLAUDE_CONFIG_DIR%"') + }) + + it('drains stdin before exiting when the pane key is missing', () => { + stubPlatform('win32') + const script = getManagedStatusLineScript('local') + const paneGuardIndex = script.indexOf( + 'if "%ORCA_PANE_KEY%"=="" goto :orca_agent_hook_drain_stdin' + ) + const captureIndex = script.indexOf('more.com') + expect(paneGuardIndex).toBeGreaterThan(-1) + expect(paneGuardIndex).toBeLessThan(captureIndex) + expect(script).toContain(':orca_agent_hook_drain_stdin') + }) +}) diff --git a/src/main/claude/statusline-script.ts b/src/main/claude/statusline-script.ts new file mode 100644 index 00000000000..36c20f5f7a1 --- /dev/null +++ b/src/main/claude/statusline-script.ts @@ -0,0 +1,85 @@ +import { + buildPosixHookPayloadCapture, + buildWindowsHookStdinDrainEpilogue, + WINDOWS_HOOK_STDIN_DRAIN_LABEL, + WINDOWS_HOOK_STDIN_READER +} from '../agent-hooks/hook-stdin-contract' +import { CLAUDE_STATUSLINE_PATHNAME } from '../../shared/claude-statusline-rate-limits' + +const STATUSLINE_CLEANUP_LABEL = 'orca_statusline_cleanup' + +// Why: Claude Code pipes `rate_limits` to the statusLine command on every turn; forwarding +// it gives Orca live usage without spending the OAuth usage endpoint's tight budget. +// Emits no stdout so the in-terminal status line stays visually unchanged. +export function getManagedStatusLineScript(target: 'local' | 'posix' = 'local'): string { + if (target === 'local' && process.platform === 'win32') { + return [ + '@echo off', + 'setlocal', + // Why: pane key is static PTY env (the endpoint file never sets it), so it can gate before stdin is consumed. + `if "%ORCA_PANE_KEY%"=="" goto :${WINDOWS_HOOK_STDIN_DRAIN_LABEL}`, + // Why: cmd has no builtin stdin capture, so buffer the payload in a per-pane temp file + // (%RANDOM% collides across same-second cmd spawns) to guard before any curl spawn. + 'set "ORCA_STATUSLINE_PAYLOAD_FILE=%TEMP%\\orca-claude-statusline-%ORCA_PANE_KEY::=_%.tmp"', + `${WINDOWS_HOOK_STDIN_READER} >"%ORCA_STATUSLINE_PAYLOAD_FILE%" 2>nul`, + // Why: rate_limits appears only for Claude.ai-subscriber sessions after the first API response; the + // statusline ticks ~3x/sec during streaming, so skip the endpoint call and curl spawn otherwise. + // Why: \" is the MSVC argv escape — findstr sees the quoted JSON key, so a cwd containing rate_limits can't false-match (POSIX guard parity). + '"%SystemRoot%\\System32\\findstr.exe" /c:\\"rate_limits\\" "%ORCA_STATUSLINE_PAYLOAD_FILE%" >nul 2>nul', + `if errorlevel 1 goto :${STATUSLINE_CLEANUP_LABEL}`, + // 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', + `if "%ORCA_AGENT_HOOK_PORT%"=="" goto :${STATUSLINE_CLEANUP_LABEL}`, + `if "%ORCA_AGENT_HOOK_TOKEN%"=="" goto :${STATUSLINE_CLEANUP_LABEL}`, + // Why: pre-build the field from an always-defined variable so an unset CLAUDE_CONFIG_DIR posts + // empty (matching POSIX and the null attribution snapshot), never a literal %VAR% token. + 'set "ORCA_STATUSLINE_CONFIG_DIR_FIELD=configDir="', + 'if defined CLAUDE_CONFIG_DIR set "ORCA_STATUSLINE_CONFIG_DIR_FIELD=configDir=%CLAUDE_CONFIG_DIR%"', + [ + '"%SystemRoot%\\System32\\curl.exe" -sS -X POST', + `"http://127.0.0.1:%ORCA_AGENT_HOOK_PORT%${CLAUDE_STATUSLINE_PATHNAME}"`, + '--connect-timeout 0.5 --max-time 1.5', + '-H "Content-Type: application/x-www-form-urlencoded"', + '-H "X-Orca-Agent-Hook-Token: %ORCA_AGENT_HOOK_TOKEN%"', + '--data-urlencode "paneKey=%ORCA_PANE_KEY%"', + '--data-urlencode "%ORCA_STATUSLINE_CONFIG_DIR_FIELD%"', + '--data-urlencode "env=%ORCA_AGENT_HOOK_ENV%"', + '--data-urlencode "version=%ORCA_AGENT_HOOK_VERSION%"', + '--data-urlencode "payload@%ORCA_STATUSLINE_PAYLOAD_FILE%"', + '>nul 2>&1' + ].join(' '), + `:${STATUSLINE_CLEANUP_LABEL}`, + 'del "%ORCA_STATUSLINE_PAYLOAD_FILE%" >nul 2>nul', + 'exit /b 0', + ...buildWindowsHookStdinDrainEpilogue(), + '' + ].join('\r\n') + } + + return [ + '#!/bin/sh', + ...buildPosixHookPayloadCapture(), + // Why: rate_limits appears only for Claude.ai-subscriber sessions after the first API response; skip the post (and its curl spawn) otherwise. + 'case "$payload" in', + ' *\'"rate_limits"\'*) ;;', + ' *) exit 0 ;;', + 'esac', + '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', + `printf '%s' "$payload" | curl -sS -X POST "http://127.0.0.1:\${ORCA_AGENT_HOOK_PORT}${CLAUDE_STATUSLINE_PATHNAME}" \\`, + ' --connect-timeout 0.5 --max-time 1.5 \\', + ' -H "Content-Type: application/x-www-form-urlencoded" \\', + ' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\', + ' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\', + ' --data-urlencode "configDir=${CLAUDE_CONFIG_DIR}" \\', + ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', + ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', + ' --data-urlencode "payload@-" >/dev/null 2>&1 || true', + 'exit 0', + '' + ].join('\n') +} diff --git a/src/main/index.ts b/src/main/index.ts index 314c9d4ce31..6f7d56f2c04 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1922,6 +1922,10 @@ app.whenReady().then(async () => { rateLimits.setClaudeAuthPreparationResolver((target) => claudeRuntimeAuth!.prepareForRateLimitFetch(target) ) + // Why: live Claude sessions stream usage windows through their statusLine command; feeding them here avoids OAuth usage-endpoint polling (and its 429s). + agentHookServer.setClaudeStatusLineListener((event) => { + rateLimits?.ingestLiveClaudeRateLimits(event) + }) rateLimits.setOpenCodeGoConfigResolver(() => { const settings = store!.getSettings() return { diff --git a/src/main/rate-limits/claude-fetcher.test.ts b/src/main/rate-limits/claude-fetcher.test.ts index 95f7d607ac0..7eb218f04de 100644 --- a/src/main/rate-limits/claude-fetcher.test.ts +++ b/src/main/rate-limits/claude-fetcher.test.ts @@ -856,15 +856,20 @@ describe('fetchClaudeRateLimits', () => { message: 'Rate limited. Please try again later.' } }), - { status: 429 } + { status: 429, headers: { 'retry-after': '3000' } } ) ) - await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ + const before = Date.now() + const result = await fetchClaudeRateLimits({ authPreparation }) + expect(result).toMatchObject({ provider: 'claude', status: 'error', - error: 'Claude usage is rate limited right now.' + error: 'Claude usage is rate limited right now.', + usageMetadata: expect.objectContaining({ failureKind: 'rate-limited' }) }) + expect(result.usageMetadata?.retryAtMs).toBeGreaterThanOrEqual(before + 3000 * 1000) + expect(result.usageMetadata?.retryAtMs).toBeLessThanOrEqual(Date.now() + 3000 * 1000) expect(netFetchMock).toHaveBeenCalledWith( 'https://api.anthropic.com/api/oauth/usage', @@ -877,6 +882,41 @@ describe('fetchClaudeRateLimits', () => { expect(fetchViaPty).not.toHaveBeenCalled() }) + it('omits retryAtMs when a 429 has no Retry-After header', async () => { + const configDir = '/Users/test/.claude' + const authPreparation: ClaudeRuntimeAuthPreparation = { + configDir, + envPatch: { CLAUDE_CONFIG_DIR: configDir }, + stripAuthEnv: false, + provenance: 'system' + } + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( + JSON.stringify({ + claudeAiOauth: { + accessToken: 'expired-oauth-token', + refreshToken: 'refresh-token', + expiresAt: Date.now() - 60_000 + } + }) + ) + netFetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + error: { + type: 'rate_limit_error', + message: 'Rate limited. Please try again later.' + } + }), + { status: 429 } + ) + ) + + const result = await fetchClaudeRateLimits({ authPreparation }) + expect(result.status).toBe('error') + expect(result.usageMetadata?.retryAtMs).toBeUndefined() + expect(fetchViaPty).not.toHaveBeenCalled() + }) + it('uses CLI fallback for OAuth auth failures when automatic repair is safe', async () => { const configDir = '/Users/test/.claude' const authPreparation: ClaudeRuntimeAuthPreparation = { diff --git a/src/main/rate-limits/claude-fetcher.ts b/src/main/rate-limits/claude-fetcher.ts index e37e831d54d..206748e1bc5 100644 --- a/src/main/rate-limits/claude-fetcher.ts +++ b/src/main/rate-limits/claude-fetcher.ts @@ -33,6 +33,7 @@ import { refreshClaudeOauthCredentials } from '../claude-accounts/oauth-refresh' import { createOAuthUsageError, OAuthUsageError } from './claude-oauth-usage-error' +import { mapClaudeUsageWindow, type ClaudeUsageWindowInput } from './claude-usage-window' import { withMacTailscaleDnsHint } from '../network/macos-tailscale-dns-diagnostic' import { ensureElectronProxyFromEnvironment } from '../network/proxy-settings' import { resolveClaudeUsageRefreshPlan } from './claude-usage-refresh-plan' @@ -278,11 +279,7 @@ function warnClaudeUsageFetchFailure( // OAuth API fetch // --------------------------------------------------------------------------- -type OAuthUsageWindow = { - utilization?: number - used_percentage?: number - resets_at?: string | number -} +type OAuthUsageWindow = ClaudeUsageWindowInput type OAuthUsageLimit = { kind?: string @@ -316,73 +313,6 @@ function abortedClaudeRateLimitResult(): ProviderRateLimits { } } -function parseResetTimestamp(value: string | number | undefined): number | null { - if (typeof value === 'number') { - if (!Number.isFinite(value)) { - return null - } - return value > 10_000_000_000 ? value : value * 1000 - } - - if (!value) { - return null - } - - const numericValue = Number(value) - if (Number.isFinite(numericValue) && value.trim() !== '') { - return numericValue > 10_000_000_000 ? numericValue : numericValue * 1000 - } - - const parsed = new Date(value).getTime() - return Number.isNaN(parsed) ? null : parsed -} - -function parseResetDescription(resetValue: string | number | undefined): string | null { - const resetTimestamp = parseResetTimestamp(resetValue) - if (resetTimestamp === null) { - return null - } - try { - const date = new Date(resetTimestamp) - const now = new Date() - const isToday = date.toDateString() === now.toDateString() - if (isToday) { - return date.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }) - } - return date.toLocaleDateString(undefined, { - weekday: 'short', - hour: 'numeric', - minute: '2-digit' - }) - } catch { - return null - } -} - -function mapWindow( - raw: OAuthUsageWindow | undefined, - windowMinutes: number -): RateLimitWindow | null { - if (!raw) { - return null - } - const usedPercent = - typeof raw.utilization === 'number' - ? raw.utilization - : typeof raw.used_percentage === 'number' - ? raw.used_percentage - : null - if (usedPercent === null) { - return null - } - return { - usedPercent: Math.min(100, Math.max(0, usedPercent)), - windowMinutes, - resetsAt: parseResetTimestamp(raw.resets_at), - resetDescription: parseResetDescription(raw.resets_at) - } -} - function mapFableWeeklyWindow(data: OAuthUsageResponse): RateLimitWindow | null { // Why: model quotas moved to structured scoped limits; prefer them but keep legacy weekly fields for older responses. const scoped = Array.isArray(data.limits) @@ -396,13 +326,13 @@ function mapFableWeeklyWindow(data: OAuthUsageResponse): RateLimitWindow | null ) : undefined return ( - mapWindow( + mapClaudeUsageWindow( scoped ? { used_percentage: scoped.percent, resets_at: scoped.resets_at } : undefined, 10080 ) ?? - mapWindow(data.fable_weekly, 10080) ?? - mapWindow(data.fable_seven_day, 10080) ?? - mapWindow(data.seven_day_fable, 10080) + mapClaudeUsageWindow(data.fable_weekly, 10080) ?? + mapClaudeUsageWindow(data.fable_seven_day, 10080) ?? + mapClaudeUsageWindow(data.seven_day_fable, 10080) ) } @@ -443,8 +373,8 @@ async function fetchViaOAuth(token: string, signal?: AbortSignal): Promise): Response { + return new Response('{"error":{"type":"rate_limit_error"}}', { status: 429, headers }) +} + +describe('createOAuthUsageError', () => { + it('captures a numeric Retry-After on 429', async () => { + const error = await createOAuthUsageError(rateLimitedResponse({ 'retry-after': '3037' })) + expect(error.status).toBe(429) + expect(error.skipPtyFallback).toBe(true) + expect(error.retryAfterMs).toBe(3037 * 1000) + expect(error.message).toBe('Claude usage is rate limited right now.') + }) + + it('captures an HTTP-date Retry-After on 429', async () => { + const error = await createOAuthUsageError( + rateLimitedResponse({ 'retry-after': new Date(Date.now() + 90_000).toUTCString() }) + ) + expect(error.retryAfterMs).toBeGreaterThan(0) + expect(error.retryAfterMs).toBeLessThanOrEqual(90_000) + }) + + it('caps Retry-After at 24 hours', async () => { + const error = await createOAuthUsageError( + rateLimitedResponse({ 'retry-after': String(48 * 60 * 60) }) + ) + expect(error.retryAfterMs).toBe(24 * 60 * 60 * 1000) + }) + + it('ignores missing, invalid, and non-positive Retry-After values', async () => { + for (const headers of [ + undefined, + { 'retry-after': 'soon' }, + { 'retry-after': '0' }, + { 'retry-after': '-5' } + ]) { + const error = await createOAuthUsageError(rateLimitedResponse(headers)) + expect(error.retryAfterMs).toBeNull() + } + }) + + it('does not capture Retry-After for non-429 responses', async () => { + const error = await createOAuthUsageError( + new Response('{"error":{"message":"Invalid token"}}', { + status: 401, + headers: { 'retry-after': '60' } + }) + ) + expect(error.status).toBe(401) + expect(error.retryAfterMs).toBeNull() + }) +}) diff --git a/src/main/rate-limits/claude-oauth-usage-error.ts b/src/main/rate-limits/claude-oauth-usage-error.ts index 8742ff08f2f..1ccb1471de6 100644 --- a/src/main/rate-limits/claude-oauth-usage-error.ts +++ b/src/main/rate-limits/claude-oauth-usage-error.ts @@ -1,8 +1,12 @@ +// Why: a corrupt/hostile Retry-After must not gate usage refreshes for days. +const MAX_RETRY_AFTER_MS = 24 * 60 * 60 * 1000 + export class OAuthUsageError extends Error { constructor( message: string, readonly status: number, - readonly skipPtyFallback: boolean + readonly skipPtyFallback: boolean, + readonly retryAfterMs: number | null = null ) { super(message) } @@ -14,10 +18,28 @@ export async function createOAuthUsageError(res: Response): Promise 0 ? Math.min(seconds * 1000, MAX_RETRY_AFTER_MS) : null + } + // Why: Retry-After may also be an HTTP-date (RFC 9110). + const dateMs = Date.parse(header) + if (!Number.isFinite(dateMs)) { + return null + } + const delta = dateMs - Date.now() + return delta > 0 ? Math.min(delta, MAX_RETRY_AFTER_MS) : null +} + async function describeOAuthUsageError(res: Response): Promise { if (res.status === 429) { return 'Claude usage is rate limited right now.' diff --git a/src/main/rate-limits/claude-usage-window.ts b/src/main/rate-limits/claude-usage-window.ts new file mode 100644 index 00000000000..dc4b7f49e23 --- /dev/null +++ b/src/main/rate-limits/claude-usage-window.ts @@ -0,0 +1,76 @@ +import type { RateLimitWindow } from '../../shared/rate-limit-types' + +// Why: shared by the OAuth usage fetcher and the statusline live feed, whose window payloads share this shape. +export type ClaudeUsageWindowInput = { + utilization?: number + used_percentage?: number + resets_at?: string | number +} + +// Why: 1e10 sits between any plausible seconds epoch (<2286) and any millisecond epoch (>2001), so it distinguishes the two units without extra metadata. +export function parseClaudeUsageResetTimestamp(value: string | number | undefined): number | null { + if (typeof value === 'number') { + if (!Number.isFinite(value)) { + return null + } + return value > 10_000_000_000 ? value : value * 1000 + } + + if (!value) { + return null + } + + const numericValue = Number(value) + if (Number.isFinite(numericValue) && value.trim() !== '') { + return numericValue > 10_000_000_000 ? numericValue : numericValue * 1000 + } + + const parsed = new Date(value).getTime() + return Number.isNaN(parsed) ? null : parsed +} + +function parseClaudeUsageResetDescription(resetValue: string | number | undefined): string | null { + const resetTimestamp = parseClaudeUsageResetTimestamp(resetValue) + if (resetTimestamp === null) { + return null + } + try { + const date = new Date(resetTimestamp) + const now = new Date() + const isToday = date.toDateString() === now.toDateString() + if (isToday) { + return date.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }) + } + return date.toLocaleDateString(undefined, { + weekday: 'short', + hour: 'numeric', + minute: '2-digit' + }) + } catch { + return null + } +} + +export function mapClaudeUsageWindow( + raw: ClaudeUsageWindowInput | undefined, + windowMinutes: number +): RateLimitWindow | null { + if (!raw) { + return null + } + const usedPercent = + typeof raw.utilization === 'number' + ? raw.utilization + : typeof raw.used_percentage === 'number' + ? raw.used_percentage + : null + if (usedPercent === null) { + return null + } + return { + usedPercent: Math.min(100, Math.max(0, usedPercent)), + windowMinutes, + resetsAt: parseClaudeUsageResetTimestamp(raw.resets_at), + resetDescription: parseClaudeUsageResetDescription(raw.resets_at) + } +} diff --git a/src/main/rate-limits/service.test.ts b/src/main/rate-limits/service.test.ts index 78a374269f5..c4c15f2c3f8 100644 --- a/src/main/rate-limits/service.test.ts +++ b/src/main/rate-limits/service.test.ts @@ -561,6 +561,390 @@ describe('RateLimitService', () => { } }) + it('waits out Retry-After before automated Claude refetches, then recovers', async () => { + vi.useFakeTimers() + try { + vi.mocked(fetchClaudeRateLimits) + .mockImplementationOnce(async () => ({ + ...errorProvider('claude', 'Claude usage is rate limited right now.'), + usageMetadata: { failureKind: 'rate-limited', retryAtMs: Date.now() + 40 * 60 * 1000 } + })) + .mockImplementation(async () => okProvider('claude', 18)) + mockFreshBackgroundProviderFetches() + + const service = new RateLimitService() + const window = new FakeRateLimitWindow() + service.attach(asRateLimitWindow(window)) + service.start({ fetchImmediately: false }) + + await vi.advanceTimersByTimeAsync(1000) + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1) + + // Activations that would normally retry immediately must respect the server's Retry-After. + window.emit('focus') + await vi.advanceTimersByTimeAsync(0) + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1) + + // The 15- and 30-minute poll cycles land inside the 40-minute window: other providers refresh, Claude is skipped. + await vi.advanceTimersByTimeAsync(15 * 60 * 1000) + expect(vi.mocked(fetchCodexRateLimits).mock.calls.length).toBeGreaterThan(1) + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(15 * 60 * 1000) + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1) + + // The 45-minute poll cycle is past the window and refetches Claude. + await vi.advanceTimersByTimeAsync(15 * 60 * 1000) + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(2) + expect(service.getState().claude?.status).toBe('ok') + + service.stop() + } finally { + vi.useRealTimers() + } + }) + + it('lets a user-directed refresh bypass the Claude Retry-After gate', async () => { + vi.useFakeTimers() + try { + vi.mocked(fetchClaudeRateLimits) + .mockImplementationOnce(async () => ({ + ...errorProvider('claude', 'Claude usage is rate limited right now.'), + usageMetadata: { failureKind: 'rate-limited', retryAtMs: Date.now() + 40 * 60 * 1000 } + })) + .mockImplementation(async () => okProvider('claude', 18)) + mockFreshBackgroundProviderFetches() + + const service = new RateLimitService() + + await service.refresh() + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1) + expect(service.getState().claude?.status).toBe('error') + + await service.refresh() + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(2) + expect(service.getState().claude?.status).toBe('ok') + } finally { + vi.useRealTimers() + } + }) + + it('keeps last-known usage through a rate-limited window past the generic stale threshold', async () => { + vi.useFakeTimers() + try { + vi.mocked(fetchClaudeRateLimits) + .mockImplementationOnce(async () => okProvider('claude', 18)) + .mockImplementation(async () => ({ + ...errorProvider('claude', 'Claude usage is rate limited right now.'), + usageMetadata: { failureKind: 'rate-limited' } + })) + mockFreshBackgroundProviderFetches() + + const service = new RateLimitService() + + await service.refresh() + expect(service.getState().claude?.status).toBe('ok') + + // 31 minutes later the generic 30-minute stale policy would drop the snapshot; rate-limited failures keep it. + await vi.advanceTimersByTimeAsync(31 * 60 * 1000) + await service.refresh() + + const claude = service.getState().claude + expect(claude?.status).toBe('error') + expect(claude?.error).toBe('Claude usage is rate limited right now.') + expect(claude?.session?.usedPercent).toBe(18) + } finally { + vi.useRealTimers() + } + }) + + it('ingests statusline usage, clears errors, and skips OAuth polls while the live feed is fresh', async () => { + vi.useFakeTimers() + try { + vi.mocked(fetchClaudeRateLimits).mockImplementation(async () => ({ + ...errorProvider('claude', 'Claude usage is rate limited right now.'), + usageMetadata: { failureKind: 'rate-limited' } + })) + mockFreshBackgroundProviderFetches() + + const service = new RateLimitService() + const window = new FakeRateLimitWindow() + service.attach(asRateLimitWindow(window)) + service.start({ fetchImmediately: false }) + + await vi.advanceTimersByTimeAsync(1000) + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1) + expect(service.getState().claude?.status).toBe('error') + + // A live session reports usage 12 minutes in; the error state clears without any OAuth call. + await vi.advanceTimersByTimeAsync(12 * 60 * 1000 - 1000) + service.ingestLiveClaudeRateLimits({ + configDir: null, + fiveHour: { used_percentage: 23.5, resets_at: Math.floor(Date.now() / 1000) + 3600 }, + sevenDay: { used_percentage: 41.2 } + }) + + const claude = service.getState().claude + expect(claude?.status).toBe('ok') + expect(claude?.error).toBeNull() + expect(claude?.session?.usedPercent).toBe(23.5) + expect(claude?.weekly?.usedPercent).toBe(41.2) + expect(claude?.usageMetadata?.source).toBe('live-session') + + // The 15-minute poll cycle lands inside the live-feed freshness window: other providers refresh, Claude's OAuth fetch is skipped. + const codexCallsBeforePoll = vi.mocked(fetchCodexRateLimits).mock.calls.length + await vi.advanceTimersByTimeAsync(3 * 60 * 1000) + expect(vi.mocked(fetchCodexRateLimits).mock.calls.length).toBeGreaterThan( + codexCallsBeforePoll + ) + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1) + + // Once the live feed goes stale, automated refetches resume. + await vi.advanceTimersByTimeAsync(15 * 60 * 1000) + expect(vi.mocked(fetchClaudeRateLimits).mock.calls.length).toBeGreaterThan(1) + + service.stop() + } finally { + vi.useRealTimers() + } + }) + + it('drops statusline posts before attribution is known or from a mismatched config dir', async () => { + vi.useFakeTimers() + try { + vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 18)) + mockFreshBackgroundProviderFetches() + + const service = new RateLimitService() + + // No fetch cycle has captured the selected account's config dir yet. + service.ingestLiveClaudeRateLimits({ + configDir: null, + fiveHour: { used_percentage: 50 }, + sevenDay: null + }) + expect(service.getState().claude).toBeNull() + + await service.refresh() + expect(service.getState().claude?.session?.usedPercent).toBe(18) + + // A managed-account session must not overwrite the system-default bar. + service.ingestLiveClaudeRateLimits({ + configDir: '/some/managed/dir', + fiveHour: { used_percentage: 99 }, + sevenDay: null + }) + expect(service.getState().claude?.session?.usedPercent).toBe(18) + + service.ingestLiveClaudeRateLimits({ + configDir: null, + fiveHour: { used_percentage: 33 }, + sevenDay: null + }) + expect(service.getState().claude?.session?.usedPercent).toBe(33) + } finally { + vi.useRealTimers() + } + }) + + it('keeps the other window when a statusline post carries only one', async () => { + vi.useFakeTimers() + try { + vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 18)) + mockFreshBackgroundProviderFetches() + + const service = new RateLimitService() + await service.refresh() + + service.ingestLiveClaudeRateLimits({ + configDir: null, + fiveHour: { used_percentage: 20 }, + sevenDay: { used_percentage: 41 } + }) + expect(service.getState().claude?.weekly?.usedPercent).toBe(41) + + // A later post reporting only the 5h window must not wipe the weekly bar. + await vi.advanceTimersByTimeAsync(1000) + service.ingestLiveClaudeRateLimits({ + configDir: null, + fiveHour: { used_percentage: 25 }, + sevenDay: null + }) + const claude = service.getState().claude + expect(claude?.session?.usedPercent).toBe(25) + expect(claude?.weekly?.usedPercent).toBe(41) + } finally { + vi.useRealTimers() + } + }) + + it('dedupes identical statusline posts within the throttle window', async () => { + vi.useFakeTimers() + try { + vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 18)) + mockFreshBackgroundProviderFetches() + + const service = new RateLimitService() + await service.refresh() + + const event = { + configDir: null, + fiveHour: { used_percentage: 20, resets_at: 1738425600 }, + sevenDay: null + } + service.ingestLiveClaudeRateLimits(event) + const firstUpdatedAt = service.getState().claude?.updatedAt + + await vi.advanceTimersByTimeAsync(1000) + service.ingestLiveClaudeRateLimits(event) + expect(service.getState().claude?.updatedAt).toBe(firstUpdatedAt) + + // A changed window updates immediately despite the throttle. + service.ingestLiveClaudeRateLimits({ + ...event, + fiveHour: { used_percentage: 21, resets_at: 1738425600 } + }) + expect(service.getState().claude?.session?.usedPercent).toBe(21) + } finally { + vi.useRealTimers() + } + }) + + it('does not roll back a live-session snapshot that arrived while a claude fetch was in flight', async () => { + vi.useFakeTimers() + try { + const parkedClaudeFetch = deferred() + vi.mocked(fetchClaudeRateLimits) + .mockImplementationOnce(async () => okProvider('claude', 18)) + .mockImplementationOnce(() => parkedClaudeFetch.promise) + mockFreshBackgroundProviderFetches() + + const service = new RateLimitService() + await service.refresh() + + const secondRefresh = service.refresh() + await flushMicrotasks() + + // A live statusline post lands while the second fetch is parked in flight. + service.ingestLiveClaudeRateLimits({ + configDir: null, + fiveHour: { used_percentage: 41 }, + sevenDay: null + }) + expect(service.getState().claude?.session?.usedPercent).toBe(41) + + parkedClaudeFetch.resolve({ + provider: 'claude', + session: null, + weekly: null, + updatedAt: Date.now(), + error: 'boom', + status: 'error' + }) + await secondRefresh + + // The failed fetch must not roll the bar back to the pre-cycle snapshot or flip it to error. + const claude = service.getState().claude + expect(claude?.status).toBe('ok') + expect(claude?.session?.usedPercent).toBe(41) + expect(claude?.usageMetadata?.source).toBe('live-session') + } finally { + vi.useRealTimers() + } + }) + + it('keeps a populated weekly bar when a live post carries only the five-hour window', async () => { + vi.useFakeTimers() + try { + vi.mocked(fetchClaudeRateLimits).mockResolvedValue({ + ...okProvider('claude', 18), + weekly: { usedPercent: 62, windowMinutes: 10080, resetsAt: null, resetDescription: null } + }) + mockFreshBackgroundProviderFetches() + + const service = new RateLimitService() + await service.refresh() + expect(service.getState().claude?.weekly?.usedPercent).toBe(62) + + service.ingestLiveClaudeRateLimits({ + configDir: null, + fiveHour: { used_percentage: 23.5 }, + sevenDay: null + }) + + const claude = service.getState().claude + expect(claude?.session?.usedPercent).toBe(23.5) + expect(claude?.weekly?.usedPercent).toBe(62) + expect(claude?.usageMetadata?.source).toBe('live-session') + } finally { + vi.useRealTimers() + } + }) + + it('does not restore the outgoing account auth snapshot when the account switches mid-resolve', async () => { + vi.useFakeTimers() + try { + const staleClaudeFetch = deferred() + vi.mocked(fetchClaudeRateLimits) + .mockImplementationOnce(() => staleClaudeFetch.promise) + .mockImplementation(async () => okProvider('claude', 18)) + mockFreshBackgroundProviderFetches() + + const authGate = deferred() + let resolverCalls = 0 + const service = new RateLimitService() + service.setClaudeAuthPreparationResolver(async () => { + resolverCalls += 1 + const outgoing = resolverCalls === 1 + if (outgoing) { + await authGate.promise + } + return { + configDir: outgoing ? '/outgoing/.claude' : '/incoming/.claude', + runtime: 'host', + wslDistro: null, + wslLinuxConfigDir: null, + envPatch: { + CLAUDE_CONFIG_DIR: outgoing ? '/outgoing/.claude' : '/incoming/.claude' + }, + stripAuthEnv: false, + provenance: outgoing ? 'managed:outgoing' : 'managed:incoming' + } + }) + + // The first cycle is parked inside the outgoing account's resolver await when the switch lands. + const firstRefresh = service.refresh() + await flushMicrotasks() + const switchPromise = service.refreshForClaudeAccountChange('outgoing-account') + await flushMicrotasks() + authGate.resolve() + await flushMicrotasks(8) + + // The stale cycle resumed after the switch; while its Claude fetch is still in flight, + // a live post from the outgoing session must not land on the incoming account's bar. + const beforeOutgoingPost = service.getState().claude?.session?.usedPercent + service.ingestLiveClaudeRateLimits({ + configDir: '/outgoing/.claude', + fiveHour: { used_percentage: 99 }, + sevenDay: null + }) + expect(service.getState().claude?.session?.usedPercent).toBe(beforeOutgoingPost) + + staleClaudeFetch.resolve(okProvider('claude', 18)) + await Promise.all([firstRefresh, switchPromise]) + + // The incoming account's own sessions still attribute correctly. + service.ingestLiveClaudeRateLimits({ + configDir: '/incoming/.claude', + fiveHour: { used_percentage: 55 }, + sevenDay: null + }) + expect(service.getState().claude?.session?.usedPercent).toBe(55) + } finally { + vi.useRealTimers() + } + }) + it('keeps a settled error chip settled during background refetches instead of flashing fetching', async () => { vi.useFakeTimers() try { diff --git a/src/main/rate-limits/service.ts b/src/main/rate-limits/service.ts index 13dfd7a62c4..7af75e3ccfb 100644 --- a/src/main/rate-limits/service.ts +++ b/src/main/rate-limits/service.ts @@ -9,6 +9,8 @@ import type { } from '../../shared/rate-limit-types' import { fetchClaudeRateLimits, fetchManagedAccountUsage } from './claude-fetcher' import type { InactiveClaudeAccountInfo } from './claude-fetcher' +import { mapClaudeUsageWindow } from './claude-usage-window' +import type { ClaudeStatusLineRateLimits } from '../../shared/claude-statusline-rate-limits' import { consumeCodexRateLimitResetCredit, fetchCodexRateLimits } from './codex-fetcher' import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' import type { NetworkProxySettings } from '../../shared/network-proxy' @@ -83,6 +85,10 @@ const INDIVIDUALLY_REFRESHABLE_PROVIDERS: ReadonlySet = 'grok' ]) const STALE_THRESHOLD_MS = 30 * 60 * 1000 // 30 minutes — after this, stale data is dropped +// Why: usage-endpoint 429 windows can outlast the generic threshold (Retry-After ~1h); quota is informational, so a stale snapshot beats a bare "Limited". +const RATE_LIMITED_STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000 +// Why: statusline posts arrive on every turn; skip renderer pushes for identical windows so streaming sessions don't spam state updates. +const LIVE_CLAUDE_INGEST_DEDUPE_MS = 30 * 1000 const INACTIVE_FETCH_DEBOUNCE_MS = 60 * 1000 // 60 seconds — debounce fetch-on-open const DEFERRED_STARTUP_ACTIVE_REFRESH_MS = 1000 @@ -120,6 +126,22 @@ function toErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error) } +function normalizeClaudeConfigDir(dir: string | null | undefined): string | null { + // Why: the same dir can arrive with mixed separators (Windows env vs statusline JSON); unify them so attribution compares paths, not spellings. Case is left alone — Linux paths are case-sensitive. + const trimmed = dir?.trim().replace(/\\/g, '/').replace(/\/+$/, '') + return trimmed || null +} + +function isSameUsageWindow( + a: ProviderRateLimits['session'], + b: ProviderRateLimits['session'] +): boolean { + if (!a || !b) { + return a === b + } + return a.usedPercent === b.usedPercent && a.resetsAt === b.resetsAt +} + export class RateLimitService { private state: InternalRateLimitState = { claude: null, @@ -168,6 +190,8 @@ export class RateLimitService { private fetchIdleResolvers: (() => void)[] = [] private codexFetchGeneration = 0 private claudeFetchGeneration = 0 + // Why: statusline ingest must attribute live windows to the selected account without re-running the side-effectful auth sync per post. + private lastClaudeAuthSnapshot: { configDir: string | null; provenance: string } | null = null private opencodeFetchGeneration = 0 private minimaxFetchGeneration = 0 private lastOpencodeConfigHash = '' @@ -433,6 +457,8 @@ export class RateLimitService { this.claudeFetchGeneration += 1 // Why: a new account/target starts with a clean retry schedule. this.activeFailureStreakByProvider.claude = 0 + // Why: statusline posts from the outgoing account's sessions must not land on the incoming account's bar mid-switch. + this.lastClaudeAuthSnapshot = null this.lastInactiveClaudeFetchAt = 0 this.updateState({ ...this.state, @@ -448,6 +474,10 @@ export class RateLimitService { this.claudeFetchTarget = nextTarget this.claudeFetchGeneration += 1 this.activeFailureStreakByProvider.claude = 0 + if (targetChanged) { + // Why: statusline posts from the outgoing target's sessions must not land on the incoming target's bar mid-switch. + this.lastClaudeAuthSnapshot = null + } this.updateState({ ...this.state, claude: this.withFetchingStatus(targetChanged ? null : this.state.claude, 'claude') @@ -758,6 +788,10 @@ export class RateLimitService { } // Why: a failed startup read is not fresh data; keep it eligible for activation recovery, throttled per provider. if (limits.status === 'error') { + // Why: the server told us when to come back (Retry-After); retrying earlier burns the endpoint's budget and keeps the 429 alive. + if (this.isRetryAfterActive(limits)) { + continue + } const lastRetryAt = this.lastActiveFailureRetryAtByProvider[provider] const throttleMs = INDIVIDUALLY_REFRESHABLE_PROVIDERS.has(provider) ? Math.min( @@ -847,11 +881,14 @@ export class RateLimitService { try { let shouldContinue = true + // Why: only user-directed (force) fetches may bypass a provider's Retry-After gate; queued reruns inherit force because only forced calls queue them. + let cycleForce = options?.force ?? false while (shouldContinue) { const signal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchAllCycle(fetchSignal) + this.runFetchAllCycle(fetchSignal, { force: cycleForce }) ) shouldContinue = false + cycleForce = true if (signal.aborted) { break } @@ -872,7 +909,7 @@ export class RateLimitService { if (this.claudeOnlyFetchQueued) { this.claudeOnlyFetchQueued = false const claudeSignal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchClaudeOnlyCycle(fetchSignal) + this.runFetchClaudeOnlyCycle(fetchSignal, { force: true }) ) if (claudeSignal.aborted) { break @@ -917,7 +954,7 @@ export class RateLimitService { if (this.fullFetchQueued) { this.fullFetchQueued = false const fullSignal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchAllCycle(fetchSignal) + this.runFetchAllCycle(fetchSignal, { force: true }) ) if (fullSignal.aborted) { break @@ -931,7 +968,7 @@ export class RateLimitService { if (this.claudeOnlyFetchQueued) { this.claudeOnlyFetchQueued = false const claudeSignal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchClaudeOnlyCycle(fetchSignal) + this.runFetchClaudeOnlyCycle(fetchSignal, { force: true }) ) if (claudeSignal.aborted) { break @@ -965,18 +1002,21 @@ export class RateLimitService { try { let shouldContinue = true + // Why: only user-directed (force) fetches may bypass a provider's Retry-After gate; queued reruns inherit force because only forced calls queue them. + let cycleForce = options?.force ?? false while (shouldContinue) { const signal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchClaudeOnlyCycle(fetchSignal) + this.runFetchClaudeOnlyCycle(fetchSignal, { force: cycleForce }) ) shouldContinue = false + cycleForce = true if (signal.aborted) { break } if (this.fullFetchQueued) { this.fullFetchQueued = false const fullSignal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchAllCycle(fetchSignal) + this.runFetchAllCycle(fetchSignal, { force: true }) ) if (fullSignal.aborted) { break @@ -1035,7 +1075,7 @@ export class RateLimitService { if (this.fullFetchQueued) { this.fullFetchQueued = false const fullSignal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchAllCycle(fetchSignal) + this.runFetchAllCycle(fetchSignal, { force: true }) ) if (fullSignal.aborted) { break @@ -1058,7 +1098,7 @@ export class RateLimitService { if (this.claudeOnlyFetchQueued) { this.claudeOnlyFetchQueued = false const claudeSignal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchClaudeOnlyCycle(fetchSignal) + this.runFetchClaudeOnlyCycle(fetchSignal, { force: true }) ) if (claudeSignal.aborted) { break @@ -1242,6 +1282,119 @@ export class RateLimitService { } } + // Why: hitting a usage endpoint before its Retry-After expires burns the budget for nothing and keeps the 429 window alive. + private isRetryAfterActive(limits: ProviderRateLimits | null): boolean { + return Boolean( + limits?.status === 'error' && + limits.usageMetadata?.retryAtMs && + limits.usageMetadata.retryAtMs > Date.now() + ) + } + + // Why: a live Claude session already streams fresh usage windows; spending the OAuth usage endpoint's tight budget on the same data invites 429s. + private isLiveClaudeUsageFresh(limits: ProviderRateLimits | null): boolean { + return Boolean( + limits?.status === 'ok' && + limits.usageMetadata?.source === 'live-session' && + Date.now() - limits.updatedAt < MIN_REFETCH_MS + ) + } + + private shouldSkipAutomatedClaudeFetch(limits: ProviderRateLimits | null): boolean { + return this.isRetryAfterActive(limits) || this.isLiveClaudeUsageFresh(limits) + } + + private resolveClaudeFetchApply( + fresh: ProviderRateLimits, + previous: ProviderRateLimits | null + ): ProviderRateLimits { + // Why: a live statusline post can land while an OAuth cycle is in flight; a failed fetch must not + // roll the bar back to the pre-cycle snapshot or flip the just-refreshed live data to error. + const current = this.state.claude + if (fresh.status !== 'ok' && current && this.isLiveClaudeUsageFresh(current)) { + return current + } + return this.applyStalePolicy(fresh, previous) + } + + private rememberClaudeAuthSnapshot( + authPreparation: ClaudeRuntimeAuthPreparation | undefined, + claudeGeneration: number, + claudeTarget: NormalizedClaudeAccountSelectionTarget + ): void { + // Why: an account switch during the resolver await already cleared the snapshot; restoring the outgoing account's configDir here would cross-attribute its live posts to the new bar. + if ( + claudeGeneration !== this.claudeFetchGeneration || + !this.isSameClaudeTarget(claudeTarget, this.claudeFetchTarget) + ) { + return + } + this.lastClaudeAuthSnapshot = { + configDir: normalizeClaudeConfigDir(authPreparation?.envPatch.CLAUDE_CONFIG_DIR), + provenance: authPreparation?.provenance ?? 'system' + } + } + + /** Live usage windows forwarded from a Claude session's statusLine command. */ + ingestLiveClaudeRateLimits(event: ClaudeStatusLineRateLimits): void { + // Why: attribution needs the selected account's config dir; until a fetch cycle captures it, drop posts rather than guess the account. + const snapshot = this.lastClaudeAuthSnapshot + if (!snapshot) { + // Why: breadcrumbs make a silently dark live feed diagnosable — dropped posts are otherwise invisible. + console.debug('[rate-limits] dropped live Claude usage: no auth snapshot yet', { + eventConfigDir: event.configDir + }) + return + } + // Why: sessions of other accounts (or other runtimes) report their own quota; mixing them into the active account's bar would lie. + if (normalizeClaudeConfigDir(event.configDir) !== snapshot.configDir) { + console.debug('[rate-limits] dropped live Claude usage: configDir mismatch', { + eventConfigDir: event.configDir, + snapshotConfigDir: snapshot.configDir + }) + return + } + const freshSession = mapClaudeUsageWindow(event.fiveHour ?? undefined, 300) + const freshWeekly = mapClaudeUsageWindow(event.sevenDay ?? undefined, 10080) + if (!freshSession && !freshWeekly) { + return + } + const previous = this.state.claude + // Why: statusline payloads can carry a single window; an absent one means "no update", not "cleared" — keep the other bar populated. + const session = freshSession ?? previous?.session ?? null + const weekly = freshWeekly ?? previous?.weekly ?? null + if ( + previous?.status === 'ok' && + previous.usageMetadata?.source === 'live-session' && + Date.now() - previous.updatedAt < LIVE_CLAUDE_INGEST_DEDUPE_MS && + isSameUsageWindow(previous.session, session) && + isSameUsageWindow(previous.weekly, weekly) + ) { + return + } + this.activeFailureStreakByProvider.claude = 0 + this.updateState({ + ...this.state, + claude: { + provider: 'claude', + session, + weekly, + // Why: the statusline payload has no Fable scoped window; keep the last OAuth-provided one visible. + // Tradeoff: while live posts keep the OAuth poll gated, fableWeekly stays frozen until the session idles past the freshness window. + fableWeekly: previous?.fableWeekly ?? null, + updatedAt: Date.now(), + error: null, + status: 'ok', + usageMetadata: { + source: 'live-session', + lastSuccessfulSource: 'live-session', + credentialSource: previous?.usageMetadata?.credentialSource, + authProvenance: snapshot.provenance + } + } + }) + } + private trackActiveFailureStreak( provider: ActiveRateLimitProvider, fresh: ProviderRateLimits @@ -1287,17 +1440,22 @@ export class RateLimitService { return { ...current, status: 'fetching' } } - private async runFetchAllCycle(signal: AbortSignal): Promise { + private async runFetchAllCycle( + signal: AbortSignal, + options?: { force?: boolean } + ): Promise { if (signal.aborted) { return } const claudeTarget = this.claudeFetchTarget + // Why: capture before the resolver await so an account switch during it invalidates both the snapshot and the state apply. + const claudeGeneration = this.claudeFetchGeneration const claudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget) if (signal.aborted) { return } + this.rememberClaudeAuthSnapshot(claudeAuthPreparation, claudeGeneration, claudeTarget) const claudeProvenance = claudeAuthPreparation?.provenance ?? 'system' - const claudeGeneration = this.claudeFetchGeneration const codexTarget = this.codexFetchTarget const codexHomePath = this.codexHomePathResolver?.(codexTarget) ?? null const codexProvenance = this.getCodexProvenance(codexTarget, codexHomePath) @@ -1360,15 +1518,21 @@ export class RateLimitService { (reason) => ({ status: 'rejected', reason }) as const ) + // Why: skip automated Claude fetches while a Retry-After window is open or a live session feed is fresher than the OAuth poll would be. + const claudeFetchGated = + !options?.force && this.shouldSkipAutomatedClaudeFetch(previousState.claude) + const [claudeResult, codexResult, geminiResult, opencodeGoResult, kimiResult, miniMaxResult] = await Promise.allSettled([ - fetchClaudeRateLimits({ - authPreparation: claudeAuthPreparation, - allowPtyFallback: this.shouldAllowClaudePtyFallback(claudeAuthPreparation), - allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement(), - networkProxySettings: this.networkProxySettingsResolver?.(), - signal - }), + claudeFetchGated + ? Promise.resolve(previousState.claude as ProviderRateLimits) + : fetchClaudeRateLimits({ + authPreparation: claudeAuthPreparation, + allowPtyFallback: this.shouldAllowClaudePtyFallback(claudeAuthPreparation), + allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement(), + networkProxySettings: this.networkProxySettingsResolver?.(), + signal + }), missingWslCodexHome ?? fetchCodexRateLimits({ codexHomePath, @@ -1488,7 +1652,9 @@ export class RateLimitService { const latestCodexProvenance = this.getCodexProvenance(codexTarget, latestCodexHomePath) const shouldApplyCodex = codexGeneration === this.codexFetchGeneration && codexProvenance === latestCodexProvenance + // Why: a gated cycle made no Claude attempt; applying its passthrough result would grow the failure streak and reset stale-policy clocks for free. const shouldApplyClaude = + !claudeFetchGated && claudeGeneration === this.claudeFetchGeneration && claudeProvenance === latestClaudeProvenance && this.isSameClaudeTarget(claudeTarget, this.claudeFetchTarget) @@ -1515,7 +1681,7 @@ export class RateLimitService { this.updateState({ ...this.state, claude: shouldApplyClaude - ? this.applyStalePolicy(claude, previousState.claude) + ? this.resolveClaudeFetchApply(claude, previousState.claude) : this.state.claude, codex: shouldApplyCodex ? this.applyStalePolicy(codex, previousState.codex) @@ -1612,17 +1778,26 @@ export class RateLimitService { }) } - private async runFetchClaudeOnlyCycle(signal: AbortSignal): Promise { + private async runFetchClaudeOnlyCycle( + signal: AbortSignal, + options?: { force?: boolean } + ): Promise { if (signal.aborted) { return } + // Why: skip automated Claude fetches while a Retry-After window is open or a live session feed is fresher than the OAuth poll would be. + if (!options?.force && this.shouldSkipAutomatedClaudeFetch(this.state.claude)) { + return + } const claudeTarget = this.claudeFetchTarget + // Why: capture before the resolver await so an account switch during it invalidates both the snapshot and the state apply. + const claudeGeneration = this.claudeFetchGeneration const claudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget) if (signal.aborted) { return } + this.rememberClaudeAuthSnapshot(claudeAuthPreparation, claudeGeneration, claudeTarget) const claudeProvenance = claudeAuthPreparation?.provenance ?? 'system' - const claudeGeneration = this.claudeFetchGeneration const previousState = this.state this.updateState({ @@ -1667,7 +1842,7 @@ export class RateLimitService { this.updateState({ ...this.state, claude: shouldApplyClaude - ? this.applyStalePolicy(claude, previousState.claude) + ? this.resolveClaudeFetchApply(claude, previousState.claude) : this.state.claude }) } @@ -1745,7 +1920,11 @@ export class RateLimitService { } // Previous data is too old — don't show stale data - if (Date.now() - previous.updatedAt > STALE_THRESHOLD_MS) { + const staleThresholdMs = + fresh.usageMetadata?.failureKind === 'rate-limited' + ? RATE_LIMITED_STALE_THRESHOLD_MS + : STALE_THRESHOLD_MS + if (Date.now() - previous.updatedAt > staleThresholdMs) { return fresh } diff --git a/src/shared/claude-statusline-rate-limits.test.ts b/src/shared/claude-statusline-rate-limits.test.ts new file mode 100644 index 00000000000..b36b892f1d5 --- /dev/null +++ b/src/shared/claude-statusline-rate-limits.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest' +import { parseClaudeStatusLineBody } from './claude-statusline-rate-limits' + +function formBody(payload: unknown, configDir?: string): Record { + return { + payload: JSON.stringify(payload), + ...(configDir !== undefined ? { configDir } : {}) + } +} + +describe('parseClaudeStatusLineBody', () => { + it('extracts both windows and the session config dir', () => { + const parsed = parseClaudeStatusLineBody( + formBody( + { + rate_limits: { + five_hour: { used_percentage: 23.5, resets_at: 1738425600 }, + seven_day: { used_percentage: 41.2, resets_at: 1712059200 } + } + }, + '/home/dev/.config/managed-claude' + ) + ) + expect(parsed).toEqual({ + configDir: '/home/dev/.config/managed-claude', + fiveHour: { used_percentage: 23.5, resets_at: 1738425600 }, + sevenDay: { used_percentage: 41.2, resets_at: 1712059200 } + }) + }) + + it('treats a missing/empty configDir as a system-default session', () => { + const parsed = parseClaudeStatusLineBody( + formBody({ rate_limits: { five_hour: { used_percentage: 5 } } }, '') + ) + expect(parsed?.configDir).toBeNull() + expect(parsed?.fiveHour).toEqual({ used_percentage: 5, resets_at: undefined }) + expect(parsed?.sevenDay).toBeNull() + }) + + it('passes through a string resets_at so schema drift degrades gracefully', () => { + const parsed = parseClaudeStatusLineBody( + formBody({ + rate_limits: { + five_hour: { used_percentage: 12, resets_at: '2026-07-20T10:00:00Z' }, + seven_day: { used_percentage: 3, resets_at: ' ' } + } + }) + ) + expect(parsed?.fiveHour).toEqual({ used_percentage: 12, resets_at: '2026-07-20T10:00:00Z' }) + expect(parsed?.sevenDay).toEqual({ used_percentage: 3, resets_at: undefined }) + }) + + it('falls back to the OAuth-shaped utilization field so schema drift degrades gracefully', () => { + const parsed = parseClaudeStatusLineBody( + formBody({ + rate_limits: { + five_hour: { utilization: 37, resets_at: 1_750_000_000 }, + seven_day: { used_percentage: 8, utilization: 99 } + } + }) + ) + expect(parsed?.fiveHour).toEqual({ utilization: 37, resets_at: 1_750_000_000 }) + // used_percentage wins when both are present — it is the documented statusline field. + expect(parsed?.sevenDay).toEqual({ used_percentage: 8, resets_at: undefined }) + }) + + it('returns null when rate_limits is absent or empty', () => { + expect( + parseClaudeStatusLineBody(formBody({ context_window: { used_percentage: 8 } })) + ).toBeNull() + expect(parseClaudeStatusLineBody(formBody({ rate_limits: {} }))).toBeNull() + expect(parseClaudeStatusLineBody(formBody({ rate_limits: null }))).toBeNull() + }) + + it('rejects malformed bodies without throwing', () => { + expect(parseClaudeStatusLineBody(null)).toBeNull() + expect(parseClaudeStatusLineBody('raw string')).toBeNull() + expect(parseClaudeStatusLineBody({ payload: 'not json' })).toBeNull() + expect(parseClaudeStatusLineBody({ payload: '"just a string"' })).toBeNull() + expect( + parseClaudeStatusLineBody( + formBody({ rate_limits: { five_hour: { used_percentage: 'NaN' } } }) + ) + ).toBeNull() + }) +}) diff --git a/src/shared/claude-statusline-rate-limits.ts b/src/shared/claude-statusline-rate-limits.ts new file mode 100644 index 00000000000..c2ac5df727b --- /dev/null +++ b/src/shared/claude-statusline-rate-limits.ts @@ -0,0 +1,83 @@ +// Why: Claude Code (>=2.1.80) pipes `rate_limits` to the statusLine command on every +// turn — piggybacked on Messages API responses, so reading it costs no usage-endpoint +// budget (the endpoint 429s under Orca's polling; see rate-limits/service.ts). + +export const CLAUDE_STATUSLINE_PATHNAME = '/statusline/claude' + +export type ClaudeStatusLineWindow = { + used_percentage?: number + /** OAuth-usage-shaped sibling field (0-100); accepted so a CLI schema drift degrades instead of going dark. */ + utilization?: number + /** Unix epoch seconds when the window resets, if known; tolerates an ISO/date string if the schema drifts. */ + resets_at?: number | string +} + +export type ClaudeStatusLineRateLimits = { + /** CLAUDE_CONFIG_DIR of the reporting session; null for system-default sessions. */ + configDir: string | null + fiveHour: ClaudeStatusLineWindow | null + sevenDay: ClaudeStatusLineWindow | null +} + +function finiteNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined +} + +function parseWindow(value: unknown): ClaudeStatusLineWindow | null { + if (typeof value !== 'object' || value === null) { + return null + } + const raw = value as { used_percentage?: unknown; utilization?: unknown; resets_at?: unknown } + const usedPercentage = finiteNumber(raw.used_percentage) + // Why: mirror mapClaudeUsageWindow's OAuth-shape tolerance (utilization, 0-100) so a statusline field rename degrades instead of silently darkening the feed. + const utilization = usedPercentage === undefined ? finiteNumber(raw.utilization) : undefined + if (usedPercentage === undefined && utilization === undefined) { + return null + } + // Why: resets_at is epoch seconds today, but pass a string/ISO value through so schema drift degrades to a parseable timestamp (see parseClaudeUsageResetTimestamp) instead of silently dropping it. + const resetsAt = + typeof raw.resets_at === 'number' && Number.isFinite(raw.resets_at) + ? raw.resets_at + : typeof raw.resets_at === 'string' && raw.resets_at.trim() + ? raw.resets_at + : undefined + return { + ...(usedPercentage !== undefined ? { used_percentage: usedPercentage } : {}), + ...(utilization !== undefined ? { utilization } : {}), + resets_at: resetsAt + } +} + +/** + * Parses the form-encoded body posted by the managed Claude statusline script. + * Returns null when the payload carries no usable rate-limit windows. + */ +export function parseClaudeStatusLineBody(body: unknown): ClaudeStatusLineRateLimits | null { + if (typeof body !== 'object' || body === null) { + return null + } + const fields = body as { payload?: unknown; configDir?: unknown } + if (typeof fields.payload !== 'string' || !fields.payload) { + return null + } + let payload: unknown + try { + payload = JSON.parse(fields.payload) + } catch { + return null + } + if (typeof payload !== 'object' || payload === null) { + return null + } + const rateLimits = (payload as { rate_limits?: unknown }).rate_limits + if (typeof rateLimits !== 'object' || rateLimits === null) { + return null + } + const fiveHour = parseWindow((rateLimits as { five_hour?: unknown }).five_hour) + const sevenDay = parseWindow((rateLimits as { seven_day?: unknown }).seven_day) + if (!fiveHour && !sevenDay) { + return null + } + const configDir = typeof fields.configDir === 'string' ? fields.configDir.trim() : '' + return { configDir: configDir || null, fiveHour, sevenDay } +} diff --git a/src/shared/rate-limit-types.ts b/src/shared/rate-limit-types.ts index 46e79b79ac2..83210fba2cc 100644 --- a/src/shared/rate-limit-types.ts +++ b/src/shared/rate-limit-types.ts @@ -15,7 +15,7 @@ export type RateLimitBucket = RateLimitWindow & { name: string } -export type UsageRateLimitSource = 'oauth' | 'cli' | 'web' +export type UsageRateLimitSource = 'oauth' | 'cli' | 'web' | 'live-session' export type UsageRateLimitFailureKind = | 'missing-credentials' @@ -41,6 +41,8 @@ export type UsageRateLimitMetadata = { authProvenance?: string deferredByLiveClaudeSession?: boolean lastSuccessfulSource?: UsageRateLimitSource + /** Unix ms timestamp before which usage refetches should not be attempted (from HTTP Retry-After). */ + retryAtMs?: number } export type ProviderRateLimits = { From 7cf052bcabf459b18d125579c735e5ee867f87b9 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Tue, 21 Jul 2026 03:39:05 -0400 Subject: [PATCH 56/58] fix(mobile): stop pairing QR column from starving CJK copy (#9700) (#9711) Pin the large-QR grid track to a shared size token so long under-QR copy (relay-degraded notice) cannot max-content the auto column and collapse the pairing step to one glyph per line. Constrain the QR stack for wrap and lock the layout contract in tests. --- .../src/assets/mobile-page-qr-layout.test.ts | 33 ++++++++++++++++ src/renderer/src/assets/mobile-page.css | 38 +++++++++++++++---- .../src/components/mobile/MobileHero.test.tsx | 9 +++-- .../src/components/mobile/MobileHero.tsx | 13 ++++--- 4 files changed, 78 insertions(+), 15 deletions(-) create mode 100644 src/renderer/src/assets/mobile-page-qr-layout.test.ts diff --git a/src/renderer/src/assets/mobile-page-qr-layout.test.ts b/src/renderer/src/assets/mobile-page-qr-layout.test.ts new file mode 100644 index 00000000000..b08d4201208 --- /dev/null +++ b/src/renderer/src/assets/mobile-page-qr-layout.test.ts @@ -0,0 +1,33 @@ +import fs from 'node:fs' +import { describe, expect, it } from 'vitest' + +const mobilePageCss = fs.readFileSync(new URL('./mobile-page.css', import.meta.url), 'utf8') + +describe('mobile page QR grid layout (#9700)', () => { + it('defines a shared large-QR size token used by the box and grid tracks', () => { + expect(mobilePageCss).toMatch(/--mp-qr-large-size:\s*184px/) + expect(mobilePageCss).toMatch( + /\.mobile-page-root \.mp-qr-large\s*{[^}]*width:\s*var\(--mp-qr-large-size\)/s + ) + }) + + // Why: `auto` sized the QR track to the unwrapped relay-degraded notice and + // starved the copy column so CJK wrapped one glyph per line (#9700). + it('pins step and pairing QR columns to the QR size instead of auto', () => { + expect(mobilePageCss).toMatch( + /\.mobile-page-root \.mp-step2-layout\s*{[^}]*grid-template-columns:\s*minmax\(0,\s*1fr\)\s+var\(--mp-qr-large-size\)/s + ) + expect(mobilePageCss).toMatch( + /\.mobile-page-root \.mp-pairing-layout\s*{[^}]*grid-template-columns:\s*minmax\(0,\s*1fr\)\s+var\(--mp-qr-large-size\)/s + ) + expect(mobilePageCss).not.toMatch( + /\.mobile-page-root \.mp-(?:step2|pairing)-layout\s*{[^}]*grid-template-columns:\s*minmax\(0,\s*1fr\)\s+auto/s + ) + }) + + it('lets under-QR stack children shrink so long notices wrap inside the track', () => { + expect(mobilePageCss).toMatch( + /\.mobile-page-root \.mp-qr-stack\s*>\s*\*\s*{[^}]*max-width:\s*100%[^}]*min-width:\s*0/s + ) + }) +}) diff --git a/src/renderer/src/assets/mobile-page.css b/src/renderer/src/assets/mobile-page.css index cc009527d33..6c0d16ee55e 100644 --- a/src/renderer/src/assets/mobile-page.css +++ b/src/renderer/src/assets/mobile-page.css @@ -37,6 +37,7 @@ --mp-flow-card-border-width: 1px; --mp-flow-card-inset: calc(var(--mp-flow-card-padding) + var(--mp-flow-card-border-width)); --mp-flow-shell-height: 400px; + --mp-qr-large-size: 184px; --m-bg-base: #111111; --m-bg-panel: #1a1a1a; --m-bg-raised: #242424; @@ -583,14 +584,15 @@ .mobile-page-root .mp-qr-large { box-sizing: border-box; - width: 184px; - height: 184px; + width: var(--mp-qr-large-size); + height: var(--mp-qr-large-size); + /* Why: keep img size in lockstep with the box (padding 10px each side). */ padding: 10px; } .mobile-page-root .mp-qr-large img { - width: 164px; - height: 164px; + width: calc(var(--mp-qr-large-size) - 20px); + height: calc(var(--mp-qr-large-size) - 20px); } .mobile-page-root .mp-qr-refreshing { @@ -615,6 +617,19 @@ flex-direction: column; align-items: center; gap: 10px; + /* Why: stack sits in a fixed QR grid track; fill the track so under-QR copy + has a definite width to wrap against. */ + box-sizing: border-box; + width: 100%; + min-width: 0; +} + +/* Why: align-items:center sizes children to max-content. Cap width AND set + min-width:0 so max-width can actually win over flex/grid min-size:auto and + long under-QR copy (relay-degraded notice, regenerate label) wraps. */ +.mobile-page-root .mp-qr-stack > * { + max-width: 100%; + min-width: 0; } .mobile-page-root .mp-link-under { @@ -639,17 +654,24 @@ cursor: not-allowed; } -/* Step 1 (Get the app) — borderless layout, page-1 typography */ +/* Step 1 (Get the app) + Step 2 (Pair) — QR column width */ +/* Why: pin the QR column to the QR's own width rather than `auto`. When the flow + card is narrow (mp-hero pins it to minmax(360px,560px)) an `auto` track grows + to its content's max-content — the relay-degraded notice under the pairing QR + is an unwrapped single line — starving the minmax(0,1fr) copy column to ~1 char + wide, so CJK copy wrapped one glyph per line (#9700). A fixed track stops that + starvation; .mp-qr-stack min-width:0 + max-width:100% (and the notice's + min-w-0 text span) stop the notice overflowing the track. */ .mobile-page-root .mp-step2-layout { display: grid; - grid-template-columns: minmax(0, 1fr) auto; + grid-template-columns: minmax(0, 1fr) var(--mp-qr-large-size); gap: 28px; align-items: start; } .mobile-page-root .mp-pairing-layout { display: grid; - grid-template-columns: minmax(0, 1fr) auto; + grid-template-columns: minmax(0, 1fr) var(--mp-qr-large-size); grid-template-areas: 'copy qr' 'relay qr' @@ -661,10 +683,12 @@ .mobile-page-root .mp-pairing-copy { grid-area: copy; + min-width: 0; } .mobile-page-root .mp-pairing-qr { grid-area: qr; + min-width: 0; /* Why: the QR belongs to the pairing heading, not the paragraph below it. */ margin-top: 48px; } diff --git a/src/renderer/src/components/mobile/MobileHero.test.tsx b/src/renderer/src/components/mobile/MobileHero.test.tsx index 04fff8c9092..8e1d237a00d 100644 --- a/src/renderer/src/components/mobile/MobileHero.test.tsx +++ b/src/renderer/src/components/mobile/MobileHero.test.tsx @@ -138,9 +138,12 @@ describe('HeroFlow height', () => { pairQrDataUrl: 'data:image/png;base64,qr', relayDegraded: true }) - expect(screen.getByTestId('relay-degraded-notice')).toHaveTextContent( - 'only works on your local network' - ) + const notice = screen.getByTestId('relay-degraded-notice') + expect(notice).toHaveTextContent('only works on your local network') + // Why: wrap-capable text item inside the fixed QR track (#9700); bare text + // nodes in a flex row cannot shrink below max-content and overflow the track. + expect(notice.querySelector('.min-w-0')).not.toBeNull() + expect(notice.className).toMatch(/\bmin-w-0\b/) expect(screen.getByText('Orca Relay is in beta.')).toBeInTheDocument() }) diff --git a/src/renderer/src/components/mobile/MobileHero.tsx b/src/renderer/src/components/mobile/MobileHero.tsx index 335a691a020..cdb49f7c743 100644 --- a/src/renderer/src/components/mobile/MobileHero.tsx +++ b/src/renderer/src/components/mobile/MobileHero.tsx @@ -290,14 +290,17 @@ export function HeroFlow({ {relayDegraded ? (

- {translate( - 'auto.components.mobile.MobileHero.relayDegradedNotice', - 'Relay couldn’t be reached — this code only works on your local network.' - )} + {/* Why: min-w-0 so the flex text item can wrap inside the fixed QR track (#9700). */} + + {translate( + 'auto.components.mobile.MobileHero.relayDegradedNotice', + 'Relay couldn’t be reached — this code only works on your local network.' + )} +

) : null}
From 16074b27bf9d0bfb6de31128e8ca33191c9f74d0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 21 Jul 2026 01:12:41 -0700 Subject: [PATCH 57/58] fix(dashboard-popout): keep moving card under the open terminal (#9709) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The card column-move uses the View Transitions API, whose snapshot pseudo-elements render in the browser top layer — above any z-index, including the terminal dialog's z-50 Radix portal. So a card morphing columns briefly flickered over the open terminal. Skip the view transition while the terminal dialog is open; the card just settles into its new column under the dialog (which covers the board anyway) instead of animating on top. --- .../useDashboardSnapshot.test.tsx | 99 +++++++++++++++++++ .../dashboard-popout/useDashboardSnapshot.ts | 15 ++- 2 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 src/renderer/src/components/dashboard-popout/useDashboardSnapshot.test.tsx diff --git a/src/renderer/src/components/dashboard-popout/useDashboardSnapshot.test.tsx b/src/renderer/src/components/dashboard-popout/useDashboardSnapshot.test.tsx new file mode 100644 index 00000000000..6c5d29c2ef3 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/useDashboardSnapshot.test.tsx @@ -0,0 +1,99 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { renderHook } from '@testing-library/react' +import type { DashboardCard, DashboardSnapshot } from '../../../../shared/dashboard-snapshot' +import { useDashboardSnapshot } from './useDashboardSnapshot' + +;(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +function card(overrides: Partial): DashboardCard { + return { + paneKey: 'pk', + ptyId: 'p1', + agentType: 'claude', + bucket: 'working', + dotState: 'working', + task: 't', + repoId: 'r1', + worktreeId: 'w1', + tabId: 'tab1', + leafId: 'l1', + repoName: 'Repo', + worktreeName: 'wt', + startedAt: 0, + finishedAt: null, + stateChangedAt: 0, + unseen: false, + ...overrides + } +} + +function snapshot(cards: DashboardCard[]): DashboardSnapshot { + return { generatedAt: 1, cards } +} + +let apply: (next: DashboardSnapshot) => void +const startViewTransition = vi.fn((cb: () => void) => { + cb() + return { + finished: Promise.resolve(), + ready: Promise.resolve(), + updateCallbackDone: Promise.resolve() + } +}) + +/** A Radix dialog marks its open content with role + data-state; mimic that so + * the hook's top-layer-conflict guard sees an "open terminal". */ +function openTerminalDialog(): HTMLElement { + const el = document.createElement('div') + el.setAttribute('role', 'dialog') + el.setAttribute('data-state', 'open') + document.body.appendChild(el) + return el +} + +describe('useDashboardSnapshot', () => { + beforeEach(() => { + ;(window as unknown as { api: unknown }).api = { + dashboard: { + onSnapshot: (cb: (next: DashboardSnapshot) => void) => { + apply = cb + return () => {} + }, + requestSnapshot: vi.fn(async () => {}) + } + } + ;(document as unknown as { startViewTransition: unknown }).startViewTransition = + startViewTransition + window.matchMedia = vi.fn().mockReturnValue({ matches: false }) as unknown as typeof matchMedia + }) + afterEach(() => { + document.body.innerHTML = '' + vi.clearAllMocks() + }) + + it('runs a view transition when a card changes column', () => { + const { result } = renderHook(() => useDashboardSnapshot()) + act(() => apply(snapshot([card({ bucket: 'idle' })]))) + startViewTransition.mockClear() // ignore the initial populate + + act(() => apply(snapshot([card({ bucket: 'working' })]))) + expect(startViewTransition).toHaveBeenCalledTimes(1) + expect(result.current.cards[0].bucket).toBe('working') + }) + + it('skips the view transition — but still applies the update — while the terminal dialog is open', () => { + const { result } = renderHook(() => useDashboardSnapshot()) + act(() => apply(snapshot([card({ bucket: 'idle' })]))) + startViewTransition.mockClear() // ignore the initial populate + openTerminalDialog() + + act(() => apply(snapshot([card({ bucket: 'working' })]))) + // Why: the card's View Transition snapshot would paint in the browser top + // layer, above the z-50 dialog — so we jump instead of morphing. + expect(startViewTransition).not.toHaveBeenCalled() + expect(result.current.cards[0].bucket).toBe('working') + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/useDashboardSnapshot.ts b/src/renderer/src/components/dashboard-popout/useDashboardSnapshot.ts index 0a35499ea87..5445c662a87 100644 --- a/src/renderer/src/components/dashboard-popout/useDashboardSnapshot.ts +++ b/src/renderer/src/components/dashboard-popout/useDashboardSnapshot.ts @@ -18,6 +18,14 @@ function prefersReducedMotion(): boolean { return window.matchMedia?.('(prefers-reduced-motion: reduce)').matches === true } +/** Why: View Transition snapshots render in the browser top layer, which paints + * above any z-index — so a card morphing columns would flicker OVER the open + * terminal dialog (a z-50 Radix portal). Skip the transition while it's open; + * the card just settles under the dialog, which the user isn't watching. */ +function terminalDialogIsOpen(): boolean { + return document.querySelector('[role="dialog"][data-state="open"]') !== null +} + /** * Pop-out side of the dashboard bridge: subscribe to snapshots relayed from the * main window and request an initial one on mount. When a card changes column @@ -35,7 +43,12 @@ export function useDashboardSnapshot(): DashboardSnapshot { columnSignatureRef.current = nextSignature const startViewTransition = document.startViewTransition?.bind(document) - if (!layoutChanged || prefersReducedMotion() || !startViewTransition) { + if ( + !layoutChanged || + prefersReducedMotion() || + terminalDialogIsOpen() || + !startViewTransition + ) { setSnapshot(next) return } From 827cd49f410f042ae29ee886538d5aa8cf8c0c46 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 21 Jul 2026 01:14:58 -0700 Subject: [PATCH 58/58] Fix Linear skill-install modal CTA hierarchy (#9643) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix Linear skill-install modal CTA hierarchy Remove the redundant 'Not now' button from the Linear agent-skill install modal and make Install the filled primary action, matching the other setup surfaces (filled primary + muted dismiss). 'Not now' was doing exactly what the dialog's × already does (session snooze via onOpenChange), but styled as an outline button in the footer-right CTA slot it read as the primary action over Install — which itself was only an outline button buried in the panel body. Users reflexively clicked the dismiss. - Drop 'Not now' + its unused onSnoozeForSession prop / i18n key (all locales) - Add opt-in installVariant to AgentSkillSetupPanel (defaults 'outline'; settings surfaces unchanged) and pass 'default' from the Linear modal - Update tests to drive the session snooze via the dialog × * Keep 'Not now' as the safe dismiss; demote 'Don't show again' to a muted link This modal auto-opens unsolicited (first activation of a Linear-linked worktree with the skill missing), so the reflexive dismiss must be non-destructive. Removing 'Not now' left the permanent 'Don't show again' as the only labeled footer button — the reflexive target — which would nuke a useful prompt forever. Restore 'Not now' (session snooze) as a quiet ghost — the easy, safe dismiss, matching Neil's original ask ('not now is fine, just not the CTA'). Demote 'Don't show again' to a muted text link so permanent suppression takes intent. Install stays the filled primary. Restores onSnoozeForSession wiring + notNow i18n key (all locales); tests exercise the Not now snooze path again. * Remove 'Not now' from Linear skill-install modal; × handles session dismiss Per product call: the modal is typically reached by clicking 'Set up' on the reminder toast (an opt-in), and the dialog's × already dismisses for the session. Drop the redundant 'Not now' button. Footer keeps only the quiet muted 'Don't show again' (permanent opt-out); Install stays the filled primary. Removes onSnoozeForSession wiring + notNow i18n key (all locales); tests exercise the × (Close) session-snooze path. * Move permanent opt-out to an EyeOff icon next to the × (matches SetupGuideModal) Replaces the footer 'Don't show again' link with a quiet EyeOff icon button beside the dialog ×, matching the house pattern for hiding a setup/teaching surface (SetupGuideModal, StatusBarUsageEmptyCta, SetupGuideSidebarEntry). The footer is gone entirely, so Install is the only labeled action; the two 'make it go away' controls (× = for now, EyeOff = never) sit together and neither is a tempting text button. - Rendered last in the branch so initial dialog focus lands on Install, not the hide button (Enter-on-open must not permanently dismiss). - Wrapped in a local TooltipProvider so it works outside the app's global provider (isolated tests/mounts). - Reuses the existing dontShowAgain i18n key for aria-label + tooltip. - Tests locate the control by aria-label now that it has no text. * Align EyeOff hide icon with the × (top-3 not top-3.5) The icon-xs button is 24px tall vs the bare 16px × close, so its centered icon sat 2px low. top-3 lines the two icon centers up (measured pixel-exact in the running app). --- .../settings/AgentSkillSetupPanel.tsx | 8 ++- .../sidebar/LinearAgentSkillSetupDialog.tsx | 55 +++++++++++++------ ...ntSkillSetupPrompt.reminder-toast.test.tsx | 11 +++- .../LinearAgentSkillSetupPrompt.test.tsx | 17 ++++-- .../sidebar/LinearAgentSkillSetupPrompt.tsx | 1 - src/renderer/src/i18n/locales/en.json | 1 - src/renderer/src/i18n/locales/es.json | 1 - src/renderer/src/i18n/locales/ja.json | 1 - src/renderer/src/i18n/locales/ko.json | 1 - src/renderer/src/i18n/locales/zh.json | 1 - 10 files changed, 64 insertions(+), 33 deletions(-) diff --git a/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx b/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx index 4e986a2686f..02862251d54 100644 --- a/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx +++ b/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState, type ReactNode } from 'react' +import { useCallback, useEffect, useState, type ComponentProps, type ReactNode } from 'react' import { Copy, Loader2, RefreshCw, Terminal } from 'lucide-react' import { toast } from 'sonner' import { IntegrationStatusPill } from '../integration-status-pill' @@ -44,6 +44,9 @@ type AgentSkillSetupPanelProps = { showRecheckWhenInstalled?: boolean installLabel?: string installedInstallLabel?: string + // Why: defaults to 'outline' so settings panels stay unchanged; modals that make + // Install the sole footer CTA pass 'default' for a filled primary hierarchy. + installVariant?: ComponentProps['variant'] actionHint?: ReactNode openingHint?: ReactNode footer?: ReactNode @@ -81,6 +84,7 @@ export function AgentSkillSetupPanel({ showRecheckWhenInstalled = true, installLabel = 'Install', installedInstallLabel = 'Update', + installVariant = 'outline', actionHint, openingHint, footer, @@ -170,7 +174,7 @@ export function AgentSkillSetupPanel({ {!installed || showInstallWhenInstalled ? ( - - + {/* Why: permanent opt-out as a quiet EyeOff icon next to the × — matching + SetupGuideModal's "hide this setup surface" affordance — instead of a + footer button that competes with (or gets reflex-clicked over) Install. + Rendered last so the dialog's initial focus lands on Install, not here — + otherwise Enter-on-open would permanently dismiss. */} + + + + + + + {translate( + 'auto.components.sidebar.LinearAgentSkillSetupPrompt.dontShowAgain', + "Don't show again" + )} + + + )} diff --git a/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.reminder-toast.test.tsx b/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.reminder-toast.test.tsx index 11e550b4bc5..d05496a19fe 100644 --- a/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.reminder-toast.test.tsx +++ b/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.reminder-toast.test.tsx @@ -143,8 +143,10 @@ async function snoozeInitialModal( props: ComponentProps ): Promise { await renderPrompt(props) + // Why: the modal's casual dismiss is the dialog × (session snooze) now that + // "Not now" is removed. await act(async () => { - findBodyButton('Not now')?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + findBodyButton('Close')?.dispatchEvent(new MouseEvent('click', { bubbles: true })) }) await unmountPrompt() } @@ -203,7 +205,7 @@ describe('LinearAgentSkillSetupPrompt reminder toast', () => { Reflect.deleteProperty(window, 'api') }) - it('shows a warning toast on a later modal-only activation after Not now', async () => { + it('shows a warning toast on a later modal-only activation after a casual close', async () => { await snoozeInitialModal({ linked: true, remote: false, surface: 'modal' }) await renderPrompt({ linked: true, remote: false, surface: 'modal' }) @@ -343,7 +345,10 @@ describe('LinearAgentSkillSetupPrompt reminder toast', () => { }) mocks.toastDismiss.mockClear() await act(async () => { - findBodyButton("Don't show again")?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + // Why: permanent dismiss is now an EyeOff icon button (aria-label, no text). + document.body + .querySelector('button[aria-label="Don\'t show again"]') + ?.dispatchEvent(new MouseEvent('click', { bubbles: true })) }) expect(window.localStorage.getItem(HOST_DISMISS_STORAGE_KEY)).toBe('1') diff --git a/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.test.tsx b/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.test.tsx index 420467d1aef..0d784cb1033 100644 --- a/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.test.tsx +++ b/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.test.tsx @@ -452,7 +452,7 @@ describe('LinearAgentSkillSetupPrompt', () => { ) }) - it('auto-opens as a modal-only prompt and treats Not now as a casual close', async () => { + it('auto-opens as a modal-only prompt and treats the × close as a casual snooze', async () => { await renderPrompt({ linked: true, remote: false, surface: 'modal' }) expect(container?.textContent).not.toContain('Set up Linear agent skill') @@ -461,17 +461,21 @@ describe('LinearAgentSkillSetupPrompt', () => { ) expect(document.body.textContent).toContain('Orca CLI and Linear agent skill are missing.') expect(document.body.textContent).toContain('Mock install') + // Why: the permanent opt-out is an EyeOff icon (no visible text); the casual + // dismiss is the dialog ×. Neither "Not now" nor any dismiss label shows as text. + expect(document.body.textContent).not.toContain('Not now') expect(mocks.panelProps.at(-1)).toEqual( expect.objectContaining({ preInstallNotice: 'CLI registration notice' }) ) - const notNowButton = Array.from(document.body.querySelectorAll('button')).find( - (button) => button.textContent === 'Not now' + // Why: the × must snooze for the session, not persist a permanent dismissal. + const closeButton = Array.from(document.body.querySelectorAll('button')).find( + (button) => button.textContent === 'Close' ) await act(async () => { - notNowButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + closeButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) }) expect(window.localStorage.getItem(HOST_DISMISS_STORAGE_KEY)).toBeNull() @@ -911,8 +915,9 @@ describe('LinearAgentSkillSetupPrompt', () => { it('permanently dismisses the modal-only prompt when requested', async () => { await renderPrompt({ linked: true, remote: false, surface: 'modal' }) - const dismissButton = Array.from(document.body.querySelectorAll('button')).find( - (button) => button.textContent === "Don't show again" + // Why: permanent dismiss is now an EyeOff icon button (aria-label, no text). + const dismissButton = document.body.querySelector( + 'button[aria-label="Don\'t show again"]' ) await act(async () => { dismissButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) diff --git a/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.tsx b/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.tsx index e6157339c4a..d840807a7e6 100644 --- a/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.tsx +++ b/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.tsx @@ -338,7 +338,6 @@ export function LinearAgentSkillSetupPrompt({ setSetupDialogOpen(false) }} onDismissPermanently={dismissPermanently} - onSnoozeForSession={snoozeForSession} onDone={closeSuccessModal} /> ) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 9de3068eba4..78dce5f59e5 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -4674,7 +4674,6 @@ "modalDescription": "Install the Linear skill from a terminal.", "modalPrompt": "Enable agents to read and edit the attached Linear ticket.", "dontShowAgain": "Don't show again", - "notNow": "Not now", "missingBoth": "Orca CLI and Linear agent skill are missing.", "missingCli": "Orca CLI is missing.", "missingSkill": "Linear agent skill is missing.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 3e0090677fc..57451380c2b 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -4586,7 +4586,6 @@ "modalDescription": "Instala el skill de Linear desde una terminal.", "modalPrompt": "Permite que los Agents lean y editen el issue de Linear adjunto.", "dontShowAgain": "No volver a mostrar", - "notNow": "Ahora no", "missingBoth": "Faltan la CLI de Orca y el skill de Linear para Agents.", "missingCli": "Falta la CLI de Orca.", "missingSkill": "Falta el skill de Linear para Agents.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 2258a9bdc71..a7ee7072c59 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -4586,7 +4586,6 @@ "modalDescription": "terminal から Linear スキルをインストールします。", "modalPrompt": "Agents が添付された Linear チケットを読み書きできるようにします。", "dontShowAgain": "今後表示しない", - "notNow": "後で", "missingBoth": "Orca CLI と Linear agent スキルがありません。", "missingCli": "Orca CLI がありません。", "missingSkill": "Linear agent スキルがありません。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index eda40a50d5a..92bbb8ec6a7 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -4586,7 +4586,6 @@ "modalDescription": "terminal에서 Linear 스킬을 설치합니다.", "modalPrompt": "agents가 첨부된 Linear 티켓을 읽고 편집할 수 있게 합니다.", "dontShowAgain": "다시 표시하지 않음", - "notNow": "나중에", "missingBoth": "Orca CLI와 Linear agent 스킬이 없습니다.", "missingCli": "Orca CLI가 없습니다.", "missingSkill": "Linear agent 스킬이 없습니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 35ec638a697..8064647193c 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -4586,7 +4586,6 @@ "modalDescription": "从终端安装 Linear 技能。", "modalPrompt": "允许智能体读取和编辑已附加的 Linear ticket。", "dontShowAgain": "不再显示", - "notNow": "暂不", "missingBoth": "缺少 Orca CLI 和 Linear 智能体技能。", "missingCli": "缺少 Orca CLI。", "missingSkill": "缺少 Linear 智能体技能。",