diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index a53950d4d42..17bd0c8fe47 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -115,6 +115,11 @@ const winSpeechNativeResource = { to: 'node_modules/sherpa-onnx-win-x64' } +// Why mirrored, not imported: this config is CJS loaded by electron-builder outside the TS build. +// Keep in sync with isMarkdownDocumentName() in src/main/ipc/markdown-documents.ts and with +// config/nsis/orca-installer-hooks.nsh, which registers the same set on Windows. +const MARKDOWN_FILE_EXTENSIONS = ['md', 'markdown', 'mdx'] + /** @type {import('electron-builder').Configuration} */ module.exports = { appId, @@ -387,12 +392,24 @@ module.exports = { shortcutName: '${productName}', uninstallDisplayName: '${productName}', createDesktopShortcut: 'always', - // Why: on a real uninstall, stop and remove the relocated terminal daemon - // (which lives outside the install dir under LOCALAPPDATA by design). Guarded - // by ${isUpdated} inside so it never runs during an update's uninstallOldVersion. - include: resolve(__dirname, 'nsis', 'daemon-host-uninstall.nsh') + // Why: electron-builder allows one include, so both Windows installer hooks live in it - + // the relocated-daemon uninstall sweep (guarded by ${isUpdated} so it never runs during an + // update's uninstallOldVersion) and the additive markdown "Open with" registration. + // Windows markdown association is deliberately NOT done via `fileAssociations`; see the + // header comment in that file for why that would steal the user's default .md handler. + include: resolve(__dirname, 'nsis', 'orca-installer-hooks.nsh') }, mac: { + // Why rank Alternate: Orca joins Finder's "Open With" list for Markdown without claiming + // LSHandlerRank ownership, so whichever editor the user already prefers stays the default. + // Why one entry per extension: app-builder-lib globs `*.${ext}`, which an array would break. + fileAssociations: MARKDOWN_FILE_EXTENSIONS.map((ext) => ({ + ext, + name: 'Markdown Document', + description: 'Markdown Document', + role: 'Editor', + rank: 'Alternate' + })), icon: 'resources/build/icon.icns', entitlements: 'resources/build/entitlements.mac.plist', entitlementsInherit: 'resources/build/entitlements.mac.plist', @@ -479,6 +496,12 @@ module.exports = { artifactName: 'orca-macos-${arch}.${ext}' }, linux: { + // Why mimeTypes and not fileAssociations: shared-mime-info already maps *.md/*.markdown to + // text/markdown, so reusing that type puts Orca in the Open With list without shipping a glob + // override. A desktop entry's MimeType only adds a handler - mimeapps.list still owns the + // default. .mdx is deliberately absent: Ubuntu 24.04's mime database maps it to + // application/x-genesis-32x-rom, so claiming it here would need a glob override. + mimeTypes: ['text/markdown'], // Why: Ubuntu desktop ships GNOME Orca as the `orca` package and /usr/bin/orca. // The Linux installer should not claim those system package/file names. executableName: 'orca-ide', diff --git a/config/nsis/daemon-host-uninstall.nsh b/config/nsis/daemon-host-uninstall.nsh deleted file mode 100644 index dc3a497ce67..00000000000 --- a/config/nsis/daemon-host-uninstall.nsh +++ /dev/null @@ -1,23 +0,0 @@ -; Clean up the relocated terminal daemon on a REAL uninstall. -; -; Why: the daemon host is deliberately copied to a distinct image name -; (orca-terminal-daemon.exe) under %LOCALAPPDATA%\Orca\daemon-host so that app -; UPDATES cannot kill it — that relocation is what keeps terminals alive across -; updates. The same design means a normal uninstall's process sweep and file -; removal both miss it, leaving an orphaned daemon plus its runtime copy behind. -; -; The ${isUpdated} guard is essential: electron-builder runs this uninstaller as -; part of uninstallOldVersion on EVERY update, and killing the daemon there would -; defeat the whole feature. Only clean up on a genuine uninstall. -; -; The image name and the LOCALAPPDATA folder name must stay in sync with -; DAEMON_HOST_EXE_NAME and LOCAL_HOST_ROOT_NAME in -; src/main/daemon/daemon-host-relocation.ts. -!macro customUnInstall - ${ifNot} ${isUpdated} - nsExec::Exec 'taskkill /F /IM orca-terminal-daemon.exe' - ; Give the OS a moment to release the image lock before removing the tree. - Sleep 500 - RMDir /r "$LOCALAPPDATA\Orca\daemon-host" - ${endIf} -!macroend diff --git a/config/nsis/orca-installer-hooks.nsh b/config/nsis/orca-installer-hooks.nsh new file mode 100644 index 00000000000..ca80c99fc6d --- /dev/null +++ b/config/nsis/orca-installer-hooks.nsh @@ -0,0 +1,79 @@ +; electron-builder NSIS hooks for the Orca Windows installer. +; +; electron-builder accepts exactly ONE `nsis.include` file, so every customInstall / +; customUnInstall hook Orca needs lives here. + +; --------------------------------------------------------------------------- +; Markdown "Open with Orca" (issue #10138) +; +; Why hand-rolled instead of electron-builder's `fileAssociations` on Windows: +; app-builder-lib emits !insertmacro APP_ASSOCIATE, whose first line is +; WriteRegStr SHELL_CONTEXT "Software\Classes\.md" "" "" +; That overwrites whichever editor currently owns .md, with no backup, for every +; existing user on their next UPDATE - and APP_UNASSOCIATE never restores it, so +; uninstalling Orca would leave .md pointing at a deleted ProgID. +; +; These writes are additive only. Registering a ProgID plus an OpenWithProgids +; hint and an Applications\\SupportedTypes entry puts Orca in Explorer's +; "Open with" list and in "Choose another app", while the default handler stays +; exactly where the user left it. Never add a `Software\Classes\.` default +; value here. +; +; MARKDOWN_PROGID must stay in sync with the extension list handled by +; isMarkdownDocumentName() in src/main/ipc/markdown-documents.ts. +; --------------------------------------------------------------------------- +!define MARKDOWN_PROGID "Orca.Markdown" + +!macro ORCA_REGISTER_MARKDOWN_OPEN_WITH EXT + WriteRegNone SHELL_CONTEXT "Software\Classes\${EXT}\OpenWithProgids" "${MARKDOWN_PROGID}" + WriteRegStr SHELL_CONTEXT "Software\Classes\Applications\${APP_EXECUTABLE_FILENAME}\SupportedTypes" "${EXT}" "" +!macroend + +!macro ORCA_UNREGISTER_MARKDOWN_OPEN_WITH EXT + DeleteRegValue SHELL_CONTEXT "Software\Classes\${EXT}\OpenWithProgids" "${MARKDOWN_PROGID}" + DeleteRegValue SHELL_CONTEXT "Software\Classes\Applications\${APP_EXECUTABLE_FILENAME}\SupportedTypes" "${EXT}" +!macroend + +!macro customInstall + WriteRegStr SHELL_CONTEXT "Software\Classes\${MARKDOWN_PROGID}" "" "Markdown Document" + WriteRegStr SHELL_CONTEXT "Software\Classes\${MARKDOWN_PROGID}\DefaultIcon" "" "$appExe,0" + WriteRegStr SHELL_CONTEXT "Software\Classes\${MARKDOWN_PROGID}\shell\open" "" "Open with ${PRODUCT_NAME}" + WriteRegStr SHELL_CONTEXT "Software\Classes\${MARKDOWN_PROGID}\shell\open\command" "" '"$appExe" "%1"' + !insertmacro ORCA_REGISTER_MARKDOWN_OPEN_WITH ".md" + !insertmacro ORCA_REGISTER_MARKDOWN_OPEN_WITH ".markdown" + !insertmacro ORCA_REGISTER_MARKDOWN_OPEN_WITH ".mdx" + ; Why: Explorer caches the association list until told otherwise. + System::Call "shell32::SHChangeNotify(i,i,i,i) (0x08000000, 0x1000, 0, 0)" +!macroend + +; --------------------------------------------------------------------------- +; Clean up the relocated terminal daemon on a REAL uninstall. +; +; Why: the daemon host is deliberately copied to a distinct image name +; (orca-terminal-daemon.exe) under %LOCALAPPDATA%\Orca\daemon-host so that app +; UPDATES cannot kill it — that relocation is what keeps terminals alive across +; updates. The same design means a normal uninstall's process sweep and file +; removal both miss it, leaving an orphaned daemon plus its runtime copy behind. +; +; The ${isUpdated} guard is essential: electron-builder runs this uninstaller as +; part of uninstallOldVersion on EVERY update, and killing the daemon there would +; defeat the whole feature. Only clean up on a genuine uninstall. +; +; The image name and the LOCALAPPDATA folder name must stay in sync with +; DAEMON_HOST_EXE_NAME and LOCAL_HOST_ROOT_NAME in +; src/main/daemon/daemon-host-relocation.ts. +!macro customUnInstall + ${ifNot} ${isUpdated} + nsExec::Exec 'taskkill /F /IM orca-terminal-daemon.exe' + ; Give the OS a moment to release the image lock before removing the tree. + Sleep 500 + RMDir /r "$LOCALAPPDATA\Orca\daemon-host" + ${endIf} + ; Why outside the ${isUpdated} guard: customInstall rewrites these on every update, so + ; dropping them during uninstallOldVersion is correct and keeps the pair symmetric. + DeleteRegKey SHELL_CONTEXT "Software\Classes\${MARKDOWN_PROGID}" + !insertmacro ORCA_UNREGISTER_MARKDOWN_OPEN_WITH ".md" + !insertmacro ORCA_UNREGISTER_MARKDOWN_OPEN_WITH ".markdown" + !insertmacro ORCA_UNREGISTER_MARKDOWN_OPEN_WITH ".mdx" + System::Call "shell32::SHChangeNotify(i,i,i,i) (0x08000000, 0x1000, 0, 0)" +!macroend diff --git a/config/scripts/electron-builder-markdown-associations.test.mjs b/config/scripts/electron-builder-markdown-associations.test.mjs new file mode 100644 index 00000000000..7ae3b1c9428 --- /dev/null +++ b/config/scripts/electron-builder-markdown-associations.test.mjs @@ -0,0 +1,116 @@ +import { existsSync } from 'node:fs' +import { readFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { basename } from 'node:path' +import { describe, expect, it } from 'vitest' + +const require = createRequire(import.meta.url) +const electronBuilderConfig = require('../electron-builder.config.cjs') + +const MARKDOWN_EXTENSIONS = ['md', 'markdown', 'mdx'] + +// The exact shape app-builder-lib's APP_ASSOCIATE emits: a write to the DEFAULT ("") +// value of Software\Classes\.. Additive `WriteRegNone ...\OpenWithProgids` must not +// match, or the guard below would be unfalsifiable. +const DEFAULT_HANDLER_WRITE = /WriteRegStr\s+SHELL_CONTEXT\s+"Software\\Classes\\\.[a-z]+"\s+""/i + +// The hooks file documents the forbidden line in prose, so match executable script only. +const stripNsisCommentLines = (source) => + source + .split('\n') + .filter((line) => !/^\s*[;#]/.test(line)) + .join('\n') + +const readInstallerHooks = () => readFile(electronBuilderConfig.nsis.include, 'utf8') + +describe('electron-builder markdown file associations', () => { + // Why: any top-level (or `win.`) fileAssociations entry makes app-builder-lib's NSIS + // packager emit `!insertmacro APP_ASSOCIATE`, whose first line writes that DEFAULT value + // — silently taking .md from whichever editor owns it, for every existing user on their + // next UPDATE, with APP_UNASSOCIATE never restoring it. `rank: 'Alternate'` cannot + // prevent this; it is LSHandlerRank and applies to macOS only. So the mac block must + // stay under `mac.` — hoisting it up "to share it with Windows" is what this test blocks. + it('never claims the Windows default markdown handler', () => { + expect(electronBuilderConfig.fileAssociations).toBeUndefined() + expect(electronBuilderConfig.win?.fileAssociations).toBeUndefined() + }) + + it('joins the macOS Open With list for every markdown extension without owning it', () => { + const associations = electronBuilderConfig.mac.fileAssociations + // One entry per extension: an array `ext` would break the Linux packager's `*.${ext}` glob. + expect([...associations].map((association) => association.ext).sort()).toEqual( + [...MARKDOWN_EXTENSIONS].sort() + ) + for (const association of associations) { + expect(association).toMatchObject({ role: 'Editor', rank: 'Alternate' }) + } + }) + + // Why mimeTypes and not linux.fileAssociations: shared-mime-info already maps markdown to + // text/markdown, so the desktop entry only adds a handler and mimeapps.list keeps owning + // the default. A fileAssociations entry would ship a redundant glob override instead. + it('reuses the existing shared-mime-info markdown type on Linux', () => { + expect(electronBuilderConfig.linux.mimeTypes).toContain('text/markdown') + expect(electronBuilderConfig.linux.fileAssociations).toBeUndefined() + }) + + it('points the single NSIS include at the installer hooks file on disk', () => { + const includePath = electronBuilderConfig.nsis.include + expect(existsSync(includePath)).toBe(true) + expect(basename(includePath)).toBe('orca-installer-hooks.nsh') + }) + + // Guard for the guard: proves DEFAULT_HANDLER_WRITE really matches a takeover line, so + // the assertion below is a live check rather than a regex that can never fire. + it('recognizes an APP_ASSOCIATE-style default-handler write', () => { + for (const takeover of [ + ' WriteRegStr SHELL_CONTEXT "Software\\Classes\\.md" "" "Orca.Markdown"', + 'WriteRegStr SHELL_CONTEXT "Software\\Classes\\.markdown" "" "$0"' + ]) { + expect(takeover).toMatch(DEFAULT_HANDLER_WRITE) + } + expect( + 'WriteRegNone SHELL_CONTEXT "Software\\Classes\\.md\\OpenWithProgids" "Orca.Markdown"' + ).not.toMatch(DEFAULT_HANDLER_WRITE) + // Comment stripping must drop prose that quotes the bad line without swallowing a real + // one that happens to carry a trailing comment. + const stripped = stripNsisCommentLines( + [ + '; WriteRegStr SHELL_CONTEXT "Software\\Classes\\.md" "" ""', + ' WriteRegStr SHELL_CONTEXT "Software\\Classes\\.md" "" "$0" ; oops' + ].join('\n') + ) + expect(stripped.split('\n')).toHaveLength(1) + expect(stripped).toMatch(DEFAULT_HANDLER_WRITE) + }) + + it('registers Windows markdown Open With additively, never as the default', async () => { + const hooks = await readInstallerHooks() + + expect(stripNsisCommentLines(hooks)).not.toMatch(DEFAULT_HANDLER_WRITE) + // The additive hint that puts Orca in Explorer's "Open with" list. + expect(hooks).toMatch( + /WriteRegNone\s+SHELL_CONTEXT\s+"Software\\Classes\\\$\{EXT\}\\OpenWithProgids"/ + ) + expect(hooks).toMatch(/!macro\s+ORCA_REGISTER_MARKDOWN_OPEN_WITH\s+EXT/) + for (const ext of MARKDOWN_EXTENSIONS) { + expect(hooks).toContain(`ORCA_REGISTER_MARKDOWN_OPEN_WITH ".${ext}"`) + expect(hooks).toContain(`ORCA_UNREGISTER_MARKDOWN_OPEN_WITH ".${ext}"`) + } + expect(hooks).toMatch(/!macro\s+customInstall\b/) + expect(hooks).toMatch(/!macro\s+customUnInstall\b/) + }) + + // Why: this include was renamed from daemon-host-uninstall.nsh to carry the markdown + // hooks too. electron-builder allows only one include, so a merge that drops the daemon + // sweep would silently orphan a running orca-terminal-daemon.exe on every uninstall. + it('keeps the daemon-host uninstall sweep across the include rename', async () => { + const hooks = await readInstallerHooks() + + expect(hooks).toContain('orca-terminal-daemon.exe') + expect(hooks).toContain('$LOCALAPPDATA\\Orca\\daemon-host') + // Without this guard, uninstallOldVersion would kill the daemon on every update — + // defeating the relocation that keeps terminals alive across updates. + expect(hooks).toMatch(/\$\{ifNot\}\s+\$\{isUpdated\}/) + }) +}) diff --git a/config/scripts/space-sharing-copy.mjs b/config/scripts/space-sharing-copy.mjs index 191bd8bffdc..01e9c8ac5ef 100644 --- a/config/scripts/space-sharing-copy.mjs +++ b/config/scripts/space-sharing-copy.mjs @@ -110,32 +110,60 @@ export function makeTreeReadOnly(targetPath, chmod = chmodSync) { chmod(targetPath, 0o755) } +/** + * Restore owner write permission across a private copy. + * + * Counterpart to `makeTreeReadOnly`: clonefile, reflink and `cpSync` all carry the source's mode + * across, so a tree copied from the write-protected shared cache lands read-only and every patch + * the caller then makes -- `plutil -replace`, `codesign` -- fails with EACCES. Only the owner bit + * comes back; group and other stay as the source left them. + */ +export function makeTreeWritable(targetPath, chmod = chmodSync) { + for (const entry of readdirSync(targetPath, { withFileTypes: true })) { + const entryPath = join(targetPath, entry.name) + if (entry.isDirectory()) { + makeTreeWritable(entryPath, chmod) + } else if (!entry.isSymbolicLink()) { + const mode = statSync(entryPath, { throwIfNoEntry: false })?.mode + chmod(entryPath, mode === undefined ? 0o644 : mode | 0o200) + } + } + chmod(targetPath, 0o755) +} + /** * Share storage when possible, otherwise copy the bytes. * * Never hardlinks: this is for trees the caller goes on to patch, where shared inodes would write - * through into the source. + * through into the source. The copy is unprotected on the way out for the same reason -- a private + * tree the caller cannot write to is useless to it. */ export function copyPrivateTree(sourcePath, destinationPath, options = {}) { const platform = options.platform ?? process.platform const copy = options.copy ?? copyTreeVerbatim + const unprotect = options.unprotect ?? makeTreeWritable const privateMechanisms = new Set(['clone', 'reflink']) + let result = { mechanism: null, copyError: null } if (getShareMechanisms(platform).some((mechanism) => privateMechanisms.has(mechanism))) { try { - const mechanism = shareTree(sourcePath, destinationPath, { - ...options, - hardlink: () => { - throw new Error('hardlinks would not be private') - } - }) - return { mechanism, copyError: null } + result = { + mechanism: shareTree(sourcePath, destinationPath, { + ...options, + hardlink: () => { + throw new Error('hardlinks would not be private') + } + }), + copyError: null + } } catch (copyError) { copy(sourcePath, destinationPath) - return { mechanism: null, copyError } + result = { mechanism: null, copyError } } + } else { + copy(sourcePath, destinationPath) } - copy(sourcePath, destinationPath) - return { mechanism: null, copyError: null } + unprotect(destinationPath) + return result } function copyTreeVerbatim(sourcePath, destinationPath) { diff --git a/config/scripts/space-sharing-copy.test.ts b/config/scripts/space-sharing-copy.test.ts index 3ce35aae25e..351f44b286e 100644 --- a/config/scripts/space-sharing-copy.test.ts +++ b/config/scripts/space-sharing-copy.test.ts @@ -19,6 +19,7 @@ import { copyPrivateTree, hardlinkTree, makeTreeReadOnly, + makeTreeWritable, shareTree } from './space-sharing-copy.mjs' @@ -170,7 +171,41 @@ describe('makeTreeReadOnly', () => { ) }) +describe('makeTreeWritable', () => { + it.runIf(process.platform !== 'win32')('undoes makeTreeReadOnly for the owner', () => { + const { source } = makeTree() + makeTreeReadOnly(source) + makeTreeWritable(source) + const file = path.join(source, 'nested', 'file') + expect(statSync(file).mode & 0o200).toBe(0o200) + expect(() => writeFileSync(file, 'mutated')).not.toThrow() + }) + + it.runIf(process.platform !== 'win32')('adds no write permission beyond the owner', () => { + const { source } = makeTree() + const executable = path.join(source, 'electron') + writeFileSync(executable, 'binary') + chmodSync(executable, 0o555) + makeTreeWritable(source) + expect(statSync(executable).mode & 0o777).toBe(0o755) + }) +}) + describe('copyPrivateTree', () => { + it.runIf(process.platform !== 'win32')( + 'hands back a tree the caller can patch, even from a write-protected source', + () => { + const { root, source } = makeTree() + const destination = path.join(root, 'private') + makeTreeReadOnly(source) + copyPrivateTree(source, destination) + // The regression this guards: the shared Electron dist is read-only, clonefile/reflink/cpSync + // all carry that across, and `pn dev` then died patching the copied bundle's Info.plist. + expect(() => writeFileSync(path.join(destination, 'nested', 'file'), 'patched')).not.toThrow() + expect(readFileSync(path.join(source, 'nested', 'file'), 'utf8')).toBe('contents') + } + ) + it('never hardlinks, because the caller patches what it gets back', () => { const { root, source } = makeTree() const destination = path.join(root, 'private') diff --git a/docs/reference/git-compatibility.md b/docs/reference/git-compatibility.md index 3004b8888ab..0e8b1f257d3 100644 --- a/docs/reference/git-compatibility.md +++ b/docs/reference/git-compatibility.md @@ -42,6 +42,17 @@ authority. | `merge-tree-write-tree` | Derive real-merge conflicts and no-op tree proofs | Omit the conflict summary and keep conservative branch cleanup behavior before Git 2.38 | | `merge-tree-merge-base` | Supply the already-resolved merge base | Use the older two-commit `merge-tree --write-tree` form | +### Placeholders That Fail Open + +`GitCapabilityCache` records commands Git *rejects*. A `git log --format` +placeholder Git does not know is not rejected: Git echoes it verbatim and exits +zero, so there is no error to remember and no probe to cache. Ask for both forms +in one record and pick at parse time. + +| Placeholder | Preferred behavior | Compatibility behavior | +| ---------------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | +| `%(decorate:…)` | Git 2.43 separates commit decorations with `\x1f`, so ref names containing commas survive | The same record also carries `%D` (Git 2.10); an unexpanded `%(decorate` placeholder selects it, at the cost of comma-splitting | + ## Why Not `simple-git` `simple-git` is a process wrapper around the installed Git binary. Its custom diff --git a/mobile/src/terminal/terminal-webview-html-source.test-support.ts b/mobile/src/terminal/terminal-webview-html-source.test-support.ts index 25902305f41..19a9cfc07ba 100644 --- a/mobile/src/terminal/terminal-webview-html-source.test-support.ts +++ b/mobile/src/terminal/terminal-webview-html-source.test-support.ts @@ -1,24 +1,31 @@ import { readFileSync } from 'node:fs' -const SOURCE_FILES = [ - './terminal-webview-html.ts', - './terminal-webview-html/document-shell.ts', - './terminal-webview-html/runtime-state-and-text-scaling.ts', - './terminal-webview-html/fit-scale-and-write-queue.ts', - './terminal-webview-html/terminal-init-and-write.ts', - './terminal-webview-html/host-message-router.ts', - './terminal-webview-html/selection-state-and-eviction.ts', - './terminal-webview-html/term-observers-and-mode-mirroring.ts', - './terminal-webview-html/mouse-report-and-scroll-routing.ts', - './terminal-webview-html/smooth-scroll-and-cell-geometry.ts', - './terminal-webview-html/selection-overlay.ts', - './terminal-webview-html/surface-touch-gestures.ts', - './terminal-webview-html/message-bridge-and-document-close.ts' -] as const +const COMPOSER_FILE = './terminal-webview-html.ts' +const SLICE_IMPORT_RE = /^import \{[^}]*\} from '(\.\/terminal-webview-html\/[\w-]+)'$/gm +const COMPOSED_ENTRY_RE = /^ {2}TERMINAL_HTML_\w+,?$/gm -/** Reads the TypeScript source that assembles the in-WebView document. */ +function readSource(relativePath: string): string { + return readFileSync(new URL(relativePath, import.meta.url), 'utf8') +} + +/** + * Reads the TypeScript source that assembles the in-WebView document. + * + * Why: the slice list is derived from the composer's own imports rather than duplicated, so a + * new slice cannot join the emitted document while staying invisible to the tests that search + * this source. The count cross-check catches an import shape the regex cannot see. + */ export function readTerminalWebViewHtmlSource(): string { - return SOURCE_FILES.map((relativePath) => - readFileSync(new URL(relativePath, import.meta.url), 'utf8') - ).join('\n') + const composer = readSource(COMPOSER_FILE) + const slices = [...composer.matchAll(SLICE_IMPORT_RE)].map((match) => `${match[1]}.ts`) + const composedCount = [...composer.matchAll(COMPOSED_ENTRY_RE)].length + if (composedCount === 0) { + throw new Error('no composed WebView document slices found') + } + if (slices.length !== composedCount) { + throw new Error( + `WebView document slice imports (${slices.length}) do not match composed entries (${composedCount})` + ) + } + return [composer, ...slices.map(readSource)].join('\n') } diff --git a/mobile/src/terminal/terminal-webview-html.ts b/mobile/src/terminal/terminal-webview-html.ts index 40b7a2db22c..17fadd4d26c 100644 --- a/mobile/src/terminal/terminal-webview-html.ts +++ b/mobile/src/terminal/terminal-webview-html.ts @@ -1,6 +1,8 @@ import { TERMINAL_HTML_DOCUMENT_SHELL } from './terminal-webview-html/document-shell' import { TERMINAL_HTML_RUNTIME_STATE_AND_TEXT_SCALING } from './terminal-webview-html/runtime-state-and-text-scaling' -import { TERMINAL_HTML_FIT_SCALE_AND_WRITE_QUEUE } from './terminal-webview-html/fit-scale-and-write-queue' +import { TERMINAL_HTML_FIT_SCALE } from './terminal-webview-html/terminal-fit-scale' +import { TERMINAL_HTML_MOUSE_MODE_DECSET_SCAN } from './terminal-webview-html/mouse-mode-decset-scan' +import { TERMINAL_HTML_WRITE_QUEUE } from './terminal-webview-html/write-queue' import { TERMINAL_HTML_INIT_AND_WRITE } from './terminal-webview-html/terminal-init-and-write' import { TERMINAL_HTML_HOST_MESSAGE_ROUTER } from './terminal-webview-html/host-message-router' import { TERMINAL_HTML_SELECTION_STATE_AND_EVICTION } from './terminal-webview-html/selection-state-and-eviction' @@ -19,7 +21,9 @@ export { MOBILE_TERMINAL_CARET_OPTIONS } from './terminal-webview-html/theme' export const XTERM_HTML = [ TERMINAL_HTML_DOCUMENT_SHELL, TERMINAL_HTML_RUNTIME_STATE_AND_TEXT_SCALING, - TERMINAL_HTML_FIT_SCALE_AND_WRITE_QUEUE, + TERMINAL_HTML_FIT_SCALE, + TERMINAL_HTML_MOUSE_MODE_DECSET_SCAN, + TERMINAL_HTML_WRITE_QUEUE, TERMINAL_HTML_INIT_AND_WRITE, TERMINAL_HTML_HOST_MESSAGE_ROUTER, TERMINAL_HTML_SELECTION_STATE_AND_EVICTION, diff --git a/mobile/src/terminal/terminal-webview-html/fit-scale-and-write-queue.ts b/mobile/src/terminal/terminal-webview-html/fit-scale-and-write-queue.ts deleted file mode 100644 index 074185d43a5..00000000000 --- a/mobile/src/terminal/terminal-webview-html/fit-scale-and-write-queue.ts +++ /dev/null @@ -1,288 +0,0 @@ -import { TERMINAL_WEBVIEW_THEME_JS } from '../terminal-webview-theme-injected' - -// Also carries the DECSET mouse-mode scanner: emitted-document order pins it between these two concerns. -export const TERMINAL_HTML_FIT_SCALE_AND_WRITE_QUEUE = `${TERMINAL_WEBVIEW_THEME_JS} - - function getCellHeight() { - if (!term || !term._core) return 15; - var core = term._core; - if (core._renderService && core._renderService.dimensions) { - return core._renderService.dimensions.css.cell.height || 15; - } - return 15; - } - - // Why: clamp pan so the terminal content always covers the viewport - // when zoomed in. When content is smaller than viewport in a - // dimension, pin to top-left (no floating in the middle). - function clampPan() { - if (!term || !term.element) return; - var ts = getTotalScale(); - var cw = term.element.scrollWidth * ts; - var ch = term.element.scrollHeight * ts; - var vpW = window.innerWidth; - var vpH = window.innerHeight; - if (cw > vpW) { - panX = Math.min(0, Math.max(vpW - cw, panX)); - } else { - panX = 0; - } - if (ch > vpH) { - panY = Math.min(0, Math.max(vpH - ch, panY)); - } else { - panY = 0; - } - } - - // Why: intentional no-op. Mobile replays a live PTY snapshot then applies - // live cursor-relative chunks from that same PTY; resizing only the WebView - // xterm changes cursor coordinates and makes TUI repaint chunks duplicate or - // overlap. Kept as a no-op so its call sites stay legible. - function adjustRowsForViewport() {} - - // Why: cold-start fit. After init() opens xterm, the renderer needs - // several frames before cell dimensions are computed. Reading too early - // gives cellWidth=0 (renderer service not ready) or scrollWidth=0 (DOM - // not laid out), and computeFitScale returns 1 → no zoom. - // - // Gate: cellWidth × cols is the canonical "logical width" of the grid - // and reflects xterm's layout decision, independent of buffer content. - // We commit when cellWidth becomes positive (renderer ready). Fallback: - // if cellWidth never becomes available, gate on stable positive - // scrollWidth (xterm rendered something). Cap at 60 frames (~1s @60Hz) - // so a backgrounded WebView never spins forever. - var FIT_RETRY_MAX_FRAMES = 60; - var fitRetryToken = 0; - function applyFitScale(reason) { - if (!term || !term.element) return; - var token = ++fitRetryToken; - var attempts = 0; - var lastScrollWidth = -1; - function attempt() { - if (token !== fitRetryToken) return; - if (!term || !term.element) return; - attempts++; - var cellW = getCellWidth(); - if (cellW > 0 && term.cols > 0) { - commitFitScale(reason, attempts, 'cellW'); - return; - } - var w = term.element.scrollWidth; - if (w > 0 && w === lastScrollWidth) { - commitFitScale(reason, attempts, 'stableSW'); - return; - } - lastScrollWidth = w; - if (attempts >= FIT_RETRY_MAX_FRAMES) { - flog('commit-timeout', { - reason: reason, - attempts: attempts, - cellW: cellW, - scrollWidth: w, - cols: term.cols - }); - commitFitScale(reason, attempts, 'timeout'); - return; - } - requestAnimationFrame(attempt); - } - requestAnimationFrame(attempt); - } - - function commitFitScale(reason, attempts, gate) { - if (!term || !term.element) return; - var preSnapScale = computeFitScale(); - currentScale = preSnapScale; - // Why: when scale is very close to 1 (e.g. 0.97 from xterm scrollbar - // sub-pixels) snap to 1 to avoid imperceptible shrinkage that prevents - // a second applyFitScale from observing a "no-op needed" state. - if (currentScale >= 0.95) currentScale = 1; - userScale = 1; - panX = 0; - panY = 0; - smoothScrollOffsetY = 0; - updateTransform(); - adjustRowsForViewport(); - - var cellW = getCellWidth(); - var sw = term.element.scrollWidth; - var vpW = window.innerWidth; - var expectedW = cellW * term.cols; - var suspect = - currentScale === 1 && term.cols > 0 && expectedW > vpW + 1; // expected wider than viewport but no zoom - if (suspect) { - flog('commit-SUSPECT', { - reason: reason, - attempts: attempts, - gate: gate, - preSnapScale: preSnapScale, - finalScale: currentScale, - cellW: cellW, - cols: term.cols, - expectedW: expectedW, - scrollWidth: sw, - vpWidth: vpW - }); - } - repositionOverlay(); - } - - function isAltScreenActive(data) { - if (typeof data !== 'string') return false; - var on = data.lastIndexOf(ESC + '[?1049h'); - var off = data.lastIndexOf(ESC + '[?1049l'); - return on !== -1 && on > off; - } - - function normalizeInitialData(data) { - if (!isAltScreenActive(data)) return data; - var on = data.lastIndexOf(ESC + '[?1049h'); - // Why: SerializeAddon can include normal-buffer scrollback before the - // active alternate-screen snapshot. Replaying both into a fresh mobile - // xterm duplicates TUI frames and can flatten SGR attributes. - return on > 0 ? data.slice(on) : data; - } - - function updateMouseModeFromData(data) { - if (typeof data !== 'string' || data.length === 0) return; - var input = mouseModeScanTail + data; - mouseModeScanTail = extractMouseModeScanTail(input); - var re = new RegExp(ESC + 'c|' + ESC + '\\\\[\\\\?([0-9;]+)([hl])|' + C1_CSI + '\\\\?([0-9;]+)([hl])', 'g'); - var match; - while ((match = re.exec(input)) !== null) { - if (match[0] === ESC + 'c') { - trackedMouseTrackingMode = 'none'; - sgrMouseMode = false; - sgrMousePixelsMode = false; - continue; - } - var enabled = (match[2] || match[4]) === 'h'; - var params = (match[1] || match[3]).split(';'); - for (var i = 0; i < params.length; i++) { - if (params[i] === '') continue; - var param = Number(params[i]); - if (!Number.isInteger(param)) continue; - if (param === 9) trackedMouseTrackingMode = enabled ? 'x10' : 'none'; - if (param === 1000) trackedMouseTrackingMode = enabled ? 'vt200' : 'none'; - if (param === 1002) trackedMouseTrackingMode = enabled ? 'drag' : 'none'; - if (param === 1003) trackedMouseTrackingMode = enabled ? 'any' : 'none'; - if (param === 1006) { - sgrMouseMode = enabled; - sgrMousePixelsMode = false; - } - if (param === 1016) { - sgrMouseMode = false; - sgrMousePixelsMode = enabled; - } - } - } - } - - function resetWriteQueue() { - writeQueue = []; - writeQueueHead = 0; - } - - function isStatusDotPresentationSelector(value) { - return value === TEXT_PRESENTATION_SELECTOR || value === EMOJI_PRESENTATION_SELECTOR; - } - - function endsWithStatusDotPresentationSequence(data) { - var i = data.length - 1; - while (i >= 0 && isStatusDotPresentationSelector(data.charAt(i))) i--; - return i >= 0 && data.charAt(i) === CLAUDE_STATUS_DOT; - } - - // Why: iOS WebKit promotes Claude's record/status dot to a colorful emoji glyph. - function normalizeStatusDotPresentation(data) { - if (typeof data !== 'string' || data.length === 0) return data; - if (statusDotPendingSelector) { - statusDotPendingSelector = false; - var strippedPendingSelectors = false; - while (data.length > 0 && isStatusDotPresentationSelector(data.charAt(0))) data = data.slice(1); - strippedPendingSelectors = data.length === 0; - if (strippedPendingSelectors) { - statusDotPendingSelector = true; - return ''; - } - } - var normalized = data.replace(CLAUDE_STATUS_DOT_PATTERN, CLAUDE_STATUS_DOT + TEXT_PRESENTATION_SELECTOR); - statusDotPendingSelector = endsWithStatusDotPresentationSequence(data); - return normalized; - } - - function enqueueWrite(data) { - writeQueue.push(normalizeStatusDotPresentation(data)); - } - - function enqueueWriteBoundary(callback) { - writeQueue.push(callback); - } - - function nextQueuedWrite() { - if (writeQueueHead >= writeQueue.length) { - resetWriteQueue(); - return undefined; - } - var next = writeQueue[writeQueueHead]; - writeQueueHead++; - // Why: high-throughput terminals can enqueue faster than xterm parses; - // compact consumed slots so drain work stays O(1) without retaining old chunks. - if (writeQueueHead > 128 && writeQueueHead * 2 > writeQueue.length) { - writeQueue = writeQueue.slice(writeQueueHead); - writeQueueHead = 0; - } - return next; - } - - function disposeTermObservers() { - var disposables = termObserverDisposables; - termObserverDisposables = []; - for (var i = 0; i < disposables.length; i++) { - try { disposables[i] && disposables[i].dispose && disposables[i].dispose(); } catch (e) {} - } - } - - function extractMouseModeScanTail(input) { - var start = Math.max(input.lastIndexOf(ESC), input.lastIndexOf(C1_CSI)); - if (start === -1) return ''; - var tail = input.slice(start); - // Why: PTY/SSH chunks can split a long combined DECSET before the final h/l. - // Keep parser state far beyond normal mode lists while still bounding memory. - if (tail.length > PRIVATE_MODE_SCAN_TAIL_LIMIT) return ''; - if (tail === ESC || tail === ESC + '[' || tail === C1_CSI) return tail; - if (tail.indexOf(ESC + '[?') === 0) { - return /^[0-9;]*$/.test(tail.slice(3)) ? tail : ''; - } - if (tail.indexOf(C1_CSI + '?') === 0) { - return /^[0-9;]*$/.test(tail.slice(2)) ? tail : ''; - } - return ''; - } - - function pumpWrites(gen) { - if (!ready || !term || writesDraining || gen !== terminalGeneration) return; - var next = nextQueuedWrite(); - if (typeof next !== 'string') { - if (typeof next === 'function') return next(), pumpWrites(gen); - var callbacks = afterDrainCallbacks; - afterDrainCallbacks = []; - for (var i = 0; i < callbacks.length; i++) callbacks[i](); - return; - } - writesDraining = true; - // Why: xterm.write() parses asynchronously. Row adjustment/resizing must - // wait until replayed SGR attributes have landed in the buffer. - term.write(next, function() { - if (gen !== terminalGeneration) return; - writesDraining = false; - pumpWrites(gen); - }); - } - - function afterWritesDrained(callback) { - afterDrainCallbacks.push(callback); - pumpWrites(terminalGeneration); - } - -` diff --git a/mobile/src/terminal/terminal-webview-html/mouse-mode-decset-scan.ts b/mobile/src/terminal/terminal-webview-html/mouse-mode-decset-scan.ts new file mode 100644 index 00000000000..6f0685df87e --- /dev/null +++ b/mobile/src/terminal/terminal-webview-html/mouse-mode-decset-scan.ts @@ -0,0 +1,52 @@ +export const TERMINAL_HTML_MOUSE_MODE_DECSET_SCAN = ` function isAltScreenActive(data) { + if (typeof data !== 'string') return false; + var on = data.lastIndexOf(ESC + '[?1049h'); + var off = data.lastIndexOf(ESC + '[?1049l'); + return on !== -1 && on > off; + } + + function normalizeInitialData(data) { + if (!isAltScreenActive(data)) return data; + var on = data.lastIndexOf(ESC + '[?1049h'); + // Why: SerializeAddon can include normal-buffer scrollback before the + // active alternate-screen snapshot. Replaying both into a fresh mobile + // xterm duplicates TUI frames and can flatten SGR attributes. + return on > 0 ? data.slice(on) : data; + } + + function updateMouseModeFromData(data) { + if (typeof data !== 'string' || data.length === 0) return; + var input = mouseModeScanTail + data; + mouseModeScanTail = extractMouseModeScanTail(input); + var re = new RegExp(ESC + 'c|' + ESC + '\\\\[\\\\?([0-9;]+)([hl])|' + C1_CSI + '\\\\?([0-9;]+)([hl])', 'g'); + var match; + while ((match = re.exec(input)) !== null) { + if (match[0] === ESC + 'c') { + trackedMouseTrackingMode = 'none'; + sgrMouseMode = false; + sgrMousePixelsMode = false; + continue; + } + var enabled = (match[2] || match[4]) === 'h'; + var params = (match[1] || match[3]).split(';'); + for (var i = 0; i < params.length; i++) { + if (params[i] === '') continue; + var param = Number(params[i]); + if (!Number.isInteger(param)) continue; + if (param === 9) trackedMouseTrackingMode = enabled ? 'x10' : 'none'; + if (param === 1000) trackedMouseTrackingMode = enabled ? 'vt200' : 'none'; + if (param === 1002) trackedMouseTrackingMode = enabled ? 'drag' : 'none'; + if (param === 1003) trackedMouseTrackingMode = enabled ? 'any' : 'none'; + if (param === 1006) { + sgrMouseMode = enabled; + sgrMousePixelsMode = false; + } + if (param === 1016) { + sgrMouseMode = false; + sgrMousePixelsMode = enabled; + } + } + } + } + +` diff --git a/mobile/src/terminal/terminal-webview-html/terminal-fit-scale.ts b/mobile/src/terminal/terminal-webview-html/terminal-fit-scale.ts new file mode 100644 index 00000000000..b756bcb550c --- /dev/null +++ b/mobile/src/terminal/terminal-webview-html/terminal-fit-scale.ts @@ -0,0 +1,130 @@ +import { TERMINAL_WEBVIEW_THEME_JS } from '../terminal-webview-theme-injected' + +// Opens with the injected theme block: it lands at this point in the emitted document. +export const TERMINAL_HTML_FIT_SCALE = `${TERMINAL_WEBVIEW_THEME_JS} + + function getCellHeight() { + if (!term || !term._core) return 15; + var core = term._core; + if (core._renderService && core._renderService.dimensions) { + return core._renderService.dimensions.css.cell.height || 15; + } + return 15; + } + + // Why: clamp pan so the terminal content always covers the viewport + // when zoomed in. When content is smaller than viewport in a + // dimension, pin to top-left (no floating in the middle). + function clampPan() { + if (!term || !term.element) return; + var ts = getTotalScale(); + var cw = term.element.scrollWidth * ts; + var ch = term.element.scrollHeight * ts; + var vpW = window.innerWidth; + var vpH = window.innerHeight; + if (cw > vpW) { + panX = Math.min(0, Math.max(vpW - cw, panX)); + } else { + panX = 0; + } + if (ch > vpH) { + panY = Math.min(0, Math.max(vpH - ch, panY)); + } else { + panY = 0; + } + } + + // Why: intentional no-op. Mobile replays a live PTY snapshot then applies + // live cursor-relative chunks from that same PTY; resizing only the WebView + // xterm changes cursor coordinates and makes TUI repaint chunks duplicate or + // overlap. Kept as a no-op so its call sites stay legible. + function adjustRowsForViewport() {} + + // Why: cold-start fit. After init() opens xterm, the renderer needs + // several frames before cell dimensions are computed. Reading too early + // gives cellWidth=0 (renderer service not ready) or scrollWidth=0 (DOM + // not laid out), and computeFitScale returns 1 → no zoom. + // + // Gate: cellWidth × cols is the canonical "logical width" of the grid + // and reflects xterm's layout decision, independent of buffer content. + // We commit when cellWidth becomes positive (renderer ready). Fallback: + // if cellWidth never becomes available, gate on stable positive + // scrollWidth (xterm rendered something). Cap at 60 frames (~1s @60Hz) + // so a backgrounded WebView never spins forever. + var FIT_RETRY_MAX_FRAMES = 60; + var fitRetryToken = 0; + function applyFitScale(reason) { + if (!term || !term.element) return; + var token = ++fitRetryToken; + var attempts = 0; + var lastScrollWidth = -1; + function attempt() { + if (token !== fitRetryToken) return; + if (!term || !term.element) return; + attempts++; + var cellW = getCellWidth(); + if (cellW > 0 && term.cols > 0) { + commitFitScale(reason, attempts, 'cellW'); + return; + } + var w = term.element.scrollWidth; + if (w > 0 && w === lastScrollWidth) { + commitFitScale(reason, attempts, 'stableSW'); + return; + } + lastScrollWidth = w; + if (attempts >= FIT_RETRY_MAX_FRAMES) { + flog('commit-timeout', { + reason: reason, + attempts: attempts, + cellW: cellW, + scrollWidth: w, + cols: term.cols + }); + commitFitScale(reason, attempts, 'timeout'); + return; + } + requestAnimationFrame(attempt); + } + requestAnimationFrame(attempt); + } + + function commitFitScale(reason, attempts, gate) { + if (!term || !term.element) return; + var preSnapScale = computeFitScale(); + currentScale = preSnapScale; + // Why: when scale is very close to 1 (e.g. 0.97 from xterm scrollbar + // sub-pixels) snap to 1 to avoid imperceptible shrinkage that prevents + // a second applyFitScale from observing a "no-op needed" state. + if (currentScale >= 0.95) currentScale = 1; + userScale = 1; + panX = 0; + panY = 0; + smoothScrollOffsetY = 0; + updateTransform(); + adjustRowsForViewport(); + + var cellW = getCellWidth(); + var sw = term.element.scrollWidth; + var vpW = window.innerWidth; + var expectedW = cellW * term.cols; + var suspect = + currentScale === 1 && term.cols > 0 && expectedW > vpW + 1; // expected wider than viewport but no zoom + if (suspect) { + flog('commit-SUSPECT', { + reason: reason, + attempts: attempts, + gate: gate, + preSnapScale: preSnapScale, + finalScale: currentScale, + cellW: cellW, + cols: term.cols, + expectedW: expectedW, + scrollWidth: sw, + vpWidth: vpW + }); + } + repositionOverlay(); + } + +` diff --git a/mobile/src/terminal/terminal-webview-html/write-queue.ts b/mobile/src/terminal/terminal-webview-html/write-queue.ts new file mode 100644 index 00000000000..ae8ed85297f --- /dev/null +++ b/mobile/src/terminal/terminal-webview-html/write-queue.ts @@ -0,0 +1,110 @@ +// Also carries disposeTermObservers() and extractMouseModeScanTail(): both belong to +// other concerns, but emitted-document order pins them inside this queue. +export const TERMINAL_HTML_WRITE_QUEUE = ` function resetWriteQueue() { + writeQueue = []; + writeQueueHead = 0; + } + + function isStatusDotPresentationSelector(value) { + return value === TEXT_PRESENTATION_SELECTOR || value === EMOJI_PRESENTATION_SELECTOR; + } + + function endsWithStatusDotPresentationSequence(data) { + var i = data.length - 1; + while (i >= 0 && isStatusDotPresentationSelector(data.charAt(i))) i--; + return i >= 0 && data.charAt(i) === CLAUDE_STATUS_DOT; + } + + // Why: iOS WebKit promotes Claude's record/status dot to a colorful emoji glyph. + function normalizeStatusDotPresentation(data) { + if (typeof data !== 'string' || data.length === 0) return data; + if (statusDotPendingSelector) { + statusDotPendingSelector = false; + var strippedPendingSelectors = false; + while (data.length > 0 && isStatusDotPresentationSelector(data.charAt(0))) data = data.slice(1); + strippedPendingSelectors = data.length === 0; + if (strippedPendingSelectors) { + statusDotPendingSelector = true; + return ''; + } + } + var normalized = data.replace(CLAUDE_STATUS_DOT_PATTERN, CLAUDE_STATUS_DOT + TEXT_PRESENTATION_SELECTOR); + statusDotPendingSelector = endsWithStatusDotPresentationSequence(data); + return normalized; + } + + function enqueueWrite(data) { + writeQueue.push(normalizeStatusDotPresentation(data)); + } + + function enqueueWriteBoundary(callback) { + writeQueue.push(callback); + } + + function nextQueuedWrite() { + if (writeQueueHead >= writeQueue.length) { + resetWriteQueue(); + return undefined; + } + var next = writeQueue[writeQueueHead]; + writeQueueHead++; + // Why: high-throughput terminals can enqueue faster than xterm parses; + // compact consumed slots so drain work stays O(1) without retaining old chunks. + if (writeQueueHead > 128 && writeQueueHead * 2 > writeQueue.length) { + writeQueue = writeQueue.slice(writeQueueHead); + writeQueueHead = 0; + } + return next; + } + + function disposeTermObservers() { + var disposables = termObserverDisposables; + termObserverDisposables = []; + for (var i = 0; i < disposables.length; i++) { + try { disposables[i] && disposables[i].dispose && disposables[i].dispose(); } catch (e) {} + } + } + + function extractMouseModeScanTail(input) { + var start = Math.max(input.lastIndexOf(ESC), input.lastIndexOf(C1_CSI)); + if (start === -1) return ''; + var tail = input.slice(start); + // Why: PTY/SSH chunks can split a long combined DECSET before the final h/l. + // Keep parser state far beyond normal mode lists while still bounding memory. + if (tail.length > PRIVATE_MODE_SCAN_TAIL_LIMIT) return ''; + if (tail === ESC || tail === ESC + '[' || tail === C1_CSI) return tail; + if (tail.indexOf(ESC + '[?') === 0) { + return /^[0-9;]*$/.test(tail.slice(3)) ? tail : ''; + } + if (tail.indexOf(C1_CSI + '?') === 0) { + return /^[0-9;]*$/.test(tail.slice(2)) ? tail : ''; + } + return ''; + } + + function pumpWrites(gen) { + if (!ready || !term || writesDraining || gen !== terminalGeneration) return; + var next = nextQueuedWrite(); + if (typeof next !== 'string') { + if (typeof next === 'function') return next(), pumpWrites(gen); + var callbacks = afterDrainCallbacks; + afterDrainCallbacks = []; + for (var i = 0; i < callbacks.length; i++) callbacks[i](); + return; + } + writesDraining = true; + // Why: xterm.write() parses asynchronously. Row adjustment/resizing must + // wait until replayed SGR attributes have landed in the buffer. + term.write(next, function() { + if (gen !== terminalGeneration) return; + writesDraining = false; + pumpWrites(gen); + }); + } + + function afterWritesDrained(callback) { + afterDrainCallbacks.push(callback); + pumpWrites(terminalGeneration); + } + +` diff --git a/mobile/src/terminal/terminal-webview-payload-hash.test.ts b/mobile/src/terminal/terminal-webview-payload-hash.test.ts new file mode 100644 index 00000000000..f8bfa4bd134 --- /dev/null +++ b/mobile/src/terminal/terminal-webview-payload-hash.test.ts @@ -0,0 +1,17 @@ +import { createHash } from 'node:crypto' +import { describe, expect, it } from 'vitest' +import { XTERM_HTML } from './terminal-webview-html' + +// Why: every other WebView test exercises one slice of the document, so an edit to an +// uncovered region ships silently. A diff here means the emitted WebView source changed — +// update these values only when that change is deliberate, and only after checking the +// document still runs. Refactors that merely move slice boundaries must leave them alone. +const EXPECTED_SHA256 = '42cc000faddc3b58b8fd4855f848c7878f0cd6166c613f66d733645e8e1b9608' +const EXPECTED_LENGTH = 729776 + +describe('terminal WebView payload', () => { + it('composes the expected document', () => { + expect(XTERM_HTML.length).toBe(EXPECTED_LENGTH) + expect(createHash('sha256').update(XTERM_HTML, 'utf8').digest('hex')).toBe(EXPECTED_SHA256) + }) +}) diff --git a/src/main/daemon/daemon-host-relocation.ts b/src/main/daemon/daemon-host-relocation.ts index a7e8f2b6db2..6d94bea06e4 100644 --- a/src/main/daemon/daemon-host-relocation.ts +++ b/src/main/daemon/daemon-host-relocation.ts @@ -34,7 +34,7 @@ export type RelocatedDaemonHost = { const HOST_SUBDIR = 'daemon-host' const MARKER_NAME = '.materialized.json' -// LOCAL appData (not roaming) so OneDrive/roaming never syncs this ~260MB runtime. Shared with NSIS uninstall (config/nsis/daemon-host-uninstall.nsh) — keep in sync. +// LOCAL appData (not roaming) so OneDrive/roaming never syncs this ~260MB runtime. Shared with NSIS uninstall (config/nsis/orca-installer-hooks.nsh) — keep in sync. const LOCAL_HOST_ROOT_NAME = 'Orca' // Copy of Orca.exe renamed to a distinct image name so the NSIS updater's `taskkill /IM Orca.exe` can't match it. diff --git a/src/main/git/exact-ref-probe.ts b/src/main/git/exact-ref-probe.ts index 6b13cc718c5..96bb421b8e8 100644 --- a/src/main/git/exact-ref-probe.ts +++ b/src/main/git/exact-ref-probe.ts @@ -19,6 +19,8 @@ export type ExactRefProbeSetResult = { type ExactRefPresence = 'present' | 'absent' | 'unknown' const EXACT_REF_PROBE_CONCURRENCY = 8 +// SHA-1 and SHA-256 repositories both report a full object id here. +const OBJECT_ID_PATTERN = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/ export function isShowRefNoMatchError(error: unknown): boolean { const record = error && typeof error === 'object' ? (error as Record) : undefined @@ -126,3 +128,50 @@ export async function probeAnyExactRef( await Promise.all(Array.from({ length: workerCount }, () => probeNext())) return { found, unknown } } + +/** Runs Git with a stdin payload. Only hosts that can feed a child's stdin supply one. */ +export type ExactRefProbeStdinExec = ( + argv: string[], + options: ExactRefProbeExecOptions & { stdin: string } +) => Promise<{ stdout: string }> + +/** `cat-file --batch-check` reports every ref from one child, and reports a missing ref as data + * rather than a failed exit — so a batch stays as decidable as a per-ref `show-ref --verify`. + * A repo with many remotes otherwise pays one subprocess per remote on every conflict check. */ +export async function probeAnyExactRefBatched( + runGit: ExactRefProbeStdinExec, + refs: readonly string[], + options: ExactRefProbeExecOptions = {} +): Promise<{ found: boolean; unknown: boolean }> { + const uniqueRefs = [...new Set(refs)] + const safeRefs = uniqueRefs.filter((ref) => isSafeGitRefName(ref)) + if (safeRefs.length === 0) { + return { found: false, unknown: uniqueRefs.length > 0 } + } + let stdout: string + try { + ;({ stdout } = await runGit(['cat-file', '--batch-check'], { + ...options, + stdin: `${safeRefs.join('\n')}\n` + })) + } catch { + return { found: false, unknown: true } + } + const lines = stdout.split('\n').filter((line) => line.trim().length > 0) + // One line per input, in order; a short read means the batch never answered for the rest. + if (lines.length !== safeRefs.length) { + return { found: false, unknown: true } + } + let unknown = safeRefs.length !== uniqueRefs.length + for (const line of lines) { + const [head, type] = line.split(' ') + if (OBJECT_ID_PATTERN.test(head) && type !== undefined && type !== 'missing') { + return { found: true, unknown: false } + } + if (type !== 'missing') { + // `ambiguous`, or a spelling this Git reports differently; neither proves absence. + unknown = true + } + } + return { found: false, unknown } +} diff --git a/src/main/git/repo-branch-conflict-real-git.test.ts b/src/main/git/repo-branch-conflict-real-git.test.ts new file mode 100644 index 00000000000..34092273eda --- /dev/null +++ b/src/main/git/repo-branch-conflict-real-git.test.ts @@ -0,0 +1,49 @@ +import { execFileSync } from 'node:child_process' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { getBranchConflictKind } from './repo-branch-conflict' + +describe('branch conflict real Git contract', () => { + const tempPaths: string[] = [] + + afterEach(() => { + for (const path of tempPaths.splice(0)) { + rmSync(path, { recursive: true, force: true }) + } + }) + + it('decides remote conflicts from one batched probe across many remotes', async () => { + const repoPath = mkdtempSync(join(tmpdir(), 'orca-branch-conflict-')) + tempPaths.push(repoPath) + const git = (...args: string[]): string => + execFileSync('git', args, { cwd: repoPath, encoding: 'utf8' }) + + git('init', '--quiet') + git('config', 'user.name', 'Orca Test') + git('config', 'user.email', 'orca@example.test') + git('config', 'commit.gpgSign', 'false') + git('config', 'core.hooksPath', '.git/no-hooks') + writeFileSync(join(repoPath, 'fixture.txt'), 'base\n') + git('add', 'fixture.txt') + git('commit', '--quiet', '-m', 'base') + const head = git('rev-parse', 'HEAD').trim() + + // Many remotes is the shape that used to cost one subprocess each. + for (let index = 0; index < 12; index += 1) { + git('remote', 'add', `remote${index}`, 'https://example.test/repo.git') + } + git('update-ref', 'refs/remotes/remote7/taken', head) + + await expect(getBranchConflictKind(repoPath, 'taken')).resolves.toBe('remote') + await expect(getBranchConflictKind(repoPath, 'free')).resolves.toBeNull() + // The allowed base ref is the one remote spelling that is not a conflict. + await expect( + getBranchConflictKind(repoPath, 'taken', 'refs/remotes/remote7/taken') + ).resolves.toBeNull() + + git('branch', 'local-only', head) + await expect(getBranchConflictKind(repoPath, 'local-only')).resolves.toBe('local') + }) +}) diff --git a/src/main/git/repo-branch-conflict.test.ts b/src/main/git/repo-branch-conflict.test.ts index dab8dd396d6..873c8bd3340 100644 --- a/src/main/git/repo-branch-conflict.test.ts +++ b/src/main/git/repo-branch-conflict.test.ts @@ -134,3 +134,123 @@ describe('getBranchConflictKindViaExec', () => { expect(exec).not.toHaveBeenCalled() }) }) + +describe('getBranchConflictKindViaExec batched remote probe', () => { + function remoteNames(count: number): string { + return `${Array.from({ length: count }, (_, index) => `remote${index}`).join('\n')}\n` + } + + function baseExec(calls: string[][]): (argv: string[]) => Promise<{ stdout: string }> { + return async (argv) => { + calls.push(argv) + if (argv[0] === 'rev-parse') { + throw new Error('local branch is absent') + } + if (argv[0] === 'remote') { + return { stdout: remoteNames(3) } + } + throw new Error(`unexpected git command: ${argv.join(' ')}`) + } + } + + it('asks one batched child instead of one probe per remote', async () => { + const calls: string[][] = [] + const stdinPayloads: (string | undefined)[] = [] + const exec = baseExec(calls) + const batched = async ( + argv: string[], + options: { stdin: string } + ): Promise<{ stdout: string }> => { + calls.push(argv) + stdinPayloads.push(options.stdin) + return { + stdout: [ + 'refs/remotes/remote0/feature missing', + 'refs/remotes/remote1/feature missing', + 'refs/remotes/remote2/feature missing' + ].join('\n') + } + } + + await expect( + getBranchConflictKindViaExec(exec, 'feature', undefined, {}, batched) + ).resolves.toBeNull() + expect(calls).toEqual([ + ['rev-parse', '--verify', 'refs/heads/feature'], + ['remote'], + ['cat-file', '--batch-check'] + ]) + expect(stdinPayloads).toEqual([ + 'refs/remotes/remote0/feature\nrefs/remotes/remote1/feature\nrefs/remotes/remote2/feature\n' + ]) + }) + + it('reports a remote conflict from the batched answer', async () => { + const calls: string[][] = [] + const exec = baseExec(calls) + const batched = async (): Promise<{ stdout: string }> => ({ + stdout: [ + 'refs/remotes/remote0/feature missing', + `${'a'.repeat(40)} commit 214`, + 'refs/remotes/remote2/feature missing' + ].join('\n') + }) + + await expect( + getBranchConflictKindViaExec(exec, 'feature', undefined, {}, batched) + ).resolves.toBe('remote') + }) + + it('falls back to per-ref probes when the batch cannot answer', async () => { + const calls: string[][] = [] + const exec = async (argv: string[]): Promise<{ stdout: string }> => { + calls.push(argv) + if (argv[0] === 'rev-parse') { + throw new Error('local branch is absent') + } + if (argv[0] === 'remote') { + return { stdout: remoteNames(3) } + } + if (argv[0] === 'show-ref') { + if (argv[4] === 'refs/remotes/remote1/feature') { + return { stdout: 'abc refs/remotes/remote1/feature\n' } + } + throw Object.assign(new Error('missing'), { code: 1, stderr: '' }) + } + throw new Error(`unexpected git command: ${argv.join(' ')}`) + } + const batched = async (): Promise<{ stdout: string }> => { + throw new Error('cat-file is unavailable') + } + + await expect( + getBranchConflictKindViaExec(exec, 'feature', undefined, {}, batched) + ).resolves.toBe('remote') + expect(calls.filter((argv) => argv[0] === 'show-ref')).toHaveLength(3) + }) + + it('treats a short batch read as undecided rather than as absence', async () => { + const calls: string[][] = [] + const exec = async (argv: string[]): Promise<{ stdout: string }> => { + calls.push(argv) + if (argv[0] === 'rev-parse') { + throw new Error('local branch is absent') + } + if (argv[0] === 'remote') { + return { stdout: remoteNames(3) } + } + if (argv[0] === 'show-ref') { + throw Object.assign(new Error('missing'), { code: 1, stderr: '' }) + } + throw new Error(`unexpected git command: ${argv.join(' ')}`) + } + const batched = async (): Promise<{ stdout: string }> => ({ + stdout: 'refs/remotes/remote0/feature missing' + }) + + await expect( + getBranchConflictKindViaExec(exec, 'feature', undefined, {}, batched) + ).resolves.toBeNull() + expect(calls.filter((argv) => argv[0] === 'show-ref')).toHaveLength(3) + }) +}) diff --git a/src/main/git/repo-branch-conflict.ts b/src/main/git/repo-branch-conflict.ts index 162d5ef53b3..c799d3770be 100644 --- a/src/main/git/repo-branch-conflict.ts +++ b/src/main/git/repo-branch-conflict.ts @@ -4,8 +4,10 @@ import { gitExecFileAsync } from './runner' import { isSafeGitRefName } from '../../shared/git-status-upstream-ref' import { probeAnyExactRef, + probeAnyExactRefBatched, type ExactRefProbeExec, - type ExactRefProbeExecOptions + type ExactRefProbeExecOptions, + type ExactRefProbeStdinExec } from './exact-ref-probe' export type BranchConflictKind = 'local' | 'remote' @@ -79,12 +81,31 @@ function buildRemoteBranchConflictRefs( return [...refs] } +/** One batched child answers for every remote; the per-ref probes only run when the host cannot + * feed stdin, or when the batch came back undecided. */ +async function probeAnyRemoteConflictRef( + exec: ExactRefProbeExec, + batchedExec: ExactRefProbeStdinExec | undefined, + candidateRefs: readonly string[], + probeOptions: ExactRefProbeExecOptions +): Promise<{ found: boolean }> { + if (batchedExec) { + // A present ref is always decisive, so `found` never survives with `unknown` set. + const batched = await probeAnyExactRefBatched(batchedExec, candidateRefs, probeOptions) + if (!batched.unknown) { + return { found: batched.found } + } + } + return probeAnyExactRef(exec, candidateRefs, probeOptions) +} + /** Run branch-conflict policy through the host that owns Git execution. */ export async function getBranchConflictKindViaExec( exec: ExactRefProbeExec, branchName: string, allowedBaseRef?: string, - options: ExactRefProbeExecOptions = {} + options: ExactRefProbeExecOptions = {}, + batchedExec?: ExactRefProbeStdinExec ): Promise { if (!canQueryRemoteBranchName(branchName)) { return null @@ -104,7 +125,12 @@ export async function getBranchConflictKindViaExec( return null } - const { found: hasRemoteConflict } = await probeAnyExactRef(exec, candidateRefs, probeOptions) + const { found: hasRemoteConflict } = await probeAnyRemoteConflictRef( + exec, + batchedExec, + candidateRefs, + probeOptions + ) return hasRemoteConflict ? 'remote' : null } catch { @@ -119,15 +145,22 @@ export function getBranchConflictKind( options: LocalGitExecOptions = {} ): Promise { const execOptions = gitExecOptions(path, options) + const runLocalGit = ( + argv: string[], + commandOptions?: ExactRefProbeExecOptions & { stdin?: string } + ): Promise<{ stdout: string }> => + gitExecFileAsync(argv, { + ...execOptions, + ...(commandOptions?.maxBuffer === undefined ? {} : { maxBuffer: commandOptions.maxBuffer }), + ...(commandOptions?.timeoutMs === undefined ? {} : { timeout: commandOptions.timeoutMs }), + ...(commandOptions?.stdin === undefined ? {} : { stdin: commandOptions.stdin }) + }) return getBranchConflictKindViaExec( - (argv, commandOptions) => - gitExecFileAsync(argv, { - ...execOptions, - ...(commandOptions?.maxBuffer === undefined ? {} : { maxBuffer: commandOptions.maxBuffer }), - ...(commandOptions?.timeoutMs === undefined ? {} : { timeout: commandOptions.timeoutMs }) - }), + runLocalGit, branchName, - allowedBaseRef + allowedBaseRef, + {}, + (argv, commandOptions) => runLocalGit(argv, commandOptions) ) } diff --git a/src/main/index.ts b/src/main/index.ts index acf913b2c7a..522e59b908f 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -12,6 +12,7 @@ import { registerMainProcessIpcHandlers } from './startup/main-process-ipc-boots import { initializeMainProcessReady } from './startup/main-process-ready' import { installMainProcessQuitHandlers } from './startup/main-process-quit' import { shouldActivateDesktopForSecondInstance } from './startup/single-instance-lock' +import { resolveOpenedMarkdownDocuments } from './startup/os-opened-markdown-files' function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {}): BrowserWindow { return openMainWindowController(options) @@ -27,6 +28,7 @@ function requestDesktopActivation(argv: readonly string[] = []): void { state.skillShareDeepLinks.capture(argv, (shareId) => { state.mainWindow?.webContents.send('ui:openSkillShare', shareId) }) + state.osOpenedMarkdownFiles.capture(argv, publishOsOpenedMarkdownFiles) // Why: a duplicate `orca serve` must not drag a headless server into opening a desktop window (#11935). if (!shouldActivateDesktopForSecondInstance(argv)) { return @@ -34,6 +36,39 @@ function requestDesktopActivation(argv: readonly string[] = []): void { state.desktopActivationGate?.requestActivation() } +/** + * Hands buffered OS-opened markdown paths to a renderer that has proven it is listening. + * + * Until that proof arrives the paths stay buffered, because `webContents.send` to a renderer + * with no listener attached is dropped silently and the queue would be gone. + */ +function publishOsOpenedMarkdownFiles(): void { + const targetWindow = state.mainWindow + if (!state.markdownFileOpenListenerReady || !targetWindow || targetWindow.isDestroyed()) { + return + } + // Why consumed before the await: a renderer pull racing this resolve must not take the same + // batch again. The restore() calls hand it back if delivery turns out to be impossible. + const filePaths = state.osOpenedMarkdownFiles.consume() + if (filePaths.length === 0) { + return + } + void resolveOpenedMarkdownDocuments(filePaths) + .then((documents) => { + if (targetWindow.isDestroyed() || targetWindow.webContents.isDestroyed()) { + state.osOpenedMarkdownFiles.restore(filePaths) + return + } + if (documents.length > 0) { + targetWindow.webContents.send('ui:openMarkdownFiles', documents) + } + }) + .catch((error) => { + state.osOpenedMarkdownFiles.restore(filePaths) + console.warn('[os-open] Failed to resolve OS-opened markdown files:', error) + }) +} + const handleMacAppActivation = createMacAppActivationHandler({ getWindow: () => state.mainWindow, requestActivation: requestDesktopActivation @@ -53,7 +88,22 @@ if (preflightReady) { event.preventDefault() requestDesktopActivation([url]) }) + // Why: macOS delivers "Open With" as open-file, often before `ready`, and only to a handler + // that claims the event. Non-markdown paths stay unclaimed so the OS default handler wins. + app.on('open-file', (event, filePath) => { + if (!state.osOpenedMarkdownFiles.captureFilePaths([filePath], publishOsOpenedMarkdownFiles)) { + return + } + event.preventDefault() + // Why gated on isReady: pre-ready the cold-start window is already on its way, and + // activating the gate here would try to open one before Electron can. + if (app.isReady()) { + requestDesktopActivation() + } + }) state.skillShareDeepLinks.capture(process.argv) + // Why no publish: nothing is listening this early, so the first renderer pulls these on mount. + state.osOpenedMarkdownFiles.capture(process.argv) registerMainProcessIpcHandlers() installMainProcessQuitHandlers() void app.whenReady().then(async () => { diff --git a/src/main/ipc/worktree-base-directory-poller-marker-fanout.test.ts b/src/main/ipc/worktree-base-directory-poller-marker-fanout.test.ts new file mode 100644 index 00000000000..023a8390ccd --- /dev/null +++ b/src/main/ipc/worktree-base-directory-poller-marker-fanout.test.ts @@ -0,0 +1,84 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises' +import type * as NodeFsPromises from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { startWorktreeBaseDirectoryPoller } from './worktree-base-directory-poller' +import type { + WorktreeBaseRepoWatchConfig, + WorktreeBaseWatchTarget +} from './worktree-base-directory-event-filter' + +// Why: the backstop full scan stats a `.git` marker per candidate dir; an +// unbounded fan-out at hundreds of worktrees would queue thousands of `stat` +// calls on libuv's 4-thread pool (#17828). +const { concurrency } = vi.hoisted(() => ({ concurrency: { current: 0, peak: 0 } })) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + stat: async (...args: Parameters) => { + concurrency.current += 1 + concurrency.peak = Math.max(concurrency.peak, concurrency.current) + try { + return await actual.stat(...args) + } finally { + concurrency.current -= 1 + } + } + } +}) + +function makeTarget(path: string): WorktreeBaseWatchTarget { + const repoConfig: WorktreeBaseRepoWatchConfig = { + repoId: 'repo-1', + repoName: 'project', + nestWorkspaces: false + } + return { + key: `base:local:${path}`, + kind: 'base', + path, + repos: new Map([[repoConfig.repoId, repoConfig]]) + } +} + +describe('worktree base directory poller marker fan-out (#17828)', () => { + const cleanups: (() => Promise)[] = [] + + beforeEach(() => { + concurrency.current = 0 + concurrency.peak = 0 + }) + + afterEach(async () => { + await Promise.all(cleanups.splice(0).map((cleanup) => cleanup())) + }) + + it('bounds concurrent `.git`-marker stats regardless of candidate count', async () => { + const root = await realpath(await mkdtemp(join(tmpdir(), 'orca-base-poller-fanout-'))) + cleanups.push(() => rm(root, { recursive: true, force: true })) + const candidateCount = 200 + for (let i = 0; i < candidateCount; i++) { + const worktree = join(root, `wt-${i}`) + await mkdir(worktree) + await writeFile(join(worktree, '.git'), 'gitdir: elsewhere') + } + + const target = makeTarget(root) + const poller = await startWorktreeBaseDirectoryPoller( + target, + () => target.repos, + () => {}, + { pollIntervalMs: 100_000 } + ) + cleanups.push(() => poller.unsubscribe()) + + // 200 candidates stated unbounded would peak near 200 concurrent `stat` + // calls; bounding the marker probe keeps the peak independent of count — + // while still overlapping requests (not serialized one-at-a-time). + expect(concurrency.peak).toBeGreaterThan(1) + expect(concurrency.peak).toBeLessThan(20) + }) +}) diff --git a/src/main/ipc/worktree-base-directory-poller.ts b/src/main/ipc/worktree-base-directory-poller.ts index 42ee184b811..201d9782fba 100644 --- a/src/main/ipc/worktree-base-directory-poller.ts +++ b/src/main/ipc/worktree-base-directory-poller.ts @@ -1,6 +1,8 @@ import { readdir, stat } from 'node:fs/promises' +import type { Dirent } from 'node:fs' import { join } from 'node:path' import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' +import { forEachWithConcurrency } from '../../shared/map-with-concurrency' import { isMainWindowVisible, onMainWindowBecameVisible } from '../window/main-window-visibility' import type { WorktreeBaseRepoWatchConfig, @@ -92,6 +94,11 @@ export const WORKTREE_BASE_BACKSTOP_TICKS = 15 // backstop scan cover the pathological case. const PENDING_MARKER_MAX_TICKS = 300 +// Why: matches the git-common poller's fan-out bound (#17828) — bounded +// concurrency turns hundreds of serial round trips into a handful of batches +// without dumping every candidate onto libuv's 4-thread pool at once. +const MARKER_PROBE_CONCURRENCY = 8 + function statSignature(s: { mtimeMs: number; ctimeMs: number; ino: number }): string { return `${s.mtimeMs}:${s.ctimeMs}:${s.ino}` } @@ -123,6 +130,14 @@ type BaseSnapshot = { gateSignatures: string[] } +async function readdirSafe(path: string): Promise { + try { + return await readdir(path, { withFileTypes: true }) + } catch { + return [] + } +} + // Depth-1 worktree dirs (flat layout), plus depth-2 dirs under each nested // repo's container, mirroring what worktree-base-directory-event-filter // matches: `/.git` completion markers and `` deletions. @@ -144,14 +159,9 @@ async function snapshotBase( .map((config) => normalizeRuntimePathForComparison(config.repoName)) ) - let rootEntries - try { - rootEntries = await readdir(rootPath, { withFileTypes: true }) - } catch { - // Root vanished: an empty snapshot diffs into delete events for every - // previously-known worktree dir, matching the old watcher's error path. - return { markers, gateDirs, gateSignatures } - } + // Root vanished or unreadable: readdirSafe yields [], producing the same + // empty markers/candidates result as the old watcher's error path. + const rootEntries = await readdirSafe(rootPath) const candidates: string[] = [] for (const entry of rootEntries) { @@ -165,12 +175,7 @@ async function snapshotBase( if (nestedRepoNames.has(normalizeRuntimePathForComparison(entry.name))) { gateDirs.push(entryPath) gateSignatures.push(await dirSignature(entryPath)) - let subEntries - try { - subEntries = await readdir(entryPath, { withFileTypes: true }) - } catch { - subEntries = [] - } + const subEntries = await readdirSafe(entryPath) for (const sub of subEntries) { if (sub.isDirectory() || sub.isSymbolicLink()) { candidates.push(join(entryPath, sub.name)) @@ -179,9 +184,9 @@ async function snapshotBase( } } - for (const dir of candidates) { + await forEachWithConcurrency(candidates, MARKER_PROBE_CONCURRENCY, async (dir) => { markers.set(dir, await hasGitMarker(dir)) - } + }) return { markers, gateDirs, gateSignatures } } diff --git a/src/main/ipc/worktree-git-common-entry-snapshot.ts b/src/main/ipc/worktree-git-common-entry-snapshot.ts index d14f4ec8a1d..6dad6e0a048 100644 --- a/src/main/ipc/worktree-git-common-entry-snapshot.ts +++ b/src/main/ipc/worktree-git-common-entry-snapshot.ts @@ -39,11 +39,33 @@ export async function snapshotGitCommonEntry( previous: GitCommonEntrySnapshot | undefined, forceFullScan: boolean ): Promise { - // Structural leaves change in place every tick; only index uses the entry-dir gate. + // Git writes HEAD/index/config.worktree/locked via a lock file + rename inside the + // entry dir, so the entry dir's own signature moves on every one of those writes + // (verified against git 2.55: checkout, commit, amend, reset, ref updates, stash, + // worktree lock/unlock, config --worktree, index writes all move it). The one + // in-place exception is `gitdir` (worktree move/repair), which the periodic + // forceFullScan backstop (INDEX_BACKSTOP_TICKS) below re-stats regardless of this + // gate. Gating all of these leaves on the entry-dir signature turns an unchanged + // entry into a single stat per tick instead of stat-ing every leaf every tick. + const nextDirSignature = await gitCommonDirectorySignature(entryPath) + if (nextDirSignature === 'missing') { + return ( + previous ?? { + dirSignature: nextDirSignature, + structuralSignatures: new Map(), + indexSignature: null, + headLogSignature: null + } + ) + } + const shouldRescan = forceFullScan || !previous || previous.dirSignature !== nextDirSignature + if (!shouldRescan) { + return previous + } const structuralSignatures = new Map() - const [nextDirSignature, headLogSignature] = await Promise.all([ - gitCommonDirectorySignature(entryPath), + const [headLogSignature, indexSignature] = await Promise.all([ gitCommonFileSignature(join(entryPath, HEAD_LOG_FILE)), + gitCommonFileSignature(join(entryPath, INDEX_FILE)), Promise.all( STRUCTURAL_METADATA_FILES.map(async (name) => { const signature = await gitCommonFileSignature(join(entryPath, name)) @@ -53,20 +75,6 @@ export async function snapshotGitCommonEntry( }) ) ]) - if (nextDirSignature === 'missing') { - return ( - previous ?? { - dirSignature: nextDirSignature, - structuralSignatures, - indexSignature: null, - headLogSignature - } - ) - } - const shouldReadIndex = forceFullScan || !previous || previous.dirSignature !== nextDirSignature - const indexSignature = shouldReadIndex - ? await gitCommonFileSignature(join(entryPath, INDEX_FILE)) - : previous.indexSignature return { dirSignature: nextDirSignature, structuralSignatures, diff --git a/src/main/ipc/worktree-git-common-narrow-watch.ts b/src/main/ipc/worktree-git-common-narrow-watch.ts index b60ac9877e8..99e245998a1 100644 --- a/src/main/ipc/worktree-git-common-narrow-watch.ts +++ b/src/main/ipc/worktree-git-common-narrow-watch.ts @@ -73,6 +73,11 @@ export async function startGitCommonNarrowWatch( .unsubscribe() .catch(() => {}) .then(() => + // Crash fuse tripped: this poller is now the sole change signal until a + // future existence-poll upgrade (follow-up: #17878). Its own per-entry + // dir-signature gate (worktree-git-common-entry-snapshot.ts) already keeps + // an unchanged entry to a single stat, so a fixed `pollIntervalMs` cadence + // stays cheap at high worktree counts without needing to stretch itself. startGitCommonPolling( target.path, onEvents, diff --git a/src/main/ipc/worktree-git-common-polling.test.ts b/src/main/ipc/worktree-git-common-polling.test.ts new file mode 100644 index 00000000000..179b73e2c33 --- /dev/null +++ b/src/main/ipc/worktree-git-common-polling.test.ts @@ -0,0 +1,237 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdir, mkdtemp, rename, rm, writeFile } from 'node:fs/promises' +import type * as NodeFsPromises from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, sep } from 'node:path' +import { startGitCommonPolling } from './worktree-git-common-polling' +import type { + WorktreeBasePollEvent, + WorktreePollerWindowVisibility +} from './worktree-base-directory-poller' + +// Why: measure the fan-out this poller issues per scan (peak concurrent `stat` +// calls, `readdir` call count as a proxy for "a tick ran") without depending on +// real disk timing (#17828). `entryZeroStatCalls` tracks every stat under a +// specific pre-existing entry (its dir plus every leaf), used to prove the +// entry-dir signature gate keeps an unchanged entry to one stat per tick. +const { statDelayMs, readdirCalls, concurrency, entryZeroStatCalls } = vi.hoisted(() => ({ + statDelayMs: { current: 0 }, + readdirCalls: { count: 0 }, + concurrency: { current: 0, peak: 0 }, + entryZeroStatCalls: { count: 0 } +})) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + readdir: (...args: Parameters) => { + readdirCalls.count += 1 + return actual.readdir(...args) + }, + stat: async (...args: Parameters) => { + concurrency.current += 1 + concurrency.peak = Math.max(concurrency.peak, concurrency.current) + const path = args[0] + const entryZeroSegment = `${sep}wt-0` + if ( + typeof path === 'string' && + (path.endsWith(entryZeroSegment) || path.includes(`${entryZeroSegment}${sep}`)) + ) { + entryZeroStatCalls.count += 1 + } + try { + if (statDelayMs.current > 0) { + await new Promise((resolve) => setTimeout(resolve, statDelayMs.current)) + } + return await actual.stat(...args) + } finally { + concurrency.current -= 1 + } + } + } +}) + +const alwaysVisible: WorktreePollerWindowVisibility = { + isWindowVisible: () => true, + onWindowBecameVisible: () => () => {} +} + +async function makeCommonDir(entryCount: number): Promise { + const root = await mkdtemp(join(tmpdir(), 'git-common-polling-test-')) + for (let i = 0; i < entryCount; i++) { + const entryPath = join(root, 'worktrees', `wt-${i}`) + await mkdir(join(entryPath, 'logs'), { recursive: true }) + await Promise.all([ + writeFile(join(entryPath, 'HEAD'), 'ref: refs/heads/main\n'), + writeFile(join(entryPath, 'gitdir'), `${join(root, `checkout-${i}`, '.git')}\n`), + writeFile(join(entryPath, 'index'), Buffer.from([0])), + writeFile(join(entryPath, 'logs', 'HEAD'), '0000 aaaa\n') + ]) + } + return root +} + +describe('startGitCommonPolling fan-out bounds (#17828)', () => { + const cleanups: (() => Promise)[] = [] + const dirsToRemove: string[] = [] + + beforeEach(() => { + statDelayMs.current = 0 + readdirCalls.count = 0 + concurrency.current = 0 + concurrency.peak = 0 + entryZeroStatCalls.count = 0 + }) + + afterEach(async () => { + await Promise.all(cleanups.splice(0).map((cleanup) => cleanup())) + await Promise.all( + dirsToRemove.splice(0).map((dir) => rm(dir, { recursive: true, force: true })) + ) + vi.useRealTimers() + }) + + it('bounds concurrent per-entry stat fan-out regardless of entry count', async () => { + const commonDir = await makeCommonDir(200) + dirsToRemove.push(commonDir) + const sub = await startGitCommonPolling(commonDir, () => {}, 100_000, alwaysVisible) + cleanups.push(() => sub.unsubscribe()) + // 200 entries x ~6 concurrent structural stats each would peak near 1,200 + // unbounded; bounding to 8 in-flight entries keeps the peak independent of + // entry count instead of scaling with it. + expect(concurrency.peak).toBeLessThan(80) + }) + + it('never overlaps a scan with itself even when ticks fire faster than a scan completes', async () => { + const commonDir = await makeCommonDir(10) + dirsToRemove.push(commonDir) + statDelayMs.current = 20 + const pollIntervalMs = 5 + const sub = await startGitCommonPolling(commonDir, () => {}, pollIntervalMs, alwaysVisible) + cleanups.push(() => sub.unsubscribe()) + readdirCalls.count = 0 + // ~60 would-be 5ms ticks elapse in this window while every stat takes 20ms; + // the ticking guard must serialize scans, not launch overlapping ones. + await new Promise((resolve) => setTimeout(resolve, 300)) + expect(readdirCalls.count).toBeLessThan(10) + }) + + it('costs exactly one stat per tick for an unchanged entry', async () => { + const commonDir = await makeCommonDir(1) + dirsToRemove.push(commonDir) + const pollIntervalMs = 20 + const sub = await startGitCommonPolling(commonDir, () => {}, pollIntervalMs, alwaysVisible) + cleanups.push(() => sub.unsubscribe()) + + // Let the bootstrap snapshot (which always fully reads every entry once) settle. + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)) + readdirCalls.count = 0 + entryZeroStatCalls.count = 0 + await vi.waitFor( + () => { + expect(readdirCalls.count).toBeGreaterThanOrEqual(5) + }, + { timeout: 2_000 } + ) + // Without the entry-dir signature gate, an unchanged entry still costs ~6 + // stats every tick (HEAD/gitdir/locked/config.worktree/logs/HEAD/index). + // With the gate, only the entry dir itself is stat'd once nothing changed — + // one stat per tick, in lockstep with the readdir tripwire. + expect(entryZeroStatCalls.count).toBeLessThanOrEqual(readdirCalls.count + 1) + expect(entryZeroStatCalls.count).toBeGreaterThanOrEqual(readdirCalls.count - 1) + }) + + it('detects a HEAD rewrite via lock+rename on the next tick', async () => { + const commonDir = await makeCommonDir(1) + dirsToRemove.push(commonDir) + const events: WorktreeBasePollEvent[][] = [] + const pollIntervalMs = 20 + const sub = await startGitCommonPolling( + commonDir, + (batch) => events.push(batch), + pollIntervalMs, + alwaysVisible + ) + cleanups.push(() => sub.unsubscribe()) + // Let the bootstrap snapshot settle before mutating. + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)) + + const entryDir = join(commonDir, 'worktrees', 'wt-0') + const headPath = join(entryDir, 'HEAD') + const headLockPath = join(entryDir, 'HEAD.lock') + // Every real git ref write goes through a lock file + rename inside the entry + // dir (never an in-place overwrite), which moves the entry dir's own signature. + await writeFile(headLockPath, 'ref: refs/heads/feature\n') + await rename(headLockPath, headPath) + + await vi.waitFor( + () => { + expect(events.flat()).toContainEqual({ type: 'update', path: headPath }) + }, + { timeout: pollIntervalMs * 10 } + ) + }) + + it('detects an in-place gitdir rewrite only once the periodic backstop rescans it', async () => { + const commonDir = await makeCommonDir(1) + dirsToRemove.push(commonDir) + const events: WorktreeBasePollEvent[][] = [] + const pollIntervalMs = 10 + const sub = await startGitCommonPolling( + commonDir, + (batch) => events.push(batch), + pollIntervalMs, + alwaysVisible + ) + cleanups.push(() => sub.unsubscribe()) + // Let the bootstrap snapshot settle before mutating. + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)) + + const entryDir = join(commonDir, 'worktrees', 'wt-0') + const gitdirPath = join(entryDir, 'gitdir') + // `gitdir` is the one structural leaf git rewrites in place (worktree move/repair), + // so the entry dir's own signature never moves — the periodic ungated backstop + // (INDEX_BACKSTOP_TICKS = 15) is the only thing that catches it. + await writeFile(gitdirPath, `${join(commonDir, 'checkout-moved', '.git')}\n`) + + // Not caught by the next several ticks: the gate stays closed since nothing + // moved the entry dir's own signature. + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs * 5)) + expect(events.flat()).not.toContainEqual({ type: 'update', path: gitdirPath }) + + // Eventually caught regardless of the gate, once tick 15 forces the periodic backstop. + await vi.waitFor( + () => { + expect(events.flat()).toContainEqual({ type: 'update', path: gitdirPath }) + }, + { timeout: pollIntervalMs * 40 } + ) + }) + + it('still detects entry add/remove correctly with bounded concurrency', async () => { + const commonDir = await makeCommonDir(5) + dirsToRemove.push(commonDir) + const events: WorktreeBasePollEvent[][] = [] + const sub = await startGitCommonPolling( + commonDir, + (batch) => events.push(batch), + 20, + alwaysVisible + ) + cleanups.push(() => sub.unsubscribe()) + + const newEntry = join(commonDir, 'worktrees', 'wt-new') + await mkdir(join(newEntry, 'logs'), { recursive: true }) + await writeFile(join(newEntry, 'HEAD'), 'ref: refs/heads/main\n') + + await vi.waitFor(() => { + expect(events.flat()).toContainEqual({ type: 'create', path: newEntry }) + }) + + await rm(newEntry, { recursive: true }) + await vi.waitFor(() => { + expect(events.flat()).toContainEqual({ type: 'delete', path: newEntry }) + }) + }) +}) diff --git a/src/main/ipc/worktree-git-common-polling.ts b/src/main/ipc/worktree-git-common-polling.ts index 0418f4a90fd..4b43835a81f 100644 --- a/src/main/ipc/worktree-git-common-polling.ts +++ b/src/main/ipc/worktree-git-common-polling.ts @@ -1,5 +1,6 @@ import { readdir } from 'node:fs/promises' import { join } from 'node:path' +import { forEachWithConcurrency } from '../../shared/map-with-concurrency' import { PRIMARY_CHECKOUT_METADATA_FILES } from './worktree-git-common-metadata-files' import { diffGitCommon, @@ -23,18 +24,26 @@ import { // same way the base poller's backstop rescan does. const INDEX_BACKSTOP_TICKS = 15 +// Why: an unbounded fan-out across every worktree admin entry queues thousands +// of ops on libuv's 4-thread default pool, starving every other main-process +// fs call for the scan's duration (#17828). 8 mirrors the existing +// head-identity/exact-ref-probe pools — enough to saturate typical local +// disks without monopolizing the pool. Since snapshotGitCommonEntry's own +// entry-dir gate (see worktree-git-common-entry-snapshot.ts) keeps most ticks +// down to 1 stat per unchanged entry, real in-flight is now bounded by this +// limit rather than limit × per-entry stat count. +const GIT_COMMON_SNAPSHOT_CONCURRENCY = 8 + async function snapshotStatusRefSignatures( paths: ReadonlySet ): Promise> { const signatures = new Map() - await Promise.all( - [...paths].map(async (path) => { - const signature = await gitCommonFileSignature(path) - if (signature !== null) { - signatures.set(path, signature) - } - }) - ) + await forEachWithConcurrency([...paths], GIT_COMMON_SNAPSHOT_CONCURRENCY, async (path) => { + const signature = await gitCommonFileSignature(path) + if (signature !== null) { + signatures.set(path, signature) + } + }) return signatures } @@ -91,12 +100,10 @@ async function snapshotGitCommon( } const entries = new Map() - await Promise.all( - entryPaths.map(async (entryPath) => { - const previousEntry = previous?.entries.get(entryPath) - entries.set(entryPath, await snapshotGitCommonEntry(entryPath, previousEntry, forceFullScan)) - }) - ) + await forEachWithConcurrency(entryPaths, GIT_COMMON_SNAPSHOT_CONCURRENCY, async (entryPath) => { + const previousEntry = previous?.entries.get(entryPath) + entries.set(entryPath, await snapshotGitCommonEntry(entryPath, previousEntry, forceFullScan)) + }) // Why: the expensive per-entry `index` read stays gated on each entry's own dir signature; onFullScan // now reflects an ungated index-metadata backstop fan-out (forceFullScan) — the real periodic cost — // rather than the always-run worktrees-dir readdir. diff --git a/src/main/ipc/worktree-git-common-watch.ts b/src/main/ipc/worktree-git-common-watch.ts index 9de2c8c3392..8ed696872f7 100644 --- a/src/main/ipc/worktree-git-common-watch.ts +++ b/src/main/ipc/worktree-git-common-watch.ts @@ -60,6 +60,8 @@ export async function startGitCommonWatch( } } } + // Why: Electron only ships darwin/linux/win32, all covered by NARROW_WATCH_PLATFORMS + // above, so this branch is defensive dead code in production, not a reachable fallback. return startGitCommonPolling( target.path, onEvents, diff --git a/src/main/ipc/worktree-push-target-cleanup.test.ts b/src/main/ipc/worktree-push-target-cleanup.test.ts index 577235c86dd..0b736acd482 100644 --- a/src/main/ipc/worktree-push-target-cleanup.test.ts +++ b/src/main/ipc/worktree-push-target-cleanup.test.ts @@ -4,6 +4,8 @@ import type { WorktreeMeta } from '../../shared/worktree/meta-types' import type { GitPushTarget } from '../../shared/worktree/types' import { cleanupUnusedWorktreePushTargetRemoteWithExec, + findWorktreeMetaReferencingRemote, + hasBranchConfigUsingRemote, sameGitHubRemoteUrl, type GitRemoteExec, type WorktreePushTargetStore @@ -253,6 +255,70 @@ describe('cleanupUnusedWorktreePushTargetRemoteWithExec', () => { }) }) +describe('hasBranchConfigUsingRemote', () => { + it('requireExistingBranch: false (default) protects on config alone, even if the branch is gone', async () => { + const exec = makeExec({ branchConfig: `branch.contributor/fix.remote ${FORK_REMOTE}` }) + await expect(hasBranchConfigUsingRemote(exec, REPO_PATH, forkTarget())).resolves.toBe(true) + }) + + it('requireExistingBranch: true only protects when the referencing branch still exists', async () => { + const exec = vi.fn(async (args: string[]) => { + if (args[0] === 'config') { + return { stdout: `branch.contributor/fix.remote ${FORK_REMOTE}`, stderr: '' } + } + if (args[0] === 'for-each-ref') { + return { stdout: 'main\ncontributor/fix\n', stderr: '' } + } + return { stdout: '', stderr: '' } + }) + await expect( + hasBranchConfigUsingRemote(exec, REPO_PATH, forkTarget(), { requireExistingBranch: true }) + ).resolves.toBe(true) + }) + + it('requireExistingBranch: true does not protect on a stale config entry from a deleted branch', async () => { + const exec = vi.fn(async (args: string[]) => { + if (args[0] === 'config') { + return { stdout: `branch.contributor/fix.remote ${FORK_REMOTE}`, stderr: '' } + } + if (args[0] === 'for-each-ref') { + return { stdout: 'main\n', stderr: '' } // contributor/fix no longer exists + } + return { stdout: '', stderr: '' } + }) + await expect( + hasBranchConfigUsingRemote(exec, REPO_PATH, forkTarget(), { requireExistingBranch: true }) + ).resolves.toBe(false) + }) + + it('extracts branch names containing dots correctly', async () => { + const exec = vi.fn(async (args: string[]) => { + if (args[0] === 'config') { + return { stdout: `branch.release/1.2.3.remote ${FORK_REMOTE}`, stderr: '' } + } + if (args[0] === 'for-each-ref') { + return { stdout: 'release/1.2.3\n', stderr: '' } + } + return { stdout: '', stderr: '' } + }) + await expect( + hasBranchConfigUsingRemote(exec, REPO_PATH, forkTarget(), { requireExistingBranch: true }) + ).resolves.toBe(true) + }) +}) + +describe('findWorktreeMetaReferencingRemote', () => { + it('scopes matches to the given repo id and excludes worktrees without a pushTarget', () => { + const store = storeOf({ + 'repo-1::/wt/a': forkTarget(), + 'repo-1::/wt/b': undefined, + 'repo-2::/wt/c': forkTarget() + }) + const matches = findWorktreeMetaReferencingRemote(store, 'repo-1', forkTarget()) + expect(matches.map((match) => match.worktreeId)).toEqual(['repo-1::/wt/a']) + }) +}) + describe('sameGitHubRemoteUrl', () => { it('matches SSH and HTTPS forms of the same GitHub fork', () => { expect( diff --git a/src/main/ipc/worktree-push-target-cleanup.ts b/src/main/ipc/worktree-push-target-cleanup.ts index 2bda01c5a89..9bf29f718b2 100644 --- a/src/main/ipc/worktree-push-target-cleanup.ts +++ b/src/main/ipc/worktree-push-target-cleanup.ts @@ -1,9 +1,12 @@ // Why: fork-PR worktrees can add a contributor's fork as a git remote. When such // a worktree is deleted we prune that remote, but only when it's truly unused. // This module holds that decision logic behind an injectable `execGit` boundary so -// the multi-fork cleanup matrix is unit-testable without a real repo. +// the multi-fork cleanup matrix is unit-testable without a real repo. The same +// predicates back the periodic sweep in `worktree-push-target-reconciliation.ts`, +// which inverts them over every `pr-*` remote instead of one removed worktree. import type { Store } from '../persistence' +import type { WorktreeMeta } from '../../shared/worktree/meta-types' import type { GitPushTarget } from '../../shared/worktree/types' import { parseGitHubOwnerRepo } from '../github/gh-utils' import { getRepoIdFromWorktreeId } from '../../shared/worktree/id' @@ -29,61 +32,113 @@ export function sameGitHubRemoteUrl(left: string, right: string): boolean { ) } +/** A worktree metadata entry, in the same repo as `target`, whose pushTarget references it. */ +export type WorktreeMetaReferencingRemote = { worktreeId: string; meta: WorktreeMeta } + +// Exported so the reconciliation sweep can inspect *which* worktrees reference a remote +// (to check liveness/provenance) instead of only the single-target yes/no this file needs. +export function findWorktreeMetaReferencingRemote( + store: WorktreePushTargetStore, + repoId: string, + target: Pick +): WorktreeMetaReferencingRemote[] { + return Object.entries(store.getAllWorktreeMeta()) + .filter(([worktreeId, meta]) => { + // Why: git remotes are repo-local; matching metadata from another repo + // must not pin this repo's fork remote forever. + if (getRepoIdFromWorktreeId(worktreeId) !== repoId || !meta.pushTarget) { + return false + } + const otherRemoteUrl = meta.pushTarget.remoteUrl + const targetRemoteUrl = target.remoteUrl + return ( + meta.pushTarget.remoteName === target.remoteName || + (typeof otherRemoteUrl === 'string' && + typeof targetRemoteUrl === 'string' && + sameGitHubRemoteUrl(otherRemoteUrl, targetRemoteUrl)) + ) + }) + .map(([worktreeId, meta]) => ({ worktreeId, meta })) +} + function isPushTargetUsedByAnotherWorktree( store: WorktreePushTargetStore, removedWorktreeId: string, target: GitPushTarget ): boolean { const removedRepoId = getRepoIdFromWorktreeId(removedWorktreeId) - return Object.entries(store.getAllWorktreeMeta()).some(([worktreeId, meta]) => { - // Why: git remotes are repo-local; matching metadata from another repo - // must not pin this repo's fork remote forever. - const belongsToSameRepo = getRepoIdFromWorktreeId(worktreeId) === removedRepoId - if (worktreeId === removedWorktreeId || !belongsToSameRepo || !meta.pushTarget) { - return false - } - const otherRemoteUrl = meta.pushTarget.remoteUrl - const targetRemoteUrl = target.remoteUrl - return ( - meta.pushTarget.remoteName === target.remoteName || - (typeof otherRemoteUrl === 'string' && - typeof targetRemoteUrl === 'string' && - sameGitHubRemoteUrl(otherRemoteUrl, targetRemoteUrl)) - ) - }) + return findWorktreeMetaReferencingRemote(store, removedRepoId, target).some( + ({ worktreeId }) => worktreeId !== removedWorktreeId + ) } -async function hasBranchConfigUsingRemote( +export type BranchConfigMatch = { branchName: string } + +// Exported for the sweep, which additionally verifies each matched branch still exists +// before treating it as a reason to keep the remote (`requireExistingBranch`). +export async function hasBranchConfigUsingRemote( execGit: GitRemoteExec, repoPath: string, - target: GitPushTarget + target: Pick, + options: { requireExistingBranch?: boolean } = {} +): Promise { + let stdout: string + try { + ;({ stdout } = await execGit( + ['config', '--get-regexp', '^branch\\..*\\.(remote|pushRemote)$'], + repoPath + )) + } catch { + return false + } + const matches: BranchConfigMatch[] = [] + // Why: git config output can be large; avoid materializing line/split arrays here. + for (const line of iterateProcessOutputLines(stdout)) { + const parsed = parseBranchRemoteConfigLine(line) + if (parsed && (parsed.value === target.remoteName || parsed.value === target.remoteUrl)) { + matches.push({ branchName: parsed.branchName }) + } + } + if (matches.length === 0) { + return false + } + if (!options.requireExistingBranch) { + return true + } + return branchesExist( + execGit, + repoPath, + matches.map((match) => match.branchName) + ) +} + +async function branchesExist( + execGit: GitRemoteExec, + repoPath: string, + branchNames: string[] ): Promise { try { const { stdout } = await execGit( - ['config', '--get-regexp', '^branch\\..*\\.(remote|pushRemote)$'], + ['for-each-ref', '--format=%(refname:short)', 'refs/heads/'], repoPath ) - // Why: git config output can be large; avoid materializing line/split arrays here. - for (const line of iterateProcessOutputLines(stdout)) { - const value = readBranchRemoteConfigValue(line) - if (value === target.remoteName || value === target.remoteUrl) { - return true - } - } - return false + const existingBranches = new Set(iterateProcessOutputLines(stdout)) + return branchNames.some((branchName) => existingBranches.has(branchName)) } catch { return false } } -function readBranchRemoteConfigValue(line: string): string | null { +function parseBranchRemoteConfigLine(line: string): { branchName: string; value: string } | null { let index = 0 while (index < line.length && isBranchConfigSeparator(line.charCodeAt(index))) { index += 1 } + const keyStart = index while (index < line.length && !isBranchConfigSeparator(line.charCodeAt(index))) { index += 1 } + const key = line.slice(keyStart, index) while (index < line.length && isBranchConfigSeparator(line.charCodeAt(index))) { index += 1 } @@ -96,7 +151,30 @@ function readBranchRemoteConfigValue(line: string): string | null { while (valueEnd > valueStart && isBranchConfigSeparator(line.charCodeAt(valueEnd - 1))) { valueEnd -= 1 } - return valueStart < valueEnd ? line.slice(valueStart, valueEnd) : null + if (valueStart >= valueEnd) { + return null + } + const branchName = extractBranchNameFromConfigKey(key) + return branchName ? { branchName, value: line.slice(valueStart, valueEnd) } : null +} + +// `branch..remote` / `branch..pushRemote`; `` may itself contain dots +// (e.g. `release/1.2.3`), so only the known trailing suffix is stripped. +function extractBranchNameFromConfigKey(key: string): string | null { + const prefix = 'branch.' + if (!key.startsWith(prefix)) { + return null + } + const rest = key.slice(prefix.length) + const lastDot = rest.lastIndexOf('.') + if (lastDot <= 0) { + return null + } + const suffix = rest.slice(lastDot + 1) + if (suffix !== 'remote' && suffix !== 'pushRemote') { + return null + } + return rest.slice(0, lastDot) } function isBranchConfigSeparator(code: number): boolean { diff --git a/src/main/ipc/worktree-push-target-reconciliation-real-git.test.ts b/src/main/ipc/worktree-push-target-reconciliation-real-git.test.ts new file mode 100644 index 00000000000..3c226b3475f --- /dev/null +++ b/src/main/ipc/worktree-push-target-reconciliation-real-git.test.ts @@ -0,0 +1,173 @@ +// Real-binary coverage for the pr-* remote reconciliation sweep (#17828): the mocked-exec suite +// proves the decision matrix, but not that `git remote -v`, `git config --get-regexp`, and +// `git for-each-ref` are parsed correctly against real Git output. +import { execFile } from 'node:child_process' +import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { promisify } from 'node:util' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { WorktreeMeta } from '../../shared/worktree/meta-types' +import type { GitPushTarget } from '../../shared/worktree/types' +import type { GitRemoteExec, WorktreePushTargetStore } from './worktree-push-target-cleanup' +import { reconcileOrphanedPrRemotesWithExec } from './worktree-push-target-reconciliation' + +const execFileAsync = promisify(execFile) + +const REPO_ID = 'repo-1' +const FORK_REMOTE = 'pr-contributor-orca' + +let scratchDir = '' +let repoPath = '' +let forkPath = '' + +async function git(args: string[], cwd: string): Promise { + const { stdout } = await execFileAsync('git', args, { cwd }) + return stdout +} + +const execGit: GitRemoteExec = (args, cwd) => execFileAsync('git', args, { cwd }) + +function worktreeId(suffix: string): string { + return `${REPO_ID}::${suffix}` +} + +function forkTarget(overrides: Partial = {}): GitPushTarget { + return { + remoteName: FORK_REMOTE, + branchName: 'contributor/fix', + remoteUrl: forkPath, + remoteCreated: true, + ...overrides + } +} + +function storeOf(entries: Record): WorktreePushTargetStore { + const meta: Record = {} + for (const [id, pushTarget] of Object.entries(entries)) { + meta[id] = { pushTarget } as unknown as WorktreeMeta + } + return { getAllWorktreeMeta: () => meta } +} + +beforeEach(async () => { + // realpath: macOS hands out /var/... temp paths while Git reports /private/var/... + scratchDir = await realpath(await mkdtemp(join(tmpdir(), 'orca-pr-remote-reconcile-'))) + repoPath = join(scratchDir, 'repo') + forkPath = join(scratchDir, 'fork') + await mkdir(repoPath, { recursive: true }) + await git(['init', '-q'], repoPath) + await git(['config', 'user.name', 'Orca Test'], repoPath) + await git(['config', 'user.email', 'orca@example.test'], repoPath) + await git(['config', 'commit.gpgSign', 'false'], repoPath) + await git(['config', 'core.hooksPath', '.git/no-hooks'], repoPath) + await writeFile(join(repoPath, 'seed.txt'), 'seed\n') + await git(['add', '-A'], repoPath) + await git(['commit', '-qm', 'seed'], repoPath) + + // A second local "fork" repo the pr-* remote points at, so `remote add`/fetch behave normally. + await git(['clone', '-q', repoPath, forkPath], scratchDir) + await git(['config', 'user.name', 'Orca Test'], forkPath) + await git(['config', 'user.email', 'orca@example.test'], forkPath) + await git(['config', 'commit.gpgSign', 'false'], forkPath) + await git(['config', 'core.hooksPath', '.git/no-hooks'], forkPath) + await git(['checkout', '-qb', 'contributor/fix'], forkPath) + await writeFile(join(forkPath, 'fork.txt'), 'fork change\n') + await git(['add', '-A'], forkPath) + await git(['commit', '-qm', 'fork change'], forkPath) + + await git(['remote', 'add', FORK_REMOTE, forkPath], repoPath) + await git( + [ + 'fetch', + FORK_REMOTE, + `+refs/heads/contributor/fix:refs/remotes/${FORK_REMOTE}/contributor/fix` + ], + repoPath + ) +}) + +afterEach(async () => { + await rm(scratchDir, { recursive: true, force: true }) +}) + +describe('reconcileOrphanedPrRemotesWithExec against the real Git binary', () => { + it('leaves a user-created remote alone: naming/URL shape is not proof of provenance', async () => { + const reclaimed = await reconcileOrphanedPrRemotesWithExec( + repoPath, + REPO_ID, + storeOf({}), // no worktree metadata anywhere claims this remote + execGit, + [] + ) + expect(reclaimed).toEqual([]) + await expect(git(['remote'], repoPath)).resolves.toContain(FORK_REMOTE) + }) + + it('leaves the remote alone while a live worktree still references it', async () => { + const worktreePath = join(scratchDir, 'wt-live') + await git(['worktree', 'add', '-q', worktreePath, '-b', 'contributor/fix-local'], repoPath) + + const reclaimed = await reconcileOrphanedPrRemotesWithExec( + repoPath, + REPO_ID, + storeOf({ [worktreeId(worktreePath)]: forkTarget() }), + execGit, + [worktreePath] + ) + expect(reclaimed).toEqual([]) + await expect(git(['remote'], repoPath)).resolves.toContain(FORK_REMOTE) + }) + + it('leaves the remote alone while its branch still exists (preserve-on-delete kept alive)', async () => { + await git(['branch', 'contributor/fix', `${FORK_REMOTE}/contributor/fix`], repoPath) + await git(['config', `branch.contributor/fix.remote`, FORK_REMOTE], repoPath) + + const reclaimed = await reconcileOrphanedPrRemotesWithExec( + repoPath, + REPO_ID, + storeOf({ [worktreeId(join(scratchDir, 'wt-gone'))]: forkTarget() }), + execGit, + [] // the worktree that created it is gone, but the branch it preserved is not + ) + expect(reclaimed).toEqual([]) + await expect(git(['remote'], repoPath)).resolves.toContain(FORK_REMOTE) + }) + + it('reclaims the remote once the branch that pinned it is deleted (path 2)', async () => { + await git(['branch', 'contributor/fix', `${FORK_REMOTE}/contributor/fix`], repoPath) + await git(['config', `branch.contributor/fix.remote`, FORK_REMOTE], repoPath) + // Delete only the ref, leaving the config behind, exactly as `update-ref -d` alone would -- + // proving the sweep checks branch existence rather than trusting stale config. + await rm(join(repoPath, '.git', 'refs', 'heads', 'contributor', 'fix')) + + const reclaimed = await reconcileOrphanedPrRemotesWithExec( + repoPath, + REPO_ID, + storeOf({ [worktreeId(join(scratchDir, 'wt-gone'))]: forkTarget() }), + execGit, + [] + ) + expect(reclaimed).toEqual([FORK_REMOTE]) + await expect(git(['remote'], repoPath)).resolves.not.toContain(FORK_REMOTE) + }) + + it('reclaims a remote orphaned by a worktree removed outside Orca (path 3)', async () => { + const worktreePath = join(scratchDir, 'wt-externally-removed') + await git(['worktree', 'add', '-q', worktreePath, '-b', 'contributor/fix-local-2'], repoPath) + // Simulate a plain `git worktree remove` the user ran outside Orca: Orca's metadata for + // that worktree is still sitting in the store (nothing told it to clean up), but the + // worktree itself is gone. + await git(['worktree', 'remove', '--force', worktreePath], repoPath) + + const reclaimed = await reconcileOrphanedPrRemotesWithExec( + repoPath, + REPO_ID, + storeOf({ [worktreeId(worktreePath)]: forkTarget() }), + execGit, + [] // listWorktrees no longer reports it + ) + expect(reclaimed).toEqual([FORK_REMOTE]) + await expect(git(['remote'], repoPath)).resolves.not.toContain(FORK_REMOTE) + }) +}) diff --git a/src/main/ipc/worktree-push-target-reconciliation.test.ts b/src/main/ipc/worktree-push-target-reconciliation.test.ts new file mode 100644 index 00000000000..ed29809a151 --- /dev/null +++ b/src/main/ipc/worktree-push-target-reconciliation.test.ts @@ -0,0 +1,262 @@ +import { describe, expect, it, vi, type Mock } from 'vitest' +import { validateGitExecArgs } from '../../relay/git-exec-validator' +import type { WorktreeMeta } from '../../shared/worktree/meta-types' +import type { GitPushTarget } from '../../shared/worktree/types' +import type { GitRemoteExec, WorktreePushTargetStore } from './worktree-push-target-cleanup' +import { + _resetPrRemoteReconciliationRateLimitForTests, + isOrcaGeneratedPrRemoteName, + reconcileOrphanedPrRemotesWithExec +} from './worktree-push-target-reconciliation' + +type ExecMock = Mock + +const REPO_PATH = '/repo-root' +const REPO_ID = 'repo-1' +const FORK_URL = 'git@github.com:contributor/orca.git' +const FORK_REMOTE = 'pr-contributor-orca' + +function worktreeId(suffix: string): string { + return `${REPO_ID}::${suffix}` +} + +function forkTarget(overrides: Partial = {}): GitPushTarget { + return { + remoteName: FORK_REMOTE, + branchName: 'contributor/fix', + remoteUrl: FORK_URL, + remoteCreated: true, + ...overrides + } +} + +function metaWith(pushTarget: GitPushTarget | undefined): WorktreeMeta { + return { pushTarget } as unknown as WorktreeMeta +} + +function storeOf(entries: Record): WorktreePushTargetStore { + const meta: Record = {} + for (const [id, pushTarget] of Object.entries(entries)) { + meta[id] = metaWith(pushTarget) + } + return { getAllWorktreeMeta: () => meta } +} + +type ExecScript = { + remotes?: string + branchConfig?: string + localBranches?: string +} + +function makeExec(script: ExecScript = {}): ExecMock { + const { remotes = '', branchConfig = '', localBranches = '' } = script + return vi.fn(async (args: string[]) => { + if (args[0] === 'remote' && args[1] === '-v') { + return { stdout: remotes, stderr: '' } + } + if (args[0] === 'config') { + return { stdout: branchConfig, stderr: '' } + } + if (args[0] === 'for-each-ref') { + return { stdout: localBranches, stderr: '' } + } + if (args[0] === 'remote' && args[1] === 'remove') { + return { stdout: '', stderr: '' } + } + return { stdout: '', stderr: '' } + }) +} + +function remoteLines(entries: { name: string; url: string }[]): string { + return entries + .flatMap(({ name, url }) => [`${name}\t${url} (fetch)`, `${name}\t${url} (push)`]) + .join('\n') +} + +function removeCalls(exec: ExecMock): string[][] { + return exec.mock.calls + .map(([args]) => args) + .filter((args) => args[0] === 'remote' && args[1] === 'remove') +} + +describe('isOrcaGeneratedPrRemoteName', () => { + it('matches Orca-generated names, including disambiguated ones', () => { + expect(isOrcaGeneratedPrRemoteName('pr-head')).toBe(true) + expect(isOrcaGeneratedPrRemoteName('pr-contributor-orca')).toBe(true) + expect(isOrcaGeneratedPrRemoteName('pr-head-2')).toBe(true) + expect(isOrcaGeneratedPrRemoteName('pr-contributor-orca-3')).toBe(true) + }) + + it('does not match unrelated remote names', () => { + expect(isOrcaGeneratedPrRemoteName('origin')).toBe(false) + expect(isOrcaGeneratedPrRemoteName('upstream')).toBe(false) + expect(isOrcaGeneratedPrRemoteName('project-remote')).toBe(false) + }) +}) + +describe('reconcileOrphanedPrRemotesWithExec', () => { + it('leaves a remote alone when no worktree metadata ever proves Orca created it (user-created, ambiguous)', async () => { + // Same naming shape a user could coincidentally pick; no pushTarget anywhere claims it. + const exec = makeExec({ remotes: remoteLines([{ name: FORK_REMOTE, url: FORK_URL }]) }) + const reclaimed = await reconcileOrphanedPrRemotesWithExec( + REPO_PATH, + REPO_ID, + storeOf({}), + exec, + [] + ) + expect(reclaimed).toEqual([]) + expect(removeCalls(exec)).toEqual([]) + }) + + it('leaves a remote alone that is not shaped like an Orca-generated pr-* remote', async () => { + const exec = makeExec({ + remotes: remoteLines([{ name: 'my-fork', url: FORK_URL }]) + }) + const reclaimed = await reconcileOrphanedPrRemotesWithExec( + REPO_PATH, + REPO_ID, + storeOf({ [worktreeId('/wt/a')]: { ...forkTarget(), remoteName: 'my-fork' } }), + exec, + [] + ) + expect(reclaimed).toEqual([]) + expect(removeCalls(exec)).toEqual([]) + }) + + it('leaves a remote alone that a live worktree still references (path guard)', async () => { + const exec = makeExec({ remotes: remoteLines([{ name: FORK_REMOTE, url: FORK_URL }]) }) + const reclaimed = await reconcileOrphanedPrRemotesWithExec( + REPO_PATH, + REPO_ID, + storeOf({ [worktreeId('/wt/a')]: forkTarget() }), + exec, + ['/wt/a'] + ) + expect(reclaimed).toEqual([]) + expect(removeCalls(exec)).toEqual([]) + }) + + it('leaves a remote alone that is referenced by an existing branch (path 2, branch still kept)', async () => { + const exec = makeExec({ + remotes: remoteLines([{ name: FORK_REMOTE, url: FORK_URL }]), + branchConfig: `branch.contributor/fix.remote ${FORK_REMOTE}`, + localBranches: 'contributor/fix\nmain' + }) + // Metadata for the worktree that created it is gone, but the branch it preserved lives on. + const reclaimed = await reconcileOrphanedPrRemotesWithExec( + REPO_PATH, + REPO_ID, + storeOf({ [worktreeId('/wt/gone')]: forkTarget() }), + exec, + [] + ) + expect(reclaimed).toEqual([]) + expect(removeCalls(exec)).toEqual([]) + }) + + it('reclaims a remote whose protecting branch config is stale (path 2, branch since deleted)', async () => { + const exec = makeExec({ + remotes: remoteLines([{ name: FORK_REMOTE, url: FORK_URL }]), + // Config line survives even though `contributor/fix` no longer exists. + branchConfig: `branch.contributor/fix.remote ${FORK_REMOTE}`, + localBranches: 'main' + }) + const reclaimed = await reconcileOrphanedPrRemotesWithExec( + REPO_PATH, + REPO_ID, + storeOf({ [worktreeId('/wt/gone')]: forkTarget() }), + exec, + [] + ) + expect(reclaimed).toEqual([FORK_REMOTE]) + expect(removeCalls(exec)).toEqual([['remote', 'remove', FORK_REMOTE]]) + }) + + it('reclaims a remote left behind by a worktree removed outside Orca (path 3)', async () => { + const exec = makeExec({ remotes: remoteLines([{ name: FORK_REMOTE, url: FORK_URL }]) }) + // Metadata still records the (now-vanished) worktree's Orca-created pushTarget. + const reclaimed = await reconcileOrphanedPrRemotesWithExec( + REPO_PATH, + REPO_ID, + storeOf({ [worktreeId('/wt/gone')]: forkTarget() }), + exec, + [] // no live worktrees at all + ) + expect(reclaimed).toEqual([FORK_REMOTE]) + expect(removeCalls(exec)).toEqual([['remote', 'remove', FORK_REMOTE]]) + }) + + it('reclaims a remote even when the only referencing metadata lacks remoteCreated, as long as another entry proves provenance (path 1)', async () => { + const exec = makeExec({ remotes: remoteLines([{ name: FORK_REMOTE, url: FORK_URL }]) }) + const reclaimed = await reconcileOrphanedPrRemotesWithExec( + REPO_PATH, + REPO_ID, + storeOf({ + // The worktree whose removal originally bailed (legacy metadata, no remoteCreated flag)... + [worktreeId('/wt/legacy')]: forkTarget({ remoteCreated: false }), + // ...but a sibling that reused the remote correctly inherited ownership, and is also gone. + [worktreeId('/wt/sibling-gone')]: forkTarget({ remoteCreated: true }) + }), + exec, + [] + ) + expect(reclaimed).toEqual([FORK_REMOTE]) + }) + + it('never touches origin or upstream even if metadata is malformed', async () => { + const exec = makeExec({ + remotes: remoteLines([ + { name: 'origin', url: FORK_URL }, + { name: 'upstream', url: FORK_URL } + ]) + }) + const reclaimed = await reconcileOrphanedPrRemotesWithExec( + REPO_PATH, + REPO_ID, + storeOf({ [worktreeId('/wt/a')]: forkTarget({ remoteName: 'origin' }) }), + exec, + [] + ) + expect(reclaimed).toEqual([]) + expect(removeCalls(exec)).toEqual([]) + }) + + it('scopes provenance and liveness to the same repo (remotes are repo-local)', async () => { + const exec = makeExec({ remotes: remoteLines([{ name: FORK_REMOTE, url: FORK_URL }]) }) + const reclaimed = await reconcileOrphanedPrRemotesWithExec( + REPO_PATH, + REPO_ID, + storeOf({ 'repo-2::/wt/other-repo': forkTarget() }), + exec, + [] + ) + expect(reclaimed).toEqual([]) + }) + + it('sends only argv the relay accepts, so the sweep is not silently skipped over SSH', async () => { + // Exercise every branch (config probe, for-each-ref probe, and the reclaim itself). + const exec = makeExec({ + remotes: remoteLines([{ name: FORK_REMOTE, url: FORK_URL }]), + branchConfig: `branch.contributor/fix.remote ${FORK_REMOTE}`, + localBranches: 'main' + }) + await reconcileOrphanedPrRemotesWithExec( + REPO_PATH, + REPO_ID, + storeOf({ [worktreeId('/wt/gone')]: forkTarget() }), + exec, + [] + ) + expect(exec.mock.calls.length).toBeGreaterThan(0) + for (const [args] of exec.mock.calls) { + expect(() => validateGitExecArgs(args)).not.toThrow() + } + }) +}) + +describe('reconcileOrphanedPrRemotes rate limiting', () => { + it('exposes a test reset so repeated test runs are not affected by prior cooldowns', () => { + expect(() => _resetPrRemoteReconciliationRateLimitForTests()).not.toThrow() + }) +}) diff --git a/src/main/ipc/worktree-push-target-reconciliation.ts b/src/main/ipc/worktree-push-target-reconciliation.ts new file mode 100644 index 00000000000..e59da7bdfe7 --- /dev/null +++ b/src/main/ipc/worktree-push-target-reconciliation.ts @@ -0,0 +1,204 @@ +// Why: `pr-*` remotes Orca adds for fork-PR review are only ever pruned by +// `worktree-push-target-cleanup.ts`, and only when a *single* worktree removal +// triggers it. Three things escape that: (1) legacy/reused metadata missing the +// `remoteCreated` flag, (2) a "preserve branch on delete" pinning its remote via +// `branch.*.remote` config long after the worktree is gone, and (3) a worktree +// removed outside Orca entirely (no removal event ever fires). This sweep +// inverts the same safety predicates over every `pr-*` remote in the repo +// instead of one removal, so all three eventually get reclaimed. It never adds +// new safety logic — see `worktree-push-target-cleanup.ts` for the predicates. + +import { gitExecFileAsync } from '../git/runner' +import { listWorktrees } from '../git/worktree' +import type { SshGitProvider } from '../providers/ssh-git-provider' +import type { GitPushTarget } from '../../shared/worktree/types' +import { WORKTREE_ID_SEPARATOR, worktreeIdComparisonKey } from '../../shared/worktree/id' +import { iterateProcessOutputLines } from '../../shared/process-output-field-scanner' +import { + findWorktreeMetaReferencingRemote, + hasBranchConfigUsingRemote, + type GitRemoteExec, + type WorktreePushTargetStore +} from './worktree-push-target-cleanup' + +// Orca only ever mints `pr-head` or `pr--` (see `sanitizeRemoteName`), optionally +// disambiguated with `-2`..`-99` (see `ensureUniqueRemoteName`). The naming convention alone is +// not proof of provenance -- a user could name a remote `pr-foo` -- so this only narrows which +// remotes are even considered; `hasOrcaCreatedProvenance` below is the actual safety gate. +const ORCA_PR_REMOTE_NAME_PATTERN = + /^pr-(?:head|[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?)(?:-[0-9]{1,2})?$/ + +export function isOrcaGeneratedPrRemoteName(name: string): boolean { + return ORCA_PR_REMOTE_NAME_PATTERN.test(name) +} + +type PrRemoteCandidate = { name: string; url: string } + +async function listPrRemoteCandidates( + execGit: GitRemoteExec, + repoPath: string +): Promise { + let stdout: string + try { + ;({ stdout } = await execGit(['remote', '-v'], repoPath)) + } catch { + return [] + } + const candidates = new Map() + for (const line of iterateProcessOutputLines(stdout)) { + const parsed = parseRemoteVerboseLine(line) + if (parsed?.direction === 'fetch' && isOrcaGeneratedPrRemoteName(parsed.name)) { + candidates.set(parsed.name, parsed.url) + } + } + return [...candidates.entries()].map(([name, url]) => ({ name, url })) +} + +function parseRemoteVerboseLine( + line: string +): { name: string; url: string; direction: 'fetch' | 'push' } | null { + const tabIndex = line.indexOf('\t') + if (tabIndex === -1) { + return null + } + const name = line.slice(0, tabIndex) + const match = /^(.*) \((fetch|push)\)$/.exec(line.slice(tabIndex + 1).trim()) + return match ? { name, url: match[1], direction: match[2] as 'fetch' | 'push' } : null +} + +async function shouldReclaimPrRemote( + execGit: GitRemoteExec, + repoPath: string, + repoId: string, + store: WorktreePushTargetStore, + remote: PrRemoteCandidate, + liveWorktreeKeys: ReadonlySet +): Promise { + const target: Pick = { + remoteName: remote.name, + remoteUrl: remote.url + } + const referencingEntries = findWorktreeMetaReferencingRemote(store, repoId, target) + // Provenance gate: only touch a remote some worktree's persisted pushTarget explicitly + // recorded Orca creating. Naming and URL shape are necessary but not sufficient proof. + if (!referencingEntries.some(({ meta }) => meta.pushTarget?.remoteCreated === true)) { + return false + } + const stillClaimedByLiveWorktree = referencingEntries.some(({ worktreeId }) => { + const key = worktreeIdComparisonKey(worktreeId) + return key !== null && liveWorktreeKeys.has(key) + }) + if (stillClaimedByLiveWorktree) { + return false + } + // A branch that still exists may push to this fork again later; only a branch that's + // actually gone (force-deleted, or deleted outside the "preserve on delete" flow) frees it. + if ( + await hasBranchConfigUsingRemote(execGit, repoPath, target, { requireExistingBranch: true }) + ) { + return false + } + return true +} + +// Exported for unit/real-git tests: the `execGit` seam and injected live-worktree paths let +// tests drive the sweep without a real repo (or with one, for the real-git coverage). +export async function reconcileOrphanedPrRemotesWithExec( + repoPath: string, + repoId: string, + store: WorktreePushTargetStore, + execGit: GitRemoteExec, + liveWorktreePaths: readonly string[] +): Promise { + const liveWorktreeKeys = new Set( + liveWorktreePaths + .map((path) => worktreeIdComparisonKey(`${repoId}${WORKTREE_ID_SEPARATOR}${path}`)) + .filter((key): key is string => key !== null) + ) + const reclaimed: string[] = [] + for (const remote of await listPrRemoteCandidates(execGit, repoPath)) { + if (await shouldReclaimPrRemote(execGit, repoPath, repoId, store, remote, liveWorktreeKeys)) { + await execGit(['remote', 'remove', remote.name], repoPath) + reclaimed.push(remote.name) + } + } + return reclaimed +} + +// Why: the sweep costs a handful of git subprocesses (remote -v, worktree list, per-candidate +// config/for-each-ref); bound to once per repo per cooldown so bursts of removals don't repeat it. +const RECONCILE_COOLDOWN_MS = 60 * 60 * 1000 +const lastReconciledAtByRepoId = new Map() + +function shouldReconcileNow(repoId: string): boolean { + const last = lastReconciledAtByRepoId.get(repoId) + return last === undefined || Date.now() - last >= RECONCILE_COOLDOWN_MS +} + +export function _resetPrRemoteReconciliationRateLimitForTests(): void { + lastReconciledAtByRepoId.clear() +} + +function logReclaimed(repoPath: string, reclaimed: string[]): void { + if (reclaimed.length > 0) { + console.log( + `[worktrees] Reclaimed ${reclaimed.length} orphaned PR remote(s) in ${repoPath}: ${reclaimed.join(', ')}` + ) + } +} + +/** Best-effort, rate-limited sweep run alongside single-target cleanup (see call sites). */ +export async function reconcileOrphanedPrRemotes( + repoPath: string, + repoId: string, + store: WorktreePushTargetStore, + gitOptions: { wslDistro?: string } = {} +): Promise { + if (!shouldReconcileNow(repoId)) { + return + } + lastReconciledAtByRepoId.set(repoId, Date.now()) + try { + const liveWorktrees = await listWorktrees(repoPath, gitOptions) + logReclaimed( + repoPath, + await reconcileOrphanedPrRemotesWithExec( + repoPath, + repoId, + store, + (args, cwd) => gitExecFileAsync(args, { cwd, ...gitOptions }), + liveWorktrees.map((worktree) => worktree.path) + ) + ) + } catch (error) { + console.warn(`[worktrees] Failed to reconcile orphaned PR remotes for ${repoPath}`, error) + } +} + +/** SSH counterpart of {@link reconcileOrphanedPrRemotes}; the execution host owns the remotes. */ +export async function reconcileOrphanedPrRemotesSsh( + provider: SshGitProvider, + repoPath: string, + repoId: string, + store: WorktreePushTargetStore +): Promise { + if (!shouldReconcileNow(repoId)) { + return + } + lastReconciledAtByRepoId.set(repoId, Date.now()) + try { + const liveWorktrees = await provider.listWorktrees(repoPath) + logReclaimed( + repoPath, + await reconcileOrphanedPrRemotesWithExec( + repoPath, + repoId, + store, + (args, cwd) => provider.exec(args, cwd), + liveWorktrees.map((worktree) => worktree.path) + ) + ) + } catch (error) { + console.warn(`[worktrees] Failed to reconcile orphaned PR remotes (SSH) for ${repoPath}`, error) + } +} diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 82d54f1d39a..7d28f38f2db 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -104,6 +104,10 @@ import { type GitRemoteExec, type WorktreePushTargetStore } from './worktree-push-target-cleanup' +import { + reconcileOrphanedPrRemotes, + reconcileOrphanedPrRemotesSsh +} from './worktree-push-target-reconciliation' import { configureCreatedWorktreePushTargetWithExec, ensureUniqueRemoteName, @@ -1030,6 +1034,18 @@ export async function cleanupUnusedWorktreePushTargetRemote( } catch (error) { console.warn(`[worktrees] Failed to clean up fork PR remote for ${removedWorktreeId}`, error) } + // Why: also catches remotes this specific removal couldn't reclaim (legacy metadata, + // a preserved branch since deleted, a worktree removed outside Orca) -- see + // worktree-push-target-reconciliation.ts. Rate-limited internally; safe to call every removal. + // Not awaited: a repo with a large backlog (the scenario this exists for) can have dozens of + // candidate remotes, each probed with a couple of git subprocesses -- that must never add + // latency to the worktree-removal call the user is waiting on. It catches its own errors. + void reconcileOrphanedPrRemotes( + repoPath, + getRepoIdFromWorktreeId(removedWorktreeId), + store, + gitOptions + ) } export async function configureCreatedWorktreePushTarget( @@ -1134,6 +1150,14 @@ export async function cleanupUnusedWorktreePushTargetRemoteSsh( error ) } + // Why: SSH counterpart of the sweep above -- the execution host owns these remotes. + // Not awaited for the same reason as the local path: never add sweep latency to removal. + void reconcileOrphanedPrRemotesSsh( + provider, + repoPath, + getRepoIdFromWorktreeId(removedWorktreeId), + store + ) } async function readRemoteEffectiveHooks( @@ -2165,130 +2189,139 @@ export async function createLocalWorktree( let lastExistingReviewNumber: number | null = null const shouldRetireGeneratedName = args.nameWasGenerated === true && isGeneratedWorktreeCreateName(sanitizedName) - const retiredNameRegistry = shouldRetireGeneratedName - ? await getRetiredNameRegistryForRepo(store, repo, store.getRepos(), settings) - : null - const isRetiredName = retiredNameRegistry ? createRetiredNameLookup(retiredNameRegistry) : null - // Why: a create-from-review branch override may already exist locally; suffix both branch and path instead of blocking the user. - for (let suffix = 1, attempts = 0; attempts < WORKTREE_CREATE_MAX_SUFFIX_ATTEMPTS; suffix += 1) { - effectiveSanitizedName = shouldRetireGeneratedName - ? getGeneratedWorktreeCreateCandidate( - sanitizedName, - suffix, - retiredNameRegistry?.exhaustedTiers - ) - : getWorktreeCreateCandidate(sanitizedName, suffix) - effectiveRequestedName = shouldRetireGeneratedName - ? effectiveSanitizedName - : requestedName.trim() - ? getWorktreeCreateCandidate(requestedName, suffix) - : effectiveSanitizedName - if (isRetiredName?.(effectiveSanitizedName)) { - continue - } - attempts += 1 - lastExistingReviewNumber = null + await timing.time('resolve_name', async () => { + const retiredNameRegistry = shouldRetireGeneratedName + ? await getRetiredNameRegistryForRepo(store, repo, store.getRepos(), settings) + : null + const isRetiredName = retiredNameRegistry ? createRetiredNameLookup(retiredNameRegistry) : null + // Why: a create-from-review branch override may already exist locally; suffix both branch and path instead of blocking the user. + for ( + let suffix = 1, attempts = 0; + attempts < WORKTREE_CREATE_MAX_SUFFIX_ATTEMPTS; + suffix += 1 + ) { + effectiveSanitizedName = shouldRetireGeneratedName + ? getGeneratedWorktreeCreateCandidate( + sanitizedName, + suffix, + retiredNameRegistry?.exhaustedTiers + ) + : getWorktreeCreateCandidate(sanitizedName, suffix) + effectiveRequestedName = shouldRetireGeneratedName + ? effectiveSanitizedName + : requestedName.trim() + ? getWorktreeCreateCandidate(requestedName, suffix) + : effectiveSanitizedName + if (isRetiredName?.(effectiveSanitizedName)) { + continue + } + attempts += 1 + lastExistingReviewNumber = null - branchName = await resolveCreateBranchName( - repo.path, - selectedExistingLocalBranchName - ? selectedExistingLocalBranchName - : getBranchNameOverrideCandidate(args.branchNameOverride, suffix), - effectiveSanitizedName, - settings, - username, - localWorktreeGitOptions - ) - checkoutExistingBranch = await canCheckoutExistingLocalBranch( - repo.path, - branchName, - baseBranch, - localWorktreeGitOptions - ) - if (checkoutExistingBranch && !selectedExistingLocalBranchName) { - // Why: suffix retries may need a new path, but an existing-branch checkout must keep the user-selected branch, not a sibling. - selectedExistingLocalBranchName = branchName - } - lastBranchConflictKind = checkoutExistingBranch - ? null - : await getBranchConflictKind(repo.path, branchName, baseBranch, localWorktreeGitOptions) - const allowedPushTargetRemoteConflict = - lastBranchConflictKind && - isAllowedPushTargetRemoteConflict(lastBranchConflictKind, branchName, args) - if (lastBranchConflictKind) { - if (allowedPushTargetRemoteConflict) { - lastExistingPR = null - let lookupFailed = false - const selectedReview = getSelectedReviewBranch(args) - if (selectedReview?.provider === 'github') { - try { - lastExistingPR = await getLocalGitHubPrForBranch( - repo.path, - branchName, - localWorktreeGitOptions - ) - } catch { - lookupFailed = true - } - if (!lookupFailed && isMatchingSelectedGitHubPr(lastExistingPR, args, branchName)) { - lastBranchConflictKind = null - } else if (lastExistingPR) { - lastExistingReviewNumber = lastExistingPR.number - } - } else if (selectedReview) { - let hostedReview: Awaited> = null - try { - hostedReview = await getSelectedHostedReviewForBranch(repo, branchName, args) - } catch { - lookupFailed = true - } - if (!lookupFailed && hostedReview?.matchesSelected) { - lastBranchConflictKind = null - } else if (hostedReview) { - lastExistingReviewNumber = hostedReview.number + branchName = await resolveCreateBranchName( + repo.path, + selectedExistingLocalBranchName + ? selectedExistingLocalBranchName + : getBranchNameOverrideCandidate(args.branchNameOverride, suffix), + effectiveSanitizedName, + settings, + username, + localWorktreeGitOptions + ) + checkoutExistingBranch = await canCheckoutExistingLocalBranch( + repo.path, + branchName, + baseBranch, + localWorktreeGitOptions + ) + if (checkoutExistingBranch && !selectedExistingLocalBranchName) { + // Why: suffix retries may need a new path, but an existing-branch checkout must keep the user-selected branch, not a sibling. + selectedExistingLocalBranchName = branchName + } + lastBranchConflictKind = checkoutExistingBranch + ? null + : await getBranchConflictKind(repo.path, branchName, baseBranch, localWorktreeGitOptions) + const allowedPushTargetRemoteConflict = + lastBranchConflictKind && + isAllowedPushTargetRemoteConflict(lastBranchConflictKind, branchName, args) + if (lastBranchConflictKind) { + if (allowedPushTargetRemoteConflict) { + lastExistingPR = null + let lookupFailed = false + const selectedReview = getSelectedReviewBranch(args) + if (selectedReview?.provider === 'github') { + try { + lastExistingPR = await getLocalGitHubPrForBranch( + repo.path, + branchName, + localWorktreeGitOptions + ) + } catch { + lookupFailed = true + } + if (!lookupFailed && isMatchingSelectedGitHubPr(lastExistingPR, args, branchName)) { + lastBranchConflictKind = null + } else if (lastExistingPR) { + lastExistingReviewNumber = lastExistingPR.number + } + } else if (selectedReview) { + let hostedReview: Awaited> = null + try { + hostedReview = await getSelectedHostedReviewForBranch(repo, branchName, args) + } catch { + lookupFailed = true + } + if (!lookupFailed && hostedReview?.matchesSelected) { + lastBranchConflictKind = null + } else if (hostedReview) { + lastExistingReviewNumber = hostedReview.number + } } } } - } - if (lastBranchConflictKind) { - continue - } - - // Why: gh pr list is a ~1–3s network call; only probe PR conflicts after a branch collision (suffix > 1) so the common no-collision path skips it. - if (suffix > 1 && !checkoutExistingBranch) { - lastExistingPR = null - try { - lastExistingPR = await getLocalGitHubPrForBranch( - repo.path, - branchName, - localWorktreeGitOptions - ) - } catch { - // GitHub API may be unreachable, rate-limited, or token missing - } - if (lastExistingPR && !isMatchingSelectedGitHubPr(lastExistingPR, args, branchName)) { - lastExistingReviewNumber = lastExistingPR.number + if (lastBranchConflictKind) { continue } - } - worktreePath = ensurePathWithinWorkspace( - computeWorktreePath(effectiveSanitizedName, repo.path, worktreePathSettings), - workspaceRoot - ) - if (existsSync(worktreePath)) { - continue - } + // Why: gh pr list is a ~1–3s network call; only probe PR conflicts after a branch collision (suffix > 1) so the common no-collision path skips it. + if (suffix > 1 && !checkoutExistingBranch) { + lastExistingPR = null + try { + lastExistingPR = await getLocalGitHubPrForBranch( + repo.path, + branchName, + localWorktreeGitOptions + ) + } catch { + // GitHub API may be unreachable, rate-limited, or token missing + } + if (lastExistingPR && !isMatchingSelectedGitHubPr(lastExistingPR, args, branchName)) { + lastExistingReviewNumber = lastExistingPR.number + continue + } + } - resolved = true - break - } + worktreePath = ensurePathWithinWorkspace( + computeWorktreePath(effectiveSanitizedName, repo.path, worktreePathSettings), + workspaceRoot + ) + if (existsSync(worktreePath)) { + continue + } + + resolved = true + break + } + }) if (!resolved) { // Why: every suffix collided; reject with a specific reason so the user sees why create failed instead of a generic error or hung spinner. - if (lastExistingReviewNumber !== null) { + // Read once and format eagerly: the suffix loop assigns this from a callback, so the `let`'s + // narrowing does not reach the message. + const existingReviewNumber = lastExistingReviewNumber + if (existingReviewNumber !== null) { throw new Error( - `Branch "${branchName}" already has PR #${lastExistingReviewNumber}. Pick a different ${branchConflictSubject}.` + `Branch "${branchName}" already has PR #${String(existingReviewNumber)}. Pick a different ${branchConflictSubject}.` ) } if (lastBranchConflictKind) { @@ -2337,14 +2370,17 @@ export async function createLocalWorktree( emitCreateWorktreeProgress(mainWindow, 'creating', args.creationId) let preparedPushTarget: GitPushTarget | undefined - if (args.pushTarget) { + const requestedPushTarget = args.pushTarget + if (requestedPushTarget) { // Why: validate/fetch the contributor remote before create so a failure doesn't leave a half-created worktree with conflicts on retry. - preparedPushTarget = await prepareWorktreePushTarget( - repo.path, - args.pushTarget, - store, - repo.id, - localWorktreeGitOptions + preparedPushTarget = await timing.time('prepare_push_target', () => + prepareWorktreePushTarget( + repo.path, + requestedPushTarget, + store, + repo.id, + localWorktreeGitOptions + ) ) } diff --git a/src/main/ipc/worktrees-ssh-repo-owner-resolution.test.ts b/src/main/ipc/worktrees-ssh-repo-owner-resolution.test.ts index 6a398e9b02d..b3231da86f6 100644 --- a/src/main/ipc/worktrees-ssh-repo-owner-resolution.test.ts +++ b/src/main/ipc/worktrees-ssh-repo-owner-resolution.test.ts @@ -1,7 +1,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { GitWorktreeInfo, Worktree } from '../../shared/worktree/types' import type { ProviderRequestId } from '../../shared/detected-worktree-provider-contract' -import { LOCAL_EXECUTION_HOST_ID, toSshExecutionHostId } from '../../shared/execution-host' +import { + LOCAL_EXECUTION_HOST_ID, + toRuntimeExecutionHostId, + toSshExecutionHostId +} from '../../shared/execution-host' import { getSshProviderAuthority } from '../ssh/ssh-provider-authority' import { listWorktreesMock, @@ -443,7 +447,76 @@ describe('registerWorktreeHandlers', () => { expect(store.removeWorktreeMeta).not.toHaveBeenCalled() }) - it('refuses to retire metadata for non-SSH hosts and unowned repos', async () => { + // Runtime-host rows are exempt from gcStaleWorktreeMeta exactly as SSH ones are, so a paired + // client needs this path to ever drop them (#17776). + it('retires runtime-host metadata an authoritative scan proved gone', async () => { + const runtimeHostId = toRuntimeExecutionHostId('env-1') + const runtimeRepo = { + id: 'repo-1', + path: '/home/orca/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + executionHostId: runtimeHostId + } + const metaById: Record> = { + 'repo-1::/home/orca/deleted': makeWorktreeMeta({ hostId: runtimeHostId }), + 'repo-1::/home/orca/other-host': makeWorktreeMeta({ + hostId: toSshExecutionHostId('target-a') + }) + } + store.getRepos.mockReturnValue([runtimeRepo]) + store.getProjectHostSetups.mockReturnValue([]) + store.getAllWorktreeMeta.mockReturnValue(metaById) + store.removeWorktreeMeta.mockImplementation((worktreeId: string) => { + delete metaById[worktreeId] + }) + + const forgotten = await handlers['worktrees:forgetRemovedForExecutionHost'](null, { + repoId: runtimeRepo.id, + executionHostId: runtimeHostId, + worktreeIds: ['repo-1::/home/orca/deleted', 'repo-1::/home/orca/other-host'] + }) + + // The row stamped to another host needs that host's own scan, not this one's. + expect(forgotten).toEqual({ forgottenWorktreeIds: ['repo-1::/home/orca/deleted'] }) + expect(store.removeWorktreeMeta).toHaveBeenCalledExactlyOnceWith( + 'repo-1::/home/orca/deleted', + runtimeHostId + ) + }) + + // A repo that reaches its checkouts over SSH is not the runtime host's to condemn. The refusal + // comes from `findExactRepoOwner`: a runtime `executionHostId` beside a `connectionId` is + // contradictory ownership evidence, so no owner resolves at all. + it('refuses to retire a connection-backed repo under a runtime host id', async () => { + const runtimeHostId = toRuntimeExecutionHostId('env-1') + store.getRepos.mockReturnValue([ + { + id: 'repo-1', + path: '/home/orca/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + executionHostId: runtimeHostId, + connectionId: 'target-a' + } + ]) + store.getAllWorktreeMeta.mockReturnValue({ + 'repo-1::/home/orca/deleted': makeWorktreeMeta({ hostId: runtimeHostId }) + }) + + expect( + await handlers['worktrees:forgetRemovedForExecutionHost'](null, { + repoId: 'repo-1', + executionHostId: runtimeHostId, + worktreeIds: ['repo-1::/home/orca/deleted'] + }) + ).toEqual({ forgottenWorktreeIds: [] }) + expect(store.removeWorktreeMeta).not.toHaveBeenCalled() + }) + + it('refuses to retire metadata for non-executing hosts and unowned repos', async () => { const sshRepo = { id: 'repo-1', path: '/remote/repo-a', diff --git a/src/main/ipc/worktrees/create/register-worktree-create-handlers.ts b/src/main/ipc/worktrees/create/register-worktree-create-handlers.ts index 775a0859790..f371529963c 100644 --- a/src/main/ipc/worktrees/create/register-worktree-create-handlers.ts +++ b/src/main/ipc/worktrees/create/register-worktree-create-handlers.ts @@ -4,7 +4,10 @@ import type { CreateWorktreeResult, AdoptProvisionedRootArgs } from '../../../../shared/worktree/create-types' -import { withWorktreeSpan } from '../../../observability/instrumentation' +import { + addWorktreeCreatePhaseAttributes, + withWorktreeSpan +} from '../../../observability/instrumentation' import { workspaceSourceSchema } from '../../../../shared/telemetry-events' import type { WorkspaceSource } from '../../../../shared/telemetry-events' import { @@ -36,7 +39,7 @@ export function registerWorktreeCreateHandlers(context: WorktreeIpcContext): voi async (_event, rawArgs: CreateWorktreeArgs): Promise => { const args = normalizeLinkedWorkItemFields(rawArgs) // Why span here: parent the child git spans for the trace tree; don't attach branch name/remote URL (user content) — repo ID is the safer correlator. - return withWorktreeSpan({ stage: 'create' }, async () => { + return withWorktreeSpan({ stage: 'create' }, async (span) => { const repo = store.getRepo(args.repoId) if (!repo) { throw new Error(`Repo not found: ${args.repoId}`) @@ -74,6 +77,9 @@ export function registerWorktreeCreateHandlers(context: WorktreeIpcContext): voi throw error } finishAutomationWorkspaceProvenanceRequest(args.automationProvenanceRequest) + if (result.timing) { + addWorktreeCreatePhaseAttributes(span, result.timing) + } // Why: reaching here means create succeeded (helpers throw); skip a separate workspace_initialized (telemetry-plan.md§Deferred); never send the branch name. track('workspace_created', { diff --git a/src/main/ipc/worktrees/listing/register-host-catalog-handlers.ts b/src/main/ipc/worktrees/listing/register-host-catalog-handlers.ts index 0d47f3638c2..4467506e790 100644 --- a/src/main/ipc/worktrees/listing/register-host-catalog-handlers.ts +++ b/src/main/ipc/worktrees/listing/register-host-catalog-handlers.ts @@ -103,11 +103,21 @@ export function registerHostCatalogHandlers(context: WorktreeIpcContext): void { const requestedExecutionHostId = args?.executionHostId ?? 'ssh:' const worktreeIds = Array.isArray(args?.worktreeIds) ? args.worktreeIds : [] const parsedHost = parseExecutionHostId(requestedExecutionHostId) - if (parsedHost?.kind !== 'ssh' || worktreeIds.length === 0) { + // Runtime hosts belong here for the same reason SSH ones do: their rows are exempt from + // gcStaleWorktreeMeta, so a scan-proven removal is the only thing that ever retires them. + if ( + (parsedHost?.kind !== 'ssh' && parsedHost?.kind !== 'runtime') || + worktreeIds.length === 0 + ) { return nothingForgotten } + // No runtime arm in the check below: `findExactRepoOwner` already refuses a repo carrying both + // a runtime `executionHostId` and a `connectionId`, because `resolveRepoOwnershipEvidence` + // calls that pair contradictory and one non-owned candidate voids the whole lookup. A second + // check would be unreachable, and unreachable code on a destructive path reads as a guarantee + // it is not making. const repo = findExactRepoOwner(store, args?.repoId ?? '', requestedExecutionHostId) - if (!repo || repo.connectionId !== parsedHost.targetId) { + if (!repo || (parsedHost.kind === 'ssh' && repo.connectionId !== parsedHost.targetId)) { return nothingForgotten } // Why: a folder workspace's meta IS the workspace record, not a checkout row — gcStaleWorktreeMeta skips diff --git a/src/main/observability/instrumentation.test.ts b/src/main/observability/instrumentation.test.ts index 2f7d1da05dd..978854897e4 100644 --- a/src/main/observability/instrumentation.test.ts +++ b/src/main/observability/instrumentation.test.ts @@ -3,6 +3,7 @@ import { _resetTracerForTests, setActiveSink, type TracerSink } from './tracer' import { _gitSpanSamplingBucketCountForTests, _resetGitSpanSamplingForTests, + addWorktreeCreatePhaseAttributes, withGitSpan } from './instrumentation' @@ -167,3 +168,50 @@ describe('withGitSpan sampling', () => { expect(_gitSpanSamplingBucketCountForTests()).toBe(1) }) }) + +describe('addWorktreeCreatePhaseAttributes', () => { + function capture(): { + attributes: Record + span: Parameters[0] + } { + const attributes: Record = {} + const span = { + setAttribute: (key: string, value: unknown) => { + attributes[key] = value + } + } as unknown as Parameters[0] + return { attributes, span } + } + + it('counts concurrent phases once when measuring unattributed time', () => { + const { attributes, span } = capture() + // Create resolves shared directories and .worktreeinclude concurrently; summing their + // durations would claim 400ms of coverage for a 200ms window. + addWorktreeCreatePhaseAttributes(span, { + totalDurationMs: 1000, + phases: [ + { phase: 'resolve_shared_directories', startedAtMs: 100, durationMs: 200 }, + { phase: 'resolve_worktreeinclude', startedAtMs: 150, durationMs: 150 } + ] + }) + + expect(attributes['worktree.create.phase.resolve_shared_directories_ms']).toBe(200) + expect(attributes['worktree.create.phase.resolve_worktreeinclude_ms']).toBe(150) + // Covered wall clock is 100..300, so 800ms is genuinely unaccounted for. + expect(attributes['worktree.create.unattributed_ms']).toBe(800) + }) + + it('sums disjoint phases and never reports negative unattributed time', () => { + const { attributes, span } = capture() + addWorktreeCreatePhaseAttributes(span, { + totalDurationMs: 500, + phases: [ + { phase: 'resolve_name', startedAtMs: 0, durationMs: 100 }, + { phase: 'git_worktree_add', startedAtMs: 300, durationMs: 200 } + ] + }) + + expect(attributes['worktree.create.total_ms']).toBe(500) + expect(attributes['worktree.create.unattributed_ms']).toBe(200) + }) +}) diff --git a/src/main/observability/instrumentation.ts b/src/main/observability/instrumentation.ts index bab57b74f64..fb57aa5a870 100644 --- a/src/main/observability/instrumentation.ts +++ b/src/main/observability/instrumentation.ts @@ -202,10 +202,11 @@ export type WorktreeSpanArgs = { readonly path?: string } -/** Wrap a worktree-setup phase in a `worktree.` span. */ +/** Wrap a worktree-setup phase in a `worktree.` span. The callback receives the span so a + * create can attach its own phase breakdown; the git children alone leave the waits invisible. */ export async function withWorktreeSpan( meta: WorktreeSpanArgs, - fn: () => Promise + fn: (span: ActiveSpan) => Promise ): Promise { return withSpan( `worktree.${meta.stage}`, @@ -214,12 +215,64 @@ export async function withWorktreeSpan( if (meta.path) { span.setAttribute('worktree.path', meta.path) } - return await fn() + return await fn(span) }, { attributes: { kind: 'worktree' } } ) } +type WorktreeCreatePhaseTiming = { + readonly phase: string + readonly startedAtMs: number + readonly durationMs: number +} + +/** Wall-clock span covered by at least one phase. Create runs some phases concurrently, so summing + * durations double-counts and would report overlap as coverage the phases never had. */ +function measuredWallClockMs(phases: readonly WorktreePhaseInterval[]): number { + const intervals = [...phases] + .map((phase) => [phase.startedAtMs, phase.startedAtMs + phase.durationMs] as const) + .sort((left, right) => left[0] - right[0]) + let covered = 0 + let openedAt: number | null = null + let closesAt = 0 + for (const [start, end] of intervals) { + if (openedAt === null) { + openedAt = start + closesAt = end + continue + } + if (start <= closesAt) { + closesAt = Math.max(closesAt, end) + continue + } + covered += closesAt - openedAt + openedAt = start + closesAt = end + } + return openedAt === null ? 0 : covered + (closesAt - openedAt) +} + +type WorktreePhaseInterval = Pick + +/** Records a create's phase breakdown on its span. Phase names are already a closed vocabulary in + * the recorder, so they are safe to key on; nothing here carries a branch name or a path. */ +export function addWorktreeCreatePhaseAttributes( + span: ActiveSpan, + timing: { totalDurationMs: number; phases: readonly WorktreeCreatePhaseTiming[] } +): void { + span.setAttribute('worktree.create.total_ms', Math.round(timing.totalDurationMs)) + for (const phase of timing.phases) { + span.setAttribute(`worktree.create.phase.${phase.phase}_ms`, Math.round(phase.durationMs)) + } + // What the phases do not cover is the number that matters when create feels slow for no visible + // reason, so name it rather than leaving it to subtraction. + span.setAttribute( + 'worktree.create.unattributed_ms', + Math.max(0, Math.round(timing.totalDurationMs - measuredWallClockMs(timing.phases))) + ) +} + /** Closed set so a typo can't silently mint an orphan span name. */ export type WorktreeRemoveStage = | 'archive_hook' diff --git a/src/main/orca-profiles/profile-project-worktree-identity.ts b/src/main/orca-profiles/profile-project-worktree-identity.ts index 1586a0e0120..8cf58dd1bd5 100644 --- a/src/main/orca-profiles/profile-project-worktree-identity.ts +++ b/src/main/orca-profiles/profile-project-worktree-identity.ts @@ -66,6 +66,24 @@ export function rekeyOwnerKey( return null } +/** + * Every worktree locator an owner key could name. + * + * Two readings, because one key can be both: with a repo literally named `worktree`, + * `worktree::/p` is a `::` locator AND parses as a `worktree:` workspace key naming + * repo `` (empty). `ownerKeyBelongsToRepo` accepts either, so a caller that reasons about a key + * without a repo id in hand has to consider both or it will disagree with the predicate. + */ +export function ownerKeyWorktreeIds(ownerKey: string): string[] { + const rawOwnerKey = isWorktreeHostIdentity(ownerKey) + ? getWorktreeIdFromHostIdentity(ownerKey) + : ownerKey + const scope = parseWorkspaceKey(ownerKey) + return scope?.type === 'worktree' && scope.worktreeId !== rawOwnerKey + ? [rawOwnerKey, scope.worktreeId] + : [rawOwnerKey] +} + export function ownerKeyBelongsToRepo(ownerKey: string, repoId: string): boolean { const rawOwnerKey = isWorktreeHostIdentity(ownerKey) ? getWorktreeIdFromHostIdentity(ownerKey) diff --git a/src/main/persistence-cohort-and-identity-migration.test.ts b/src/main/persistence-cohort-and-identity-migration.test.ts index 4081672c821..a894b8a4c63 100644 --- a/src/main/persistence-cohort-and-identity-migration.test.ts +++ b/src/main/persistence-cohort-and-identity-migration.test.ts @@ -397,6 +397,8 @@ describe('Store.migrateWorktreeIdentity', () => { it('moves persisted mobile selections across reloads', async () => { const store = await createStore() + // Registered on purpose: rows owned by an unregistered repo id are swept as orphans on load. + store.addRepo(makeRepo({ id: 'repo1', path: '/repo1' })) store.setMobileClientTabSelections({ 'device-a': { [OLD]: { activeTabId: 'tab-1', activeGroupId: null, activeTabIdByGroupId: {} } diff --git a/src/main/persistence-cross-host-pane-identity.test.ts b/src/main/persistence-cross-host-pane-identity.test.ts index 2b6a70da934..479d3727837 100644 --- a/src/main/persistence-cross-host-pane-identity.test.ts +++ b/src/main/persistence-cross-host-pane-identity.test.ts @@ -10,6 +10,7 @@ import { createStore, writeDataFile, readDataFile, + makeRepo, makeTerminalTab } from './persistence-test-harness' @@ -53,6 +54,11 @@ describe('cross-host pane identity migration', () => { it('refuses hostless alias and acknowledgement rewrites for a tab id two partitions share', async () => { writeDataFile({ schemaVersion: 1, + // Registered on purpose: rows owned by an unregistered repo id are swept as orphans on load. + repos: [ + makeRepo({ id: 'repo-local', path: '/repo-local' }), + makeRepo({ id: 'repo-a', path: '/repo-a' }) + ], workspaceSession: makeLegacyPaneSession('repo-local', 'local-pty'), workspaceSessionsByHostId: { 'ssh:host-a': makeLegacyPaneSession('repo-a', 'pty-a') diff --git a/src/main/persistence-deregistered-repo-residue.test.ts b/src/main/persistence-deregistered-repo-residue.test.ts new file mode 100644 index 00000000000..3a7a3372b3c --- /dev/null +++ b/src/main/persistence-deregistered-repo-residue.test.ts @@ -0,0 +1,240 @@ +// Why this file exists: deregistering a project used to strand every row it owned. No sweeper could +// reach them -- the missing-directory prune is gated on the repo still being registered, and a +// paired client's mirror of a remote host's rows is keyed by ids that client never registers, so the +// owning host's removal never reached it (#17776). +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { rmSync, mkdtempSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { getDefaultWorkspaceSession } from '../shared/constants' +import { composeWorktreeHostIdentity } from '../shared/worktree/host-qualified-identity' +import { folderWorkspaceKey, worktreeWorkspaceKey } from '../shared/workspace-scope' +import type { PersistedState } from '../shared/persisted-state-types' +import { + testState, + createStore, + writeDataFile, + readDataFile, + makeRepo, + makeTerminalTab +} from './persistence-test-harness' + +vi.mock('./ssh/ssh-config-parser', () => ({ + loadUserSshConfig: vi.fn(), + sshConfigHostsToTargets: vi.fn() +})) + +vi.mock('electron', () => ({ + app: { getPath: () => testState.dir }, + safeStorage: { isEncryptionAvailable: () => false } +})) + +vi.mock('./telemetry/client', () => ({ track: vi.fn() })) +vi.mock('./telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn().mockReturnValue({}) })) + +const LIVE_REPO = 'live-repo' +const GONE_REPO = 'gone-repo' +const LIVE_WORKTREE = `${LIVE_REPO}::/workspace/live` +const GONE_WORKTREE = `${GONE_REPO}::/workspace/orphan` +const RUNTIME_HOST = 'runtime:env-a' + +const sleepingAgentFor = (worktreeId: string, tabId = 'tab-1') => ({ + [`${tabId}:leaf-1`]: { + paneKey: `${tabId}:leaf-1`, + tabId, + worktreeId, + agent: 'codex' as const, + providerSession: { key: 'session_id' as const, id: 'sess-1' }, + prompt: 'sleeping', + state: 'waiting' as const, + capturedAt: 1, + updatedAt: 1, + origin: 'worktree-sleep' as const + } +}) + +const sessionFor = (worktreeId: string, tabId = 'tab-1') => ({ + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [worktreeId]: [makeTerminalTab({ id: tabId, worktreeId })] + }, + activeTabTypeByWorktree: { [worktreeId]: 'terminal' as const }, + lastVisitedAtByWorktreeId: { [worktreeId]: 123 }, + // The residue `profile-project-session-field-disposition` flags as leaking on repo removal. + sleepingAgentSessionsByPaneKey: sleepingAgentFor(worktreeId, tabId) +}) + +describe('deregistered repo residue', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-orphan-sweep-')) + }) + + afterEach(() => { + rmSync(testState.dir, { recursive: true, force: true }) + }) + + it('drops metadata, identity rows and sessions owned by an unregistered repo id', async () => { + const seed = await createStore() + seed.addRepo(makeRepo({ id: LIVE_REPO, path: '/workspace/live' })) + seed.addRepo(makeRepo({ id: GONE_REPO, path: '/workspace/orphan' })) + seed.setWorktreeMetaForHost(LIVE_WORKTREE, 'local', { displayName: 'Live' }) + seed.setWorktreeMetaForHost(GONE_WORKTREE, 'local', { displayName: 'Orphan' }) + seed.setWorkspaceSession(sessionFor(GONE_WORKTREE), 'local') + seed.flush() + + // Deregister by hand: the point is that a row can outlive its repo however that happened. + const persisted = readDataFile() as PersistedState + persisted.repos = persisted.repos.filter((repo) => repo.id !== GONE_REPO) + writeDataFile(persisted) + + const reloaded = await createStore() + reloaded.flush() + const swept = readDataFile() as PersistedState + + expect(Object.keys(swept.worktreeMeta)).toEqual([LIVE_WORKTREE]) + expect(swept.worktreeIdentityAliases).not.toHaveProperty( + composeWorktreeHostIdentity('local', GONE_WORKTREE) + ) + expect(Object.keys(swept.worktreeMetaByIdentity ?? {})).toHaveLength(1) + const session = swept.workspaceSession + expect(session.tabsByWorktree).toEqual({}) + expect(session.lastVisitedAtByWorktreeId).toEqual({}) + expect(session.activeTabTypeByWorktree).toEqual({}) + expect(session.sleepingAgentSessionsByPaneKey ?? {}).toEqual({}) + }) + + it("sweeps a remote host's session partition the owning host's removal can never reach", async () => { + writeDataFile({ + schemaVersion: 1, + repos: [makeRepo({ id: LIVE_REPO, path: '/workspace/live' })], + worktreeMeta: {}, + workspaceSessionsByHostId: { + [RUNTIME_HOST]: sessionFor(GONE_WORKTREE) + } + }) + + const store = await createStore() + store.flush() + + const partition = store.getWorkspaceSession(RUNTIME_HOST) + expect(partition.tabsByWorktree).toEqual({}) + expect(partition.activeTabTypeByWorktree).toEqual({}) + }) + + it('keeps rows for every registered repo, on any execution host', async () => { + const remoteWorktree = `${LIVE_REPO}::/home/user/remote` + writeDataFile({ + schemaVersion: 1, + repos: [makeRepo({ id: LIVE_REPO, path: '/home/user/live', executionHostId: RUNTIME_HOST })], + worktreeMeta: { [remoteWorktree]: { hostId: RUNTIME_HOST, status: 'active' } }, + workspaceSessionsByHostId: { [RUNTIME_HOST]: sessionFor(remoteWorktree) } + }) + + const store = await createStore() + + expect(store.getWorktreeMeta(remoteWorktree)).toBeDefined() + const partition = store.getWorkspaceSession(RUNTIME_HOST) + expect(partition.tabsByWorktree[remoteWorktree]).toHaveLength(1) + // Also proves the sleeping-agent fixture is well-formed, so the sweep assertions above bite. + expect(Object.keys(partition.sleepingAgentSessionsByPaneKey ?? {})).toHaveLength(1) + }) + + it('leaves folder-workspace session rows alone: their keys name no repo', async () => { + const workspaceKey = folderWorkspaceKey('folder-1') + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + workspaceSession: { + ...getDefaultWorkspaceSession(), + lastVisitedAtByWorktreeId: { [workspaceKey]: 7 } + } + }) + + const store = await createStore() + + expect(store.getWorkspaceSession('local').lastVisitedAtByWorktreeId).toEqual({ + [workspaceKey]: 7 + }) + }) + + // Regression: the pane-keyed records are pruned by the worktreeId they name, not by their own key, + // so an orphan whose ONLY residue is a sleeping agent survived -- and re-seeded the sweep on every + // launch, so the store never self-cleared and every load scheduled another save. + it("drops a sleeping agent that is the orphan repo's only residue, and self-clears", async () => { + writeDataFile({ + schemaVersion: 1, + repos: [makeRepo({ id: LIVE_REPO, path: '/workspace/live' })], + worktreeMeta: {}, + workspaceSession: { + ...getDefaultWorkspaceSession(), + sleepingAgentSessionsByPaneKey: sleepingAgentFor(GONE_WORKTREE) + } + }) + + const store = await createStore() + store.flush() + expect(store.getWorkspaceSession('local').sleepingAgentSessionsByPaneKey ?? {}).toEqual({}) + // On disk, not just in memory: if the flush had not persisted the cleanup, the next load would + // silently redo it and the self-clearing assertion below would pass without meaning anything. + const persisted = readDataFile() as PersistedState + expect(persisted.workspaceSession.sleepingAgentSessionsByPaneKey ?? {}).toEqual({}) + + // Self-clearing: with the residue gone nothing re-seeds the orphan id, so the next launch has + // no work. Before the fix this stayed non-empty forever and every load scheduled another save. + const reloaded = await createStore() + expect(reloaded.sweepDeregisteredRepoResidue()).toEqual([]) + }) + + // The session scalars are pruned by bespoke rules, not by owner key, so no owner-key loop reaches + // them. Each has to be able to seed the sweep on its own or an orphan named only there is stuck. + it.each([ + { label: 'activeWorktreeId', session: { activeWorktreeId: GONE_WORKTREE } }, + // Canonical `worktree:` form, which needs unwrapping before the repo id is visible. + { + label: 'activeWorkspaceKey', + session: { activeWorkspaceKey: worktreeWorkspaceKey(GONE_WORKTREE) } + }, + { + label: 'activeWorktreeIdsOnShutdown', + session: { activeWorktreeIdsOnShutdown: [GONE_WORKTREE] } + } + ])("clears $label when it is the orphan repo's only residue", async ({ session }) => { + writeDataFile({ + schemaVersion: 1, + repos: [makeRepo({ id: LIVE_REPO, path: '/workspace/live' })], + worktreeMeta: {}, + workspaceSessionsByHostId: { + [RUNTIME_HOST]: { ...getDefaultWorkspaceSession(), ...session } + } + }) + + const store = await createStore() + store.flush() + + const partition = store.getWorkspaceSession(RUNTIME_HOST) + expect(partition.activeWorktreeId ?? null).toBeNull() + expect(partition.activeWorkspaceKey ?? null).toBeNull() + expect(partition.activeWorktreeIdsOnShutdown ?? []).toEqual([]) + const reloaded = await createStore() + expect(reloaded.sweepDeregisteredRepoResidue()).toEqual([]) + }) + + // Why: a sweep that dirtied every launch would rewrite the profile forever and mask real changes. + it('leaves a profile with no orphans byte-identical across reloads', async () => { + const seed = await createStore() + seed.addRepo(makeRepo({ id: LIVE_REPO, path: '/workspace/live' })) + seed.setWorktreeMetaForHost(LIVE_WORKTREE, 'local', { displayName: 'Live' }) + seed.setWorkspaceSession(sessionFor(LIVE_WORKTREE), 'local') + seed.flush() + + const canonicalizing = await createStore() + canonicalizing.flush() + const canonical = JSON.stringify(readDataFile()) + + const reloaded = await createStore() + reloaded.flush() + + expect(JSON.stringify(readDataFile())).toBe(canonical) + }) +}) diff --git a/src/main/persistence-host-partitioned-sessions.test.ts b/src/main/persistence-host-partitioned-sessions.test.ts index 023737ed386..aa2e46e52fd 100644 --- a/src/main/persistence-host-partitioned-sessions.test.ts +++ b/src/main/persistence-host-partitioned-sessions.test.ts @@ -123,6 +123,9 @@ describe('Store host-partitioned workspace sessions', () => { } }) + // Registered on purpose: rows owned by an unregistered repo id are swept as orphans on load. + const makeRepos = (...repoIds: string[]) => repoIds.map((id) => makeRepo({ id, path: `/${id}` })) + it('migrates a legacy workspaceSession blob into the local partition', async () => { writeDataFile({ schemaVersion: 1, @@ -194,6 +197,7 @@ describe('Store host-partitioned workspace sessions', () => { writeDataFile({ schemaVersion: 1, workspaceSession: makeHostSession('local-repo'), + repos: makeRepos('repo-ssh'), workspaceSessionsByHostId: { 'ssh:ssh-1': makeLegacyPaneHostSession('repo-ssh', 'remote-pty') }, @@ -224,6 +228,7 @@ describe('Store host-partitioned workspace sessions', () => { writeDataFile({ schemaVersion: 1, workspaceSession: makeHostSession('local-repo'), + repos: makeRepos('repo-a', 'repo-b'), workspaceSessionsByHostId: { 'ssh:host-a': makeLegacyPaneHostSession('repo-a', 'pty-a'), 'ssh:host-b': makeLegacyPaneHostSession('repo-b', 'pty-b') @@ -488,6 +493,7 @@ describe('Store host-partitioned workspace sessions', () => { it('removes one orphaned worktree with a host-scoped topology fence', async () => { const store = await createStore() + store.addRepo(makeRepo({ id: 'repo-gone', path: '/repo-gone' })) const worktreeId = 'repo-gone::/workspace/stale' const session = { ...makeHostSession('repo-gone'), @@ -728,6 +734,7 @@ describe('Store host-partitioned workspace sessions', () => { const worktreeId = 'repo-1::/worktree' writeDataFile({ schemaVersion: 1, + repos: makeRepos('repo-1'), workspaceSessionsByHostId: { 'runtime:good': makeHostSession('good-repo'), // activeRepoId must be string|null; a number fails the zod parse. @@ -753,6 +760,7 @@ describe('Store host-partitioned workspace sessions', () => { const worktreeId = 'repo-1::/worktree' writeDataFile({ schemaVersion: 1, + repos: makeRepos('repo-1'), workspaceSession: { ...makeHostSession('local-repo'), // A projected/truncated write can leave a top-level field the wrong type; @@ -813,6 +821,7 @@ describe('Store host-partitioned workspace sessions', () => { const worktreeId = 'repo-1::/worktree' const profile = await canonicalize({ schemaVersion: 1, + repos: makeRepos('repo-1'), workspaceSession: { ...makeHostSession('local-repo'), tabsByWorktree: { [worktreeId]: [makeTerminalTab({ id: 'tab-keep', worktreeId })] } @@ -845,6 +854,7 @@ describe('Store host-partitioned workspace sessions', () => { const worktreeId = 'repo-1::/worktree' const profile = await canonicalize({ schemaVersion: 1, + repos: makeRepos('repo-1'), workspaceSessionsByHostId: { 'runtime:env-a': { ...makeHostSession('runtime-repo'), diff --git a/src/main/persistence-initial-load.test.ts b/src/main/persistence-initial-load.test.ts index 6d0c0f11d5e..934ab44e1cb 100644 --- a/src/main/persistence-initial-load.test.ts +++ b/src/main/persistence-initial-load.test.ts @@ -150,6 +150,8 @@ describe('Store', () => { it('does not restore a terminal tab after its durable close flush returns', async () => { const store = await createStore() + // Registered on purpose: rows owned by an unregistered repo id are swept as orphans on load. + store.addRepo(makeRepo({ id: 'repo-1', path: '/repo-1' })) const worktreeId = 'repo-1::/tmp/worktree-1' const tabId = 'terminal-1' const session: WorkspaceSessionState = { diff --git a/src/main/persistence-native-chat-tab-view-mode.test.ts b/src/main/persistence-native-chat-tab-view-mode.test.ts index 3e3544c7495..9bcaab15494 100644 --- a/src/main/persistence-native-chat-tab-view-mode.test.ts +++ b/src/main/persistence-native-chat-tab-view-mode.test.ts @@ -58,7 +58,7 @@ describe('Store native-chat tab viewMode persistence', () => { const WORKTREE = 'repo1::/worktree' writeDataFile({ schemaVersion: 1, - repos: [makeRepo()], + repos: [makeRepo({ id: 'repo1', path: '/repo1' })], worktreeMeta: {}, settings: {}, ui: {}, diff --git a/src/main/persistence-repo-lifecycle.test.ts b/src/main/persistence-repo-lifecycle.test.ts index 1653f78297e..66f3fc2c32e 100644 --- a/src/main/persistence-repo-lifecycle.test.ts +++ b/src/main/persistence-repo-lifecycle.test.ts @@ -737,7 +737,10 @@ describe('Store', () => { it('reassignSshTargetId persists a worktree-meta-only re-point (no matching repo)', async () => { const store = await createStore() - // A meta on the old SSH host with no repo row — the re-point must still be persisted, not memory-only. + // A meta on the old SSH host with no repo row for that host — the re-point must still be + // persisted, not memory-only. The repo id stays registered so the load-time orphan sweep, + // which only reads repo ids, leaves the row alone. + store.addRepo(makeRepo({ id: 'r1', path: '/r1' })) store.setWorktreeMeta('r1::/remote/wt', { displayName: 'wt', hostId: 'ssh:ssh-old' }) const repoIds = store.reassignSshTargetId('ssh-old', 'ssh-new') @@ -787,6 +790,7 @@ describe('Store', () => { it('reassignSshTargetId re-keys a session partition stored under the old ssh host id', async () => { const store = await createStore() + store.addRepo(makeRepo({ id: 'r1', path: '/r1' })) store.setWorkspaceSession( { activeRepoId: null, diff --git a/src/main/persistence-settings-update.test.ts b/src/main/persistence-settings-update.test.ts index dc3a3c7ebb5..9af63ca7ccd 100644 --- a/src/main/persistence-settings-update.test.ts +++ b/src/main/persistence-settings-update.test.ts @@ -708,7 +708,7 @@ describe('Store', () => { } writeDataFile({ schemaVersion: 1, - repos: [makeRepo()], + repos: [makeRepo({ id: 'repo1', path: '/repo1' })], worktreeMeta: { 'repo1::/worktree-a': { status: 'active' }, 'repo1::/worktree-b': { status: 'active' } diff --git a/src/main/persistence-ssh-targets-and-pane-keys.test.ts b/src/main/persistence-ssh-targets-and-pane-keys.test.ts index 6c186ade077..c11ecbf0bc2 100644 --- a/src/main/persistence-ssh-targets-and-pane-keys.test.ts +++ b/src/main/persistence-ssh-targets-and-pane-keys.test.ts @@ -346,7 +346,7 @@ describe('Store', () => { const acknowledgedAt = 1_700_000_000_000 writeDataFile({ schemaVersion: 1, - repos: [makeRepo()], + repos: [makeRepo({ id: 'repo1', path: '/repo1' })], worktreeMeta: {}, settings: {}, ui: { @@ -408,7 +408,7 @@ describe('Store', () => { writeDataFile({ schemaVersion: 1, - repos: [makeRepo()], + repos: [makeRepo({ id: 'repo1', path: '/repo1' })], worktreeMeta: {}, settings: {}, ui: { diff --git a/src/main/persistence-worktree-lineage-and-backups.test.ts b/src/main/persistence-worktree-lineage-and-backups.test.ts index ef5e83745be..372e8b0e33b 100644 --- a/src/main/persistence-worktree-lineage-and-backups.test.ts +++ b/src/main/persistence-worktree-lineage-and-backups.test.ts @@ -166,6 +166,8 @@ describe('Store', () => { describe('mobileClientTabSelectionsByDeviceId', () => { it('persists device tab selections across reloads and drops malformed payloads', async () => { const store = await createStore() + // Registered on purpose: rows owned by an unregistered repo id are swept as orphans on load. + store.addRepo(makeRepo({ id: 'repo-1', path: '/repo-1' })) store.setMobileClientTabSelections({ 'device-a': { 'repo-1::/tmp/wt': { activeTabId: 'tab-1', activeGroupId: 'g1', activeTabIdByGroupId: {} } @@ -188,6 +190,7 @@ describe('Store', () => { it('prunes selections for a removed repo worktree', async () => { const store = await createStore() store.addRepo(makeRepo()) + store.addRepo(makeRepo({ id: 'other-repo', path: '/other-repo' })) store.setMobileClientTabSelections({ 'device-a': { 'r1::/tmp/wt': { diff --git a/src/main/persistence/loading-store/repo-lifecycle-operations.ts b/src/main/persistence/loading-store/repo-lifecycle-operations.ts index 095090ee1ca..c7bdbd9de37 100644 --- a/src/main/persistence/loading-store/repo-lifecycle-operations.ts +++ b/src/main/persistence/loading-store/repo-lifecycle-operations.ts @@ -12,10 +12,13 @@ import { import { mergeProjectHostSetupCompatibilityState } from '../tracking-repos/project-host-compatibility' import { RepoOrderPersistenceOperations } from '../tracking-repos/repo-order-operations' import { pruneWorktreeStateForRepo as pruneWorktreeStateForRepoOperation } from '../tracking-repos/repo-worktree-pruning' +import { collectDeregisteredRepoIds } from '../tracking-repos/deregistered-repo-residue' import { hydrateRepo as hydrateRepoOperation } from '../tracking-repos/repo-hydration' import { RepoUpdatePersistenceOperations } from '../tracking-repos/repo-update-operations' import { ProjectHostSetupPersistenceOperations } from '../tracking-repos/project-host-setup-update' import { bumpLocalWorktreeScanGeneration } from '../../local-worktree-scan-generation' +import type { PersistedState } from '../../../shared/persisted-state-types' +import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' import type { StoreRuntimeState } from './store-runtime-state' import type { WriteSchedulingOperations } from './write-scheduling' @@ -129,6 +132,33 @@ export class RepoLifecycleOperations { scheduleSave(this[repoLifecycleOperationsContext].scheduling) } + /** + * Drop every persisted row owned by a repo id that is no longer registered. + * + * Runs at load because no removal path can: `removeProject` only fires while the repo is still in + * `state.repos`, and a paired client's mirror of a remote host's rows is keyed by ids that client + * never registers, so the owning host's removal never reaches it (#17776). An orphan has no owner + * that could object, so this ignores the session-ownership and local-execution-host gates the + * missing-directory sweeper needs. + */ + sweepDeregisteredRepoResidue(): string[] { + const state = this[repoLifecycleOperationsContext].runtime.state + const orphanRepoIds = collectDeregisteredRepoIds(state) + if (orphanRepoIds.size === 0) { + return [] + } + for (const repoId of orphanRepoIds) { + pruneWorktreeStateForRepo(this, repoId, null) + state.workspaceSession = removeRepoFromWorkspaceSession(state.workspaceSession, repoId) + state.workspaceSessionsByHostId = removeRepoFromHostWorkspaceSessions( + state.workspaceSessionsByHostId, + repoId + ) + } + pruneDeregisteredRepoUiResidue(state.ui, orphanRepoIds) + return [...orphanRepoIds] + } + updateRepo( id: string, updates: Partial< @@ -212,6 +242,26 @@ export function pruneMobileClientTabSelections( } } +function pruneDeregisteredRepoUiResidue( + ui: PersistedState['ui'], + orphanRepoIds: ReadonlySet +): void { + const isOrphanWorktree = (worktreeId: string): boolean => + orphanRepoIds.has(getRepoIdFromWorktreeId(worktreeId)) + if (ui.lastActiveRepoId && orphanRepoIds.has(ui.lastActiveRepoId)) { + ui.lastActiveRepoId = null + } + if (ui.lastActiveWorktreeId && isOrphanWorktree(ui.lastActiveWorktreeId)) { + ui.lastActiveWorktreeId = null + } + ui.filterRepoIds = ui.filterRepoIds?.filter((repoId) => !orphanRepoIds.has(repoId)) ?? [] + for (const worktreeId of Object.keys(ui.showDotfilesByWorktree ?? {})) { + if (isOrphanWorktree(worktreeId)) { + delete ui.showDotfilesByWorktree?.[worktreeId] + } + } +} + export function getRepoUpdateOperations( owner: RepoLifecycleOperations ): RepoUpdatePersistenceOperations { diff --git a/src/main/persistence/loading-store/store.ts b/src/main/persistence/loading-store/store.ts index 888c0092ed2..017583e8f86 100644 --- a/src/main/persistence/loading-store/store.ts +++ b/src/main/persistence/loading-store/store.ts @@ -64,6 +64,9 @@ export class Store { ) const adaptedProjectGroups = this.domains.adaptation.adaptFlatFolderScanProjectGroups() this.domains.adaptation.hydrateFolderWorkspaceDiffComments() + // Load is the only place an orphaned repo id can be swept: every removal path needs the repo to + // still be registered, so rows outlive their owner without one (#17776). + const sweptRepoIds = this.domains.repos.sweepDeregisteredRepoResidue() for (const entry of normalized.migrationUnsupportedEntries) { setMigrationUnsupportedPty(entry) } @@ -78,7 +81,12 @@ export class Store { this.state.legacyPaneKeyAliasEntries = entries scheduleSave(this.domains.scheduling) }) - if (normalized.changed || this.runtime.loadNeedsSave || adaptedProjectGroups) { + if ( + normalized.changed || + this.runtime.loadNeedsSave || + adaptedProjectGroups || + sweptRepoIds.length > 0 + ) { scheduleSave(this.domains.scheduling) } } diff --git a/src/main/persistence/tracking-repos/deregistered-repo-residue.ts b/src/main/persistence/tracking-repos/deregistered-repo-residue.ts new file mode 100644 index 00000000000..c52bddbb712 --- /dev/null +++ b/src/main/persistence/tracking-repos/deregistered-repo-residue.ts @@ -0,0 +1,108 @@ +import type { PersistedState } from '../../../shared/persisted-state-types' +import { getWorktreeIdFromHostIdentity } from '../../../shared/worktree/host-qualified-identity' +import { splitWorktreeId } from '../../../shared/worktree/id' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { SESSION_FIELDS_PRUNED_BY_OWNER_KEY } from '../../orca-profiles/profile-project-session-field-disposition' +import { ownerKeyWorktreeIds } from '../../orca-profiles/profile-project-worktree-identity' + +/** + * Repo ids that still own persisted rows but no longer appear in `state.repos`. + * + * Why nothing else finds them: every other sweeper is gated on the repo still being registered, so + * deregistering a project stranded the rows it owned permanently — including a paired client's + * mirror of a remote host's session partition, which no local repo removal can reach (#17776). + */ +export function collectDeregisteredRepoIds(state: PersistedState): Set { + const liveRepoIds = new Set(state.repos.map((repo) => repo.id)) + const orphanRepoIds = new Set() + // Only a full `::` locator seeds the set. A bare key -- a folder workspace id, a + // repo-keyed topology revision, a test-shaped locator -- cannot be told apart from a repo id, and + // guessing wrong here deletes live session state. + const addWorktreeId = (worktreeId: string | null | undefined): void => { + const repoId = worktreeId ? splitWorktreeId(worktreeId)?.repoId : undefined + if (repoId && !liveRepoIds.has(repoId)) { + orphanRepoIds.add(repoId) + } + } + /** + * Seed from an owner key, which can read as two different locators (see `ownerKeyWorktreeIds`). + * All or nothing: if either reading names a live repo the key is that repo's, and seeding the + * other reading would hand the removal pass -- which accepts either -- a live row to delete. + */ + const addOwnerKey = (ownerKey: string): void => { + const repoIds = ownerKeyWorktreeIds(ownerKey).flatMap((worktreeId) => { + const repoId = splitWorktreeId(worktreeId)?.repoId + return repoId ? [repoId] : [] + }) + if (repoIds.length > 0 && repoIds.every((repoId) => !liveRepoIds.has(repoId))) { + for (const repoId of repoIds) { + orphanRepoIds.add(repoId) + } + } + } + + // Deliberately not seeded from `sparsePresetsByRepo` or `retiredWorktreeNamesByRepo`: both are + // bounded, and dropping a retired-name row would let a re-added repo reissue a name onto a cwd + // that still holds a prior occupant's agent state. + for (const worktreeId of Object.keys(state.worktreeMeta)) { + addWorktreeId(worktreeId) + } + for (const alias of Object.keys(state.worktreeIdentityAliases ?? {})) { + addWorktreeId(getWorktreeIdFromHostIdentity(alias)) + } + for (const [childId, lineage] of Object.entries(state.worktreeLineageById)) { + addWorktreeId(childId) + addWorktreeId(lineage.parentWorktreeId) + } + for (const [childKey, lineage] of Object.entries(state.workspaceLineageByChildKey)) { + addOwnerKey(childKey) + addOwnerKey(lineage.parentWorkspaceKey) + } + for (const selections of Object.values(state.mobileClientTabSelectionsByDeviceId ?? {})) { + for (const worktreeId of Object.keys(selections)) { + addWorktreeId(worktreeId) + } + } + const sessions: (WorkspaceSessionState | undefined)[] = [ + state.workspaceSession, + ...Object.values(state.workspaceSessionsByHostId ?? {}) + ] + for (const session of sessions) { + if (!session) { + continue + } + for (const field of SESSION_FIELDS_PRUNED_BY_OWNER_KEY) { + for (const ownerKey of Object.keys( + (session[field] as Record | undefined) ?? {} + )) { + addOwnerKey(ownerKey) + } + } + for (const ownerKey of Object.keys(session.tabsByWorktree ?? {})) { + addOwnerKey(ownerKey) + } + for (const ownerKey of Object.keys(session.browserTabsByWorktree ?? {})) { + addOwnerKey(ownerKey) + } + // Pruned by bespoke rules rather than by owner key, so the loop above never reaches them. + for (const ownerKey of [ + session.activeWorktreeId, + session.activeWorkspaceKey, + ...(session.activeWorktreeIdsOnShutdown ?? []) + ]) { + if (ownerKey) { + addOwnerKey(ownerKey) + } + } + // Not seeded from `terminalTopologyRevisionByRepoId`: its keys are bare repo ids by contract, + // and a bare key is exactly what `addWorktreeId` refuses to trust. Rows there are removed once + // any locator seeds their repo id, which every repo that ever opened a terminal has. + for (const record of Object.values(session.sleepingAgentSessionsByPaneKey ?? {})) { + addWorktreeId(record.worktreeId) + } + for (const tombstone of Object.values(session.terminalSurfaceTombstonesByPaneKey ?? {})) { + addWorktreeId(tombstone.worktreeId) + } + } + return orphanRepoIds +} diff --git a/src/main/persistence/tracking-repos/repo-worktree-pruning.ts b/src/main/persistence/tracking-repos/repo-worktree-pruning.ts index f6d17b24aa2..a41f7c6319d 100644 --- a/src/main/persistence/tracking-repos/repo-worktree-pruning.ts +++ b/src/main/persistence/tracking-repos/repo-worktree-pruning.ts @@ -2,6 +2,7 @@ import type { WorkspaceKey } from '../../../shared/folder-workspace-types' import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../shared/execution-host' import { parseWorkspaceKey } from '../../../shared/workspace-scope' import type { PersistedState } from '../../../shared/persisted-state-types' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { removeWorkspaceSessionOwners } from '../restoring-sessions/session-owner-removal' import { getExecutionHostIdFromWorktreeHostIdentity, @@ -58,10 +59,26 @@ export function pruneWorktreeStateForRepo( } } } - collectPrefixedKeys(Object.keys(state.worktreeMeta)) - collectPrefixedKeys(Object.keys(state.workspaceSession?.lastVisitedAtByWorktreeId ?? {})) - for (const session of Object.values(state.workspaceSessionsByHostId ?? {})) { + // Why the pane-keyed records contribute owner keys: they are pruned by the worktreeId they name, + // not by their own key, so a worktree with no meta and no visit row would otherwise keep its + // sleeping agents and tombstones forever -- and keep re-seeding the orphan sweep every load. + const collectScannedRecordOwners = (session: WorkspaceSessionState | undefined): void => { collectPrefixedKeys(Object.keys(session?.lastVisitedAtByWorktreeId ?? {})) + collectPrefixedKeys( + Object.values(session?.sleepingAgentSessionsByPaneKey ?? {}).map( + (record) => record.worktreeId + ) + ) + collectPrefixedKeys( + Object.values(session?.terminalSurfaceTombstonesByPaneKey ?? {}).map( + (tombstone) => tombstone.worktreeId + ) + ) + } + collectPrefixedKeys(Object.keys(state.worktreeMeta)) + collectScannedRecordOwners(state.workspaceSession) + for (const session of Object.values(state.workspaceSessionsByHostId ?? {})) { + collectScannedRecordOwners(session) } for (const key of Object.keys(state.worktreeMeta)) { diff --git a/src/main/runtime/rpc/orchestration-legacy-coordinator-race.test.ts b/src/main/runtime/rpc/orchestration-legacy-coordinator-race.test.ts index 9236d643e40..3040c37a9ef 100644 --- a/src/main/runtime/rpc/orchestration-legacy-coordinator-race.test.ts +++ b/src/main/runtime/rpc/orchestration-legacy-coordinator-race.test.ts @@ -656,9 +656,21 @@ describe('legacy coordinator takeover races', () => { const detectionStarted = new Promise((resolve) => { signalDetectionStarted = resolve }) + let detectionCalls = 0 vi.spyOn(harness.runtime, 'isTerminalRunningAgent').mockImplementation( () => - new Promise((resolve) => { + new Promise((resolve, reject) => { + detectionCalls += 1 + // Why reject instead of re-arming: a second call would overwrite resolveDetection and + // strand the first promise, hanging to a timeout instead of naming what changed. + if (detectionCalls > 1) { + reject( + new Error( + `isTerminalRunningAgent was called ${detectionCalls} times; this test drives exactly one detection.` + ) + ) + return + } resolveDetection = resolve signalDetectionStarted?.() }) diff --git a/src/main/runtime/runtime-managed-worktree-metadata-sweep.test.ts b/src/main/runtime/runtime-managed-worktree-metadata-sweep.test.ts new file mode 100644 index 00000000000..6df19517d64 --- /dev/null +++ b/src/main/runtime/runtime-managed-worktree-metadata-sweep.test.ts @@ -0,0 +1,123 @@ +// Why this file exists: the authoritative missing-metadata prune had exactly one caller, +// `ipcMain.handle('worktrees:listAll')`. A headless runtime host has no renderer, so it never swept +// its own repos and their `worktreeMeta` rows grew without bound (#17776). +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { mkdirSync, mkdtempSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import type { GitWorktreeInfo } from '../../shared/worktree/types' +import type { Repo } from '../../shared/repo-types' +import { testState, createStore, makeRepo } from '../persistence-test-harness' +import type { Store } from '../persistence/loading-store/store' +import { RuntimeManagedWorktreeQueries } from './runtime-managed-worktree-queries' +import type { RuntimeStore } from './runtime-store-contract' + +vi.mock('./ssh/ssh-config-parser', () => ({ + loadUserSshConfig: vi.fn(), + sshConfigHostsToTargets: vi.fn() +})) + +vi.mock('electron', () => ({ + app: { getPath: () => testState.dir }, + safeStorage: { isEncryptionAvailable: () => false } +})) + +vi.mock('./telemetry/client', () => ({ track: vi.fn() })) +vi.mock('./telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn().mockReturnValue({}) })) + +const gitWorktree = (path: string): GitWorktreeInfo => ({ + path, + branch: 'main', + head: 'abc1234', + isBare: false, + isMainWorktree: true +}) + +function queries( + store: Store, + repo: Repo, + worktrees: readonly GitWorktreeInfo[], + ok = true +): RuntimeManagedWorktreeQueries { + return new RuntimeManagedWorktreeQueries({ + getStore: () => store as unknown as RuntimeStore, + listResolved: async () => [], + resolveRepo: async () => repo, + selectRepos: () => [repo], + scanRepo: async () => ({ ok, worktrees: [...worktrees] }) + }) +} + +describe('runtime detected-worktree listing sweeps missing local metadata', () => { + let repoPath = '' + + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-runtime-sweep-')) + repoPath = join(testState.dir, 'repo') + mkdirSync(repoPath, { recursive: true }) + }) + + afterEach(() => { + rmSync(testState.dir, { recursive: true, force: true }) + }) + + // Paired with the off-host case below: same fixture, no `connectionId`. + it('drops a metadata row whose directory is gone and the scan does not list', async () => { + const store = createStore() + const repo = makeRepo({ id: 'repo-1', path: repoPath }) + store.addRepo(repo) + const missingId = `${repo.id}::${join(testState.dir, 'deleted-worktree')}` + store.setWorktreeMetaForHost(missingId, 'local', { displayName: 'Gone' }) + expect(store.getWorktreeMeta(missingId)).toBeDefined() + + await queries(store, repo, [gitWorktree(repoPath)]).listDetected(repo) + + expect(store.getWorktreeMeta(missingId)).toBeUndefined() + }) + + it('keeps a row whose directory still exists', async () => { + const store = createStore() + const repo = makeRepo({ id: 'repo-1', path: repoPath }) + store.addRepo(repo) + const livePath = join(testState.dir, 'live-worktree') + mkdirSync(livePath, { recursive: true }) + const liveId = `${repo.id}::${livePath}` + store.setWorktreeMetaForHost(liveId, 'local', { displayName: 'Live' }) + + await queries(store, repo, [gitWorktree(repoPath)]).listDetected(repo) + + expect(store.getWorktreeMeta(liveId)).toBeDefined() + }) + + // A non-authoritative scan is a failed listing, which is no evidence any checkout is gone. + it('keeps every row when the scan is not authoritative', async () => { + const store = createStore() + const repo = makeRepo({ id: 'repo-1', path: repoPath }) + store.addRepo(repo) + const missingId = `${repo.id}::${join(testState.dir, 'deleted-worktree')}` + store.setWorktreeMetaForHost(missingId, 'local', { displayName: 'Gone' }) + + await queries(store, repo, [], false).listDetected(repo) + + expect(store.getWorktreeMeta(missingId)).toBeDefined() + }) + + // The execution host owns this verdict: this host cannot stat a checkout that lives behind an SSH + // connection, so a local miss is not evidence of absence. See docs/reference/ssh-execution-boundary.md. + // + // Deliberately identical to the first case except for `connectionId`, and the row is stamped + // `local` so it is a real prune candidate. That pairing is the proof: the same fixture without a + // connection loses the row, so the connection is the only reason this one keeps it. Removing any + // single gate would not show that -- four independent checks derive from `connectionId` here. + it('never sweeps a repo whose git runs off-host', async () => { + const store = createStore() + const repo = makeRepo({ id: 'repo-1', path: repoPath, connectionId: 'build-box' }) + store.addRepo(repo) + const missingId = `${repo.id}::${join(testState.dir, 'deleted-worktree')}` + store.setWorktreeMetaForHost(missingId, 'local', { displayName: 'Gone' }) + + await queries(store, repo, [gitWorktree(repoPath)]).listDetected(repo) + + expect(store.getWorktreeMeta(missingId)).toBeDefined() + }) +}) diff --git a/src/main/runtime/runtime-managed-worktree-queries.ts b/src/main/runtime/runtime-managed-worktree-queries.ts index d444f024e84..b0ed2bc4a3b 100644 --- a/src/main/runtime/runtime-managed-worktree-queries.ts +++ b/src/main/runtime/runtime-managed-worktree-queries.ts @@ -20,6 +20,10 @@ import { } from '../../shared/worktree/visibility-sources' import { mergeWorktree } from '../ipc/worktree-logic' import { pruneLineageForMissingRepoWorktrees } from '../worktree-lineage-pruning' +import { pruneMetadataMissingFromAuthoritativeLocalScan } from '../ipc/worktrees/listing/authoritative-local-worktree-metadata-pruning' +import type { NativeLocalWorktreeMetadataScanExpectation } from '../persistence/tracking-repos/missing-local-worktree-metadata-pruning' +import { getLocalWorktreeScanGeneration } from '../local-worktree-scan-generation' +import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' import type { Store } from '../persistence' import type { RuntimeStore } from './runtime-store-contract' import type { RuntimeWorktreeScanResult } from './repo-worktree-resolution-scan' @@ -36,6 +40,31 @@ type Dependencies = { scanRepo(repo: Repo): Promise } +/** + * The destructive scan expectation for one repo, or undefined when this repo must not carry one. + * + * WSL-routed repos are excluded for the same reason the desktop listing excludes them: the listing + * runs in the distro and reports Linux paths while metadata can hold UNC ones, and v1 cannot prove + * those aliases equivalent. A runtime that needs repair throws rather than resolving routing, which + * is likewise no basis for deleting rows. + */ +function captureLocalMetadataPruneExpectation( + store: RuntimeStore, + repo: Repo +): NativeLocalWorktreeMetadataScanExpectation | undefined { + if (typeof store.captureNativeLocalWorktreeMetadataScanExpectation !== 'function') { + return undefined + } + try { + if (getLocalProjectWorktreeGitOptions(store as unknown as Store, repo).wslDistro) { + return undefined + } + } catch { + return undefined + } + return store.captureNativeLocalWorktreeMetadataScanExpectation(repo) +} + export class RuntimeManagedWorktreeQueries { constructor(private readonly deps: Dependencies) {} @@ -129,6 +158,10 @@ export class RuntimeManagedWorktreeQueries { worktrees: projectResolvedWorktreeLineage(detected, store.getAllWorktreeLineage?.() ?? {}) } } + // Why capture before the scan: listing can mutate metadata synchronously before its first + // await, and the prune revalidates against the rows as they stood when the scan was issued. + const metadataScanGeneration = getLocalWorktreeScanGeneration(repo.id) + const metadataPruneExpectation = captureLocalMetadataPruneExpectation(store, repo) let scan: RuntimeWorktreeScanResult try { scan = await this.deps.scanRepo(repo) @@ -136,6 +169,17 @@ export class RuntimeManagedWorktreeQueries { scan = { ok: false, worktrees: [] } } if (scan.ok) { + // Why the runtime sweeps too: the desktop listing that used to own this runs off `ipcMain`, + // so a headless host -- which has no renderer -- never pruned its own repos' rows (#17776). + if (metadataPruneExpectation) { + await pruneMetadataMissingFromAuthoritativeLocalScan({ + store: store as unknown as Store, + repo, + gitWorktrees: scan.worktrees, + scan: metadataPruneExpectation, + scanGeneration: metadataScanGeneration + }) + } pruneLineageForMissingRepoWorktrees(store as unknown as Store, repo, scan.worktrees) } const matcher = createWorktreeVisibilitySourceMatcher( diff --git a/src/main/runtime/runtime-store-contract.ts b/src/main/runtime/runtime-store-contract.ts index 692826b3c29..e160e039533 100644 --- a/src/main/runtime/runtime-store-contract.ts +++ b/src/main/runtime/runtime-store-contract.ts @@ -30,6 +30,9 @@ export type RuntimeStore = { removeProjectForHost?: Store['removeProjectForHost'] reorderRepos?: Store['reorderRepos'] getAllWorktreeMeta: Store['getAllWorktreeMeta'] + captureNativeLocalWorktreeMetadataScanExpectation?: Store['captureNativeLocalWorktreeMetadataScanExpectation'] + pruneSessionlessMissingLocalWorktreeMetadataForRepo?: Store['pruneSessionlessMissingLocalWorktreeMetadataForRepo'] + getProfileStorageDirectory?: Store['getProfileStorageDirectory'] getWorktreeMeta: Store['getWorktreeMeta'] setWorktreeMeta: Store['setWorktreeMeta'] setWorktreeMetaForHost?: Store['setWorktreeMetaForHost'] diff --git a/src/main/ssh-reattach-pane-cardinality.test.ts b/src/main/ssh-reattach-pane-cardinality.test.ts index 51cbdb26794..96362ffbfc2 100644 --- a/src/main/ssh-reattach-pane-cardinality.test.ts +++ b/src/main/ssh-reattach-pane-cardinality.test.ts @@ -2,7 +2,13 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { rmSync, mkdtempSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' -import { testState, createStore, makeTerminalTab, writeDataFile } from './persistence-test-harness' +import { + testState, + createStore, + makeRepo, + makeTerminalTab, + writeDataFile +} from './persistence-test-harness' import { TEST_LEAF_1, TEST_LEAF_2 } from './persistence-session-fixtures' import { getDefaultPersistedState } from '../shared/constants' @@ -196,6 +202,8 @@ describe('STA-3077: an SSH reattach binds panes without grafting them back', () it('does not clear and rebind a retired surface loaded from an older profile', async () => { const paneKey = `${TAB}:${TEST_LEAF_1}` const persisted = getDefaultPersistedState(testState.dir) + // Registered on purpose: rows owned by an unregistered repo id are swept as orphans on load. + persisted.repos = [makeRepo({ id: 'repo1', path: '/repo1' })] persisted.workspaceSession = { ...persisted.workspaceSession, ...sessionWithPane({ tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-1' }), diff --git a/src/main/startup/main-process-ipc-bootstrap.ts b/src/main/startup/main-process-ipc-bootstrap.ts index 3f364a9335c..89be84d2119 100644 --- a/src/main/startup/main-process-ipc-bootstrap.ts +++ b/src/main/startup/main-process-ipc-bootstrap.ts @@ -2,6 +2,7 @@ import { ipcMain } from 'electron' import { recoverLegacyWorkerTerminalsForRendererStartup } from './legacy-worker-renderer-recovery' import { logStartupMilestone } from './startup-diagnostics' import { mainProcessState as state } from './main-process-state' +import { resolveOpenedMarkdownDocuments } from './os-opened-markdown-files' export function registerMainProcessIpcHandlers(): void { ipcMain.handle('app:awaitFirstWindowStartupServices', async () => { @@ -36,6 +37,20 @@ export function registerMainProcessIpcHandlers(): void { state.pendingOpenSettings.matches(event.sender.id, { consume: true }) ) ipcMain.handle('ui:consumePendingSkillShare', () => state.skillShareDeepLinks.consume()) + // Why: the renderer pulls this once its ui:openMarkdownFiles listener attaches, so a + // cold-start "Open With" queued before mount still opens. The pull doubles as the proof + // that the listener is live, which is what lets main start pushing. + ipcMain.handle('ui:consumePendingMarkdownFileOpens', async () => { + state.markdownFileOpenListenerReady = true + const filePaths = state.osOpenedMarkdownFiles.consume() + try { + return await resolveOpenedMarkdownDocuments(filePaths) + } catch (error) { + // Why restored: the renderer never received these, so a later mount must still get them. + state.osOpenedMarkdownFiles.restore(filePaths) + throw error + } + }) ipcMain.handle( 'app:startupDiagnostic', (_event, event: string, details?: Record) => { diff --git a/src/main/startup/main-process-state.ts b/src/main/startup/main-process-state.ts index ea1e0a33299..05c45d5b567 100644 --- a/src/main/startup/main-process-state.ts +++ b/src/main/startup/main-process-state.ts @@ -36,6 +36,7 @@ import type { ServeOptions } from './main-process-serve' import type { HangDetectionMarker } from '../hang-watchdog/hang-detection-marker' import { ServeReadinessPublisher } from '../server/serve-readiness' import { SkillShareDeepLinkState } from './skill-share-deep-link-state' +import { OsOpenedMarkdownFileState } from './os-opened-markdown-files' import { DEFAULT_GPU_CRASH_FALLBACK_THRESHOLD, DEFAULT_GPU_CRASH_FALLBACK_WINDOW_MS, @@ -90,6 +91,12 @@ export const mainProcessState = { // Why: a tray "Settings…" click can precede the renderer's ui:openSettings listener; it pulls this one-shot on mount. pendingOpenSettings: createWebContentsTimedFlag(), skillShareDeepLinks: new SkillShareDeepLinkState(), + // Why: a Finder/Explorer "Open With" can land before any window exists; the renderer pulls this buffer on mount. + osOpenedMarkdownFiles: new OsOpenedMarkdownFileState(), + // Why a latch and not just "a window exists": a window can be up while its renderer has not + // attached the ui:openMarkdownFiles listener yet, and a push into that gap is dropped by + // Electron with no error. Only the renderer's own pull proves the listener is live. + markdownFileOpenListenerReady: false, firstWindowStartupServicesReady: Promise.resolve(), managedWslCliReconciliationReady: Promise.resolve(), managedWslCliStartupBarrierReady: Promise.resolve(), diff --git a/src/main/startup/main-window-controller.ts b/src/main/startup/main-window-controller.ts index 57763b23ab1..0d935d5f84a 100644 --- a/src/main/startup/main-window-controller.ts +++ b/src/main/startup/main-window-controller.ts @@ -145,6 +145,9 @@ export function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {} clearExpectedRendererReload(rendererWebContentsId) recordCrashBreadcrumb('main_window_loaded') logStartupMilestone('did-finish-load') + // Why cleared here: a reload drops the old ui:openMarkdownFiles listener, and the fresh + // renderer re-attaches by pulling. Pushing into the gap between would be silently lost. + state.markdownFileOpenListenerReady = false const currentStore = state.store if (currentStore && resolveConsent(currentStore.getSettings()).effective === 'enabled') { trackAppOpenedOnce() diff --git a/src/main/startup/os-opened-markdown-delivery.test.ts b/src/main/startup/os-opened-markdown-delivery.test.ts new file mode 100644 index 00000000000..0647516e3fa --- /dev/null +++ b/src/main/startup/os-opened-markdown-delivery.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it, vi } from 'vitest' +import { OsOpenedMarkdownFileState } from './os-opened-markdown-files' + +/** + * The two ways a queued "Open With" can be lost between main and the renderer. Both are + * about ownership: main must not drop paths it has not proven the renderer received. + */ +describe('os-opened markdown delivery ownership', () => { + it('keeps the batch when resolution rejects on the pull path', async () => { + const state = new OsOpenedMarkdownFileState() + state.captureFilePaths(['/notes/a.md']) + const resolve = vi.fn().mockRejectedValue(new Error('floating root unavailable')) + + // Mirrors the ipcMain.handle('ui:consumePendingMarkdownFileOpens') body. + const pull = async (): Promise => { + const filePaths = state.consume() + try { + return await resolve(filePaths) + } catch (error) { + state.restore(filePaths) + throw error + } + } + + await expect(pull()).rejects.toThrow('floating root unavailable') + // Without the restore the file would be gone and no later mount could ever open it. + expect(state.consume()).toEqual(['/notes/a.md']) + }) + + it('holds the batch while the renderer listener is not yet attached', () => { + const state = new OsOpenedMarkdownFileState() + const send = vi.fn() + let listenerReady = false + + // Mirrors publishOsOpenedMarkdownFiles()'s guard. + const publish = (): void => { + if (!listenerReady) { + return + } + const filePaths = state.consume() + if (filePaths.length > 0) { + send(filePaths) + } + } + + // A window exists, but the renderer has not mounted its bridge yet: send() here would be + // dropped by Electron with no error, and consuming would destroy the queue. + state.captureFilePaths(['/notes/a.md'], publish) + expect(send).not.toHaveBeenCalled() + + // The renderer's pull is what proves the listener is live. + listenerReady = true + state.captureFilePaths(['/notes/b.md'], publish) + expect(send).toHaveBeenCalledExactlyOnceWith(['/notes/a.md', '/notes/b.md']) + expect(state.consume()).toEqual([]) + }) + + it('restores a batch the window could no longer receive', () => { + const state = new OsOpenedMarkdownFileState() + state.captureFilePaths(['/notes/a.md']) + const filePaths = state.consume() + + // Window died between consume and send. + state.restore(filePaths) + + expect(state.consume()).toEqual(['/notes/a.md']) + }) +}) diff --git a/src/main/startup/os-opened-markdown-files.test.ts b/src/main/startup/os-opened-markdown-files.test.ts new file mode 100644 index 00000000000..f174e10d834 --- /dev/null +++ b/src/main/startup/os-opened-markdown-files.test.ts @@ -0,0 +1,306 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve, sep } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { isMarkdownDocumentName } from '../ipc/markdown-documents' +import { + MAX_PENDING_OS_OPENED_MARKDOWN_FILES, + OsOpenedMarkdownFileState, + markdownPathsFromArguments, + resolveOpenedMarkdownDocuments +} from './os-opened-markdown-files' + +vi.mock('../ipc/filesystem-auth', () => ({ + authorizeExternalPath: vi.fn() +})) +vi.mock('../ipc/floating-workspace-directory', () => ({ + ensureDefaultFloatingWorkspacePath: vi.fn() +})) + +const { authorizeExternalPath } = await import('../ipc/filesystem-auth') +const { ensureDefaultFloatingWorkspacePath } = await import('../ipc/floating-workspace-directory') + +describe('markdownPathsFromArguments', () => { + it('keeps absolute markdown paths and drops other extensions', () => { + expect( + markdownPathsFromArguments( + [ + '/Users/dev/notes/a.md', + '/Users/dev/notes/b.markdown', + '/Users/dev/notes/c.mdx', + '/Users/dev/notes/d.txt', + '/Users/dev/src/e.tsx', + '/Users/dev/notes/README' + ], + 'darwin' + ) + ).toEqual(['/Users/dev/notes/a.md', '/Users/dev/notes/b.markdown', '/Users/dev/notes/c.mdx']) + }) + + it('drops switches, including Chromium-style ones that would otherwise look like values', () => { + expect( + markdownPathsFromArguments( + ['--serve', '-v', '--allow-file-access-from-files', '/Users/dev/notes/a.md'], + 'darwin' + ) + ).toEqual(['/Users/dev/notes/a.md']) + }) + + it('drops the executable and dev entries because none of them end in a markdown extension', () => { + const nonDocumentEntries = [ + '/Applications/Orca.app/Contents/MacOS/Orca', + '/Users/dev/orca/out/main/index.js', + '/Applications/Orca.app/Contents/Resources/app.asar' + ] + // The module documents that the extension check alone excludes these; hold it to that. + for (const entry of nonDocumentEntries) { + expect(isMarkdownDocumentName(entry), entry).toBe(false) + } + expect( + markdownPathsFromArguments([...nonDocumentEntries, '/Users/dev/notes/a.md'], 'darwin') + ).toEqual(['/Users/dev/notes/a.md']) + }) + + it('drops relative paths because a second instance has no meaningful cwd', () => { + expect( + markdownPathsFromArguments(['readme.md', './docs/a.md', '../up.md', ''], 'darwin') + ).toEqual([]) + }) + + it('accepts win32 drive-letter and UNC paths', () => { + expect( + markdownPathsFromArguments( + ['C:\\Users\\dev\\todo.md', '\\\\server\\share\\a.md', 'C:\\Users\\dev\\todo.txt'], + 'win32' + ) + ).toEqual(['C:\\Users\\dev\\todo.md', '\\\\server\\share\\a.md']) + }) + + it('dedupes case-insensitively on win32 and keeps the first spelling', () => { + expect(markdownPathsFromArguments(['C:\\notes\\A.md', 'c:\\notes\\a.md'], 'win32')).toEqual([ + 'C:\\notes\\A.md' + ]) + }) + + it('normalizes parent segments before deduping', () => { + expect( + markdownPathsFromArguments(['C:\\notes\\sub\\..\\a.md', 'C:\\notes\\a.md'], 'win32') + ).toEqual(['C:\\notes\\a.md']) + expect(markdownPathsFromArguments(['/docs/../notes/a.md', '/notes/a.md'], 'darwin')).toEqual([ + '/notes/a.md' + ]) + }) + + it('does not dedupe case-insensitively on posix, where casing is a different file', () => { + expect(markdownPathsFromArguments(['/a/A.md', '/a/a.md'], 'linux')).toEqual([ + '/a/A.md', + '/a/a.md' + ]) + }) + + it('accepts a file:// URI, which the desktop entry %U field code permits', () => { + // Why defensive rather than load-bearing: GLib decodes a local file:// URI to a plain + // path before spawning (measured on Ubuntu 24.04), so Linux hits the plain-path branch + // today. The %U spec still allows a URI, and a launcher that passes one literally would + // otherwise be dropped without a trace. + expect( + markdownPathsFromArguments( + ['file:///home/me/notes/a.md', 'file:///home/me/notes/b.txt'], + 'linux' + ) + ).toEqual(['/home/me/notes/a.md']) + }) + + it('percent-decodes a file:// URI so a path with spaces still opens', () => { + expect(markdownPathsFromArguments(['file:///home/me/design%20notes.md'], 'linux')).toEqual([ + '/home/me/design notes.md' + ]) + }) + + it('decodes win32 file:// URIs, including UNC authority form', () => { + expect( + markdownPathsFromArguments( + ['file:///C:/Users/me/todo.md', 'file://server/share/a.md'], + 'win32' + ) + ).toEqual(['C:\\Users\\me\\todo.md', '\\\\server\\share\\a.md']) + }) + + it('dedupes a path delivered as both a URI and a bare path', () => { + expect(markdownPathsFromArguments(['file:///home/me/a.md', '/home/me/a.md'], 'linux')).toEqual([ + '/home/me/a.md' + ]) + }) + + it('drops a malformed or non-file URL instead of throwing', () => { + expect(() => + markdownPathsFromArguments(['file://', 'file:///%zz.md', 'https://example.com/a.md'], 'linux') + ).not.toThrow() + expect( + markdownPathsFromArguments(['file://', 'file:///%zz.md', 'https://example.com/a.md'], 'linux') + ).toEqual([]) + }) + + it('honours the platform argument rather than the host OS', () => { + const argv = ['C:\\notes\\a.md', '/notes/b.md'] + // Same argv, two platforms: a win32 path is not absolute to posix, and posix input is + // renormalized to backslashes on win32. Neither result may depend on where the suite runs. + expect(markdownPathsFromArguments(argv, 'darwin')).toEqual(['/notes/b.md']) + expect(markdownPathsFromArguments(argv, 'win32')).toEqual(['C:\\notes\\a.md', '\\notes\\b.md']) + }) +}) + +// Why resolve(): the state uses the host platform by default, so fixture paths must already be +// spelled the way the host's path module normalizes them (`\n\a.md` and a drive on Windows). +const hostPath = (name: string): string => resolve(sep, 'notes', name) + +describe('OsOpenedMarkdownFileState', () => { + it('reports no capture and does not publish when argv carries no markdown', () => { + const state = new OsOpenedMarkdownFileState() + const publish = vi.fn() + + expect(state.capture(['/Applications/Orca.app/Contents/MacOS/Orca', '--serve'], publish)).toBe( + false + ) + expect(publish).not.toHaveBeenCalled() + expect(state.consume()).toEqual([]) + }) + + it('buffers and publishes when argv carries markdown', () => { + const state = new OsOpenedMarkdownFileState() + const publish = vi.fn() + const filePath = hostPath('a.md') + + expect(state.capture(['/Applications/Orca.app/Contents/MacOS/Orca', filePath], publish)).toBe( + true + ) + expect(publish).toHaveBeenCalledTimes(1) + expect(state.consume()).toEqual([filePath]) + }) + + it('captures a single macOS open-file path', () => { + const state = new OsOpenedMarkdownFileState() + const publish = vi.fn() + const filePath = hostPath('a.md') + + expect(state.captureFilePaths([filePath], publish)).toBe(true) + expect(state.captureFilePaths([hostPath('a.png')], publish)).toBe(false) + expect(publish).toHaveBeenCalledTimes(1) + expect(state.consume()).toEqual([filePath]) + }) + + it('does not duplicate a path captured twice', () => { + const state = new OsOpenedMarkdownFileState() + const filePath = hostPath('a.md') + + state.captureFilePaths([filePath]) + state.captureFilePaths([filePath]) + state.capture(['orca', filePath]) + + expect(state.consume()).toEqual([filePath]) + }) + + it('drains the buffer on consume', () => { + const state = new OsOpenedMarkdownFileState() + const paths = [hostPath('a.md'), hostPath('b.md')] + state.captureFilePaths(paths) + + expect(state.consume()).toEqual(paths) + expect(state.consume()).toEqual([]) + }) + + it('restores an undelivered batch at the front of the buffer', () => { + const state = new OsOpenedMarkdownFileState() + state.captureFilePaths([hostPath('later.md')]) + + state.restore([hostPath('undelivered.md')]) + + expect(state.consume()).toEqual([hostPath('undelivered.md'), hostPath('later.md')]) + }) + + it('caps the buffer when captures overflow it', () => { + const state = new OsOpenedMarkdownFileState() + const overflow = MAX_PENDING_OS_OPENED_MARKDOWN_FILES + 5 + const paths = Array.from({ length: overflow }, (_, index) => hostPath(`file-${index}.md`)) + + expect(state.captureFilePaths(paths)).toBe(true) + + expect(state.consume()).toEqual(paths.slice(0, MAX_PENDING_OS_OPENED_MARKDOWN_FILES)) + }) + + it('caps the buffer when a restore overflows it', () => { + const state = new OsOpenedMarkdownFileState() + state.captureFilePaths([hostPath('pending.md')]) + const restored = Array.from({ length: MAX_PENDING_OS_OPENED_MARKDOWN_FILES }, (_, index) => + hostPath(`restored-${index}.md`) + ) + + state.restore(restored) + + const pending = state.consume() + expect(pending).toHaveLength(MAX_PENDING_OS_OPENED_MARKDOWN_FILES) + expect(pending).toEqual(restored) + }) +}) + +describe('resolveOpenedMarkdownDocuments', () => { + let floatingRoot: string + let fileRoot: string + + beforeEach(async () => { + vi.mocked(authorizeExternalPath).mockClear() + vi.mocked(ensureDefaultFloatingWorkspacePath).mockClear() + floatingRoot = await mkdtemp(join(tmpdir(), 'orca-os-open-root-')) + fileRoot = await mkdtemp(join(tmpdir(), 'orca-os-open-files-')) + vi.mocked(ensureDefaultFloatingWorkspacePath).mockResolvedValue(floatingRoot) + }) + + afterEach(async () => { + await rm(floatingRoot, { recursive: true, force: true }) + await rm(fileRoot, { recursive: true, force: true }) + }) + + it('resolves a real file outside the floating root to a basename-relative document', async () => { + const filePath = join(fileRoot, 'design notes.md') + await writeFile(filePath, '# hi\n', 'utf8') + + const documents = await resolveOpenedMarkdownDocuments([filePath]) + + expect(documents).toEqual([ + { + filePath, + relativePath: 'design notes.md', + basename: 'design notes.md', + name: 'design notes' + } + ]) + expect(authorizeExternalPath).toHaveBeenCalledWith(filePath) + }) + + it('drops a directory that merely looks like a markdown file', async () => { + const bundlePath = join(fileRoot, 'bundle.md') + await mkdir(bundlePath) + const filePath = join(fileRoot, 'real.md') + await writeFile(filePath, '# hi\n', 'utf8') + + const documents = await resolveOpenedMarkdownDocuments([bundlePath, filePath]) + + expect(documents.map((document) => document.filePath)).toEqual([filePath]) + // Security contract: a path we never validated must never be authorized for renderer reads. + expect(authorizeExternalPath).toHaveBeenCalledTimes(1) + expect(authorizeExternalPath).toHaveBeenCalledWith(filePath) + }) + + it('drops a path that no longer exists without authorizing it', async () => { + const missingPath = join(fileRoot, 'gone.md') + + expect(await resolveOpenedMarkdownDocuments([missingPath])).toEqual([]) + expect(authorizeExternalPath).not.toHaveBeenCalled() + }) + + it('returns nothing for an empty input without touching the filesystem', async () => { + expect(await resolveOpenedMarkdownDocuments([])).toEqual([]) + expect(ensureDefaultFloatingWorkspacePath).not.toHaveBeenCalled() + expect(authorizeExternalPath).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/startup/os-opened-markdown-files.ts b/src/main/startup/os-opened-markdown-files.ts new file mode 100644 index 00000000000..dae27fb7a78 --- /dev/null +++ b/src/main/startup/os-opened-markdown-files.ts @@ -0,0 +1,149 @@ +import { stat } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import type { MarkdownDocument } from '../../shared/filesystem-entry-types' +import { authorizeExternalPath } from '../ipc/filesystem-auth' +import { ensureDefaultFloatingWorkspacePath } from '../ipc/floating-workspace-directory' +import { isMarkdownDocumentName, markdownDocumentFromFilePath } from '../ipc/markdown-documents' + +// Why: a shell can only ever hand over the files the user selected; anything past this is a +// runaway argv, and buffering it unbounded would pin the paths for the whole session. +export const MAX_PENDING_OS_OPENED_MARKDOWN_FILES = 32 + +/** + * Resolves one argv entry to a local absolute path, or null if it is not one. + * + * Why file:// is accepted defensively: electron-builder appends the `%U` field code to the + * generated Linux `Exec=` line, and `%U` is specified as "URLs". GLib turns out to decode a + * local `file://` URI back to a plain path before spawning (measured on Ubuntu 24.04, via + * the same `launch_uris` call a file manager makes), so the branch below is not what fires + * there today — but the spec permits a URI, and a launcher that honours it literally would + * otherwise be silently dropped. macOS `open-file` and the Windows shell `%1` pass paths. + */ +function localPathFromArgument(argument: string, platform: NodeJS.Platform): string | null { + const pathApi = platform === 'win32' ? path.win32 : path.posix + if (argument.startsWith('file://')) { + try { + // Why the explicit windows flag: this must decode the same way on any host so the + // behaviour is testable, and it is what turns `file://server/share` back into a UNC path. + return fileURLToPath(argument, { windows: platform === 'win32' }) + } catch { + return null + } + } + return pathApi.isAbsolute(argument) ? argument : null +} + +/** + * Absolute markdown paths an OS "Open With" put on a launch or second-instance argv. + * + * Why no executable/asar/dev-entry filtering: none of those argv entries end in a markdown + * extension, so the extension check already excludes them. Relative entries are dropped + * because the shell always passes absolute paths and `cwd` is meaningless for a second instance. + */ +export function markdownPathsFromArguments( + argv: readonly string[], + platform: NodeJS.Platform = process.platform +): string[] { + const pathApi = platform === 'win32' ? path.win32 : path.posix + const seen = new Set() + const paths: string[] = [] + for (const rawArgument of argv) { + if (!rawArgument || rawArgument.startsWith('-')) { + continue + } + const argument = localPathFromArgument(rawArgument, platform) + if (!argument || !isMarkdownDocumentName(argument)) { + continue + } + const normalized = pathApi.normalize(argument) + // Why lowercased on win32: the shell round-trips drive letters and 8.3 casing + // inconsistently, and two spellings of one path must not open two tabs. + const key = platform === 'win32' ? normalized.toLowerCase() : normalized + if (seen.has(key)) { + continue + } + seen.add(key) + paths.push(normalized) + } + return paths +} + +/** + * Buffers markdown paths the OS handed us until a renderer can receive them. + * + * Mirrors SkillShareDeepLinkState: main pushes when a window is already live, and the + * renderer pulls the same buffer when its listener attaches, so a cold-start "Open With" + * that lands before mount is not dropped. + */ +export class OsOpenedMarkdownFileState { + private pending: string[] = [] + + /** Returns true when argv carried at least one markdown path. */ + capture(argv: readonly string[], publish?: () => void): boolean { + return this.add(markdownPathsFromArguments(argv), publish) + } + + /** Returns true when at least one path was a markdown document. */ + captureFilePaths(filePaths: readonly string[], publish?: () => void): boolean { + return this.add(markdownPathsFromArguments(filePaths), publish) + } + + consume(): string[] { + const pending = this.pending + this.pending = [] + return pending + } + + /** Puts an undelivered batch back at the front so the next renderer still receives it. */ + restore(filePaths: readonly string[]): void { + this.pending = [...filePaths, ...this.pending].slice(0, MAX_PENDING_OS_OPENED_MARKDOWN_FILES) + } + + private add(filePaths: readonly string[], publish?: () => void): boolean { + if (filePaths.length === 0) { + return false + } + const merged = [...this.pending] + for (const filePath of filePaths) { + if (!merged.includes(filePath)) { + merged.push(filePath) + } + } + this.pending = merged.slice(0, MAX_PENDING_OS_OPENED_MARKDOWN_FILES) + publish?.() + return true + } +} + +/** + * Turns OS-handed paths into the same `MarkdownDocument` shape the floating workspace's own + * file picker produces, authorizing each one for the renderer's later read. + */ +export async function resolveOpenedMarkdownDocuments( + filePaths: readonly string[] +): Promise { + if (filePaths.length === 0) { + return [] + } + const floatingRoot = await ensureDefaultFloatingWorkspacePath() + const documents: MarkdownDocument[] = [] + for (const filePath of filePaths) { + try { + // Why: the shell can hand over a bundle directory named `*.md`, or a path already + // deleted by the time we resolve. Authorize only something that is really a file. + if (!(await stat(filePath)).isFile()) { + continue + } + } catch { + continue + } + authorizeExternalPath(filePath) + documents.push( + markdownDocumentFromFilePath(floatingRoot, filePath, { + outsideRootRelativePath: 'basename' + }) + ) + } + return documents +} diff --git a/src/main/startup/os-opened-markdown-wiring.test.ts b/src/main/startup/os-opened-markdown-wiring.test.ts new file mode 100644 index 00000000000..179ca0820ca --- /dev/null +++ b/src/main/startup/os-opened-markdown-wiring.test.ts @@ -0,0 +1,57 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +const read = (relativePath: string): string => + // Why source text: this wiring is module-scope side effects in the entry point, which no + // unit test can import without booting Electron. These guards pin the call shapes instead. + readFileSync(join(process.cwd(), relativePath), 'utf8').replaceAll('"', "'") + +describe('os-opened markdown wiring', () => { + const index = read('src/main/index.ts') + const bootstrap = read('src/main/startup/main-process-ipc-bootstrap.ts') + const controller = read('src/main/startup/main-window-controller.ts') + + it('captures argv before the serve-duplicate early return', () => { + const captureIndex = index.indexOf( + 'state.osOpenedMarkdownFiles.capture(argv, publishOsOpenedMarkdownFiles)' + ) + const serveGuardIndex = index.indexOf('if (!shouldActivateDesktopForSecondInstance(argv)) {') + + expect(captureIndex).toBeGreaterThanOrEqual(0) + expect(serveGuardIndex).toBeGreaterThanOrEqual(0) + // A duplicate `orca serve` returns early; capturing after that would drop the user's files. + expect(captureIndex).toBeLessThan(serveGuardIndex) + }) + + it('claims the macOS open-file event so the default handler does not win it', () => { + const handlerIndex = index.indexOf("app.on('open-file'") + expect(handlerIndex).toBeGreaterThanOrEqual(0) + + const preventDefaultIndex = index.indexOf('event.preventDefault()', handlerIndex) + const nextRegistrationIndex = index.indexOf('app.on(', handlerIndex + 1) + expect(preventDefaultIndex).toBeGreaterThan(handlerIndex) + if (nextRegistrationIndex !== -1) { + expect(preventDefaultIndex).toBeLessThan(nextRegistrationIndex) + } + }) + + it('captures the cold-start argv and lets the renderer pull it after mount', () => { + expect(index).toContain('state.osOpenedMarkdownFiles.capture(process.argv)') + expect(bootstrap).toContain("ipcMain.handle('ui:consumePendingMarkdownFileOpens'") + }) + + // Why: `webContents.send` to a renderer that has not attached the listener is dropped with no + // error, so publishing on "a window exists" alone would consume the queue into a void. + it('only pushes once the renderer has proven its listener is attached', () => { + expect(index).toContain('!state.markdownFileOpenListenerReady') + expect(bootstrap).toContain('state.markdownFileOpenListenerReady = true') + // A reload drops the listener; the fresh renderer re-proves itself by pulling again. + expect(controller).toContain('state.markdownFileOpenListenerReady = false') + }) + + it('restores an undelivered batch on both the push and the pull path', () => { + expect(index).toContain('state.osOpenedMarkdownFiles.restore(filePaths)') + expect(bootstrap).toContain('state.osOpenedMarkdownFiles.restore(filePaths)') + }) +}) diff --git a/src/main/startup/run-electron-vite-dev.test.ts b/src/main/startup/run-electron-vite-dev.test.ts index 2146563373d..73d1bb21cdc 100644 --- a/src/main/startup/run-electron-vite-dev.test.ts +++ b/src/main/startup/run-electron-vite-dev.test.ts @@ -105,6 +105,56 @@ function devWrapperTestEnv(extra: NodeJS.ProcessEnv): NodeJS.ProcessEnv { return { ...env, ...extra } } +/** + * What the two cases below wait on: a ~280MB clone of Electron.app, two swiftc + * helper builds, and `codesign --deep` over the result. Six seconds on an idle + * machine; the swiftc builds alone pass fifteen when this file runs inside the + * full suite and every core is taken. The generous ceiling only costs time on a + * run that is already failing. + */ +const PREPARE_TIMEOUT_MS = 90_000 + +/** + * Spawns the wrapper with its output retained. + * + * Why retained: the wrapper reports its own failures on stderr, and discarding + * them turned a crash in prepare into a bare "Timed out waiting for condition" + * with nothing to act on. + */ +function spawnDevWrapper( + args: string[], + env: NodeJS.ProcessEnv +): { wrapper: ChildProcess; readOutput: () => string } { + const wrapper = spawn(process.execPath, args, { + cwd: resolve('.'), + env, + stdio: ['ignore', 'pipe', 'pipe'] + }) + let output = '' + const collect = (chunk: Buffer): void => { + output += chunk.toString() + } + wrapper.stdout?.on('data', collect) + wrapper.stderr?.on('data', collect) + return { wrapper, readOutput: () => output } +} + +async function waitForEnvFile(envFile: string, readOutput: () => string): Promise { + try { + await waitFor(() => { + try { + return readFileSync(envFile, 'utf8').trim().length > 0 + } catch { + return false + } + }, PREPARE_TIMEOUT_MS) + } catch (error) { + throw new Error( + `${(error as Error).message}: the dev wrapper never wrote ${envFile}. Wrapper output:\n${readOutput() || '(none)'}` + ) + } +} + describe('run-electron-vite-dev', () => { afterEach(async () => { for (const pid of processesToCleanUp) { @@ -351,26 +401,19 @@ describe('run-electron-vite-dev', () => { async function runWrapper(runId: string): Promise<{ electronExecPath: string }> { const pidFile = join(tempDir, `${runId}.pid`) const envFile = join(tempDir, `${runId}.json`) - const wrapper = spawn(process.execPath, [wrapperPath, '--remote-debugging-port=9448'], { - cwd: resolve('.'), - env: { + const { wrapper, readOutput } = spawnDevWrapper( + [wrapperPath, '--remote-debugging-port=9448'], + { ...baseEnv, ORCA_DEV_WRAPPER_TEST_PID_FILE: pidFile, ORCA_DEV_WRAPPER_TEST_ENV_FILE: envFile - }, - stdio: 'ignore' - }) + } + ) expect(wrapper.pid).toBeTypeOf('number') processesToCleanUp.add(wrapper.pid!) - await waitFor(() => { - try { - return readFileSync(envFile, 'utf8').trim().length > 0 - } catch { - return false - } - }, 20000) + await waitForEnvFile(envFile, readOutput) const trackedPids = trackPidFile(pidFile) @@ -409,7 +452,8 @@ describe('run-electron-vite-dev', () => { } } }, - 30000 + // Two full prepares, each budgeted at PREPARE_TIMEOUT_MS. + PREPARE_TIMEOUT_MS * 2 + 30_000 ) it.skipIf(process.platform !== 'darwin')( @@ -421,9 +465,9 @@ describe('run-electron-vite-dev', () => { const wrapperPath = resolve('config/scripts/run-electron-vite-dev.mjs') const fakeCliPath = resolve('src/main/startup/__fixtures__/fake-electron-vite-dev-cli.mjs') - const wrapper = spawn(process.execPath, [wrapperPath, '--remote-debugging-port=9448'], { - cwd: resolve('.'), - env: devWrapperTestEnv({ + const { wrapper, readOutput } = spawnDevWrapper( + [wrapperPath, '--remote-debugging-port=9448'], + devWrapperTestEnv({ ORCA_ELECTRON_VITE_CLI: fakeCliPath, ORCA_SKIP_DEV_CLI_PREPARE: '1', ORCA_SKIP_DEV_WEB_PREPARE: '1', @@ -431,20 +475,13 @@ describe('run-electron-vite-dev', () => { ORCA_DEV_WRAPPER_TEST_ENV_FILE: envFile, ORCA_DEV_BRANCH: 'feature/framework-symlinks', ORCA_DEV_WORKTREE_NAME: 'symlink-ui' - }), - stdio: 'ignore' - }) + }) + ) expect(wrapper.pid).toBeTypeOf('number') processesToCleanUp.add(wrapper.pid!) - await waitFor(() => { - try { - return readFileSync(envFile, 'utf8').trim().length > 0 - } catch { - return false - } - }, 20000) + await waitForEnvFile(envFile, readOutput) const trackedPids = trackPidFile(pidFile) @@ -464,6 +501,6 @@ describe('run-electron-vite-dev', () => { await stopWrapperAndTrackedPids(wrapper, trackedPids) }, - 30000 + PREPARE_TIMEOUT_MS + 30_000 ) }) diff --git a/src/main/worktree-create-preparation-burst.ts b/src/main/worktree-create-preparation-burst.ts new file mode 100644 index 00000000000..d289927ffaa --- /dev/null +++ b/src/main/worktree-create-preparation-burst.ts @@ -0,0 +1,21 @@ +import { setBoundedMapEntry } from './runtime/runtime-async-boundaries' + +/** Two creates this close together mean more are likely; an isolated create earns no replacement. */ +export const WORKTREE_CREATE_BURST_MS = 5 * 60_000 +const WORKTREE_CREATE_PREPARATION_CONSUME_MAX = 64 + +/** When each preparation key was last consumed, so a burst can be told from an isolated create. */ +const lastConsumedAt = new Map() + +/** Records this consume and reports whether it continues a burst. A replacement checkout costs a + * full tree and holds disk until its TTL, so only a user who is already creating repeatedly earns + * one; the first create of a session pays nothing for a spare nobody claims. */ +export function recordPreparationConsume(key: string, now = Date.now()): boolean { + const previous = lastConsumedAt.get(key) + setBoundedMapEntry(lastConsumedAt, key, now, WORKTREE_CREATE_PREPARATION_CONSUME_MAX) + return previous !== undefined && now - previous <= WORKTREE_CREATE_BURST_MS +} + +export function resetPreparationConsumeHistoryForTests(): void { + lastConsumedAt.clear() +} diff --git a/src/main/worktree-create-preparation-stale-cleanup.ts b/src/main/worktree-create-preparation-stale-cleanup.ts new file mode 100644 index 00000000000..4a422f672ed --- /dev/null +++ b/src/main/worktree-create-preparation-stale-cleanup.ts @@ -0,0 +1,79 @@ +import { + isWorktreeCreatePreparation, + parseWorktreePreparationOwnerPid, + parseWorktreePreparationPathOwnerPid +} from '../shared/worktree/create-preparation' +import type { AddWorktreeOptions } from './git/worktree' +import { listWorktreeGraph } from './git/worktree' +import { discardPreparedWorktree, unlockPreparedWorktree } from './git/worktree-create-preparation' +import { retryPendingPreparationDiscards } from './worktree-preparation-discard-retry' + +const STALE_PREPARATION_CLEANUP_CONCURRENCY = 4 + +const staleCleanupInFlight = new Map>() + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch (error) { + return (error as NodeJS.ErrnoException).code !== 'ESRCH' + } +} + +/** Reclaims preparations a crashed process left registered. Single-flighted per host key so a burst + * of arming calls shares one worktree listing. */ +export async function cleanupStalePreparations( + cleanupKey: string, + repoPath: string, + options: AddWorktreeOptions +): Promise { + const existing = staleCleanupInFlight.get(cleanupKey) + if (existing) { + await existing.catch(() => {}) + return + } + const cleanup = (async () => { + // Not awaited: the create path awaits this cleanup, and one stranded discard costs an unlock plus + // a `worktree remove --force` bounded at 30s each. Reclaiming leaked scratch must not delay create. + void retryPendingPreparationDiscards(cleanupKey) + const worktrees = await listWorktreeGraph(repoPath, { + ...options, + includeCreatePreparations: true + }) + const staleWorktrees = worktrees.filter(isWorktreeCreatePreparation) + let nextIndex = 0 + async function discardNextStalePreparation(): Promise { + while (nextIndex < staleWorktrees.length) { + const worktree = staleWorktrees[nextIndex] + nextIndex += 1 + const lockOwnerPid = parseWorktreePreparationOwnerPid(worktree.lockReason) + const pathOwnerPid = parseWorktreePreparationPathOwnerPid(worktree.path) + if (!lockOwnerPid || isProcessAlive(lockOwnerPid)) { + continue + } + // Preserve a branch-attached final path after a crash; only detached or + // still-hidden preparations are safe to discard automatically. + if (worktree.branch && pathOwnerPid === null) { + await unlockPreparedWorktree(repoPath, worktree.path, options).catch(() => {}) + } else if (pathOwnerPid === lockOwnerPid) { + await discardPreparedWorktree(repoPath, worktree.path, options).catch(() => {}) + } + } + } + const workerCount = Math.min(STALE_PREPARATION_CLEANUP_CONCURRENCY, staleWorktrees.length) + await Promise.all(Array.from({ length: workerCount }, () => discardNextStalePreparation())) + })() + staleCleanupInFlight.set(cleanupKey, cleanup) + try { + await cleanup.catch(() => {}) + } finally { + if (staleCleanupInFlight.get(cleanupKey) === cleanup) { + staleCleanupInFlight.delete(cleanupKey) + } + } +} + +export function resetStalePreparationCleanupForTests(): void { + staleCleanupInFlight.clear() +} diff --git a/src/main/worktree-create-preparation.test.ts b/src/main/worktree-create-preparation.test.ts index 3e03643d6a8..dababbef88d 100644 --- a/src/main/worktree-create-preparation.test.ts +++ b/src/main/worktree-create-preparation.test.ts @@ -466,4 +466,51 @@ describe('worktree create preparation registry', () => { expect(mocks.mkdir).toHaveBeenCalledWith('/workspace', { recursive: true }) expect(mocks.discard).toHaveBeenCalledTimes(1) }) + + function consumeOnce(name: string): ReturnType { + return consumePreparedWorktreeCreate({ + repoPath: repo.path, + workspaceRoot: '/workspace', + worktreePath: `/workspace/${name}`, + branch: `feature/${name}`, + baseBranch: 'origin/main' + }) + } + + it('does not re-arm after an isolated create', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + await expect(consumeOnce('only')).resolves.toEqual({}) + + // Why: a lone create would otherwise leave a full spare checkout on disk for the whole TTL. + expect(mocks.prepareCheckout).toHaveBeenCalledTimes(1) + }) + + it('re-arms a preparation once creates arrive in a burst', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + await expect(consumeOnce('first')).resolves.toEqual({}) + expect(mocks.prepareCheckout).toHaveBeenCalledTimes(1) + + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + expect(mocks.prepareCheckout).toHaveBeenCalledTimes(2) + + // No arming call follows this consume: the third checkout can only come from the re-arm. + await expect(consumeOnce('second')).resolves.toEqual({}) + expect(mocks.prepareCheckout).toHaveBeenCalledTimes(3) + + // The replacement is claimable, so a third create still skips the cold add. + await expect(consumeOnce('third')).resolves.toEqual({}) + expect(mocks.finalize).toHaveBeenCalledTimes(3) + }) + + it('does not re-arm when finalization failed', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + await expect(consumeOnce('first')).resolves.toEqual({}) + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + mocks.prepareCheckout.mockClear() + mocks.finalize.mockRejectedValueOnce(new Error('submodules prevent worktree move')) + + await expect(consumeOnce('second')).resolves.toBeNull() + + expect(mocks.prepareCheckout).not.toHaveBeenCalled() + }) }) diff --git a/src/main/worktree-create-preparation.ts b/src/main/worktree-create-preparation.ts index ff7478cb46e..22bcf5d1e2c 100644 --- a/src/main/worktree-create-preparation.ts +++ b/src/main/worktree-create-preparation.ts @@ -7,17 +7,12 @@ import { isFolderRepo } from '../shared/repo-kind' import { isWindowsAbsolutePathLike } from '../shared/cross-platform-path' import { WORKTREE_CREATE_PREPARATION_DIRECTORY, - createWorktreePreparationLockReason, - isWorktreeCreatePreparation, - parseWorktreePreparationOwnerPid, - parseWorktreePreparationPathOwnerPid + createWorktreePreparationLockReason } from '../shared/worktree/create-preparation' import type { AddWorktreeOptions, AddWorktreeResult } from './git/worktree' -import { listWorktreeGraph } from './git/worktree' import { discardPreparedWorktree, finalizePreparedWorktree, - unlockPreparedWorktree, prepareWorktreeCreateCheckout } from './git/worktree-create-preparation' import { @@ -25,17 +20,23 @@ import { getWorktreeMirrorDistro } from './project-runtime-git-options' import { computeWorkspaceRootAsync, getWorktreePathSettings } from './ipc/worktree-logic' +import { + recordPreparationConsume, + resetPreparationConsumeHistoryForTests +} from './worktree-create-preparation-burst' +import { + cleanupStalePreparations, + resetStalePreparationCleanupForTests +} from './worktree-create-preparation-stale-cleanup' import { toHostFilesystemPath } from './host-tree-removal' import { discardPreparationWithRetry, resetPendingPreparationDiscardsForTests, - retryPendingPreparationDiscards, trackPreparationDiscard } from './worktree-preparation-discard-retry' export const WORKTREE_CREATE_PREPARATION_TTL_MS = 5 * 60_000 export const WORKTREE_CREATE_PREPARATION_LIMIT = 3 -const STALE_PREPARATION_CLEANUP_CONCURRENCY = 4 type PreparationEntry = { key: string @@ -59,7 +60,6 @@ type ConsumePreparedWorktreeArgs = { } const preparations = new Map() -const staleCleanupInFlight = new Map>() function pathOps(path: string): Pick { return isWindowsAbsolutePathLike(path) ? win32 : posix @@ -79,15 +79,6 @@ function preparationKey( return `${pathKey(repoPath)}\0${pathKey(workspaceRoot)}\0${baseBranch}\0${options.wslDistro ?? ''}` } -function isProcessAlive(pid: number): boolean { - try { - process.kill(pid, 0) - return true - } catch (error) { - return (error as NodeJS.ErrnoException).code !== 'ESRCH' - } -} - function preparationHostKey(repoPath: string, options: AddWorktreeOptions): string { return `${pathKey(repoPath)}\0${options.wslDistro ?? ''}` } @@ -131,57 +122,6 @@ function enforcePreparationLimit(): void { } } -async function cleanupStalePreparations( - repoPath: string, - options: AddWorktreeOptions -): Promise { - const cleanupKey = preparationHostKey(repoPath, options) - const existing = staleCleanupInFlight.get(cleanupKey) - if (existing) { - await existing.catch(() => {}) - return - } - const cleanup = (async () => { - // Not awaited: the create path awaits this cleanup, and one stranded discard costs an unlock plus - // a `worktree remove --force` bounded at 30s each. Reclaiming leaked scratch must not delay create. - void retryPendingPreparationDiscards(cleanupKey) - const worktrees = await listWorktreeGraph(repoPath, { - ...options, - includeCreatePreparations: true - }) - const staleWorktrees = worktrees.filter(isWorktreeCreatePreparation) - let nextIndex = 0 - async function discardNextStalePreparation(): Promise { - while (nextIndex < staleWorktrees.length) { - const worktree = staleWorktrees[nextIndex] - nextIndex += 1 - const lockOwnerPid = parseWorktreePreparationOwnerPid(worktree.lockReason) - const pathOwnerPid = parseWorktreePreparationPathOwnerPid(worktree.path) - if (!lockOwnerPid || isProcessAlive(lockOwnerPid)) { - continue - } - // Preserve a branch-attached final path after a crash; only detached or - // still-hidden preparations are safe to discard automatically. - if (worktree.branch && pathOwnerPid === null) { - await unlockPreparedWorktree(repoPath, worktree.path, options).catch(() => {}) - } else if (pathOwnerPid === lockOwnerPid) { - await discardPreparedWorktree(repoPath, worktree.path, options).catch(() => {}) - } - } - } - const workerCount = Math.min(STALE_PREPARATION_CLEANUP_CONCURRENCY, staleWorktrees.length) - await Promise.all(Array.from({ length: workerCount }, () => discardNextStalePreparation())) - })() - staleCleanupInFlight.set(cleanupKey, cleanup) - try { - await cleanup.catch(() => {}) - } finally { - if (staleCleanupInFlight.get(cleanupKey) === cleanup) { - staleCleanupInFlight.delete(cleanupKey) - } - } -} - export async function prepareWorktreeCreateForRepo( store: Store, repo: Repo, @@ -205,6 +145,16 @@ export async function prepareWorktreeCreateForRepo( return existing.ready } + return startPreparation(key, repo.path, workspaceRoot, baseBranch, options) +} + +function startPreparation( + key: string, + repoPath: string, + workspaceRoot: string, + baseBranch: string, + options: AddWorktreeOptions +): Promise { enforcePreparationLimit() const preparationId = `${process.pid}-${randomUUID()}` const lockReason = createWorktreePreparationLockReason(preparationId) @@ -218,21 +168,21 @@ export async function prepareWorktreeCreateForRepo( expiration.unref() Object.assign(entry, { key, - repoPath: repo.path, + repoPath, workspaceRoot, preparedPath, options, createdAt: Date.now(), expiration, ready: (async () => { - await cleanupStalePreparations(repo.path, options) + await cleanupStalePreparations(preparationHostKey(repoPath, options), repoPath, options) await mkdir( toHostFilesystemPath( pathOps(workspaceRoot).join(workspaceRoot, WORKTREE_CREATE_PREPARATION_DIRECTORY) ), { recursive: true } ) - await prepareWorktreeCreateCheckout(repo.path, preparedPath, baseBranch, lockReason, options) + await prepareWorktreeCreateCheckout(repoPath, preparedPath, baseBranch, lockReason, options) })() } satisfies PreparationEntry) preparations.set(key, entry) @@ -266,6 +216,28 @@ async function claimPreparedWorktree( } } +/** Replaces a just-consumed preparation, but only once the user has shown they are creating in a + * burst. A replacement costs a full checkout and ~5 minutes of disk until its TTL, so arming one + * after an isolated create spends that on nobody. Never awaited: create has already returned by + * the time the replacement checkout finishes. */ +function rearmPreparation(entry: PreparationEntry, baseBranch: string): void { + // Record first: a prefetch that re-armed this key while we finalized would otherwise swallow the + // consume, and the next create would look isolated when it is really the middle of a burst. + const continuesBurst = recordPreparationConsume(entry.key) + if (preparations.has(entry.key) || !continuesBurst) { + return + } + void startPreparation( + entry.key, + entry.repoPath, + entry.workspaceRoot, + baseBranch, + entry.options + ).catch(() => { + // Why: a warm-up failure is recovered by the normal add on the next create. + }) +} + export async function consumePreparedWorktreeCreate( args: ConsumePreparedWorktreeArgs ): Promise { @@ -283,7 +255,7 @@ export async function consumePreparedWorktreeCreate( await mkdir(toHostFilesystemPath(pathOps(args.worktreePath).dirname(args.worktreePath)), { recursive: true }) - return await finalizePreparedWorktree( + const result = await finalizePreparedWorktree( args.repoPath, entry.preparedPath, args.worktreePath, @@ -292,6 +264,10 @@ export async function consumePreparedWorktreeCreate( args.refreshLocalBaseRef, options ) + // Consuming the only prepared checkout leaves the next create cold. Re-arm for a user who is + // creating in a burst; the TTL and the preparation limit still bound an unused replacement. + rearmPreparation(entry, args.baseBranch) + return result } catch (error) { await discardPreparedWorktree(args.repoPath, entry.preparedPath, options).catch(() => {}) console.warn( @@ -305,7 +281,8 @@ export async function consumePreparedWorktreeCreate( export async function _resetWorktreeCreatePreparationsForTests(): Promise { const entries = [...preparations.values()] preparations.clear() - staleCleanupInFlight.clear() + resetPreparationConsumeHistoryForTests() + resetStalePreparationCleanupForTests() await Promise.all( entries.map(async (entry) => { clearTimeout(entry.expiration) diff --git a/src/main/worktree-identity-persistence.test.ts b/src/main/worktree-identity-persistence.test.ts index c66a2d72ce8..0b6dd178e84 100644 --- a/src/main/worktree-identity-persistence.test.ts +++ b/src/main/worktree-identity-persistence.test.ts @@ -5,12 +5,26 @@ import { tmpdir } from 'node:os' import type { PersistedState } from '../shared/persisted-state-types' import { canonicalWorktreeIdentity } from '../shared/worktree/identity' import { composeWorktreeHostIdentity } from '../shared/worktree/host-qualified-identity' -import { createStore, readDataFile, testState, writeDataFile } from './persistence-test-harness' +import type { Store } from './persistence/loading-store/store' +import { + createStore, + makeRepo, + readDataFile, + testState, + writeDataFile +} from './persistence-test-harness' describe('host-qualified worktree metadata', () => { const worktreeId = 'repo-1::/workspace/feature' const ROTATED_INSTANCE_ID = '44444444-4444-4444-8444-444444444444' + // Registered on purpose: rows owned by an unregistered repo id are swept as orphans on load. + const createStoreWithRepo = (): Store => { + const store = createStore() + store.addRepo(makeRepo({ id: 'repo-1', path: '/workspace' })) + return store + } + beforeEach(() => { testState.dir = mkdtempSync(join(tmpdir(), 'orca-worktree-identity-')) }) @@ -52,7 +66,7 @@ describe('host-qualified worktree metadata', () => { }) }) it('reloads host-specific metadata without collapsing it to the legacy locator', () => { - const store = createStore() + const store = createStoreWithRepo() store.setWorktreeMetaForHost(worktreeId, 'local', { displayName: 'Local feature' }) store.setWorktreeMetaForHost(worktreeId, 'ssh:build-box', { displayName: 'Remote feature' }) store.flush() @@ -72,7 +86,7 @@ describe('host-qualified worktree metadata', () => { expect(store.getWorktreeMetaForHost(worktreeId, 'local')?.comment).toBe('after') }) it('backfills one stable instance for legacy metadata that omitted it', () => { - const seed = createStore() + const seed = createStoreWithRepo() seed.setWorktreeMeta(worktreeId, { displayName: 'Legacy feature' }) seed.flush() const legacy = readDataFile() as PersistedState @@ -97,7 +111,7 @@ describe('host-qualified worktree metadata', () => { // Fails open on purpose: an ambiguous alias used to brick reads and throw out of the worktree // listing loop, taking every workspace in the repo down with it and never self-healing. it('collapses an ambiguous locator onto its most recently active instance', () => { - const seed = createStore() + const seed = createStoreWithRepo() const first = seed.setWorktreeMetaForHost(worktreeId, 'local', { displayName: 'First' }) seed.flush() const persisted = readDataFile() as PersistedState @@ -262,7 +276,7 @@ describe('host-qualified worktree metadata', () => { it('repairs a missing canonical instance id while re-adopting an SSH target', () => { const oldHostId = 'ssh:old-target' as const const newHostId = 'ssh:new-target' as const - const seed = createStore() + const seed = createStoreWithRepo() seed.setWorktreeMetaForHost(worktreeId, oldHostId, { displayName: 'Remote feature' }) seed.flush() const persisted = readDataFile() as PersistedState @@ -318,7 +332,7 @@ describe('host-qualified worktree metadata', () => { it('deduplicates an equivalent destination during SSH target re-adoption', () => { const oldHostId = 'ssh:old-target' as const const newHostId = 'ssh:new-target' as const - const seed = createStore() + const seed = createStoreWithRepo() seed.setWorktreeMetaForHost(worktreeId, oldHostId, { displayName: 'Remote feature' }) seed.flush() const persisted = readDataFile() as PersistedState diff --git a/src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts b/src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts index e5e45d2c22a..cbb7d2da7c5 100644 --- a/src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts +++ b/src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts @@ -16,7 +16,7 @@ */ import { existsSync, mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' +import { basename, dirname, join } from 'node:path' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { getShellLaunchConfig } from './providers/local-pty-shell-ready' import { selectShellStartupFeatures } from './shell-startup-features' @@ -382,9 +382,12 @@ describe.skipIf(process.platform === 'win32')('the fixes the old wrapper was bui // value this wrapper cannot use degrades to $HOME, where zsh itself looks. const home = makeZshHome({ '.zshrc': 'export ORCA_TEST_FROM_ZSHRC=1\n' }) try { + // Unique per run: a fixed name here shares one path with every other run in + // the system temp dir, so a killed run leaves a stale directory behind and + // every later rename onto it fails with ENOTEMPTY. const { values } = await runFromRelocatedRoot( home, - join(dirname(userDataPath), '홍길동-wsl-view') + join(dirname(userDataPath), `홍길동-${basename(userDataPath)}`) ) expect(values.ORCA_TEST_FROM_ZSHRC).toBe('1') diff --git a/src/preload/api/agent-status-bridge.ts b/src/preload/api/agent-status-bridge.ts index 5bc0757c5ca..b5ac5c8b5b2 100644 --- a/src/preload/api/agent-status-bridge.ts +++ b/src/preload/api/agent-status-bridge.ts @@ -6,6 +6,7 @@ import type { } from '../../shared/agent-status-types' import type { AgentInterruptInferenceRequest } from '../../shared/agent-interrupt-intent' import type { AgentQuestionAnsweredInferenceRequest } from '../../shared/agent-question-answered-intent' +import type { PreloadApi } from '../api-types' export const agentStatusApi = { /** Listen for agent status updates forwarded from native hook receivers. */ @@ -85,4 +86,4 @@ export const agentStatusApi = { }): void => { ipcRenderer.send('agentStatus:transferPaneAuthority', args) } -} +} satisfies PreloadApi['agentStatus'] diff --git a/src/preload/api/agent-trust-bridge.ts b/src/preload/api/agent-trust-bridge.ts index f27146d9cd3..5aca3fd805c 100644 --- a/src/preload/api/agent-trust-bridge.ts +++ b/src/preload/api/agent-trust-bridge.ts @@ -1,4 +1,5 @@ import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' export const agentTrustApi = { markTrusted: (args: { @@ -6,4 +7,4 @@ export const agentTrustApi = { workspacePath: string connectionId?: string }): Promise => ipcRenderer.invoke('agentTrust:markTrusted', args) -} +} satisfies PreloadApi['agentTrust'] diff --git a/src/preload/api/ai-vault-bridge.ts b/src/preload/api/ai-vault-bridge.ts index ea9b2e1be14..917c9f02b63 100644 --- a/src/preload/api/ai-vault-bridge.ts +++ b/src/preload/api/ai-vault-bridge.ts @@ -10,19 +10,19 @@ import type { } from '../../shared/ai-vault-types' import type { AiVaultSessionTitlesArgs } from '../../shared/ai-vault-session-title' import type { AiVaultPrepareSessionResumeArgs } from '../../shared/ai-vault-resume-preparation' +import type { PreloadApi } from '../api-types' export const aiVaultApi = { - listSessions: (args?: AiVaultListArgs): Promise => - ipcRenderer.invoke('aiVault:listSessions', args), - resolveSessionTitles: (args: AiVaultSessionTitlesArgs): Promise => + listSessions: (args?: AiVaultListArgs) => ipcRenderer.invoke('aiVault:listSessions', args), + resolveSessionTitles: (args: AiVaultSessionTitlesArgs) => ipcRenderer.invoke('aiVault:resolveSessionTitles', args), cancelListSessions: (args: { requestToken: string }): Promise => ipcRenderer.invoke('aiVault:cancelListSessions', args), - prepareSessionResume: (args: AiVaultPrepareSessionResumeArgs): Promise => + prepareSessionResume: (args: AiVaultPrepareSessionResumeArgs) => ipcRenderer.invoke('aiVault:prepareSessionResume', args), - listSubagentSessions: (args: AiVaultSubagentListArgs): Promise => + listSubagentSessions: (args: AiVaultSubagentListArgs) => ipcRenderer.invoke('aiVault:listSubagentSessions', args), - getFirstUserPrompt: (args: AiVaultFirstUserPromptArgs): Promise => + getFirstUserPrompt: (args: AiVaultFirstUserPromptArgs) => ipcRenderer.invoke('aiVault:getFirstUserPrompt', args), deleteSession: (args: AiVaultDeleteSessionArgs): Promise => ipcRenderer.invoke('aiVault:deleteSession', args), @@ -31,4 +31,4 @@ export const aiVaultApi = { ipcRenderer.on('aiVault:windowFocused', listener) return () => ipcRenderer.removeListener('aiVault:windowFocused', listener) } -} +} satisfies PreloadApi['aiVault'] diff --git a/src/preload/api/app-bridge.ts b/src/preload/api/app-bridge.ts index 705d24e4bda..22d46cc40d2 100644 --- a/src/preload/api/app-bridge.ts +++ b/src/preload/api/app-bridge.ts @@ -40,6 +40,7 @@ export const appApi = { throw new Error('Failed to stage renderer state before unload.') } }, + awaitBeforeUnloadCheckpoint: () => awaitBeforeUnloadCheckpoint(), awaitFirstWindowStartupServices: (): Promise => ipcRenderer.invoke('app:awaitFirstWindowStartupServices'), prepareTerminalStartupRestoration: (): Promise => @@ -73,4 +74,4 @@ export const appApi = { ipcRenderer.invoke('app:pickFloatingWorkspaceDirectory'), writeTerminalRenderDesyncEvidence: (args: WriteTerminalRenderDesyncEvidenceArgs) => ipcRenderer.invoke('terminal:writeRenderDesyncEvidence', args) -} +} satisfies PreloadApi['app'] diff --git a/src/preload/api/automations-bridge.ts b/src/preload/api/automations-bridge.ts index 87bec12a8e9..43c3df528d8 100644 --- a/src/preload/api/automations-bridge.ts +++ b/src/preload/api/automations-bridge.ts @@ -1,5 +1,5 @@ import { ipcRenderer } from 'electron' -import type { ExternalAutomationManagerResult } from '../api-types' +import type { ExternalAutomationManagerResult, PreloadApi } from '../api-types' import type { AutomationDispatchRequest, AutomationDispatchResult, @@ -56,4 +56,4 @@ export const automationsApi = { ipcRenderer.on('automations:changed', listener) return () => ipcRenderer.removeListener('automations:changed', listener) } -} +} satisfies PreloadApi['automations'] diff --git a/src/preload/api/bitbucket-bridge.ts b/src/preload/api/bitbucket-bridge.ts index cf51ce453df..dd683b1387b 100644 --- a/src/preload/api/bitbucket-bridge.ts +++ b/src/preload/api/bitbucket-bridge.ts @@ -1,4 +1,5 @@ import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' export const bitbucketApi = { connect: (args: { @@ -12,5 +13,5 @@ export const bitbucketApi = { disconnect: (): Promise => ipcRenderer.invoke('bitbucket:disconnect'), - status: (): Promise => ipcRenderer.invoke('bitbucket:status') -} + status: () => ipcRenderer.invoke('bitbucket:status') +} satisfies PreloadApi['bitbucket'] diff --git a/src/preload/api/browser-bridge-guest-registration-and-downloads.ts b/src/preload/api/browser-bridge-guest-registration-and-downloads.ts index 3714a3bca7c..9d782971d96 100644 --- a/src/preload/api/browser-bridge-guest-registration-and-downloads.ts +++ b/src/preload/api/browser-bridge-guest-registration-and-downloads.ts @@ -6,6 +6,7 @@ import type { } from '../../shared/browser-webauthn-account' import { readBrowserClientHostIdArgument } from '../../shared/browser-client-host-id-argument' import { browserClientPageRendererRequests } from '../preload-runtime-support' +import type { PreloadApi } from '../api-types' export const browserGuestRegistrationAndDownloadsApi = { onClientPageRendererRequest: browserClientPageRendererRequests.subscribe, @@ -194,4 +195,4 @@ export const browserGuestRegistrationAndDownloadsApi = { ipcRenderer.on('browser:download-finished', listener) return () => ipcRenderer.removeListener('browser:download-finished', listener) } -} +} satisfies Partial diff --git a/src/preload/api/browser-bridge-page-interaction-and-sessions.ts b/src/preload/api/browser-bridge-page-interaction-and-sessions.ts index 0e959e0af4f..93d6001e61c 100644 --- a/src/preload/api/browser-bridge-page-interaction-and-sessions.ts +++ b/src/preload/api/browser-bridge-page-interaction-and-sessions.ts @@ -1,4 +1,5 @@ import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' export const browserPageInteractionAndSessionsApi = { onContextMenuRequested: ( @@ -81,23 +82,17 @@ export const browserPageInteractionAndSessionsApi = { }, cancelDownload: (args: { downloadId: string }): Promise => ipcRenderer.invoke('browser:cancelDownload', args), - setGrabMode: (args: { - browserPageId: string - enabled: boolean - }): Promise<{ ok: true } | { ok: false; reason: string }> => + setGrabMode: (args: { browserPageId: string; enabled: boolean }) => ipcRenderer.invoke('browser:setGrabMode', args), - awaitGrabSelection: (args: { browserPageId: string; opId: string }): Promise => + awaitGrabSelection: (args: { browserPageId: string; opId: string }) => ipcRenderer.invoke('browser:awaitGrabSelection', args), cancelGrab: (args: { browserPageId: string }): Promise => ipcRenderer.invoke('browser:cancelGrab', args), captureSelectionScreenshot: (args: { browserPageId: string rect: { x: number; y: number; width: number; height: number } - }): Promise<{ ok: true; screenshot: unknown } | { ok: false; reason: string }> => - ipcRenderer.invoke('browser:captureSelectionScreenshot', args), - extractHoverPayload: (args: { - browserPageId: string - }): Promise<{ ok: true; payload: unknown } | { ok: false; reason: string }> => + }) => ipcRenderer.invoke('browser:captureSelectionScreenshot', args), + extractHoverPayload: (args: { browserPageId: string }) => ipcRenderer.invoke('browser:extractHoverPayload', args), onGrabModeToggle: (callback: (browserPageId: string) => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, browserPageId: string) => @@ -115,7 +110,7 @@ export const browserPageInteractionAndSessionsApi = { ipcRenderer.on('browser:grabActionShortcut', listener) return () => ipcRenderer.removeListener('browser:grabActionShortcut', listener) }, - sessionListProfiles: (): Promise => ipcRenderer.invoke('browser:session:listProfiles'), + sessionListProfiles: () => ipcRenderer.invoke('browser:session:listProfiles'), prepareSshWorkspacePartition: (args: { targetId: string browserProfileId?: string @@ -126,40 +121,28 @@ export const browserPageInteractionAndSessionsApi = { scope: 'default' | 'isolated' | 'imported' label: string userAgentMode?: 'clean' | 'native' - }): Promise => ipcRenderer.invoke('browser:session:createProfile', args), + }) => ipcRenderer.invoke('browser:session:createProfile', args), sessionDeleteProfile: (args: { profileId: string }): Promise => ipcRenderer.invoke('browser:session:deleteProfile', args), - sessionImportCookies: (args: { - profileId: string - }): Promise<{ ok: true; profileId: string; summary: unknown } | { ok: false; reason: string }> => + sessionImportCookies: (args: { profileId: string }) => ipcRenderer.invoke('browser:session:importCookies', args), sessionResolvePartition: (args: { profileId: string | null }): Promise => ipcRenderer.invoke('browser:session:resolvePartition', args), - sessionDetectBrowsers: (): Promise => - ipcRenderer.invoke('browser:session:detectBrowsers'), - sessionDetectBrowsersForClientHost: (args: { - environmentId: string - }): Promise => + sessionDetectBrowsers: () => ipcRenderer.invoke('browser:session:detectBrowsers'), + sessionDetectBrowsersForClientHost: (args: { environmentId: string }) => ipcRenderer.invoke('browser:session:detectBrowsersForClientHost', args), - sessionImportFromBrowser: (args: { - profileId: string - browserFamily: string - }): Promise<{ ok: true; profileId: string; summary: unknown } | { ok: false; reason: string }> => + sessionImportFromBrowser: (args: { profileId: string; browserFamily: string }) => ipcRenderer.invoke('browser:session:importFromBrowser', args), sessionImportFromBrowserForClientHost: (args: { environmentId: string profileId: string browserFamily: string browserProfile?: string - }): Promise< - { ok: true; profileId: string; summary: unknown } | { ok: false; reason: string } | null - > => ipcRenderer.invoke('browser:session:importFromBrowserForClientHost', args), - sessionClientRouteImportSources: (args: { - environmentId: string - }): Promise> => + }) => ipcRenderer.invoke('browser:session:importFromBrowserForClientHost', args), + sessionClientRouteImportSources: (args: { environmentId: string }) => ipcRenderer.invoke('browser:session:clientRouteImportSources', args), sessionClearDefaultCookies: (): Promise => ipcRenderer.invoke('browser:session:clearDefaultCookies'), notifyActiveTabChanged: (args: { browserPageId: string }): Promise => ipcRenderer.invoke('browser:activeTabChanged', args) -} +} satisfies Partial diff --git a/src/preload/api/browser-bridge.ts b/src/preload/api/browser-bridge.ts index dca222c5843..d29b7365dc2 100644 --- a/src/preload/api/browser-bridge.ts +++ b/src/preload/api/browser-bridge.ts @@ -1,7 +1,8 @@ import { browserGuestRegistrationAndDownloadsApi } from './browser-bridge-guest-registration-and-downloads' import { browserPageInteractionAndSessionsApi } from './browser-bridge-page-interaction-and-sessions' +import type { PreloadApi } from '../api-types' export const browserApi = { ...browserGuestRegistrationAndDownloadsApi, ...browserPageInteractionAndSessionsApi -} +} satisfies PreloadApi['browser'] diff --git a/src/preload/api/claude-accounts-bridge.ts b/src/preload/api/claude-accounts-bridge.ts index 8200c17791d..69586525962 100644 --- a/src/preload/api/claude-accounts-bridge.ts +++ b/src/preload/api/claude-accounts-bridge.ts @@ -1,18 +1,18 @@ import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' export const claudeAccountsApi = { - list: (): Promise => ipcRenderer.invoke('claudeAccounts:list'), - add: (args?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null }): Promise => + list: () => ipcRenderer.invoke('claudeAccounts:list'), + add: (args?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null }) => ipcRenderer.invoke('claudeAccounts:add', args), cancelPendingLogin: (): Promise => ipcRenderer.invoke('claudeAccounts:cancelPendingLogin'), - reauthenticate: (args: { accountId: string }): Promise => + reauthenticate: (args: { accountId: string }) => ipcRenderer.invoke('claudeAccounts:reauthenticate', args), - remove: (args: { accountId: string }): Promise => - ipcRenderer.invoke('claudeAccounts:remove', args), + remove: (args: { accountId: string }) => ipcRenderer.invoke('claudeAccounts:remove', args), select: (args: { accountId: string | null runtime?: 'host' | 'wsl' wslDistro?: string | null - }): Promise => ipcRenderer.invoke('claudeAccounts:select', args) -} + }) => ipcRenderer.invoke('claudeAccounts:select', args) +} satisfies PreloadApi['claudeAccounts'] diff --git a/src/preload/api/claude-usage-bridge.ts b/src/preload/api/claude-usage-bridge.ts index 0c81e35e3e8..1b98202d88c 100644 --- a/src/preload/api/claude-usage-bridge.ts +++ b/src/preload/api/claude-usage-bridge.ts @@ -1,4 +1,8 @@ import { ipcRenderer } from 'electron' import { createUsageProviderApi } from '../usage-provider-api' +import type { PreloadApi } from '../api-types' -export const claudeUsageApi = createUsageProviderApi(ipcRenderer, 'claudeUsage') +export const claudeUsageApi = createUsageProviderApi( + ipcRenderer, + 'claudeUsage' +) satisfies PreloadApi['claudeUsage'] diff --git a/src/preload/api/cli-bridge.ts b/src/preload/api/cli-bridge.ts index 8f811cbdd40..76b574a2f44 100644 --- a/src/preload/api/cli-bridge.ts +++ b/src/preload/api/cli-bridge.ts @@ -1,5 +1,6 @@ import { ipcRenderer } from 'electron' import type { CliInstallStatus } from '../../shared/cli-install-types' +import type { PreloadApi } from '../api-types' export const cliApi = { getInstallStatus: (): Promise => ipcRenderer.invoke('cli:getInstallStatus'), @@ -11,4 +12,4 @@ export const cliApi = { ipcRenderer.invoke('cli:installWsl', args), removeWsl: (args?: { distro?: string | null }): Promise => ipcRenderer.invoke('cli:removeWsl', args) -} +} satisfies PreloadApi['cli'] diff --git a/src/preload/api/codex-accounts-bridge.ts b/src/preload/api/codex-accounts-bridge.ts index ecd32e4b923..d085de45855 100644 --- a/src/preload/api/codex-accounts-bridge.ts +++ b/src/preload/api/codex-accounts-bridge.ts @@ -1,20 +1,18 @@ import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' export const codexAccountsApi = { - list: (): Promise => ipcRenderer.invoke('codexAccounts:list'), - add: (args?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null }): Promise => + list: () => ipcRenderer.invoke('codexAccounts:list'), + add: (args?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null }) => ipcRenderer.invoke('codexAccounts:add', args), - reauthenticate: (args: { - accountId: string - activateIfSelectionWasEmpty?: boolean - }): Promise => ipcRenderer.invoke('codexAccounts:reauthenticate', args), - remove: (args: { accountId: string }): Promise => - ipcRenderer.invoke('codexAccounts:remove', args), + reauthenticate: (args: { accountId: string; activateIfSelectionWasEmpty?: boolean }) => + ipcRenderer.invoke('codexAccounts:reauthenticate', args), + remove: (args: { accountId: string }) => ipcRenderer.invoke('codexAccounts:remove', args), select: (args: { accountId: string | null runtime?: 'host' | 'wsl' wslDistro?: string | null - }): Promise => ipcRenderer.invoke('codexAccounts:select', args), + }) => ipcRenderer.invoke('codexAccounts:select', args), listStalePanes: (args: { ptyIds: string[] }): Promise< @@ -29,4 +27,4 @@ export const codexAccountsApi = { ipcRenderer.invoke('codexAccounts:listRecordedPaneLanes', args), forgetStalePanes: (args: { ptyIds: string[] }): Promise => ipcRenderer.invoke('codexAccounts:forgetStalePanes', args) -} +} satisfies PreloadApi['codexAccounts'] diff --git a/src/preload/api/codex-config-sync-bridge.ts b/src/preload/api/codex-config-sync-bridge.ts index e6c8903a698..82eedfaf0ec 100644 --- a/src/preload/api/codex-config-sync-bridge.ts +++ b/src/preload/api/codex-config-sync-bridge.ts @@ -1,6 +1,7 @@ import { ipcRenderer } from 'electron' import type { CodexConfigSyncStatus } from '../../shared/codex-config-sync-types' +import type { PreloadApi } from '../api-types' export const codexConfigSyncApi = { status: (): Promise => ipcRenderer.invoke('codexConfigSync:status') -} +} satisfies PreloadApi['codexConfigSync'] diff --git a/src/preload/api/codex-usage-bridge.ts b/src/preload/api/codex-usage-bridge.ts index 9dba4b72f82..2575f2bb0e9 100644 --- a/src/preload/api/codex-usage-bridge.ts +++ b/src/preload/api/codex-usage-bridge.ts @@ -1,4 +1,8 @@ import { ipcRenderer } from 'electron' import { createUsageProviderApi } from '../usage-provider-api' +import type { PreloadApi } from '../api-types' -export const codexUsageApi = createUsageProviderApi(ipcRenderer, 'codexUsage') +export const codexUsageApi = createUsageProviderApi( + ipcRenderer, + 'codexUsage' +) satisfies PreloadApi['codexUsage'] diff --git a/src/preload/api/computer-use-permissions-bridge.ts b/src/preload/api/computer-use-permissions-bridge.ts index be441a8682e..bd36efbdcc3 100644 --- a/src/preload/api/computer-use-permissions-bridge.ts +++ b/src/preload/api/computer-use-permissions-bridge.ts @@ -1,8 +1,9 @@ import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' export const computerUsePermissionsApi = { - getStatus: (): Promise => ipcRenderer.invoke('computerUsePermissions:getStatus'), - openSetup: (args?: { id?: string }): Promise => + getStatus: () => ipcRenderer.invoke('computerUsePermissions:getStatus'), + openSetup: (args?: { id?: string }) => ipcRenderer.invoke('computerUsePermissions:openSetup', args), - reset: (): Promise => ipcRenderer.invoke('computerUsePermissions:reset') -} + reset: () => ipcRenderer.invoke('computerUsePermissions:reset') +} satisfies PreloadApi['computerUsePermissions'] diff --git a/src/preload/api/crash-reports-bridge.ts b/src/preload/api/crash-reports-bridge.ts index 19d7044601d..a77ad412eb7 100644 --- a/src/preload/api/crash-reports-bridge.ts +++ b/src/preload/api/crash-reports-bridge.ts @@ -11,6 +11,7 @@ import type { RendererHeapStatistics } from '../../shared/renderer-heap-statisti import type { RendererProcessMemory } from '../../shared/renderer-process-memory' import { readRendererHeapStatistics } from '../renderer-heap-statistics-reader' import { readRendererProcessMemory } from '../renderer-process-memory-reader' +import type { PreloadApi } from '../api-types' export const crashReportsApi = { getLatestPending: () => ipcRenderer.invoke('crashReports:getLatestPending'), @@ -28,4 +29,4 @@ export const crashReportsApi = { ipcRenderer.invoke('crashReports:copyLatestDiagnostics', args), readHeapStatistics: (): RendererHeapStatistics | null => readRendererHeapStatistics(), readProcessMemory: (): Promise => readRendererProcessMemory() -} +} satisfies PreloadApi['crashReports'] diff --git a/src/preload/api/dashboard-bridge.ts b/src/preload/api/dashboard-bridge.ts index 17241630857..e6862504da9 100644 --- a/src/preload/api/dashboard-bridge.ts +++ b/src/preload/api/dashboard-bridge.ts @@ -5,6 +5,7 @@ import type { DashboardSnapshot, DashboardSpawnAgentArgs } from '../../shared/dashboard-snapshot' +import type { PreloadApi } from '../api-types' export const dashboardApi = { // Open the pop-out dashboard window, or focus it if already open. @@ -71,4 +72,4 @@ export const dashboardApi = { ipcRenderer.invoke('dashboardPopout:spawnAgent', args), sleepWorkspace: (args: DashboardSleepWorkspaceArgs): Promise => ipcRenderer.invoke('dashboardPopout:sleepWorkspace', args) -} +} satisfies PreloadApi['dashboard'] diff --git a/src/preload/api/developer-permissions-bridge.ts b/src/preload/api/developer-permissions-bridge.ts index 1aaa51deed3..94158cd7818 100644 --- a/src/preload/api/developer-permissions-bridge.ts +++ b/src/preload/api/developer-permissions-bridge.ts @@ -1,11 +1,11 @@ import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' export const developerPermissionsApi = { - getStatus: (): Promise => ipcRenderer.invoke('developerPermissions:getStatus'), - request: (args: { id: string }): Promise => - ipcRenderer.invoke('developerPermissions:request', args), + getStatus: () => ipcRenderer.invoke('developerPermissions:getStatus'), + request: (args: { id: string }) => ipcRenderer.invoke('developerPermissions:request', args), openSettings: (args: { id: string }): Promise => ipcRenderer.invoke('developerPermissions:openSettings', args), - testLocalNetworkConnection: (args: { host: string; port: number }): Promise => + testLocalNetworkConnection: (args: { host: string; port: number }) => ipcRenderer.invoke('developerPermissions:testLocalNetworkConnection', args) -} +} satisfies PreloadApi['developerPermissions'] diff --git a/src/preload/api/diagnostics-bridge.ts b/src/preload/api/diagnostics-bridge.ts index 6d274f9b08a..bff79fe5817 100644 --- a/src/preload/api/diagnostics-bridge.ts +++ b/src/preload/api/diagnostics-bridge.ts @@ -1,15 +1,16 @@ import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' export const diagnosticsApi = { - getStatus: (): Promise => ipcRenderer.invoke('diagnostics:getStatus'), - collectBundle: (lookbackMinutes?: number): Promise => + getStatus: () => ipcRenderer.invoke('diagnostics:getStatus'), + collectBundle: (lookbackMinutes?: number) => ipcRenderer.invoke('diagnostics:collectBundle', lookbackMinutes), openBundlePreview: (bundleSubmissionId: string): Promise => ipcRenderer.invoke('diagnostics:openBundlePreview', bundleSubmissionId), discardBundlePreview: (bundleSubmissionId: string): Promise => ipcRenderer.invoke('diagnostics:discardBundlePreview', bundleSubmissionId), - uploadBundle: (bundleSubmissionId: string): Promise => + uploadBundle: (bundleSubmissionId: string) => ipcRenderer.invoke('diagnostics:uploadBundle', bundleSubmissionId), deleteBundle: (ticketId: string): Promise => ipcRenderer.invoke('diagnostics:deleteBundle', ticketId) -} +} satisfies PreloadApi['diagnostics'] diff --git a/src/preload/api/doc-preview-bridge.ts b/src/preload/api/doc-preview-bridge.ts index 68a099d5ed2..97fbb8da975 100644 --- a/src/preload/api/doc-preview-bridge.ts +++ b/src/preload/api/doc-preview-bridge.ts @@ -8,6 +8,7 @@ import { type DocPreviewFailure } from '../../shared/doc-preview-scheme' import type { DocPreviewGrantRequest } from '../api/doc-preview-api' +import type { PreloadApi } from '../api-types' export const docPreviewApi = { mintGrant: (request: DocPreviewGrantRequest): Promise<{ grantId: string; url: string }> => @@ -28,4 +29,4 @@ export const docPreviewApi = { ipcRenderer.on(DOC_PREVIEW_LOAD_FAILURE_CHANNEL, listener) return () => ipcRenderer.removeListener(DOC_PREVIEW_LOAD_FAILURE_CHANNEL, listener) } -} +} satisfies PreloadApi['docPreview'] diff --git a/src/preload/api/e2e-bridge.ts b/src/preload/api/e2e-bridge.ts index 2876b72a265..900b17a9fcf 100644 --- a/src/preload/api/e2e-bridge.ts +++ b/src/preload/api/e2e-bridge.ts @@ -1,5 +1,6 @@ import { preloadE2EConfig } from '../e2e-config' +import type { PreloadApi } from '../api-types' export const e2eApi = { getConfig: () => preloadE2EConfig -} +} satisfies PreloadApi['e2e'] diff --git a/src/preload/api/emulator-bridge.ts b/src/preload/api/emulator-bridge.ts index f13e99fc52a..8ab56471a42 100644 --- a/src/preload/api/emulator-bridge.ts +++ b/src/preload/api/emulator-bridge.ts @@ -1,4 +1,5 @@ import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' export const emulatorApi = { startFrameStream: (args: { @@ -95,4 +96,4 @@ export const emulatorApi = { ipcRenderer.on('ui:emulatorAutoAttach', listener) return () => ipcRenderer.removeListener('ui:emulatorAutoAttach', listener) } -} +} satisfies PreloadApi['emulator'] diff --git a/src/preload/api/export-bridge.ts b/src/preload/api/export-bridge.ts index 637d4bea2ef..67ffbfae109 100644 --- a/src/preload/api/export-bridge.ts +++ b/src/preload/api/export-bridge.ts @@ -1,4 +1,5 @@ import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' export const exportApi = { htmlToPdf: (args: { @@ -7,4 +8,4 @@ export const exportApi = { }): Promise< { success: true; filePath: string } | { success: false; cancelled?: boolean; error?: string } > => ipcRenderer.invoke('export:html-to-pdf', args) -} +} satisfies PreloadApi['export'] diff --git a/src/preload/api/feedback-bridge.ts b/src/preload/api/feedback-bridge.ts index 55b3b5fcaa7..4241c5cacf9 100644 --- a/src/preload/api/feedback-bridge.ts +++ b/src/preload/api/feedback-bridge.ts @@ -1,4 +1,5 @@ import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' export const feedbackApi = { submit: (args: { @@ -10,4 +11,4 @@ export const feedbackApi = { }): Promise< { ok: true; imagesDelivered?: boolean } | { ok: false; status: number | null; error: string } > => ipcRenderer.invoke('feedback:submit', args) -} +} satisfies PreloadApi['feedback'] diff --git a/src/preload/api/fs-bridge.ts b/src/preload/api/fs-bridge.ts index 538480ce2f5..c67e9abd9e3 100644 --- a/src/preload/api/fs-bridge.ts +++ b/src/preload/api/fs-bridge.ts @@ -8,6 +8,7 @@ import type { LocalLogTailReadResult, LocalLogTailWatchArgs } from '../../shared/local-log-tail-types' +import type { PreloadApi } from '../api-types' export const fsApi = { readDir: (args: { @@ -216,4 +217,4 @@ export const fsApi = { ipcRenderer.on('fs:changed', listener) return () => ipcRenderer.removeListener('fs:changed', listener) } -} +} satisfies PreloadApi['fs'] diff --git a/src/preload/api/gh-bridge-mutations-and-projects.ts b/src/preload/api/gh-bridge-mutations-and-projects.ts index 3103cb432ec..80b746bfb79 100644 --- a/src/preload/api/gh-bridge-mutations-and-projects.ts +++ b/src/preload/api/gh-bridge-mutations-and-projects.ts @@ -35,11 +35,12 @@ import type { UpdateProjectItemFieldArgs } from '../../shared/github/project-request-types' import type { AppStarSource } from '../../shared/gh-star-source' +import type { PreloadApi } from '../api-types' export const ghMutationsAndProjectsApi = { setPRAutoMerge: (args: { repoPath: string - repoId?: string + repoId?: string | null sourceContext?: TaskSourceContext | null prNumber: number enabled: boolean @@ -49,7 +50,7 @@ export const ghMutationsAndProjectsApi = { ipcRenderer.invoke('gh:setPRAutoMerge', args), updatePRState: (args: { repoPath: string - repoId?: string + repoId?: string | null sourceContext?: TaskSourceContext | null prNumber: number updates: { state: 'open' | 'closed' } @@ -58,7 +59,7 @@ export const ghMutationsAndProjectsApi = { ipcRenderer.invoke('gh:updatePRState', args), markPRReadyForReview: (args: { repoPath: string - repoId?: string + repoId?: string | null sourceContext?: TaskSourceContext | null prNumber: number prRepo?: GitHubOwnerRepo | null @@ -66,7 +67,7 @@ export const ghMutationsAndProjectsApi = { ipcRenderer.invoke('gh:markPRReadyForReview', args), requestPRReviewers: (args: { repoPath: string - repoId?: string + repoId?: string | null sourceContext?: TaskSourceContext | null prNumber: number reviewers: string[] @@ -75,7 +76,7 @@ export const ghMutationsAndProjectsApi = { ipcRenderer.invoke('gh:requestPRReviewers', args), removePRReviewers: (args: { repoPath: string - repoId?: string + repoId?: string | null sourceContext?: TaskSourceContext | null prNumber: number reviewers: string[] @@ -84,7 +85,7 @@ export const ghMutationsAndProjectsApi = { ipcRenderer.invoke('gh:removePRReviewers', args), updateIssue: (args: { repoPath: string - repoId?: string + repoId?: string | null sourceContext?: TaskSourceContext | null number: number updates: unknown @@ -92,7 +93,7 @@ export const ghMutationsAndProjectsApi = { ipcRenderer.invoke('gh:updateIssue', args), addIssueComment: (args: { repoPath: string - repoId?: string + repoId?: string | null sourceContext?: TaskSourceContext | null number: number body: string @@ -101,7 +102,7 @@ export const ghMutationsAndProjectsApi = { }): Promise => ipcRenderer.invoke('gh:addIssueComment', args), addPRReviewCommentReply: (args: { repoPath: string - repoId?: string + repoId?: string | null sourceContext?: TaskSourceContext | null prNumber: number commentId: number @@ -113,7 +114,7 @@ export const ghMutationsAndProjectsApi = { }): Promise => ipcRenderer.invoke('gh:addPRReviewCommentReply', args), addPRReviewComment: (args: { repoPath: string - repoId?: string + repoId?: string | null sourceContext?: TaskSourceContext | null prNumber: number prRepo?: GitHubOwnerRepo | null @@ -125,12 +126,12 @@ export const ghMutationsAndProjectsApi = { }): Promise => ipcRenderer.invoke('gh:addPRReviewComment', args), listLabels: (args: { repoPath: string - repoId?: string + repoId?: string | null sourceContext?: TaskSourceContext | null }): Promise => ipcRenderer.invoke('gh:listLabels', args), listAssignableUsers: (args: { repoPath: string - repoId?: string + repoId?: string | null sourceContext?: TaskSourceContext | null }): Promise => ipcRenderer.invoke('gh:listAssignableUsers', args), onWorkItemMutated: ( @@ -199,4 +200,4 @@ export const ghMutationsAndProjectsApi = { ipcRenderer.invoke('gh:listIssueTypesBySlug', args), updateIssueTypeBySlug: (args: UpdateIssueTypeBySlugArgs): Promise => ipcRenderer.invoke('gh:updateIssueTypeBySlug', args) -} +} satisfies Partial diff --git a/src/preload/api/gh-bridge-pull-requests-and-work-items.ts b/src/preload/api/gh-bridge-pull-requests-and-work-items.ts index a4f3e1d45ef..3e4a5f6ce2a 100644 --- a/src/preload/api/gh-bridge-pull-requests-and-work-items.ts +++ b/src/preload/api/gh-bridge-pull-requests-and-work-items.ts @@ -9,33 +9,34 @@ import type { GitHubOwnerRepo } from '../../shared/github/pull-request-types' import type { GitHubWorkItem, ListWorkItemsResult } from '../../shared/github/work-item-types' import type { GitHubCreateIssueResult } from '../../shared/issue-mutation-types' import type { TaskSourceContext } from '../../shared/task-source-context' +import type { PreloadApi } from '../api-types' export const ghPullRequestsAndWorkItemsApi = { - viewer: (): Promise => ipcRenderer.invoke('gh:viewer'), - repoSlug: (args: { repoPath: string; repoId?: string }): Promise => + viewer: () => ipcRenderer.invoke('gh:viewer'), + repoSlug: (args: { repoPath: string; repoId?: string }) => ipcRenderer.invoke('gh:repoSlug', args), - repoUpstream: (args: { repoPath: string; repoId?: string }): Promise => + repoUpstream: (args: { repoPath: string; repoId?: string }) => ipcRenderer.invoke('gh:repoUpstream', args), prForBranch: (args: { repoPath: string - repoId?: string + repoId?: string | null branch: string linkedPRNumber?: number | null fallbackPRNumber?: number | null acceptMergedFallbackPR?: boolean currentHeadOid?: string | null - }): Promise => ipcRenderer.invoke('gh:prForBranch', args), - refreshPRNow: (args: { candidate: GitHubPRRefreshCandidate }): Promise => + }) => ipcRenderer.invoke('gh:prForBranch', args), + refreshPRNow: (args: { candidate: GitHubPRRefreshCandidate }) => ipcRenderer.invoke('gh:refreshPRNow', args), enqueuePRRefresh: (args: { candidate: GitHubPRRefreshCandidate reason: GitHubPRRefreshReason priority?: number - }): Promise => ipcRenderer.invoke('gh:enqueuePRRefresh', args), + }) => ipcRenderer.invoke('gh:enqueuePRRefresh', args), reportVisiblePRRefreshCandidates: (args: { candidates: GitHubPRRefreshCandidate[] generation: number - }): Promise => ipcRenderer.invoke('gh:reportVisiblePRRefreshCandidates', args), + }) => ipcRenderer.invoke('gh:reportVisiblePRRefreshCandidates', args), onPRRefreshEvent: (callback: (event: GitHubPRRefreshEvent) => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, event: GitHubPRRefreshEvent): void => callback(event) @@ -44,42 +45,42 @@ export const ghPullRequestsAndWorkItemsApi = { }, issue: (args: { repoPath: string - repoId?: string + repoId?: string | null sourceContext?: TaskSourceContext | null number: number - }): Promise => ipcRenderer.invoke('gh:issue', args), + }) => ipcRenderer.invoke('gh:issue', args), workItem: (args: { repoPath: string - repoId?: string + repoId?: string | null sourceContext?: TaskSourceContext | null number: number type?: 'issue' | 'pr' - }): Promise => ipcRenderer.invoke('gh:workItem', args), + }) => ipcRenderer.invoke('gh:workItem', args), workItemByOwnerRepo: (args: { repoPath: string - repoId?: string + repoId?: string | null owner: string repo: string host?: string number: number type: 'issue' | 'pr' - }): Promise => ipcRenderer.invoke('gh:workItemByOwnerRepo', args), + }) => ipcRenderer.invoke('gh:workItemByOwnerRepo', args), workItemDetails: (args: { repoPath: string - repoId?: string + repoId?: string | null sourceContext?: TaskSourceContext | null number: number type?: 'issue' | 'pr' - }): Promise => ipcRenderer.invoke('gh:workItemDetails', args), + }) => ipcRenderer.invoke('gh:workItemDetails', args), notifyWorkItemMutated: (args: { repoPath: string - repoId?: string + repoId?: string | null type: 'issue' | 'pr' number: number }): Promise => ipcRenderer.invoke('gh:notifyWorkItemMutated', args), prFileContents: (args: { repoPath: string - repoId?: string + repoId?: string | null sourceContext?: TaskSourceContext | null prNumber: number prRepo?: GitHubOwnerRepo | null @@ -88,12 +89,12 @@ export const ghPullRequestsAndWorkItemsApi = { status: string headSha: string baseSha: string - }): Promise => ipcRenderer.invoke('gh:prFileContents', args), - listIssues: (args: { repoPath: string; repoId?: string; limit?: number }): Promise => + }) => ipcRenderer.invoke('gh:prFileContents', args), + listIssues: (args: { repoPath: string; repoId?: string; limit?: number }) => ipcRenderer.invoke('gh:listIssues', args), createIssue: (args: { repoPath: string - repoId?: string + repoId?: string | null sourceContext?: TaskSourceContext | null title: string body: string @@ -104,7 +105,7 @@ export const ghPullRequestsAndWorkItemsApi = { ipcRenderer.invoke('gh:countWorkItems', args), listWorkItems: (args: { repoPath: string - repoId?: string + repoId?: string | null limit?: number query?: string page?: number @@ -113,26 +114,26 @@ export const ghPullRequestsAndWorkItemsApi = { ipcRenderer.invoke('gh:listWorkItems', args), prChecks: (args: { repoPath: string - repoId?: string + repoId?: string | null sourceContext?: TaskSourceContext | null prNumber: number headSha?: string prRepo?: GitHubOwnerRepo | null noCache?: boolean - }): Promise => ipcRenderer.invoke('gh:prChecks', args), + }) => ipcRenderer.invoke('gh:prChecks', args), prCheckDetails: (args: { repoPath: string - repoId?: string + repoId?: string | null sourceContext?: TaskSourceContext | null checkRunId?: number workflowRunId?: number checkName?: string url?: string | null prRepo?: GitHubOwnerRepo | null - }): Promise => ipcRenderer.invoke('gh:prCheckDetails', args), + }) => ipcRenderer.invoke('gh:prCheckDetails', args), rerunPRChecks: (args: { repoPath: string - repoId?: string + repoId?: string | null sourceContext?: TaskSourceContext | null prNumber: number headSha?: string @@ -142,15 +143,15 @@ export const ghPullRequestsAndWorkItemsApi = { ipcRenderer.invoke('gh:rerunPRChecks', args), prComments: (args: { repoPath: string - repoId?: string + repoId?: string | null sourceContext?: TaskSourceContext | null prNumber: number prRepo?: GitHubOwnerRepo | null noCache?: boolean - }): Promise => ipcRenderer.invoke('gh:prComments', args), + }) => ipcRenderer.invoke('gh:prComments', args), setPRCommentReaction: (args: { repoPath: string - repoId?: string + repoId?: string | null sourceContext?: TaskSourceContext | null reactionSubjectId: string content: GitHubReactionContent @@ -159,7 +160,7 @@ export const ghPullRequestsAndWorkItemsApi = { }): Promise => ipcRenderer.invoke('gh:setPRCommentReaction', args), resolveReviewThread: (args: { repoPath: string - repoId?: string + repoId?: string | null sourceContext?: TaskSourceContext | null threadId: string resolve: boolean @@ -167,7 +168,7 @@ export const ghPullRequestsAndWorkItemsApi = { }): Promise => ipcRenderer.invoke('gh:resolveReviewThread', args), setPRFileViewed: (args: { repoPath: string - repoId?: string + repoId?: string | null sourceContext?: TaskSourceContext | null prNumber: number prRepo?: GitHubOwnerRepo | null @@ -177,17 +178,17 @@ export const ghPullRequestsAndWorkItemsApi = { }): Promise => ipcRenderer.invoke('gh:setPRFileViewed', args), updatePRTitle: (args: { repoPath: string - repoId?: string + repoId?: string | null prNumber: number title: string prRepo?: GitHubOwnerRepo | null }): Promise => ipcRenderer.invoke('gh:updatePRTitle', args), mergePR: (args: { repoPath: string - repoId?: string + repoId?: string | null sourceContext?: TaskSourceContext | null prNumber: number method?: 'merge' | 'squash' | 'rebase' prRepo?: GitHubOwnerRepo | null }): Promise<{ ok: true } | { ok: false; error: string }> => ipcRenderer.invoke('gh:mergePR', args) -} +} satisfies Partial diff --git a/src/preload/api/gh-bridge.ts b/src/preload/api/gh-bridge.ts index 52c21a966a7..c7da93698e6 100644 --- a/src/preload/api/gh-bridge.ts +++ b/src/preload/api/gh-bridge.ts @@ -1,4 +1,8 @@ +import type { PreloadApi } from '../api-types' import { ghPullRequestsAndWorkItemsApi } from './gh-bridge-pull-requests-and-work-items' import { ghMutationsAndProjectsApi } from './gh-bridge-mutations-and-projects' -export const ghApi = { ...ghPullRequestsAndWorkItemsApi, ...ghMutationsAndProjectsApi } +export const ghApi = { + ...ghPullRequestsAndWorkItemsApi, + ...ghMutationsAndProjectsApi +} satisfies PreloadApi['gh'] diff --git a/src/preload/api/git-bash-bridge.ts b/src/preload/api/git-bash-bridge.ts index bd62dfc614a..c2186d12aa3 100644 --- a/src/preload/api/git-bash-bridge.ts +++ b/src/preload/api/git-bash-bridge.ts @@ -1,5 +1,6 @@ import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' export const gitBashApi = { isAvailable: (): Promise => ipcRenderer.invoke('gitBash:isAvailable') -} +} satisfies PreloadApi['gitBash'] diff --git a/src/preload/api/git-bridge.ts b/src/preload/api/git-bridge.ts index 89dfc8db5ae..987abab14fc 100644 --- a/src/preload/api/git-bridge.ts +++ b/src/preload/api/git-bridge.ts @@ -3,6 +3,7 @@ import type { GitForkSyncExpectedUpstream, GitForkSyncResult } from '../../share import type { GitStagingArea, GitUpstreamStatus } from '../../shared/git-status-types' import type { GitPushTarget } from '../../shared/worktree/types' import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history' +import type { PreloadApi } from '../api-types' export const gitApi = { status: (args: { @@ -13,7 +14,7 @@ export const gitApi = { reuseLineStats?: boolean branchLineTotalMergeBase?: string requestToken?: string - }): Promise => ipcRenderer.invoke('git:status', args), + }) => ipcRenderer.invoke('git:status', args), cancelStatus: (args: { requestToken: string }): Promise => ipcRenderer.invoke('git:cancelStatus', args), setStatusUpstreamRefWatch: (args: { @@ -29,7 +30,7 @@ export const gitApi = { submodulePath: string connectionId?: string area?: GitStagingArea - }): Promise => ipcRenderer.invoke('git:submoduleStatus', args), + }) => ipcRenderer.invoke('git:submoduleStatus', args), checkIgnored: (args: { worktreePath: string paths: string[] @@ -42,7 +43,7 @@ export const gitApi = { history: ( args: { worktreePath: string; connectionId?: string } & GitHistoryOptions ): Promise => ipcRenderer.invoke('git:history', args), - conflictOperation: (args: { worktreePath: string; connectionId?: string }): Promise => + conflictOperation: (args: { worktreePath: string; connectionId?: string }) => ipcRenderer.invoke('git:conflictOperation', args), abortMerge: (args: { worktreePath: string; connectionId?: string }): Promise => ipcRenderer.invoke('git:abortMerge', args), @@ -54,17 +55,11 @@ export const gitApi = { staged: boolean compareAgainstHead?: boolean connectionId?: string - }): Promise => ipcRenderer.invoke('git:diff', args), - branchCompare: (args: { - worktreePath: string - baseRef: string - connectionId?: string - }): Promise => ipcRenderer.invoke('git:branchCompare', args), - commitCompare: (args: { - worktreePath: string - commitId: string - connectionId?: string - }): Promise => ipcRenderer.invoke('git:commitCompare', args), + }) => ipcRenderer.invoke('git:diff', args), + branchCompare: (args: { worktreePath: string; baseRef: string; connectionId?: string }) => + ipcRenderer.invoke('git:branchCompare', args), + commitCompare: (args: { worktreePath: string; commitId: string; connectionId?: string }) => + ipcRenderer.invoke('git:commitCompare', args), upstreamStatus: (args: { worktreePath: string connectionId?: string @@ -108,7 +103,7 @@ export const gitApi = { filePath: string oldPath?: string connectionId?: string - }): Promise => ipcRenderer.invoke('git:branchDiff', args), + }) => ipcRenderer.invoke('git:branchDiff', args), commitDiff: (args: { worktreePath: string commitOid: string @@ -116,7 +111,7 @@ export const gitApi = { filePath: string oldPath?: string connectionId?: string - }): Promise => ipcRenderer.invoke('git:commitDiff', args), + }) => ipcRenderer.invoke('git:commitDiff', args), commit: (args: { worktreePath: string message: string @@ -130,12 +125,12 @@ export const gitApi = { sourceControlAiResolvedParams?: unknown sourceControlAi?: unknown agentCmdOverrides?: Record - }): Promise => ipcRenderer.invoke('git:generateCommitMessage', args), + }) => ipcRenderer.invoke('git:generateCommitMessage', args), discoverCommitMessageModels: (args: { agentId: string worktreePath?: string connectionId?: string - }): Promise => ipcRenderer.invoke('git:discoverCommitMessageModels', args), + }) => ipcRenderer.invoke('git:discoverCommitMessageModels', args), cancelGenerateCommitMessage: (args: { worktreePath: string connectionId?: string @@ -154,7 +149,7 @@ export const gitApi = { sourceControlAiResolvedParams?: unknown sourceControlAi?: unknown agentCmdOverrides?: Record - }): Promise => ipcRenderer.invoke('git:generatePullRequestFields', args), + }) => ipcRenderer.invoke('git:generatePullRequestFields', args), cancelGeneratePullRequestFields: (args: { worktreePath: string connectionId?: string @@ -197,4 +192,4 @@ export const gitApi = { sha: string connectionId?: string }): Promise => ipcRenderer.invoke('git:remoteCommitUrl', args) -} +} satisfies PreloadApi['git'] diff --git a/src/preload/api/gl-bridge.ts b/src/preload/api/gl-bridge.ts index c48794a4976..3977536a838 100644 --- a/src/preload/api/gl-bridge.ts +++ b/src/preload/api/gl-bridge.ts @@ -1,3 +1,4 @@ import { glApi } from '../gitlab' +import type { PreloadApi } from '../api-types' -export const glApiBridge = glApi +export const glApiBridge = glApi satisfies PreloadApi['gl'] diff --git a/src/preload/api/grok-accounts-bridge.ts b/src/preload/api/grok-accounts-bridge.ts index b4719905ec7..246fc97bc56 100644 --- a/src/preload/api/grok-accounts-bridge.ts +++ b/src/preload/api/grok-accounts-bridge.ts @@ -1,6 +1,7 @@ import { ipcRenderer } from 'electron' import type { GrokAccountStatus } from '../../shared/rate-limit-types' +import type { PreloadApi } from '../api-types' export const grokAccountsApi = { getStatus: (): Promise => ipcRenderer.invoke('grokAccounts:getStatus') -} +} satisfies PreloadApi['grokAccounts'] diff --git a/src/preload/api/hooks-bridge.ts b/src/preload/api/hooks-bridge.ts index 59a6fee71b6..75c48281b16 100644 --- a/src/preload/api/hooks-bridge.ts +++ b/src/preload/api/hooks-bridge.ts @@ -1,22 +1,14 @@ import { ipcRenderer } from 'electron' import type { WorktreeSetupLaunch } from '../../shared/worktree/launch-types' import type { ExecutionHostId } from '../../shared/execution-host' +import type { PreloadApi } from '../api-types' export const hooksApi = { - check: (args: { - repoId: string - hostId?: ExecutionHostId - }): Promise<{ - status?: 'ok' | 'error' - hasHooks: boolean - hooks: unknown - mayNeedUpdate: boolean - }> => ipcRenderer.invoke('hooks:check', args), + check: (args: { repoId: string; hostId?: ExecutionHostId }) => + ipcRenderer.invoke('hooks:check', args), - inspectSetupScriptImports: (args: { - repoId: string - hostId?: ExecutionHostId - }): Promise => ipcRenderer.invoke('hooks:inspectSetupScriptImports', args), + inspectSetupScriptImports: (args: { repoId: string; hostId?: ExecutionHostId }) => + ipcRenderer.invoke('hooks:inspectSetupScriptImports', args), createIssueCommandRunner: (args: { repoId: string @@ -41,4 +33,4 @@ export const hooksApi = { content: string hostId?: ExecutionHostId }): Promise => ipcRenderer.invoke('hooks:writeIssueCommand', args) -} +} satisfies PreloadApi['hooks'] diff --git a/src/preload/api/hosted-review-bridge.ts b/src/preload/api/hosted-review-bridge.ts index dc8323b5bf3..b7e0d12af5c 100644 --- a/src/preload/api/hosted-review-bridge.ts +++ b/src/preload/api/hosted-review-bridge.ts @@ -1,12 +1,12 @@ import { ipcRenderer } from 'electron' import type { HostedReviewForBranchArgs } from '../../shared/hosted-review' +import type { PreloadApi } from '../api-types' export const hostedReviewApi = { - forBranch: (args: HostedReviewForBranchArgs): Promise => + forBranch: (args: HostedReviewForBranchArgs) => ipcRenderer.invoke('hostedReview:forBranch', args), - getCreationEligibility: (args: unknown): Promise => + getCreationEligibility: (args: unknown) => ipcRenderer.invoke('hostedReview:getCreationEligibility', args), - create: (args: unknown): Promise => ipcRenderer.invoke('hostedReview:create', args), - createStacked: (args: unknown): Promise => - ipcRenderer.invoke('hostedReview:createStacked', args) -} + create: (args: unknown) => ipcRenderer.invoke('hostedReview:create', args), + createStacked: (args: unknown) => ipcRenderer.invoke('hostedReview:createStacked', args) +} satisfies PreloadApi['hostedReview'] diff --git a/src/preload/api/jira-bridge.ts b/src/preload/api/jira-bridge.ts index 47a27b77f68..4b6b991095d 100644 --- a/src/preload/api/jira-bridge.ts +++ b/src/preload/api/jira-bridge.ts @@ -1,5 +1,6 @@ import { ipcRenderer } from 'electron' import type { JiraProjectStatusOrder } from '../../shared/jira-types' +import type { PreloadApi } from '../api-types' export const jiraApi = { connect: (args: { @@ -7,30 +8,21 @@ export const jiraApi = { email: string apiToken: string authType?: 'cloud' | 'server' - }): Promise<{ ok: true; viewer: unknown } | { ok: false; error: string }> => - ipcRenderer.invoke('jira:connect', args), + }) => ipcRenderer.invoke('jira:connect', args), disconnect: (args?: { siteId?: string }): Promise => ipcRenderer.invoke('jira:disconnect', args), - selectSite: (args: { siteId: string }): Promise => - ipcRenderer.invoke('jira:selectSite', args), + selectSite: (args: { siteId: string }) => ipcRenderer.invoke('jira:selectSite', args), - status: (): Promise => ipcRenderer.invoke('jira:status'), + status: () => ipcRenderer.invoke('jira:status'), - readStatus: (): Promise => ipcRenderer.invoke('jira:readStatus'), + readStatus: () => ipcRenderer.invoke('jira:readStatus'), - testConnection: (args?: { - siteId?: string - }): Promise<{ ok: true; viewer: unknown } | { ok: false; error: string }> => - ipcRenderer.invoke('jira:testConnection', args), + testConnection: (args?: { siteId?: string }) => ipcRenderer.invoke('jira:testConnection', args), - searchIssues: (args: { - jql: string - limit?: number - siteId?: string - requestId?: string - }): Promise => ipcRenderer.invoke('jira:searchIssues', args), + searchIssues: (args: { jql: string; limit?: number; siteId?: string; requestId?: string }) => + ipcRenderer.invoke('jira:searchIssues', args), cancelSearchIssues: (args: { requestId: string }): Promise => ipcRenderer.invoke('jira:cancelSearchIssues', args), @@ -38,16 +30,12 @@ export const jiraApi = { filter?: 'assigned' | 'reported' | 'all' | 'done' limit?: number siteId?: string - }): Promise => ipcRenderer.invoke('jira:listIssues', args), + }) => ipcRenderer.invoke('jira:listIssues', args), - getIssue: (args: { key: string; siteId?: string }): Promise => - ipcRenderer.invoke('jira:getIssue', args), + getIssue: (args: { key: string; siteId?: string }) => ipcRenderer.invoke('jira:getIssue', args), - lookupIssueSummary: (args: { - key: string - siteId: string - requestId?: string - }): Promise => ipcRenderer.invoke('jira:lookupIssueSummary', args), + lookupIssueSummary: (args: { key: string; siteId: string; requestId?: string }) => + ipcRenderer.invoke('jira:lookupIssueSummary', args), cancelIssueSummary: (args: { requestId: string }): Promise => ipcRenderer.invoke('jira:cancelIssueSummary', args), @@ -75,36 +63,28 @@ export const jiraApi = { }): Promise<{ ok: true; id: string } | { ok: false; error: string }> => ipcRenderer.invoke('jira:addIssueComment', args), - issueComments: (args: { key: string; siteId?: string }): Promise => + issueComments: (args: { key: string; siteId?: string }) => ipcRenderer.invoke('jira:issueComments', args), - listProjects: (args?: { siteId?: string }): Promise => - ipcRenderer.invoke('jira:listProjects', args), + listProjects: (args?: { siteId?: string }) => ipcRenderer.invoke('jira:listProjects', args), - listIssueTypes: (args: { projectIdOrKey: string; siteId?: string }): Promise => + listIssueTypes: (args: { projectIdOrKey: string; siteId?: string }) => ipcRenderer.invoke('jira:listIssueTypes', args), - listCreateFields: (args: { - projectIdOrKey: string - issueTypeId: string - siteId?: string - }): Promise => ipcRenderer.invoke('jira:listCreateFields', args), + listCreateFields: (args: { projectIdOrKey: string; issueTypeId: string; siteId?: string }) => + ipcRenderer.invoke('jira:listCreateFields', args), - listPriorities: (args?: { siteId?: string }): Promise => - ipcRenderer.invoke('jira:listPriorities', args), + listPriorities: (args?: { siteId?: string }) => ipcRenderer.invoke('jira:listPriorities', args), - listAssignableUsers: (args: { - key: string - query?: string - siteId?: string - }): Promise => ipcRenderer.invoke('jira:listAssignableUsers', args), - searchUsers: (args?: { query?: string; siteId?: string }): Promise => + listAssignableUsers: (args: { key: string; query?: string; siteId?: string }) => + ipcRenderer.invoke('jira:listAssignableUsers', args), + searchUsers: (args?: { query?: string; siteId?: string }) => ipcRenderer.invoke('jira:searchUsers', args), - listTransitions: (args: { key: string; siteId?: string }): Promise => + listTransitions: (args: { key: string; siteId?: string }) => ipcRenderer.invoke('jira:listTransitions', args), getProjectStatusOrder: (args: { projectKey: string siteId?: string }): Promise => ipcRenderer.invoke('jira:getProjectStatusOrder', args) -} +} satisfies PreloadApi['jira'] diff --git a/src/preload/api/keybindings-bridge.ts b/src/preload/api/keybindings-bridge.ts index 3111ddd6676..e91e587313d 100644 --- a/src/preload/api/keybindings-bridge.ts +++ b/src/preload/api/keybindings-bridge.ts @@ -1,5 +1,6 @@ import { ipcRenderer } from 'electron' import type { KeybindingActionId, KeybindingFileSnapshot } from '../../shared/keybindings' +import type { PreloadApi } from '../api-types' export const keybindingsApi = { get: (): Promise => ipcRenderer.invoke('keybindings:get'), @@ -17,4 +18,4 @@ export const keybindingsApi = { ipcRenderer.on('keybindings:changed', listener) return () => ipcRenderer.removeListener('keybindings:changed', listener) } -} +} satisfies PreloadApi['keybindings'] diff --git a/src/preload/api/linear-bridge.ts b/src/preload/api/linear-bridge.ts index cd6ec0e9c15..8092a8e70e7 100644 --- a/src/preload/api/linear-bridge.ts +++ b/src/preload/api/linear-bridge.ts @@ -1,37 +1,30 @@ import { ipcRenderer } from 'electron' import type { LinearProjectDetail } from '../../shared/linear/project-types' +import type { PreloadApi } from '../api-types' export const linearApi = { - connect: (args: { - apiKey: string - }): Promise<{ ok: true; viewer: unknown } | { ok: false; error: string }> => - ipcRenderer.invoke('linear:connect', args), + connect: (args: { apiKey: string }) => ipcRenderer.invoke('linear:connect', args), disconnect: (args?: { workspaceId?: string }): Promise => ipcRenderer.invoke('linear:disconnect', args), - selectWorkspace: (args: { workspaceId: string }): Promise => + selectWorkspace: (args: { workspaceId: string }) => ipcRenderer.invoke('linear:selectWorkspace', args), - status: (): Promise => ipcRenderer.invoke('linear:status'), + status: () => ipcRenderer.invoke('linear:status'), - testConnection: (args?: { - workspaceId?: string - }): Promise<{ ok: true; viewer: unknown } | { ok: false; error: string }> => + testConnection: (args?: { workspaceId?: string }) => ipcRenderer.invoke('linear:testConnection', args), - searchIssues: (args: { - query: string - limit?: number - workspaceId?: string - }): Promise => ipcRenderer.invoke('linear:searchIssues', args), + searchIssues: (args: { query: string; limit?: number; workspaceId?: string }) => + ipcRenderer.invoke('linear:searchIssues', args), listIssues: (args?: { filter?: 'assigned' | 'created' | 'all' | 'completed' limit?: number workspaceId?: string attributeFilter?: unknown - }): Promise => ipcRenderer.invoke('linear:listIssues', args), + }) => ipcRenderer.invoke('linear:listIssues', args), createIssue: (args: { teamId: string @@ -49,7 +42,7 @@ export const linearApi = { | { ok: false; error: string } > => ipcRenderer.invoke('linear:createIssue', args), - getIssue: (args: { id: string; workspaceId?: string }): Promise => + getIssue: (args: { id: string; workspaceId?: string }) => ipcRenderer.invoke('linear:getIssue', args), updateIssue: (args: { @@ -66,18 +59,17 @@ export const linearApi = { }): Promise<{ ok: true; id: string } | { ok: false; error: string }> => ipcRenderer.invoke('linear:addIssueComment', args), - issueComments: (args: { issueId: string; workspaceId?: string }): Promise => + issueComments: (args: { issueId: string; workspaceId?: string }) => ipcRenderer.invoke('linear:issueComments', args), - listTeams: (args?: { workspaceId?: string }): Promise => - ipcRenderer.invoke('linear:listTeams', args), + listTeams: (args?: { workspaceId?: string }) => ipcRenderer.invoke('linear:listTeams', args), listProjects: (args?: { query?: string limit?: number workspaceId?: string force?: boolean - }): Promise => ipcRenderer.invoke('linear:listProjects', args), + }) => ipcRenderer.invoke('linear:listProjects', args), createProject: (args: { name: string @@ -94,7 +86,7 @@ export const linearApi = { }): Promise<{ ok: true; project: LinearProjectDetail } | { ok: false; error: string }> => ipcRenderer.invoke('linear:createProject', args), - getProject: (args: { id: string; workspaceId: string; force?: boolean }): Promise => + getProject: (args: { id: string; workspaceId: string; force?: boolean }) => ipcRenderer.invoke('linear:getProject', args), listProjectIssues: (args: { @@ -102,42 +94,38 @@ export const linearApi = { limit?: number workspaceId: string force?: boolean - }): Promise => ipcRenderer.invoke('linear:listProjectIssues', args), + }) => ipcRenderer.invoke('linear:listProjectIssues', args), listCustomViews: (args: { model: string limit?: number workspaceId?: string force?: boolean - }): Promise => ipcRenderer.invoke('linear:listCustomViews', args), + }) => ipcRenderer.invoke('linear:listCustomViews', args), - getCustomView: (args: { - viewId: string - model: string - workspaceId: string - force?: boolean - }): Promise => ipcRenderer.invoke('linear:getCustomView', args), + getCustomView: (args: { viewId: string; model: string; workspaceId: string; force?: boolean }) => + ipcRenderer.invoke('linear:getCustomView', args), listCustomViewIssues: (args: { viewId: string limit?: number workspaceId: string force?: boolean - }): Promise => ipcRenderer.invoke('linear:listCustomViewIssues', args), + }) => ipcRenderer.invoke('linear:listCustomViewIssues', args), listCustomViewProjects: (args: { viewId: string limit?: number workspaceId: string force?: boolean - }): Promise => ipcRenderer.invoke('linear:listCustomViewProjects', args), + }) => ipcRenderer.invoke('linear:listCustomViewProjects', args), - teamStates: (args: { teamId: string; workspaceId?: string }): Promise => + teamStates: (args: { teamId: string; workspaceId?: string }) => ipcRenderer.invoke('linear:teamStates', args), - teamLabels: (args: { teamId: string; workspaceId?: string }): Promise => + teamLabels: (args: { teamId: string; workspaceId?: string }) => ipcRenderer.invoke('linear:teamLabels', args), - teamMembers: (args: { teamId: string; workspaceId?: string }): Promise => + teamMembers: (args: { teamId: string; workspaceId?: string }) => ipcRenderer.invoke('linear:teamMembers', args) -} +} satisfies PreloadApi['linear'] diff --git a/src/preload/api/macos-tcc-prompts-bridge.ts b/src/preload/api/macos-tcc-prompts-bridge.ts index 0c6c6b184fc..ef24b9e203e 100644 --- a/src/preload/api/macos-tcc-prompts-bridge.ts +++ b/src/preload/api/macos-tcc-prompts-bridge.ts @@ -1,11 +1,14 @@ import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' export const macosTccPromptsApi = { - onThreshold: (callback: (payload: unknown) => void) => { - const listener = (_event: Electron.IpcRendererEvent, payload: unknown): void => + onThreshold: (callback: (payload: { promptCount: number }) => void) => { + const listener = (_event: Electron.IpcRendererEvent, payload: { promptCount: number }): void => callback(payload) ipcRenderer.on('macosTccPrompts:threshold', listener) - return () => ipcRenderer.removeListener('macosTccPrompts:threshold', listener) + return (): void => { + ipcRenderer.removeListener('macosTccPrompts:threshold', listener) + } }, consumePending: (): Promise<{ claimId: number; promptCount: number } | null> => ipcRenderer.invoke('macosTccPrompts:consumePending'), @@ -14,4 +17,4 @@ export const macosTccPromptsApi = { releasePending: (claimId: number): Promise => ipcRenderer.invoke('macosTccPrompts:releasePending', claimId), dismiss: (): Promise => ipcRenderer.invoke('macosTccPrompts:dismiss') -} +} satisfies PreloadApi['macosTccPrompts'] diff --git a/src/preload/api/memory-bridge.ts b/src/preload/api/memory-bridge.ts index 15af55cb09c..c1735f86e31 100644 --- a/src/preload/api/memory-bridge.ts +++ b/src/preload/api/memory-bridge.ts @@ -1,6 +1,7 @@ import { ipcRenderer } from 'electron' import type { MemorySnapshot } from '../../shared/process-stats-types' +import type { PreloadApi } from '../api-types' export const memoryApi = { getSnapshot: (): Promise => ipcRenderer.invoke('memory:getSnapshot') -} +} satisfies PreloadApi['memory'] diff --git a/src/preload/api/minimax-credentials-bridge.ts b/src/preload/api/minimax-credentials-bridge.ts index a758d9e2e5b..e99bd843909 100644 --- a/src/preload/api/minimax-credentials-bridge.ts +++ b/src/preload/api/minimax-credentials-bridge.ts @@ -1,4 +1,5 @@ import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' export const minimaxCredentialsApi = { getStatus: (): Promise<{ configured: boolean }> => @@ -7,4 +8,4 @@ export const minimaxCredentialsApi = { ipcRenderer.invoke('minimaxCredentials:saveCookie', cookie), clearCookie: (): Promise<{ configured: boolean }> => ipcRenderer.invoke('minimaxCredentials:clearCookie') -} +} satisfies PreloadApi['minimaxCredentials'] diff --git a/src/preload/api/mobile-bridge.ts b/src/preload/api/mobile-bridge.ts index 836f27f6f37..a1ad9a4c716 100644 --- a/src/preload/api/mobile-bridge.ts +++ b/src/preload/api/mobile-bridge.ts @@ -3,6 +3,7 @@ import type { MobileRelayStatus } from '../../shared/mobile-relay-status' import type { MobilePairingConnectionMode } from '../../shared/mobile-pairing-connection-mode' import type { RuntimePairingReach } from '../../shared/runtime-pairing-reach' import type { MobileRelayMintFailure } from '../../shared/mobile-relay-mint-failure' +import type { PreloadApi } from '../api-types' export const mobileApi = { listNetworkInterfaces: (): Promise<{ @@ -92,4 +93,4 @@ export const mobileApi = { ipcRenderer.on('mobile:unpairedDeviceAuthFailure', listener) return () => ipcRenderer.removeListener('mobile:unpairedDeviceAuthFailure', listener) } -} +} satisfies PreloadApi['mobile'] diff --git a/src/preload/api/native-chat-bridge.ts b/src/preload/api/native-chat-bridge.ts index 16c2906349e..3a0a5d9161a 100644 --- a/src/preload/api/native-chat-bridge.ts +++ b/src/preload/api/native-chat-bridge.ts @@ -2,7 +2,8 @@ import { ipcRenderer } from 'electron' import type { NativeChatAppendedPayload, NativeChatReadSessionResult, - NativeChatSubscriptionFrame + NativeChatSubscriptionFrame, + PreloadApi } from '../api-types' import type { AgentType } from '../../shared/native-chat-types' @@ -37,4 +38,4 @@ export const nativeChatApi = { ipcRenderer.send('nativeChat:unsubscribe', { subscriptionId: args.subscriptionId }) } } -} +} satisfies PreloadApi['nativeChat'] diff --git a/src/preload/api/notebook-bridge.ts b/src/preload/api/notebook-bridge.ts index ee307b9f0e2..436726b7783 100644 --- a/src/preload/api/notebook-bridge.ts +++ b/src/preload/api/notebook-bridge.ts @@ -1,4 +1,5 @@ import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' export const notebookApi = { runPythonCell: (args: { @@ -8,4 +9,4 @@ export const notebookApi = { connectionId?: string | null }): Promise<{ stdout: string; stderr: string; exitCode: number | null; error?: string }> => ipcRenderer.invoke('notebook:runPythonCell', args) -} +} satisfies PreloadApi['notebook'] diff --git a/src/preload/api/notifications-bridge.ts b/src/preload/api/notifications-bridge.ts index aa84fb1a8a6..70c64d4ce0d 100644 --- a/src/preload/api/notifications-bridge.ts +++ b/src/preload/api/notifications-bridge.ts @@ -8,6 +8,7 @@ import type { NotificationSoundPathResult, NotificationSoundResult } from '../../shared/notification-settings-types' +import type { PreloadApi } from '../api-types' // Why: cache one shared Audio + blob URL per sound path so notifications do not re-read large files. let cachedNotificationSound: { @@ -117,4 +118,4 @@ export const notificationsApi = { return { played: false, reason: 'playback-failed' } } } -} +} satisfies PreloadApi['notifications'] diff --git a/src/preload/api/onboarding-bridge.ts b/src/preload/api/onboarding-bridge.ts index 1b4393268ac..7939ab64810 100644 --- a/src/preload/api/onboarding-bridge.ts +++ b/src/preload/api/onboarding-bridge.ts @@ -1,5 +1,6 @@ import { ipcRenderer } from 'electron' import type { OnboardingState } from '../../shared/onboarding-state-types' +import type { PreloadApi } from '../api-types' export const onboardingApi = { get: (): Promise => ipcRenderer.invoke('onboarding:get'), @@ -8,4 +9,4 @@ export const onboardingApi = { checklist?: Partial } ): Promise => ipcRenderer.invoke('onboarding:update', updates) -} +} satisfies PreloadApi['onboarding'] diff --git a/src/preload/api/open-code-usage-bridge.ts b/src/preload/api/open-code-usage-bridge.ts index 5cc668e6e00..cd3564d2b32 100644 --- a/src/preload/api/open-code-usage-bridge.ts +++ b/src/preload/api/open-code-usage-bridge.ts @@ -1,4 +1,8 @@ import { ipcRenderer } from 'electron' import { createUsageProviderApi } from '../usage-provider-api' +import type { PreloadApi } from '../api-types' -export const openCodeUsageApi = createUsageProviderApi(ipcRenderer, 'openCodeUsage') +export const openCodeUsageApi = createUsageProviderApi( + ipcRenderer, + 'openCodeUsage' +) satisfies PreloadApi['openCodeUsage'] diff --git a/src/preload/api/pet-bridge.ts b/src/preload/api/pet-bridge.ts index 8a5d3309c5c..c51751b3161 100644 --- a/src/preload/api/pet-bridge.ts +++ b/src/preload/api/pet-bridge.ts @@ -1,5 +1,6 @@ import { ipcRenderer } from 'electron' import type { CustomPet } from '../../shared/pet-types' +import type { PreloadApi } from '../api-types' export const petApi = { import: (): Promise => ipcRenderer.invoke('pet:import'), @@ -8,4 +9,4 @@ export const petApi = { ipcRenderer.invoke('pet:read', id, fileName, kind), delete: (id: string, fileName: string, kind?: 'image' | 'bundle'): Promise => ipcRenderer.invoke('pet:delete', id, fileName, kind) -} +} satisfies PreloadApi['pet'] diff --git a/src/preload/api/plugins-bridge.ts b/src/preload/api/plugins-bridge.ts index 2488d4cfdb7..0e529e74d96 100644 --- a/src/preload/api/plugins-bridge.ts +++ b/src/preload/api/plugins-bridge.ts @@ -24,11 +24,8 @@ export const pluginsApi = { pluginKey: string panelId: string }): Promise => ipcRenderer.invoke('plugins:readPanelEntry', args), - invokeCommand: (args: { - pluginKey: string - commandId: string - args?: unknown - }): Promise => ipcRenderer.invoke('plugins:invokeCommand', args), + invokeCommand: (args: { pluginKey: string; commandId: string; args?: unknown }) => + ipcRenderer.invoke('plugins:invokeCommand', args), panelAction: (args: { sessionToken: string action: string diff --git a/src/preload/api/preflight-bridge.ts b/src/preload/api/preflight-bridge.ts index c05721a2d3b..64d317d139c 100644 --- a/src/preload/api/preflight-bridge.ts +++ b/src/preload/api/preflight-bridge.ts @@ -1,5 +1,5 @@ import { ipcRenderer } from 'electron' -import type { PreflightRuntimeContext, RefreshAgentsResult } from '../api-types' +import type { PreflightRuntimeContext, PreloadApi, RefreshAgentsResult } from '../api-types' export const preflightApi = { check: (args?: { @@ -40,4 +40,4 @@ export const preflightApi = { gitBashAvailable: boolean hostPlatform: NodeJS.Platform | null }> => ipcRenderer.invoke('preflight:detectRemoteWindowsTerminalCapabilities', args) -} +} satisfies PreloadApi['preflight'] diff --git a/src/preload/api/pty-bridge-session-control.ts b/src/preload/api/pty-bridge-session-control.ts index 7e761738256..ef6002e11c8 100644 --- a/src/preload/api/pty-bridge-session-control.ts +++ b/src/preload/api/pty-bridge-session-control.ts @@ -15,6 +15,7 @@ import type { import type { TerminalViewAttributes } from '../../shared/terminal-view-attributes' import type { PtyMainDeliveryDiagnostics } from '../../shared/pty-delivery-diagnostics' import type { AgentKind, LaunchSource, RequestKind } from '../../shared/telemetry-events' +import type { PreloadApi } from '../api-types' export const ptySessionControlApi = { spawn: (opts: { @@ -204,4 +205,4 @@ export const ptySessionControlApi = { ipcRenderer.invoke('pty:hasChildProcesses', { id }), getForegroundProcess: (id: string): Promise => ipcRenderer.invoke('pty:getForegroundProcess', { id }) -} +} satisfies Partial diff --git a/src/preload/api/pty-bridge-stream-and-serialization.ts b/src/preload/api/pty-bridge-stream-and-serialization.ts index 8fc9c48bce1..f751491c0e0 100644 --- a/src/preload/api/pty-bridge-stream-and-serialization.ts +++ b/src/preload/api/pty-bridge-stream-and-serialization.ts @@ -1,6 +1,7 @@ import { ipcRenderer } from 'electron' import type { PtyModelRestoreNeededEvent } from '../../shared/pty-model-restore-marker' import type { TerminalSideEffectBatch } from '../../shared/terminal-side-effect-facts' +import type { PreloadApi } from '../api-types' export const ptyStreamAndSerializationApi = { inspectProcess: ( @@ -138,4 +139,4 @@ export const ptyStreamAndSerializationApi = { restart: () => ipcRenderer.invoke('pty:management:restart'), macTccAttribution: () => ipcRenderer.invoke('pty:management:macTccAttribution') } -} +} satisfies Partial diff --git a/src/preload/api/pty-bridge.ts b/src/preload/api/pty-bridge.ts index df080692178..18f867e6426 100644 --- a/src/preload/api/pty-bridge.ts +++ b/src/preload/api/pty-bridge.ts @@ -1,4 +1,8 @@ +import type { PreloadApi } from '../api-types' import { ptySessionControlApi } from './pty-bridge-session-control' import { ptyStreamAndSerializationApi } from './pty-bridge-stream-and-serialization' -export const ptyApi = { ...ptySessionControlApi, ...ptyStreamAndSerializationApi } +export const ptyApi = { + ...ptySessionControlApi, + ...ptyStreamAndSerializationApi +} satisfies PreloadApi['pty'] diff --git a/src/preload/api/pwsh-bridge.ts b/src/preload/api/pwsh-bridge.ts index bd202277ad1..34ed9880ada 100644 --- a/src/preload/api/pwsh-bridge.ts +++ b/src/preload/api/pwsh-bridge.ts @@ -1,5 +1,6 @@ import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' export const pwshApi = { isAvailable: (): Promise => ipcRenderer.invoke('pwsh:isAvailable') -} +} satisfies PreloadApi['pwsh'] diff --git a/src/preload/api/rate-limits-bridge.ts b/src/preload/api/rate-limits-bridge.ts index f403d79cdc6..37af13f0006 100644 --- a/src/preload/api/rate-limits-bridge.ts +++ b/src/preload/api/rate-limits-bridge.ts @@ -4,6 +4,7 @@ import type { RateLimitRuntimeTarget, RateLimitState } from '../../shared/rate-limit-types' +import type { PreloadApi } from '../api-types' export const rateLimitsApi = { get: (): Promise => ipcRenderer.invoke('rateLimits:get'), @@ -27,4 +28,4 @@ export const rateLimitsApi = { ipcRenderer.on('rateLimits:update', listener) return () => ipcRenderer.removeListener('rateLimits:update', listener) } -} +} satisfies PreloadApi['rateLimits'] diff --git a/src/preload/api/runtime-bridge.ts b/src/preload/api/runtime-bridge.ts index c58049bcbe3..8b31e6873f5 100644 --- a/src/preload/api/runtime-bridge.ts +++ b/src/preload/api/runtime-bridge.ts @@ -9,6 +9,7 @@ import type { } from '../../shared/runtime-types' import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' import type { RuntimeEnvironmentSubscriptionHandle } from '../runtime-environment-subscriptions' +import type { PreloadApi } from '../api-types' export const runtimeApi = { syncWindowGraph: (graph: RuntimeRendererSyncWindowGraph): Promise => @@ -141,4 +142,4 @@ export const runtimeApi = { ipcRenderer.on('runtime:clientHostedBrowserRowsChanged', listener) return () => ipcRenderer.removeListener('runtime:clientHostedBrowserRowsChanged', listener) } -} +} satisfies PreloadApi['runtime'] diff --git a/src/preload/api/runtime-environments-bridge.ts b/src/preload/api/runtime-environments-bridge.ts index d018c0574c8..ddfa498dc74 100644 --- a/src/preload/api/runtime-environments-bridge.ts +++ b/src/preload/api/runtime-environments-bridge.ts @@ -9,6 +9,7 @@ import { subscribeRuntimeEnvironmentFromPreload, type RuntimeEnvironmentSubscriptionHandle } from '../runtime-environment-subscriptions' +import type { PreloadApi } from '../api-types' export const runtimeEnvironmentsApi = { list: (): Promise => @@ -90,4 +91,4 @@ export const runtimeEnvironmentsApi = { } ): Promise => subscribeRuntimeEnvironmentFromPreload(ipcRenderer, args, callbacks) -} +} satisfies PreloadApi['runtimeEnvironments'] diff --git a/src/preload/api/settings-bridge.ts b/src/preload/api/settings-bridge.ts index d8aaa23b0b7..4e7105c00cb 100644 --- a/src/preload/api/settings-bridge.ts +++ b/src/preload/api/settings-bridge.ts @@ -4,22 +4,20 @@ import type { WarpThemeImportPreview, WarpThemeImportSource } from '../../shared/terminal-custom-themes' +import type { PreloadApi } from '../api-types' export const settingsApi = { - get: (): Promise => ipcRenderer.invoke('settings:get'), + get: () => ipcRenderer.invoke('settings:get'), // Why: blocking read for the few startup decisions (terminal side-effect authority) that can't wait for async hydration. Call sparingly. - getSync: (): unknown => ipcRenderer.sendSync('settings:get-sync'), + getSync: () => ipcRenderer.sendSync('settings:get-sync'), - set: (args: Record): Promise => - ipcRenderer.invoke('settings:set', args), + set: (args: Record) => ipcRenderer.invoke('settings:set', args), - setActiveRuntimeEnvironmentPreference: (args: { - environmentId: string | null - }): Promise => + setActiveRuntimeEnvironmentPreference: (args: { environmentId: string | null }) => ipcRenderer.invoke('settings:set-active-runtime-environment-preference', args), - updatePRBotAuthorOverride: (args: { author: string; isBot: boolean }): Promise => + updatePRBotAuthorOverride: (args: { author: string; isBot: boolean }) => ipcRenderer.invoke('settings:update-pr-bot-author-override', args), listFonts: (): Promise => ipcRenderer.invoke('settings:listFonts'), @@ -36,4 +34,4 @@ export const settingsApi = { ipcRenderer.on('settings:changed', listener) return () => ipcRenderer.removeListener('settings:changed', listener) } -} +} satisfies PreloadApi['settings'] diff --git a/src/preload/api/shell-bridge.ts b/src/preload/api/shell-bridge.ts index be34c7ff70e..21ccda3fd83 100644 --- a/src/preload/api/shell-bridge.ts +++ b/src/preload/api/shell-bridge.ts @@ -4,6 +4,7 @@ import type { ShellOpenExternalEditorResult, ShellOpenLocalPathResult } from '../../shared/shell-open-types' +import type { PreloadApi } from '../api-types' export const shellApi = { openPath: (path: string): Promise => ipcRenderer.invoke('shell:openPath', path), @@ -38,4 +39,4 @@ export const shellApi = { copyFile: (args: { srcPath: string; destPath: string }): Promise => ipcRenderer.invoke('shell:copyFile', args) -} +} satisfies PreloadApi['shell'] diff --git a/src/preload/api/skills-bridge.ts b/src/preload/api/skills-bridge.ts index eafaf2ce543..4bcde9a613c 100644 --- a/src/preload/api/skills-bridge.ts +++ b/src/preload/api/skills-bridge.ts @@ -37,6 +37,7 @@ import type { SkillUpdateRun, SkillUpdateStartResult } from '../../shared/skill-freshness' +import type { PreloadApi } from '../api-types' export const skillsApi = { discover: (target?: SkillDiscoveryTarget): Promise => @@ -126,4 +127,4 @@ export const skillsApi = { ipcRenderer.on('skills:updateRun', listener) return () => ipcRenderer.removeListener('skills:updateRun', listener) } -} +} satisfies PreloadApi['skills'] diff --git a/src/preload/api/speech-bridge.ts b/src/preload/api/speech-bridge.ts index 513b219f498..dbd7e0d26ab 100644 --- a/src/preload/api/speech-bridge.ts +++ b/src/preload/api/speech-bridge.ts @@ -6,6 +6,7 @@ import type { SpeechModelState, SpeechTranscriptEvent } from '../../shared/speech-types' +import type { PreloadApi } from '../api-types' export const speechApi = { getCatalog: (): Promise => ipcRenderer.invoke('speech:getCatalog'), @@ -78,4 +79,4 @@ export const speechApi = { ipcRenderer.on('speech:error', listener) return () => ipcRenderer.removeListener('speech:error', listener) } -} +} satisfies PreloadApi['speech'] diff --git a/src/preload/api/ssh-bridge.ts b/src/preload/api/ssh-bridge.ts index 2884e4ec8c1..b0f7b89ec3d 100644 --- a/src/preload/api/ssh-bridge.ts +++ b/src/preload/api/ssh-bridge.ts @@ -17,6 +17,7 @@ import { admitSshDetectedPorts } from '../../shared/ssh-retained-payload-admission' import type { FilesystemPathFlavor } from '../../shared/filesystem-entry-types' +import type { PreloadApi } from '../api-types' export const sshApi = { listTargets: (): Promise => ipcRenderer.invoke('ssh:listTargets'), @@ -180,4 +181,4 @@ export const sshApi = { submitCredential: (args: { requestId: string; value: string | null }): Promise => ipcRenderer.invoke('ssh:submitCredential', args) -} +} satisfies PreloadApi['ssh'] diff --git a/src/preload/api/star-nag-bridge.ts b/src/preload/api/star-nag-bridge.ts index b697739a52f..49e56e4aa91 100644 --- a/src/preload/api/star-nag-bridge.ts +++ b/src/preload/api/star-nag-bridge.ts @@ -1,4 +1,5 @@ import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' export const starNagApi = { onShow: ( @@ -27,4 +28,4 @@ export const starNagApi = { ipcRenderer.invoke('star-nag:agentValueMoment'), showAgentValueMoment: (): Promise => ipcRenderer.invoke('star-nag:showAgentValueMoment'), onboardingCompleted: (): Promise => ipcRenderer.invoke('star-nag:onboardingCompleted') -} +} satisfies PreloadApi['starNag'] diff --git a/src/preload/api/stats-bridge.ts b/src/preload/api/stats-bridge.ts index 20bc823b86a..f435b9980f5 100644 --- a/src/preload/api/stats-bridge.ts +++ b/src/preload/api/stats-bridge.ts @@ -1,4 +1,5 @@ import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' export const statsApi = { getSummary: (): Promise<{ @@ -7,4 +8,4 @@ export const statsApi = { totalAgentTimeMs: number firstEventAt: number | null }> => ipcRenderer.invoke('stats:summary') -} +} satisfies PreloadApi['stats'] diff --git a/src/preload/api/terminal-preview-bridge.ts b/src/preload/api/terminal-preview-bridge.ts index c8bf5b38623..3bd94d4998b 100644 --- a/src/preload/api/terminal-preview-bridge.ts +++ b/src/preload/api/terminal-preview-bridge.ts @@ -3,6 +3,7 @@ import type { TerminalPreviewConnectResult, TerminalPreviewDataPayload } from '../../shared/terminal-preview' +import type { PreloadApi } from '../api-types' export const terminalPreviewApi = { connect: ( @@ -30,4 +31,4 @@ export const terminalPreviewApi = { ipcRenderer.on('terminalPreview:data', listener) return () => ipcRenderer.removeListener('terminalPreview:data', listener) } -} +} satisfies PreloadApi['terminalPreview'] diff --git a/src/preload/api/ui-bridge-clipboard-and-window-controls.ts b/src/preload/api/ui-bridge-clipboard-and-window-controls.ts index 867bd80026d..fdad19c2944 100644 --- a/src/preload/api/ui-bridge-clipboard-and-window-controls.ts +++ b/src/preload/api/ui-bridge-clipboard-and-window-controls.ts @@ -12,6 +12,7 @@ import { import type { NativeFileDropPayload } from '../../shared/native-file-drop' import type { ReadClipboardTextOptions } from '../../shared/clipboard-text' import { subscribeNativeFileDrop } from '../preload-runtime-support' +import type { PreloadApi } from '../api-types' export const uiClipboardAndWindowControlsApi = { onOpenDiffFromMobile: ( @@ -195,4 +196,4 @@ export const uiClipboardAndWindowControlsApi = { notifyWindowRevealed: (): void => { ipcRenderer.send('ui:window-revealed') } -} +} satisfies Partial diff --git a/src/preload/api/ui-bridge-state-and-menu-commands.ts b/src/preload/api/ui-bridge-state-and-menu-commands.ts index 25476165cfb..246cebb1613 100644 --- a/src/preload/api/ui-bridge-state-and-menu-commands.ts +++ b/src/preload/api/ui-bridge-state-and-menu-commands.ts @@ -1,6 +1,8 @@ +import type { MarkdownDocument } from '../../shared/filesystem-entry-types' import { ipcRenderer } from 'electron' import type { PersistedUIState } from '../../shared/persisted-ui-state-types' import type { KeybindingActionId } from '../../shared/keybindings' +import type { PreloadApi } from '../api-types' export const uiStateAndMenuCommandsApi = { get: () => ipcRenderer.invoke('ui:get'), @@ -26,6 +28,14 @@ export const uiStateAndMenuCommandsApi = { }, consumePendingSkillShare: (): Promise => ipcRenderer.invoke('ui:consumePendingSkillShare'), + onOpenMarkdownFiles: (callback: (documents: MarkdownDocument[]) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, documents: MarkdownDocument[]): void => + callback(documents) + ipcRenderer.on('ui:openMarkdownFiles', listener) + return () => ipcRenderer.removeListener('ui:openMarkdownFiles', listener) + }, + consumePendingMarkdownFileOpens: (): Promise => + ipcRenderer.invoke('ui:consumePendingMarkdownFileOpens'), onOpenSetupGuide: (callback: () => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent) => callback() ipcRenderer.on('ui:openSetupGuide', listener) @@ -164,4 +174,4 @@ export const uiStateAndMenuCommandsApi = { replyTabCreate: (reply: { requestId: string; browserPageId?: string; error?: string }): void => { ipcRenderer.send('browser:tabCreateReply', reply) } -} +} satisfies Partial diff --git a/src/preload/api/ui-bridge-tab-and-browser-commands.ts b/src/preload/api/ui-bridge-tab-and-browser-commands.ts index ccca9a24f5b..d275367b398 100644 --- a/src/preload/api/ui-bridge-tab-and-browser-commands.ts +++ b/src/preload/api/ui-bridge-tab-and-browser-commands.ts @@ -6,6 +6,7 @@ import type { WorktreeSetupLaunch } from '../../shared/worktree/launch-types' import { browserFindSubscriptions } from '../preload-runtime-support' +import type { PreloadApi } from '../api-types' export const uiTabAndBrowserCommandsApi = { onRequestTabSetProfile: ( @@ -200,4 +201,4 @@ export const uiTabAndBrowserCommandsApi = { ipcRenderer.on('ui:activateWorktree', listener) return () => ipcRenderer.removeListener('ui:activateWorktree', listener) } -} +} satisfies Partial diff --git a/src/preload/api/ui-bridge-terminal-and-session-tabs.ts b/src/preload/api/ui-bridge-terminal-and-session-tabs.ts index 9de47cceb0c..eaff9e8847a 100644 --- a/src/preload/api/ui-bridge-terminal-and-session-tabs.ts +++ b/src/preload/api/ui-bridge-terminal-and-session-tabs.ts @@ -11,6 +11,7 @@ import type { RuntimeTerminalCreateRequestPayload, RuntimeTerminalPresentation } from '../../shared/runtime-types' +import type { PreloadApi } from '../api-types' export const uiTerminalAndSessionTabsApi = { onCreateTerminal: ( @@ -211,4 +212,4 @@ export const uiTerminalAndSessionTabsApi = { ipcRenderer.on('ui:openFileFromMobile', listener) return () => ipcRenderer.removeListener('ui:openFileFromMobile', listener) } -} +} satisfies Partial diff --git a/src/preload/api/ui-command-event-api.ts b/src/preload/api/ui-command-event-api.ts index e63034b5233..0876104e471 100644 --- a/src/preload/api/ui-command-event-api.ts +++ b/src/preload/api/ui-command-event-api.ts @@ -1,3 +1,4 @@ +import type { MarkdownDocument } from '../../shared/filesystem-entry-types' import type { PersistedUIState } from '../../shared/persisted-ui-state-types' import type { TuiAgent } from '../../shared/tui-agent' import type { @@ -48,6 +49,10 @@ export type UiCommandEventApi = { consumePendingOpenSettings: () => Promise onOpenSkillShare: (callback: (shareId: string) => void) => () => void consumePendingSkillShare: () => Promise + /** OS "Open With" markdown paths pushed while a renderer is already listening. */ + onOpenMarkdownFiles: (callback: (documents: MarkdownDocument[]) => void) => () => void + /** Drains the "Open With" paths queued before this renderer's listener attached. */ + consumePendingMarkdownFileOpens: () => Promise onOpenSetupGuide: (callback: () => void) => () => void onOpenFeatureTour: (callback: () => void) => () => void onOpenCrashReport: (callback: () => void) => () => void diff --git a/src/preload/api/wsl-bridge.ts b/src/preload/api/wsl-bridge.ts index aeb32cdfa45..bc7869000e4 100644 --- a/src/preload/api/wsl-bridge.ts +++ b/src/preload/api/wsl-bridge.ts @@ -1,6 +1,7 @@ import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' export const wslApi = { isAvailable: (): Promise => ipcRenderer.invoke('wsl:isAvailable'), listDistros: (): Promise => ipcRenderer.invoke('wsl:listDistros') -} +} satisfies PreloadApi['wsl'] diff --git a/src/preload/app-restart-checkpoint-routing.test.ts b/src/preload/app-restart-checkpoint-routing.test.ts index 19eb6948c9a..d794a86b9ab 100644 --- a/src/preload/app-restart-checkpoint-routing.test.ts +++ b/src/preload/app-restart-checkpoint-routing.test.ts @@ -92,6 +92,20 @@ describe('native preload destructive app actions', () => { }) } + it('exposes the durable checkpoint join the lazy-chunk recovery reload depends on', async () => { + const api = await loadApi() + invoke.mockResolvedValue({ ok: true }) + + await expect(api.app.awaitBeforeUnloadCheckpoint()).resolves.toBeUndefined() + expect(invoke).toHaveBeenCalledWith('app:await-before-unload-checkpoint') + + invoke.mockResolvedValue({ ok: false }) + + await expect(api.app.awaitBeforeUnloadCheckpoint()).rejects.toThrow( + 'Failed to persist renderer state before unload.' + ) + }) + it('preserves both macOS keyboard preload adapters', async () => { const api = await loadApi() invoke.mockResolvedValue(undefined) diff --git a/src/preload/gitlab.ts b/src/preload/gitlab.ts index d6f12367db0..154e139e4c4 100644 --- a/src/preload/gitlab.ts +++ b/src/preload/gitlab.ts @@ -12,23 +12,21 @@ type GitLabRepoSelectorArgs = { } export const glApi = { - viewer: (): Promise => ipcRenderer.invoke('gitlab:viewer'), - diagnoseAuth: (): Promise => ipcRenderer.invoke('gitlab:diagnoseAuth'), - rateLimit: (args?: { force?: boolean; host?: string | null }): Promise => + viewer: () => ipcRenderer.invoke('gitlab:viewer'), + diagnoseAuth: () => ipcRenderer.invoke('gitlab:diagnoseAuth'), + rateLimit: (args?: { force?: boolean; host?: string | null }) => ipcRenderer.invoke('gitlab:rateLimit', args), - projectSlug: (args: GitLabRepoSelectorArgs): Promise => - ipcRenderer.invoke('gitlab:projectSlug', args), + projectSlug: (args: GitLabRepoSelectorArgs) => ipcRenderer.invoke('gitlab:projectSlug', args), mrForBranch: ( args: GitLabRepoSelectorArgs & { branch: string linkedMRIid?: number | null } - ): Promise => ipcRenderer.invoke('gitlab:mrForBranch', args), + ) => ipcRenderer.invoke('gitlab:mrForBranch', args), - mr: (args: GitLabRepoSelectorArgs & { iid: number }): Promise => - ipcRenderer.invoke('gitlab:mr', args), + mr: (args: GitLabRepoSelectorArgs & { iid: number }) => ipcRenderer.invoke('gitlab:mr', args), listMRs: ( args: GitLabRepoSelectorArgs & { @@ -37,7 +35,7 @@ export const glApi = { perPage?: number query?: string } - ): Promise => ipcRenderer.invoke('gitlab:listMRs', args), + ) => ipcRenderer.invoke('gitlab:listMRs', args), listWorkItems: ( args: GitLabRepoSelectorArgs & { @@ -46,9 +44,9 @@ export const glApi = { perPage?: number query?: string } - ): Promise => ipcRenderer.invoke('gitlab:listWorkItems', args), + ) => ipcRenderer.invoke('gitlab:listWorkItems', args), - issue: (args: GitLabRepoSelectorArgs & { number: number }): Promise => + issue: (args: GitLabRepoSelectorArgs & { number: number }) => ipcRenderer.invoke('gitlab:issue', args), listIssues: ( @@ -58,8 +56,7 @@ export const glApi = { limit?: number page?: number } - ): Promise<{ items: unknown[]; totalPages?: number; error?: unknown }> => - ipcRenderer.invoke('gitlab:listIssues', args), + ) => ipcRenderer.invoke('gitlab:listIssues', args), createIssue: ( args: GitLabRepoSelectorArgs & { @@ -77,25 +74,23 @@ export const glApi = { ): Promise<{ ok: true } | { ok: false; error: string }> => ipcRenderer.invoke('gitlab:updateIssue', args), - addIssueComment: ( - args: GitLabRepoSelectorArgs & { number: number; body: string } - ): Promise => ipcRenderer.invoke('gitlab:addIssueComment', args), + addIssueComment: (args: GitLabRepoSelectorArgs & { number: number; body: string }) => + ipcRenderer.invoke('gitlab:addIssueComment', args), listLabels: (args: GitLabRepoSelectorArgs): Promise => ipcRenderer.invoke('gitlab:listLabels', args), - listAssignableUsers: (args: GitLabRepoSelectorArgs): Promise => + listAssignableUsers: (args: GitLabRepoSelectorArgs) => ipcRenderer.invoke('gitlab:listAssignableUsers', args), - todos: (args: GitLabRepoSelectorArgs): Promise => - ipcRenderer.invoke('gitlab:todos', args), + todos: (args: GitLabRepoSelectorArgs) => ipcRenderer.invoke('gitlab:todos', args), workItemDetails: ( args: GitLabRepoSelectorArgs & { iid: number type: 'issue' | 'mr' } - ): Promise => ipcRenderer.invoke('gitlab:workItemDetails', args), + ) => ipcRenderer.invoke('gitlab:workItemDetails', args), closeMR: ( args: GitLabRepoSelectorArgs & { @@ -133,9 +128,9 @@ export const glApi = { reviewerIds: number[] projectRef?: unknown } - ): Promise => ipcRenderer.invoke('gitlab:updateMRReviewers', args), + ) => ipcRenderer.invoke('gitlab:updateMRReviewers', args), - addMRComment: (args: GitLabRepoSelectorArgs & { iid: number; body: string }): Promise => + addMRComment: (args: GitLabRepoSelectorArgs & { iid: number; body: string }) => ipcRenderer.invoke('gitlab:addMRComment', args), addMRInlineComment: ( @@ -144,7 +139,7 @@ export const glApi = { input: unknown projectRef?: unknown } - ): Promise => ipcRenderer.invoke('gitlab:addMRInlineComment', args), + ) => ipcRenderer.invoke('gitlab:addMRInlineComment', args), resolveMRDiscussion: ( args: GitLabRepoSelectorArgs & { @@ -152,15 +147,14 @@ export const glApi = { discussionId: string resolved: boolean } - ): Promise => ipcRenderer.invoke('gitlab:resolveMRDiscussion', args), + ) => ipcRenderer.invoke('gitlab:resolveMRDiscussion', args), jobTrace: ( args: GitLabRepoSelectorArgs & { jobId: number; projectRef?: unknown; logExcerpt?: boolean } - ): Promise => ipcRenderer.invoke('gitlab:jobTrace', args), + ) => ipcRenderer.invoke('gitlab:jobTrace', args), - retryJob: ( - args: GitLabRepoSelectorArgs & { jobId: number; projectRef?: unknown } - ): Promise => ipcRenderer.invoke('gitlab:retryJob', args), + retryJob: (args: GitLabRepoSelectorArgs & { jobId: number; projectRef?: unknown }) => + ipcRenderer.invoke('gitlab:retryJob', args), workItemByPath: ( args: GitLabRepoSelectorArgs & { @@ -169,5 +163,5 @@ export const glApi = { iid: number type: 'issue' | 'mr' } - ): Promise => ipcRenderer.invoke('gitlab:workItemByPath', args) + ) => ipcRenderer.invoke('gitlab:workItemByPath', args) } diff --git a/src/preload/index.ts b/src/preload/index.ts index ad97a911fe5..27d3ca8e062 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -182,7 +182,7 @@ const api = { mobile: mobileApi, agentStatus: agentStatusApi, speech: speechApi -} +} satisfies PreloadApi if (process.contextIsolated) { try { @@ -193,6 +193,5 @@ if (process.contextIsolated) { } } else { window.electron = electronAPI - // @ts-expect-error (define in dts) window.api = api } diff --git a/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx b/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx index 5fbb92c7636..e0b1d70d5a2 100644 --- a/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx +++ b/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx @@ -124,17 +124,18 @@ export function ReviewNotesSendMenuContent({ toast.message( activeAgentNotesSendFailureMessage(result.status, { - explicitTarget: options.explicitTarget + explicitTarget: options.explicitTarget, + code: result.code }) ) }) - .catch((error) => { - console.error('Failed to send notes:', error) + .catch(() => { + console.error('Failed to send notes:', { code: 'runtime-unverifiable' }) toast.error( - translate( - 'auto.components.editor.ReviewNotesSendMenuContent.f5096c6e4e', - 'Could not send notes.' - ) + activeAgentNotesSendFailureMessage('status-unavailable', { + explicitTarget: options.explicitTarget, + code: 'runtime-unverifiable' + }) ) }) .finally(() => { diff --git a/src/renderer/src/components/floating-terminal/use-floating-terminal-create-actions.ts b/src/renderer/src/components/floating-terminal/use-floating-terminal-create-actions.ts index a60cdc7d93e..8ef85f7e556 100644 --- a/src/renderer/src/components/floating-terminal/use-floating-terminal-create-actions.ts +++ b/src/renderer/src/components/floating-terminal/use-floating-terminal-create-actions.ts @@ -4,7 +4,7 @@ import { resolveGroupTabFromVisibleId } from '@/components/tab-group/tab-group-v import { getConnectionId } from '@/lib/connection-context' import { createUntitledMarkdownFileWithTemplateSelection } from '@/lib/create-untitled-markdown' import { ensureClientCreationActionAllowed } from '@/lib/client-creation-action-error' -import { detectLanguage } from '@/lib/language-detect' +import { openMarkdownDocumentInFloatingWorkspace } from '@/lib/open-markdown-in-floating-workspace' import { extractIpcErrorMessage } from '@/lib/ipc-error' import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' import { translate } from '@/i18n/i18n' @@ -123,21 +123,9 @@ export function useFloatingTerminalCreateActions({ if (!document) { return } - openFile( - { - filePath: document.filePath, - relativePath: document.relativePath, - worktreeId: FLOATING_TERMINAL_WORKTREE_ID, - language: detectLanguage(document.relativePath), - mode: 'edit', - runtimeEnvironmentId: null - }, - { - preview: false, - targetGroupId: activeGroup?.id, - suppressActiveRuntimeFallback: true - } - ) + openMarkdownDocumentInFloatingWorkspace(openFile, document, { + targetGroupId: activeGroup?.id + }) } catch (error) { toast.error(extractIpcErrorMessage(error, 'Failed to open markdown file.')) } diff --git a/src/renderer/src/components/github-project/group-sort.test.ts b/src/renderer/src/components/github-project/group-sort.test.ts index cd5e77738bc..57b96267bae 100644 --- a/src/renderer/src/components/github-project/group-sort.test.ts +++ b/src/renderer/src/components/github-project/group-sort.test.ts @@ -33,6 +33,27 @@ const iterationField: GitHubProjectField = { ] } +const assigneesField: GitHubProjectField = { + kind: 'field', + id: 'F_assignees', + name: 'Assignees', + dataType: 'ASSIGNEES' +} + +const labelsField: GitHubProjectField = { + kind: 'field', + id: 'F_labels', + name: 'Labels', + dataType: 'LABELS' +} + +const textField: GitHubProjectField = { + kind: 'field', + id: 'F_text', + name: 'Notes', + dataType: 'TEXT' +} + function makeRow( id: string, position: number, @@ -206,6 +227,66 @@ describe('sortRows', () => { expect(sorted.map((r) => r.id)).toEqual(['rHas', 'rEmpty']) }) + it('sorts an empty user list last in both directions, like a missing value', () => { + // Why: the DESC flip negated the empty branch, sending unassigned rows to the top. + const rows = [ + makeRow('empty-list', 0, { + F_assignees: { kind: 'users', fieldId: 'F_assignees', users: [] } + }), + makeRow('alice', 1, { + F_assignees: { + kind: 'users', + fieldId: 'F_assignees', + users: [{ login: 'alice', name: null, avatarUrl: null }] + } + }), + makeRow('no-value', 2, {}) + ] + + for (const direction of ['ASC', 'DESC'] as const) { + const view = makeView(assigneesField, { direction, field: assigneesField }) + const sorted = sortRows(makeTable(view, rows), rows) + expect(sorted.map((r) => r.id)).toEqual(['alice', 'empty-list', 'no-value']) + } + }) + + it('sorts an empty label list last in both directions, like a missing value', () => { + const rows = [ + makeRow('empty-list', 0, { + F_labels: { kind: 'labels', fieldId: 'F_labels', labels: [] } + }), + makeRow('bug', 1, { + F_labels: { + kind: 'labels', + fieldId: 'F_labels', + labels: [{ name: 'bug', color: 'ff0000' }] + } + }), + makeRow('no-value', 2, {}) + ] + + for (const direction of ['ASC', 'DESC'] as const) { + const view = makeView(labelsField, { direction, field: labelsField }) + const sorted = sortRows(makeTable(view, rows), rows) + expect(sorted.map((r) => r.id)).toEqual(['bug', 'empty-list', 'no-value']) + } + }) + + it('sorts a blank text value last in both directions, like a missing value', () => { + // Why reachable: the normalizer turns a null GitHub text/date into ''. + const rows = [ + makeRow('blank', 0, { F_text: { kind: 'text', fieldId: 'F_text', text: '' } }), + makeRow('alpha', 1, { F_text: { kind: 'text', fieldId: 'F_text', text: 'alpha' } }), + makeRow('no-value', 2, {}) + ] + + for (const direction of ['ASC', 'DESC'] as const) { + const view = makeView(textField, { direction, field: textField }) + const sorted = sortRows(makeTable(view, rows), rows) + expect(sorted.map((r) => r.id)).toEqual(['alpha', 'blank', 'no-value']) + } + }) + it('keeps sort fallback finite when row positions are absent', () => { const view = makeView(singleSelectField) const rows = [ @@ -220,6 +301,31 @@ describe('sortRows', () => { }) describe('groupRows', () => { + it('groups a present-but-empty user list with the missing-value rows', () => { + // Why: an empty list fell through to a blank-label group, which renders as "All". + const view = { ...makeView(assigneesField), groupByFields: [assigneesField] } + const rows = [ + makeRow('empty-list', 0, { + F_assignees: { kind: 'users', fieldId: 'F_assignees', users: [] } + }), + makeRow('alice', 1, { + F_assignees: { + kind: 'users', + fieldId: 'F_assignees', + users: [{ login: 'alice', name: null, avatarUrl: null }] + } + }), + makeRow('no-value', 2, {}) + ] + + const groups = groupRows(makeTable(view, rows), rows) + + expect(groups.map((group) => [group.label, group.rows.map((r) => r.id)])).toEqual([ + ['alice', ['alice']], + ['No Assignees', ['empty-list', 'no-value']] + ]) + }) + it('places the empty group last', () => { const view = { ...makeView(singleSelectField), diff --git a/src/renderer/src/components/use-worktree-jump-palette-quick-actions.ts b/src/renderer/src/components/use-worktree-jump-palette-quick-actions.ts index c6cf2c48d05..0783a7e5d23 100644 --- a/src/renderer/src/components/use-worktree-jump-palette-quick-actions.ts +++ b/src/renderer/src/components/use-worktree-jump-palette-quick-actions.ts @@ -58,6 +58,7 @@ export function useWorktreeJumpPaletteQuickActions({ groupsByWorktree, isLoading, settings, + runtimeStatusByEnvironmentId, deferredQuery, settingsResults }: WorktreeJumpPaletteQuickActionsInput) { @@ -117,6 +118,9 @@ export function useWorktreeJumpPaletteQuickActions({ openNewTerminalTabInActiveWorkspace ] ) + // Why: buildQuickActionContext() reads the store imperatively, so these voided values are the + // memo's real inputs — each one is read (some transitively, e.g. runtimeStatusByEnvironmentId + // via the managed-browser creation policy) while availability is computed. const availableActionResults = useMemo(() => { void activeView void activeWorktreeId @@ -127,6 +131,7 @@ export function useWorktreeJumpPaletteQuickActions({ void groupsByWorktree void isLoading void settings?.activeRuntimeEnvironmentId + void runtimeStatusByEnvironmentId const context = buildQuickActionContext() return actionResults.filter((action) => action.isAvailable(context).available) }, [ @@ -140,7 +145,8 @@ export function useWorktreeJumpPaletteQuickActions({ activeGroupIdByWorktree, groupsByWorktree, isLoading, - settings?.activeRuntimeEnvironmentId + settings?.activeRuntimeEnvironmentId, + runtimeStatusByEnvironmentId ]) const middleItems = useMemo<(SettingsPaletteItem | QuickActionPaletteItem)[]>( () => diff --git a/src/renderer/src/components/worktree-jump-palette-quick-action-availability.test.tsx b/src/renderer/src/components/worktree-jump-palette-quick-action-availability.test.tsx new file mode 100644 index 00000000000..69459901bfa --- /dev/null +++ b/src/renderer/src/components/worktree-jump-palette-quick-action-availability.test.tsx @@ -0,0 +1,103 @@ +// @vitest-environment happy-dom + +import { renderHook } from '@testing-library/react' +import { createRef } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { BROWSER_SCREENCAST_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' +import { buildCmdJActionResults } from '@/components/cmd-j/palette-results' +import { getCmdJQuickActions } from '@/components/cmd-j/quick-actions' +import { useWorktreeJumpPaletteQuickActions } from './use-worktree-jump-palette-quick-actions' + +const mocks = vi.hoisted(() => ({ state: {} as Record })) + +vi.mock('@/store', () => ({ useAppStore: { getState: () => mocks.state } })) +vi.mock('@/lib/worktree-runtime-owner', () => ({ + getRuntimeEnvironmentIdForWorktree: () => RUNTIME_ID +})) +vi.mock('@/components/sidebar/delete-worktree-flow', () => ({ runWorktreeDelete: vi.fn() })) + +const RUNTIME_ID = 'runtime-1' +const WORKTREE_ID = 'repo-1::/repo/wt' + +function runtimeStatuses(capabilities: string[]): Map { + return new Map([[RUNTIME_ID, { status: { capabilities, hostPlatform: 'darwin' } }]]) +} + +// Every input except runtimeStatusByEnvironmentId keeps a stable identity across rerenders, +// so a recomputation can only come from the runtime status dependency itself. +function buildStableProps() { + return { + openModal: vi.fn(), + openSettingsPage: vi.fn(), + openSettingsTarget: vi.fn(), + activeGroupSnapshotRef: createRef(), + openNewBrowserTabInActiveWorkspace: vi.fn(), + openNewMarkdownInActiveWorkspace: vi.fn(), + openNewTerminalTabInActiveWorkspace: vi.fn(), + actionResults: buildCmdJActionResults(getCmdJQuickActions()), + activeView: 'terminal', + activeWorktreeId: WORKTREE_ID, + worktreesByRepo: mocks.state.worktreesByRepo, + repos: mocks.state.repos, + sshConnectionStates: mocks.state.sshConnectionStates, + activeGroupIdByWorktree: mocks.state.activeGroupIdByWorktree, + groupsByWorktree: mocks.state.groupsByWorktree, + isLoading: false, + settings: mocks.state.settings, + deferredQuery: 'new browser tab', + settingsResults: [] + } +} + +function renderQuickActions(initialStatuses: Map) { + const stable = buildStableProps() + mocks.state.runtimeStatusByEnvironmentId = initialStatuses + const harness = renderHook( + (runtimeStatusByEnvironmentId: Map) => + useWorktreeJumpPaletteQuickActions({ ...stable, runtimeStatusByEnvironmentId } as never), + { initialProps: initialStatuses } + ) + return { + offersBrowserAction: (): boolean => + harness.result.current.middleItems.some((item) => item.id === 'quick-action:new-browser-tab'), + setRuntimeStatuses: (next: Map): void => { + mocks.state.runtimeStatusByEnvironmentId = next + harness.rerender(next) + } + } +} + +describe('worktree jump palette quick action availability', () => { + beforeEach(() => { + ;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true + mocks.state = { + activeView: 'terminal', + activeWorktreeId: WORKTREE_ID, + worktreesByRepo: { 'repo-1': [{ id: WORKTREE_ID, repoId: 'repo-1' }] }, + repos: [{ id: 'repo-1' }], + sshConnectionStates: new Map(), + activeGroupIdByWorktree: { [WORKTREE_ID]: 'group-1' }, + groupsByWorktree: { [WORKTREE_ID]: [{ id: 'group-1' }] }, + settings: { activeRuntimeEnvironmentId: RUNTIME_ID } + } + }) + afterEach(() => { + delete (globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ + }) + + it('drops the paired-web browser action when the runtime loses screencast capability', () => { + const palette = renderQuickActions(runtimeStatuses([BROWSER_SCREENCAST_RUNTIME_CAPABILITY])) + expect(palette.offersBrowserAction()).toBe(true) + + palette.setRuntimeStatuses(runtimeStatuses([])) + expect(palette.offersBrowserAction()).toBe(false) + }) + + it('restores the browser action when a capable runtime comes back', () => { + const palette = renderQuickActions(runtimeStatuses([])) + expect(palette.offersBrowserAction()).toBe(false) + + palette.setRuntimeStatuses(runtimeStatuses([BROWSER_SCREENCAST_RUNTIME_CAPABILITY])) + expect(palette.offersBrowserAction()).toBe(true) + }) +}) diff --git a/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts index 7aa5dd418cf..d105db1d3e9 100644 --- a/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts @@ -12,6 +12,7 @@ import { createDirectSshBridgeRuntime } from './direct-ssh-bridge-runtime' import { registerDirectSshStateIpcBridge } from './direct-ssh-state-ipc-bridge' import { registerMobileAndTerminalCloseIpcBridge } from './mobile-terminal-close-ipc-bridge' import { registerMobileDriverIpcBridge } from './mobile-driver-ipc-bridge' +import { registerOsMarkdownFileOpenBridge } from './os-markdown-file-open-bridge' import { registerProjectCatalogIpcBridge } from './project-catalog-ipc-bridge' import { registerRateLimitIpcBridge } from './rate-limit-ipc-bridge' import { registerRemoteWorkspaceIpcBridge } from './remote-workspace-ipc-bridge' @@ -77,6 +78,7 @@ export function installAppLifetimeIpcEvents( ) registerSettingsAndSidebarIpcBridge(unsubs) registerWorkspaceShortcutIpcBridge(unsubs) + registerOsMarkdownFileOpenBridge(unsubs) unsubs.push( window.api.ui.onActivateWorktree(({ repoId, worktreeId, setup, startup, defaultTabs }) => { void worktreeRuntime diff --git a/src/renderer/src/hooks/ipc-events/os-markdown-file-open-bridge.test.ts b/src/renderer/src/hooks/ipc-events/os-markdown-file-open-bridge.test.ts new file mode 100644 index 00000000000..d4825e89206 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/os-markdown-file-open-bridge.test.ts @@ -0,0 +1,300 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { TOGGLE_FLOATING_TERMINAL_EVENT } from '@/lib/floating-terminal' +import type { EditorFilesSlice } from '@/store/slices/editor/types/editor-files-slice' +import type { MarkdownDocument } from '../../../../shared/filesystem-entry-types' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import { registerOsMarkdownFileOpenBridge } from './os-markdown-file-open-bridge' + +const mocks = vi.hoisted(() => ({ + openFile: vi.fn(() => 'file-1'), + updateSettings: vi.fn(async () => {}), + isFloatingWorkspacePanelVisible: vi.fn(() => false), + toastError: vi.fn() +})) + +let storeState: { + openFile: typeof mocks.openFile + updateSettings: typeof mocks.updateSettings + settings: { floatingTerminalEnabled?: boolean } | undefined +} + +vi.mock('../../store', () => ({ useAppStore: { getState: () => storeState } })) +vi.mock('@/lib/floating-workspace-terminal-actions', () => ({ + isFloatingWorkspacePanelVisible: mocks.isFloatingWorkspacePanelVisible +})) +vi.mock('sonner', () => ({ toast: { error: mocks.toastError } })) +vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) + +type MarkdownFileOpenListener = (documents: MarkdownDocument[]) => void + +let frames: FrameRequestCallback[] = [] +let dispatchEvent = vi.fn() +let unhandledRejections: unknown[] = [] +const recordUnhandledRejection = (reason: unknown): void => void unhandledRejections.push(reason) + +function markdownDocument(overrides: Partial = {}): MarkdownDocument { + return { + filePath: '/Users/me/notes/README.md', + relativePath: 'README.md', + basename: 'README.md', + name: 'README', + ...overrides + } +} + +function stubPreload(ui: Record): void { + dispatchEvent = vi.fn() + vi.stubGlobal('window', { api: { ui }, dispatchEvent }) +} + +/** Runs the callbacks the bridge deferred to the next frame. */ +function runFrames(): void { + const pending = frames + frames = [] + for (const frame of pending) { + frame(0) + } +} + +/** Drains microtasks and lets Node emit any unhandled rejection the bridge leaked. */ +async function settle(): Promise { + await new Promise((resolve) => setImmediate(resolve)) + await new Promise((resolve) => setImmediate(resolve)) +} + +describe('registerOsMarkdownFileOpenBridge', () => { + beforeEach(() => { + vi.clearAllMocks() + frames = [] + unhandledRejections = [] + storeState = { + openFile: mocks.openFile, + updateSettings: mocks.updateSettings, + settings: { floatingTerminalEnabled: true } + } + mocks.openFile.mockReturnValue('file-1') + mocks.updateSettings.mockResolvedValue(undefined) + mocks.isFloatingWorkspacePanelVisible.mockReturnValue(false) + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => + frames.push(callback) + ) + vi.spyOn(console, 'error').mockImplementation(() => {}) + process.on('unhandledRejection', recordUnhandledRejection) + }) + + afterEach(() => { + process.off('unhandledRejection', recordUnhandledRejection) + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + + it('opens every document main queued before the listener attached', async () => { + stubPreload({ + onOpenMarkdownFiles: () => () => {}, + consumePendingMarkdownFileOpens: () => + Promise.resolve([ + markdownDocument(), + markdownDocument({ filePath: '/Users/me/notes/plan.md', relativePath: 'plan.md' }) + ]) + }) + + registerOsMarkdownFileOpenBridge([]) + await settle() + + expect(mocks.openFile).toHaveBeenCalledTimes(2) + expect(mocks.openFile.mock.calls.map((call) => call[0].filePath)).toEqual([ + '/Users/me/notes/README.md', + '/Users/me/notes/plan.md' + ]) + expect(mocks.openFile.mock.calls[0][0].worktreeId).toBe(FLOATING_TERMINAL_WORKTREE_ID) + }) + + it('opens documents pushed after startup and hands back the unsubscribe', async () => { + const listeners: MarkdownFileOpenListener[] = [] + const unsubscribe = vi.fn() + stubPreload({ + onOpenMarkdownFiles: (next: MarkdownFileOpenListener) => { + listeners.push(next) + return unsubscribe + }, + consumePendingMarkdownFileOpens: () => Promise.resolve([]) + }) + + const unsubs: (() => void)[] = [] + registerOsMarkdownFileOpenBridge(unsubs) + expect(unsubs).toEqual([unsubscribe]) + + listeners[0]([ + markdownDocument({ filePath: '/Users/me/notes/live.md', relativePath: 'live.md' }) + ]) + await settle() + + expect(mocks.openFile).toHaveBeenCalledTimes(1) + expect(mocks.openFile.mock.calls[0][0].filePath).toBe('/Users/me/notes/live.md') + + unsubs.forEach((teardown) => teardown()) + expect(unsubscribe).toHaveBeenCalledOnce() + }) + + it('enables the floating workspace when the setting is off', async () => { + storeState.settings = { floatingTerminalEnabled: false } + stubPreload({ + onOpenMarkdownFiles: () => () => {}, + consumePendingMarkdownFileOpens: () => Promise.resolve([markdownDocument()]) + }) + + registerOsMarkdownFileOpenBridge([]) + await settle() + + expect(mocks.updateSettings).toHaveBeenCalledWith({ floatingTerminalEnabled: true }) + }) + + it('leaves settings alone when the floating workspace is already enabled', async () => { + stubPreload({ + onOpenMarkdownFiles: () => () => {}, + consumePendingMarkdownFileOpens: () => Promise.resolve([markdownDocument()]) + }) + + registerOsMarkdownFileOpenBridge([]) + await settle() + + expect(mocks.updateSettings).not.toHaveBeenCalled() + }) + + it('defers the reveal a frame and toggles only while the panel is hidden', async () => { + stubPreload({ + onOpenMarkdownFiles: () => () => {}, + consumePendingMarkdownFileOpens: () => Promise.resolve([markdownDocument()]) + }) + + registerOsMarkdownFileOpenBridge([]) + await settle() + + expect(dispatchEvent).not.toHaveBeenCalled() + runFrames() + + expect(dispatchEvent).toHaveBeenCalledTimes(1) + expect(dispatchEvent.mock.calls[0][0].type).toBe(TOGGLE_FLOATING_TERMINAL_EVENT) + }) + + it('does not toggle when the panel is already visible', async () => { + mocks.isFloatingWorkspacePanelVisible.mockReturnValue(true) + stubPreload({ + onOpenMarkdownFiles: () => () => {}, + consumePendingMarkdownFileOpens: () => Promise.resolve([markdownDocument()]) + }) + + registerOsMarkdownFileOpenBridge([]) + await settle() + runFrames() + + expect(dispatchEvent).not.toHaveBeenCalled() + }) + + it('ignores an empty batch', async () => { + storeState.settings = { floatingTerminalEnabled: false } + stubPreload({ + onOpenMarkdownFiles: () => () => {}, + consumePendingMarkdownFileOpens: () => Promise.resolve([]) + }) + + registerOsMarkdownFileOpenBridge([]) + await settle() + runFrames() + + expect(mocks.openFile).not.toHaveBeenCalled() + expect(mocks.updateSettings).not.toHaveBeenCalled() + expect(dispatchEvent).not.toHaveBeenCalled() + }) + + it('reports a rejected pending drain without leaking an unhandled rejection', async () => { + stubPreload({ + onOpenMarkdownFiles: () => () => {}, + consumePendingMarkdownFileOpens: () => Promise.reject(new Error('ipc unavailable')) + }) + + registerOsMarkdownFileOpenBridge([]) + await settle() + + expect(mocks.toastError).toHaveBeenCalledWith('Failed to open the Markdown file.') + // Why: App.tsx awaits hydration around this registration and treats any throw as + // "session restore failed", so the bridge must swallow its own failures. + expect(unhandledRejections).toEqual([]) + }) + + it('reports a throwing openFile without leaking an unhandled rejection', async () => { + mocks.openFile.mockImplementation(() => { + throw new Error('editor slice exploded') + }) + const listeners: MarkdownFileOpenListener[] = [] + stubPreload({ + onOpenMarkdownFiles: (next: MarkdownFileOpenListener) => { + listeners.push(next) + return () => {} + }, + consumePendingMarkdownFileOpens: () => Promise.resolve([]) + }) + + registerOsMarkdownFileOpenBridge([]) + expect(() => listeners[0]([markdownDocument()])).not.toThrow() + await settle() + + expect(mocks.toastError).toHaveBeenCalledWith('Failed to open the Markdown file.') + expect(unhandledRejections).toEqual([]) + expect(dispatchEvent).not.toHaveBeenCalled() + }) + + it('keeps opening the rest of a batch when one document fails', async () => { + mocks.openFile.mockImplementationOnce(() => { + throw new Error('first document exploded') + }) + stubPreload({ + onOpenMarkdownFiles: () => () => {}, + consumePendingMarkdownFileOpens: () => + Promise.resolve([ + markdownDocument({ filePath: '/Users/me/notes/bad.md', relativePath: 'bad.md' }), + markdownDocument({ filePath: '/Users/me/notes/good.md', relativePath: 'good.md' }) + ]) + }) + + registerOsMarkdownFileOpenBridge([]) + await settle() + runFrames() + + // Why: a multi-file selection arrives as one batch; one bad file must not cost the rest. + expect(mocks.openFile).toHaveBeenCalledTimes(2) + expect(mocks.toastError).toHaveBeenCalledTimes(1) + expect(dispatchEvent).toHaveBeenCalledTimes(1) + expect(unhandledRejections).toEqual([]) + }) + + it('ignores a non-array payload from a mismatched preload', async () => { + stubPreload({ + onOpenMarkdownFiles: () => () => {}, + // Why: the payload crosses the preload boundary, so a stale preload can resolve with + // something that is not an array. Reading .length off it would throw inside the chain. + consumePendingMarkdownFileOpens: () => Promise.resolve(null as unknown as MarkdownDocument[]) + }) + + registerOsMarkdownFileOpenBridge([]) + await settle() + runFrames() + + expect(mocks.openFile).not.toHaveBeenCalled() + expect(mocks.updateSettings).not.toHaveBeenCalled() + expect(mocks.toastError).not.toHaveBeenCalled() + expect(unhandledRejections).toEqual([]) + }) + + it('tolerates a preload without the markdown open channel', async () => { + stubPreload({}) + + const unsubs: (() => void)[] = [] + expect(() => registerOsMarkdownFileOpenBridge(unsubs)).not.toThrow() + await settle() + + expect(unsubs).toEqual([]) + expect(mocks.openFile).not.toHaveBeenCalled() + expect(mocks.toastError).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/hooks/ipc-events/os-markdown-file-open-bridge.ts b/src/renderer/src/hooks/ipc-events/os-markdown-file-open-bridge.ts new file mode 100644 index 00000000000..3dd345da07c --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/os-markdown-file-open-bridge.ts @@ -0,0 +1,72 @@ +import { toast } from 'sonner' +import type { MarkdownDocument } from '../../../../shared/filesystem-entry-types' +import { TOGGLE_FLOATING_TERMINAL_EVENT } from '@/lib/floating-terminal' +import { isFloatingWorkspacePanelVisible } from '@/lib/floating-workspace-terminal-actions' +import { openMarkdownDocumentInFloatingWorkspace } from '@/lib/open-markdown-in-floating-workspace' +import { translate } from '@/i18n/i18n' +import { useAppStore } from '../../store' + +/** + * Opens markdown files the OS shell handed to Orca ("Open With" / double-click) in the + * floating workspace, which is the one editor surface that needs no project. + */ +async function openOsRequestedMarkdownFiles(documents: MarkdownDocument[]): Promise { + // Why the shape check: this payload crosses the preload boundary, so a stale or mismatched + // preload can hand back something that is not an array. Reading .length off that throws + // inside the promise chain rather than failing loudly at the boundary. + if (!Array.isArray(documents) || documents.length === 0) { + return + } + const store = useAppStore.getState() + let opened = 0 + for (const document of documents) { + // Why isolated: selecting several files hands us one batch, and one unopenable file + // must not cost the user the rest of the selection. + try { + openMarkdownDocumentInFloatingWorkspace(store.openFile, document) + opened += 1 + } catch (error) { + reportOsRequestedMarkdownFailure(error) + } + } + if (opened === 0) { + return + } + // Why enabled here: the user asked the OS for this file, and the tabs above are already in a + // surface a disabled floating workspace never renders. Same enable-then-reveal as the + // Settings "Edit keybindings in Orca" action. + if (store.settings?.floatingTerminalEnabled !== true) { + await store.updateSettings({ floatingTerminalEnabled: true }) + } + // Why deferred a frame: the panel only honors the toggle once the enabled flag has reached React. + requestAnimationFrame(() => { + if (!isFloatingWorkspacePanelVisible()) { + window.dispatchEvent(new CustomEvent(TOGGLE_FLOATING_TERMINAL_EVENT)) + } + }) +} + +function reportOsRequestedMarkdownFailure(error: unknown): void { + console.error('Failed to open markdown files requested by the OS:', error) + toast.error( + translate( + 'auto.hooks.ipc.events.os.markdown.file.open.bridge.1e9a1a63c4', + 'Failed to open the Markdown file.' + ) + ) +} + +export function registerOsMarkdownFileOpenBridge(unsubs: (() => void)[]): void { + const unsubscribe = window.api.ui.onOpenMarkdownFiles?.((documents) => { + void openOsRequestedMarkdownFiles(documents).catch(reportOsRequestedMarkdownFailure) + }) + if (unsubscribe) { + unsubs.push(unsubscribe) + } + + // Why: a cold-start "Open With" resolves before this listener attaches; drain what main queued. + const pending = window.api.ui.consumePendingMarkdownFileOpens?.() + if (pending && typeof pending.then === 'function') { + void pending.then(openOsRequestedMarkdownFiles).catch(reportOsRequestedMarkdownFailure) + } +} diff --git a/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts b/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts index 5155aa6881f..67872949590 100644 --- a/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts @@ -55,6 +55,7 @@ const EXPECTED_DIRECT_CALLBACK_METHODS = [ 'ui.onOpenDiffFromMobile', 'ui.onOpenFeatureTour', 'ui.onOpenFileFromMobile', + 'ui.onOpenMarkdownFiles', 'ui.onOpenNewWorkspace', 'ui.onOpenQuickOpen', 'ui.onOpenSettings', @@ -135,6 +136,7 @@ const EXPECTED_CALLBACK_REGISTRATION_SEQUENCE = [ 'ui.onJumpToTabIndex', 'ui.onWorktreeHistoryNavigate', 'ui.onToggleStatusBar', + 'ui.onOpenMarkdownFiles', 'ui.onActivateWorktree', 'ui.onCreateTerminal', 'ui.onRequestTerminalTabMount', diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index c04b2f260e9..7fc9c837ebc 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -1112,6 +1112,17 @@ "events": { "browserStateIpcBridge": { "docPreviewLinkFailed": "Could not open this link in Orca Browser." + }, + "os": { + "markdown": { + "file": { + "open": { + "bridge": { + "1e9a1a63c4": "Failed to open the Markdown file." + } + } + } + } } } } diff --git a/src/renderer/src/lib/active-agent-note-send-delivery.ts b/src/renderer/src/lib/active-agent-note-send-delivery.ts new file mode 100644 index 00000000000..2d9e88ae507 --- /dev/null +++ b/src/renderer/src/lib/active-agent-note-send-delivery.ts @@ -0,0 +1,152 @@ +import type { RuntimeTerminalSend } from '../../../shared/runtime-types' +import { sanitizeTerminalPasteText } from '@/components/terminal-pane/terminal-bracketed-paste' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { + BRACKETED_PASTE_BEGIN, + BRACKETED_PASTE_END, + POST_PASTE_SUBMIT_DELAY_MS +} from './agent-paste-draft' +import type { ActiveAgentNotesSendResult } from './active-agent-note-send-result' +import { + ACTIVE_AGENT_SEND_RPC_TIMEOUT_MS, + getTerminalAgentSendReadiness, + isRuntimeTerminalNotWritable, + isRuntimeTerminalUnavailable +} from './active-agent-terminal-send-readiness' +import { codeForReadinessStatus, runtimeFailureCode } from './active-agent-note-send-diagnostics' + +const ORCA_DESKTOP_TERMINAL_CLIENT = { id: 'orca-desktop', type: 'desktop' as const } + +export async function sendPromptWithLegacyCombinedSend( + runtimeTarget: Parameters[0], + terminalHandle: string, + prompt: string +): Promise { + try { + const { send } = await callRuntimeRpc<{ send: RuntimeTerminalSend }>( + runtimeTarget, + 'terminal.send', + { terminal: terminalHandle, text: prompt, enter: true, client: ORCA_DESKTOP_TERMINAL_CLIENT }, + { timeoutMs: ACTIVE_AGENT_SEND_RPC_TIMEOUT_MS } + ) + return send.accepted + ? { status: 'sent' } + : { status: 'not-writable', code: 'terminal-send-refused' } + } catch (error) { + if (isRuntimeTerminalUnavailable(error)) { + return { + status: 'no-active-terminal', + code: runtimeFailureCode(error) ?? 'runtime-unverifiable' + } + } + if (isRuntimeTerminalNotWritable(error)) { + return { status: 'not-writable', code: 'terminal_not_writable' } + } + throw error + } +} + +export async function sendPromptWithGuardedPasteAndEnter( + runtimeTarget: Parameters[0], + terminalHandle: string, + prompt: string, + options: { allowLegacyFallback: boolean } +): Promise { + const initialAgentStatus = await getTerminalAgentSendReadiness( + runtimeTarget, + terminalHandle, + options + ) + if ( + initialAgentStatus.status !== 'sendable' && + !(initialAgentStatus.status === 'no-agent' && initialAgentStatus.supportsGuardedSend) + ) { + return { + status: initialAgentStatus.status, + code: initialAgentStatus.code ?? codeForReadinessStatus(initialAgentStatus.status) + } + } + + const pastePayload = `${BRACKETED_PASTE_BEGIN}${sanitizeTerminalPasteText(prompt)}${BRACKETED_PASTE_END}` + try { + const { send } = await callRuntimeRpc<{ send: RuntimeTerminalSend }>( + runtimeTarget, + 'terminal.send', + { + terminal: terminalHandle, + text: pastePayload, + requireAgentStatus: 'sendable', + client: ORCA_DESKTOP_TERMINAL_CLIENT + }, + { timeoutMs: ACTIVE_AGENT_SEND_RPC_TIMEOUT_MS } + ) + if (!send.accepted) { + if (send.refusedReason === 'permission') { + return { status: 'permission', code: 'terminal-send-permission' } + } + if (send.refusedReason === 'no-agent') { + return { status: 'no-agent', code: 'no-agent' } + } + return { status: 'not-writable', code: 'terminal-send-refused' } + } + } catch (error) { + if (isRuntimeTerminalUnavailable(error)) { + return { + status: 'no-active-terminal', + code: runtimeFailureCode(error) ?? 'runtime-unverifiable' + } + } + if (isRuntimeTerminalNotWritable(error)) { + return { status: 'not-writable', code: 'terminal_not_writable' } + } + throw error + } + + await new Promise((resolve) => setTimeout(resolve, POST_PASTE_SUBMIT_DELAY_MS)) + try { + const submitAgentStatus = await getTerminalAgentSendReadiness( + runtimeTarget, + terminalHandle, + options + ) + if ( + submitAgentStatus.status !== 'sendable' && + !(submitAgentStatus.status === 'no-agent' && submitAgentStatus.supportsGuardedSend) + ) { + return { + status: 'partial-submit-failed', + code: submitAgentStatus.code ?? 'submit-readiness-lost' + } + } + } catch (error) { + if (isRuntimeTerminalUnavailable(error)) { + return { + status: 'partial-submit-failed', + code: runtimeFailureCode(error) ?? 'submit-terminal-unavailable' + } + } + throw error + } + + try { + const { send } = await callRuntimeRpc<{ send: RuntimeTerminalSend }>( + runtimeTarget, + 'terminal.send', + { + terminal: terminalHandle, + enter: true, + requireAgentStatus: 'sendable', + client: ORCA_DESKTOP_TERMINAL_CLIENT + }, + { timeoutMs: ACTIVE_AGENT_SEND_RPC_TIMEOUT_MS } + ) + return send.accepted + ? { status: 'sent' } + : { status: 'partial-submit-failed', code: 'submit-send-refused' } + } catch (error) { + if (isRuntimeTerminalUnavailable(error) || isRuntimeTerminalNotWritable(error)) { + return { status: 'partial-submit-failed', code: 'submit-send-error' } + } + throw error + } +} diff --git a/src/renderer/src/lib/active-agent-note-send-diagnostics.ts b/src/renderer/src/lib/active-agent-note-send-diagnostics.ts new file mode 100644 index 00000000000..155dec17e06 --- /dev/null +++ b/src/renderer/src/lib/active-agent-note-send-diagnostics.ts @@ -0,0 +1,82 @@ +import type { ActiveTerminalNoteTarget } from './active-agent-note-target' +import type { + ActiveAgentNotesSendFailureCode, + ActiveAgentNotesSendResult +} from './active-agent-note-send-result' +import { hasRuntimeRpcErrorCode } from '../../../shared/runtime-rpc-error-code' + +export const TERMINAL_RUNTIME_FAILURE_CODES = [ + 'terminal_handle_stale', + 'terminal_exited', + 'terminal_gone', + 'no_active_terminal' +] as const + +export function reportNoteSendFailure( + result: ActiveAgentNotesSendResult, + noteTarget: ActiveTerminalNoteTarget | null +): ActiveAgentNotesSendResult { + if (result.status === 'sent' || result.status === 'empty') { + return result + } + const code = result.code ?? codeForStatus(result.status) + console.warn('[review-notes] send failed', { + code, + status: result.status, + tabId: noteTarget?.tabId, + leafId: noteTarget?.leafId + }) + return { ...result, code } +} + +export function codeForReadinessStatus( + status: 'no-active-terminal' | 'no-agent' | 'permission' | 'status-unavailable' +): ActiveAgentNotesSendFailureCode { + switch (status) { + case 'no-active-terminal': + return 'no-inventory-match' + case 'no-agent': + return 'no-agent' + case 'permission': + return 'agent-permission' + case 'status-unavailable': + return 'status-unavailable' + } +} + +export function runtimeFailureCode(error: unknown): ActiveAgentNotesSendFailureCode | null { + return TERMINAL_RUNTIME_FAILURE_CODES.find((code) => hasRuntimeRpcErrorCode(error, code)) ?? null +} + +export function runtimeFailureFallbackCode(error: unknown): ActiveAgentNotesSendFailureCode { + return isTimeoutError(error) ? 'runtime-timeout' : 'runtime-unverifiable' +} + +function isTimeoutError(error: unknown): boolean { + if (hasRuntimeRpcErrorCode(error, 'runtime_timeout')) { + return true + } + const message = error instanceof Error ? error.message : String(error) + return message.includes('timeout') +} + +function codeForStatus( + status: Exclude +): ActiveAgentNotesSendFailureCode { + switch (status) { + case 'no-active-terminal': + return 'no-inventory-match' + case 'no-agent': + return 'no-agent' + case 'permission': + return 'agent-permission' + case 'status-unavailable': + return 'status-unavailable' + case 'not-ready': + return 'terminal_wait_timeout' + case 'not-writable': + return 'terminal-send-refused' + case 'partial-submit-failed': + return 'submit-send-error' + } +} diff --git a/src/renderer/src/lib/active-agent-note-send-explicit-target.test.ts b/src/renderer/src/lib/active-agent-note-send-explicit-target.test.ts index 96eb0306a40..098dd649f29 100644 --- a/src/renderer/src/lib/active-agent-note-send-explicit-target.test.ts +++ b/src/renderer/src/lib/active-agent-note-send-explicit-target.test.ts @@ -172,7 +172,7 @@ describe('active agent note send', () => { prompt: 'notes', noteTarget: { tabId: 'tab-9', leafId: OTHER_LEAF_ID } }) - ).resolves.toEqual({ status: 'status-unavailable' }) + ).resolves.toEqual({ status: 'status-unavailable', code: 'status-unavailable' }) expect(methods).toEqual(['terminal.list', 'terminal.agentStatus']) expect(testState.callRuntimeRpc).not.toHaveBeenCalledWith( @@ -275,7 +275,7 @@ describe('active agent note send', () => { prompt: 'notes', noteTarget: { tabId: 'tab-9', leafId: OTHER_LEAF_ID } }) - ).resolves.toEqual({ status: 'permission' }) + ).resolves.toEqual({ status: 'permission', code: 'agent-permission' }) expect(testState.callRuntimeRpc).not.toHaveBeenCalledWith( expect.anything(), @@ -359,7 +359,7 @@ describe('active agent note send', () => { prompt: 'notes', noteTarget: { tabId: 'tab-9', leafId: OTHER_LEAF_ID } }) - ).resolves.toEqual({ status: 'no-agent' }) + ).resolves.toEqual({ status: 'no-agent', code: 'no-agent' }) expect(methods).toEqual(['terminal.list', 'terminal.agentStatus', 'terminal.send']) }) @@ -401,7 +401,7 @@ describe('active agent note send', () => { prompt: 'notes', noteTarget: { tabId: 'tab-9', leafId: OTHER_LEAF_ID } }) - ).resolves.toEqual({ status: 'not-writable' }) + ).resolves.toEqual({ status: 'not-writable', code: 'terminal-send-refused' }) const sendCalls = testState.callRuntimeRpc.mock.calls.filter( (call) => call[1] === 'terminal.send' @@ -454,7 +454,7 @@ describe('active agent note send', () => { prompt: 'notes', noteTarget: { tabId: 'tab-9', leafId: OTHER_LEAF_ID } }) - ).resolves.toEqual({ status: 'permission' }) + ).resolves.toEqual({ status: 'permission', code: 'terminal-send-permission' }) const sendCalls = testState.callRuntimeRpc.mock.calls.filter( (call) => call[1] === 'terminal.send' @@ -508,7 +508,7 @@ describe('active agent note send', () => { prompt: 'notes', noteTarget: { tabId: 'tab-9', leafId: OTHER_LEAF_ID } }) - ).resolves.toEqual({ status: 'partial-submit-failed' }) + ).resolves.toEqual({ status: 'partial-submit-failed', code: 'submit-readiness-lost' }) const sendCalls = testState.callRuntimeRpc.mock.calls.filter( (call) => call[1] === 'terminal.send' @@ -562,7 +562,7 @@ describe('active agent note send', () => { prompt: 'notes', noteTarget: { tabId: 'tab-9', leafId: OTHER_LEAF_ID } }) - ).resolves.toEqual({ status: 'partial-submit-failed' }) + ).resolves.toEqual({ status: 'partial-submit-failed', code: 'submit-send-refused' }) }) it('maps explicit target guarded Enter permission refusal to partial-submit-failed', async () => { @@ -620,7 +620,7 @@ describe('active agent note send', () => { prompt: 'notes', noteTarget: { tabId: 'tab-9', leafId: OTHER_LEAF_ID } }) - ).resolves.toEqual({ status: 'partial-submit-failed' }) + ).resolves.toEqual({ status: 'partial-submit-failed', code: 'submit-send-refused' }) }) it('uses selected-target failure wording for explicit note targets', () => { @@ -647,8 +647,90 @@ describe('active agent note send', () => { expect(activeAgentNotesSendFailureMessage('partial-submit-failed')).toBe( 'The notes may already be pasted in the active terminal, but Orca could not submit them.' ) + expect( + activeAgentNotesSendFailureMessage('no-active-terminal', { + explicitTarget: true, + code: 'no-inventory-match' + }) + ).toBe('The selected terminal is no longer available. (no-inventory-match)') }) + it.each(['terminal_handle_stale', 'terminal_exited', 'terminal_gone'] as const)( + 'returns and logs the runtime terminal failure code %s without note contents', + async (runtimeCode) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + testState.callRuntimeRpc.mockImplementation(async (_target, method) => { + if (method === 'terminal.list') { + return { + terminals: [ + { + handle: 'term-runtime-failure', + worktreeId: 'wt-1', + worktreePath: '/repo', + branch: 'main', + tabId: 'tab-9', + leafId: OTHER_LEAF_ID, + title: 'Codex', + connected: true, + writable: true, + lastOutputAt: 1, + preview: '' + } + ], + totalCount: 1, + truncated: false + } + } + if (method === 'terminal.agentStatus') { + throw new Error(runtimeCode) + } + throw new Error(`unexpected method ${method}`) + }) + + await expect( + sendNotesToActiveAgentSession({ + worktreeId: 'wt-1', + prompt: 'private note contents', + noteTarget: { tabId: 'tab-9', leafId: OTHER_LEAF_ID } + }) + ).resolves.toEqual({ status: 'no-active-terminal', code: runtimeCode }) + + expect(warn).toHaveBeenCalledWith('[review-notes] send failed', { + code: runtimeCode, + status: 'no-active-terminal', + tabId: 'tab-9', + leafId: OTHER_LEAF_ID + }) + expect(JSON.stringify(warn.mock.calls)).not.toContain('private note contents') + warn.mockRestore() + } + ) + + it.each([ + ['remote connection closed at /private/workspace', 'runtime-unverifiable'], + ['runtime request timeout', 'runtime-timeout'] + ] as const)( + 'classifies terminal inventory failure without logging raw error details: %s', + async (message, code) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + testState.callRuntimeRpc.mockRejectedValue(new Error(message)) + + await expect( + sendNotesToActiveAgentSession({ + worktreeId: 'wt-1', + prompt: 'private note contents', + noteTarget: { tabId: 'tab-9', leafId: OTHER_LEAF_ID } + }) + ).resolves.toEqual({ status: 'status-unavailable', code }) + + const logged = JSON.stringify(warn.mock.calls) + expect(logged).toContain(code) + expect(logged).not.toContain(message) + expect(logged).not.toContain('private note contents') + warn.mockRestore() + } + ) + it('returns no-active-terminal when the explicit note target is absent from the runtime list', async () => { testState.callRuntimeRpc.mockImplementation(async (_target, method) => { if (method === 'terminal.list') { @@ -681,7 +763,7 @@ describe('active agent note send', () => { prompt: 'notes', noteTarget: { tabId: 'tab-1', leafId: OTHER_LEAF_ID } }) - ).resolves.toEqual({ status: 'no-active-terminal' }) + ).resolves.toEqual({ status: 'no-active-terminal', code: 'no-inventory-match' }) expect(testState.callRuntimeRpc).not.toHaveBeenCalledWith( expect.anything(), @@ -690,4 +772,51 @@ describe('active agent note send', () => { expect.anything() ) }) + + it('matches mirrored renderer tab IDs to host runtime tab IDs', async () => { + testState.callRuntimeRpc.mockImplementation(async (_target, method, params) => { + if (method === 'terminal.list') { + return { + terminals: [ + { + handle: 'term-mirrored', + worktreeId: 'wt-1', + worktreePath: '/repo', + branch: 'main', + tabId: 'tab-9', + leafId: OTHER_LEAF_ID, + title: 'Codex', + connected: true, + writable: true, + lastOutputAt: 1, + preview: '' + } + ], + totalCount: 1, + truncated: false + } + } + if (method === 'terminal.agentStatus') { + return { agentStatus: { handle: 'term-mirrored', isRunningAgent: true, status: 'working' } } + } + if (method === 'terminal.send') { + return { + send: { + handle: 'term-mirrored', + accepted: true, + bytesWritten: typeof params.text === 'string' ? params.text.length : 1 + } + } + } + throw new Error(`unexpected method ${method}`) + }) + + await expect( + sendNotesToActiveAgentSession({ + worktreeId: 'wt-1', + prompt: 'notes', + noteTarget: { tabId: 'web-terminal-tab-9', leafId: OTHER_LEAF_ID } + }) + ).resolves.toEqual({ status: 'sent' }) + }) }) diff --git a/src/renderer/src/lib/active-agent-note-send-focused-session.test.ts b/src/renderer/src/lib/active-agent-note-send-focused-session.test.ts index 76fcf49c4e6..118bd134df5 100644 --- a/src/renderer/src/lib/active-agent-note-send-focused-session.test.ts +++ b/src/renderer/src/lib/active-agent-note-send-focused-session.test.ts @@ -187,7 +187,7 @@ describe('active agent note send', () => { await expect( sendNotesToActiveAgentSession({ worktreeId: 'wt-1', prompt: 'notes' }) - ).resolves.toEqual({ status: 'permission' }) + ).resolves.toEqual({ status: 'permission', code: 'terminal-send-permission' }) }) it('keeps active-focused sends compatible when an older runtime lacks agentStatus', async () => { @@ -286,7 +286,7 @@ describe('active agent note send', () => { await expect( sendNotesToActiveAgentSession({ worktreeId: 'wt-1', prompt: 'notes' }) - ).resolves.toEqual({ status: 'no-agent' }) + ).resolves.toEqual({ status: 'no-agent', code: 'no-agent' }) expect(testState.callRuntimeRpc).not.toHaveBeenCalledWith( expect.anything(), @@ -330,7 +330,7 @@ describe('active agent note send', () => { await expect( sendNotesToActiveAgentSession({ worktreeId: 'wt-1', prompt: 'notes' }) - ).resolves.toEqual({ status: 'not-ready' }) + ).resolves.toEqual({ status: 'not-ready', code: 'terminal_wait_timeout' }) expect(testState.callRuntimeRpc).not.toHaveBeenCalledWith( expect.anything(), @@ -382,7 +382,7 @@ describe('active agent note send', () => { await expect( sendNotesToActiveAgentSession({ worktreeId: 'wt-1', prompt: 'notes' }) - ).resolves.toEqual({ status: 'no-active-terminal' }) + ).resolves.toEqual({ status: 'no-active-terminal', code: 'terminal_wait_not_running' }) expect(testState.callRuntimeRpc).not.toHaveBeenCalledWith( expect.anything(), @@ -435,7 +435,7 @@ describe('active agent note send', () => { await expect( sendNotesToActiveAgentSession({ worktreeId: 'wt-1', prompt: 'notes' }) - ).resolves.toEqual({ status: 'permission' }) + ).resolves.toEqual({ status: 'permission', code: 'terminal_wait_blocked' }) expect(testState.callRuntimeRpc).not.toHaveBeenCalledWith( expect.anything(), @@ -495,7 +495,7 @@ describe('active agent note send', () => { await expect( sendNotesToActiveAgentSession({ worktreeId: 'wt-1', prompt: 'notes' }) - ).resolves.toEqual({ status: 'permission' }) + ).resolves.toEqual({ status: 'permission', code: 'agent-permission' }) expect(statusChecks).toBe(2) expect(testState.callRuntimeRpc).not.toHaveBeenCalledWith( @@ -512,7 +512,7 @@ describe('active agent note send', () => { await expect( sendNotesToActiveAgentSession({ worktreeId: 'wt-1', prompt: 'notes' }) - ).resolves.toEqual({ status: 'no-active-terminal' }) + ).resolves.toEqual({ status: 'no-active-terminal', code: 'no-note-target' }) expect(testState.callRuntimeRpc).not.toHaveBeenCalled() }) diff --git a/src/renderer/src/lib/active-agent-note-send-result.ts b/src/renderer/src/lib/active-agent-note-send-result.ts index 3c292d497e4..388037d7226 100644 --- a/src/renderer/src/lib/active-agent-note-send-result.ts +++ b/src/renderer/src/lib/active-agent-note-send-result.ts @@ -9,39 +9,76 @@ export type ActiveAgentNotesSendStatus = | 'not-writable' | 'partial-submit-failed' +export type ActiveAgentNotesSendFailureCode = + | 'empty' + | 'no-note-target' + | 'no-inventory-match' + | 'terminal_handle_stale' + | 'terminal_exited' + | 'terminal_gone' + | 'no_active_terminal' + | 'terminal_wait_not_running' + | 'terminal_wait_blocked' + | 'terminal_wait_unsatisfied' + | 'terminal_wait_timeout' + | 'no-agent' + | 'agent-permission' + | 'status-unavailable' + | 'terminal-send-permission' + | 'terminal-send-refused' + | 'terminal_not_writable' + | 'submit-readiness-lost' + | 'submit-terminal-unavailable' + | 'submit-send-refused' + | 'submit-send-error' + | 'runtime-unverifiable' + | 'runtime-timeout' + export type ActiveAgentNotesSendResult = { status: ActiveAgentNotesSendStatus + code?: ActiveAgentNotesSendFailureCode } export function activeAgentNotesSendFailureMessage( status: ActiveAgentNotesSendStatus, - options: { explicitTarget?: boolean } = {} + options: { explicitTarget?: boolean; code?: ActiveAgentNotesSendFailureCode } = {} ): string { const target = options.explicitTarget ? 'selected' : 'active' + let message: string switch (status) { case 'empty': - return 'No notes to send.' + message = 'No notes to send.' + break case 'no-active-terminal': - return options.explicitTarget + message = options.explicitTarget ? 'The selected terminal is no longer available.' : 'Open the agent terminal in this worktree, then send the notes again.' + break case 'no-agent': - return `The ${target} terminal is not a recognized agent session.` + message = `The ${target} terminal is not a recognized agent session.` + break case 'permission': - return options.explicitTarget + message = options.explicitTarget ? 'The selected agent needs permission.' : 'The active agent needs permission.' + break case 'status-unavailable': - return `The ${target} agent status could not be verified.` + message = `The ${target} agent status could not be verified.` + break case 'not-ready': - return `The ${target} agent was not ready for input yet.` + message = `The ${target} agent was not ready for input yet.` + break case 'not-writable': - return `The ${target} terminal did not accept the notes.` + message = `The ${target} terminal did not accept the notes.` + break case 'partial-submit-failed': - return options.explicitTarget + message = options.explicitTarget ? 'The notes may already be pasted in the selected terminal, but Orca could not submit them.' : 'The notes may already be pasted in the active terminal, but Orca could not submit them.' + break case 'sent': - return '' + message = '' + break } + return options.code ? `${message} (${options.code})` : message } diff --git a/src/renderer/src/lib/active-agent-note-send-runtime-error-codes.test.ts b/src/renderer/src/lib/active-agent-note-send-runtime-error-codes.test.ts new file mode 100644 index 00000000000..baa41a7d139 --- /dev/null +++ b/src/renderer/src/lib/active-agent-note-send-runtime-error-codes.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest' +import { hasRuntimeRpcErrorCode, RuntimeRpcCallError } from '@/runtime/runtime-rpc-client' +import { + isRuntimeTerminalNotWritable, + isRuntimeTerminalUnavailable, + isRuntimeTimeout +} from './active-agent-terminal-send-readiness' +import { + runtimeFailureCode, + runtimeFailureFallbackCode +} from './active-agent-note-send-diagnostics' + +function runtimeError(code: string): RuntimeRpcCallError { + return new RuntimeRpcCallError({ + id: 'test-runtime-error', + ok: false, + error: { code, message: 'The terminal is no longer available' } + }) +} + +describe('active agent note runtime error codes', () => { + it.each(['terminal_handle_stale', 'terminal_exited', 'terminal_gone', 'no_active_terminal'])( + 'uses structured %s codes even with human-readable messages', + (code) => { + const error = runtimeError(code) + + expect(isRuntimeTerminalUnavailable(error)).toBe(true) + expect(runtimeFailureCode(error)).toBe(code) + } + ) + + it('uses structured terminal_not_writable with a human-readable message', () => { + const error = runtimeError('terminal_not_writable') + + expect(isRuntimeTerminalNotWritable(error)).toBe(true) + expect(hasRuntimeRpcErrorCode(error, 'terminal_not_writable')).toBe(true) + }) + + it('uses structured runtime_timeout with a human-readable message', () => { + const error = new RuntimeRpcCallError({ + id: 'test-runtime-timeout', + ok: false, + error: { code: 'runtime_timeout', message: 'Timed out waiting for the remote runtime.' } + }) + + expect(isRuntimeTimeout(error)).toBe(true) + expect(runtimeFailureFallbackCode(error)).toBe('runtime-timeout') + }) + + it('retains support for transport-rewrapped error tokens', () => { + const error = new Error("Error invoking remote method 'terminal.send': terminal_gone") + + expect(isRuntimeTerminalUnavailable(error)).toBe(true) + expect(runtimeFailureCode(error)).toBe('terminal_gone') + }) +}) diff --git a/src/renderer/src/lib/active-agent-note-send.ts b/src/renderer/src/lib/active-agent-note-send.ts index 97bb2418132..48cced68feb 100644 --- a/src/renderer/src/lib/active-agent-note-send.ts +++ b/src/renderer/src/lib/active-agent-note-send.ts @@ -1,26 +1,26 @@ -import type { RuntimeTerminalSend, RuntimeTerminalWait } from '../../../shared/runtime-types' -import { sanitizeTerminalPasteText } from '@/components/terminal-pane/terminal-bracketed-paste' +import type { RuntimeTerminalWait } from '../../../shared/runtime-types' import { useAppStore } from '@/store' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { getSettingsForWorktreeRuntimeOwner } from '@/lib/worktree-runtime-owner' -import { - findActiveRuntimeTerminal, - getActiveTerminalNoteTarget, - type ActiveTerminalNoteTarget -} from './active-agent-note-target' -import { - BRACKETED_PASTE_BEGIN, - BRACKETED_PASTE_END, - POST_PASTE_SUBMIT_DELAY_MS -} from './agent-paste-draft' +import { findActiveRuntimeTerminal, getActiveTerminalNoteTarget } from './active-agent-note-target' +import type { ActiveTerminalNoteTarget } from './active-agent-note-target' import type { ActiveAgentNotesSendResult } from './active-agent-note-send-result' import { ACTIVE_AGENT_SEND_RPC_TIMEOUT_MS, getTerminalAgentSendReadiness, - isRuntimeTerminalNotWritable, isRuntimeTerminalUnavailable, isRuntimeTimeout } from './active-agent-terminal-send-readiness' +import { + codeForReadinessStatus, + reportNoteSendFailure, + runtimeFailureCode, + runtimeFailureFallbackCode +} from './active-agent-note-send-diagnostics' +import { + sendPromptWithGuardedPasteAndEnter, + sendPromptWithLegacyCombinedSend +} from './active-agent-note-send-delivery' export { getActiveAgentNoteTarget, @@ -34,11 +34,24 @@ export { type ActiveAgentNotesSendResult, type ActiveAgentNotesSendStatus } from './active-agent-note-send-result' - const ACTIVE_AGENT_SEND_TIMEOUT_MS = 8000 -const ORCA_DESKTOP_TERMINAL_CLIENT = { id: 'orca-desktop', type: 'desktop' as const } -export async function sendNotesToActiveAgentSession({ +export async function sendNotesToActiveAgentSession(args: { + worktreeId: string + prompt: string + noteTarget?: ActiveTerminalNoteTarget + timeoutMs?: number +}): Promise { + try { + return await sendNotesToActiveAgentSessionInternal(args) + } catch (error) { + return reportNoteSendFailure( + { status: 'status-unavailable', code: runtimeFailureFallbackCode(error) }, + args.noteTarget ?? null + ) + } +} +async function sendNotesToActiveAgentSessionInternal({ worktreeId, prompt, noteTarget: explicitNoteTarget, @@ -51,20 +64,13 @@ export async function sendNotesToActiveAgentSession({ }): Promise { const trimmedPrompt = prompt.trim() if (!trimmedPrompt) { - return { status: 'empty' } + return { status: 'empty', code: 'empty' } } - const state = useAppStore.getState() - // Why: an explicit target lets the notes dropdown address ANY running agent of - // the worktree, not just the focused pane; omitted, fall back to the focused - // active terminal so existing callers keep their behavior. Routing below still - // resolves the worktree's owner host, so explicit targets stay SSH/remote-correct. const noteTarget = explicitNoteTarget ?? getActiveTerminalNoteTarget(state, worktreeId) if (!noteTarget) { - return { status: 'no-active-terminal' } + return reportNoteSendFailure({ status: 'no-active-terminal', code: 'no-note-target' }, null) } - // Route by the worktree's owner host so the agent terminal is found and driven - // on the host that actually runs it, not on the focused runtime. const runtimeTarget = getActiveRuntimeTarget( getSettingsForWorktreeRuntimeOwner(state, worktreeId) ) @@ -75,21 +81,30 @@ export async function sendNotesToActiveAgentSession({ ACTIVE_AGENT_SEND_RPC_TIMEOUT_MS ) if (!terminal) { - return { status: 'no-active-terminal' } + return reportNoteSendFailure( + { status: 'no-active-terminal', code: 'no-inventory-match' }, + noteTarget + ) } - if (explicitNoteTarget) { - return await sendPromptToExplicitAgentTarget(runtimeTarget, terminal.handle, trimmedPrompt) + return reportNoteSendFailure( + await sendPromptToExplicitAgentTarget(runtimeTarget, terminal.handle, trimmedPrompt), + noteTarget + ) } - const effectiveTimeoutMs = timeoutMs ?? ACTIVE_AGENT_SEND_TIMEOUT_MS const initialAgentStatus = await getTerminalAgentSendReadiness(runtimeTarget, terminal.handle, { allowLegacyFallback: true }) if (initialAgentStatus.status !== 'sendable') { - return { status: initialAgentStatus.status } + return reportNoteSendFailure( + { + status: initialAgentStatus.status, + code: initialAgentStatus.code ?? codeForReadinessStatus(initialAgentStatus.status) + }, + noteTarget + ) } - try { const { wait } = await callRuntimeRpc<{ wait: RuntimeTerminalWait }>( runtimeTarget, @@ -98,160 +113,64 @@ export async function sendNotesToActiveAgentSession({ { timeoutMs: effectiveTimeoutMs + 5000 } ) if (wait.status !== 'running') { - return { status: 'no-active-terminal' } + return reportNoteSendFailure( + { status: 'no-active-terminal', code: 'terminal_wait_not_running' }, + noteTarget + ) } if (wait.blockedReason) { - return { status: 'permission' } + return reportNoteSendFailure( + { status: 'permission', code: 'terminal_wait_blocked' }, + noteTarget + ) } if (!wait.satisfied) { - return { status: 'not-ready' } + return reportNoteSendFailure( + { status: 'not-ready', code: 'terminal_wait_unsatisfied' }, + noteTarget + ) } } catch (error) { if (isRuntimeTerminalUnavailable(error)) { - return { status: 'no-active-terminal' } + return reportNoteSendFailure( + { status: 'no-active-terminal', code: runtimeFailureCode(error) ?? 'runtime-unverifiable' }, + noteTarget + ) } if (isRuntimeTimeout(error)) { - return { status: 'not-ready' } + return reportNoteSendFailure( + { status: 'not-ready', code: 'terminal_wait_timeout' }, + noteTarget + ) } throw error } - const finalAgentStatus = await getTerminalAgentSendReadiness(runtimeTarget, terminal.handle, { allowLegacyFallback: true }) if (finalAgentStatus.status !== 'sendable') { - return { status: finalAgentStatus.status } + return reportNoteSendFailure( + { + status: finalAgentStatus.status, + code: finalAgentStatus.code ?? codeForReadinessStatus(finalAgentStatus.status) + }, + noteTarget + ) } if (finalAgentStatus.supportsGuardedSend) { - return await sendPromptWithGuardedPasteAndEnter(runtimeTarget, terminal.handle, trimmedPrompt, { - allowLegacyFallback: false - }) - } - - // Why: protocol-compatible older SSH runtimes do not know the guarded send - // option. They already passed terminal.wait + legacy isRunningAgent checks, - // so preserve the old active-focused send path for remote compatibility. - return await sendPromptWithLegacyCombinedSend(runtimeTarget, terminal.handle, trimmedPrompt) -} - -async function sendPromptWithLegacyCombinedSend( - runtimeTarget: ReturnType, - terminalHandle: string, - prompt: string -): Promise { - try { - const { send } = await callRuntimeRpc<{ send: RuntimeTerminalSend }>( - runtimeTarget, - 'terminal.send', - { - terminal: terminalHandle, - text: prompt, - enter: true, - client: ORCA_DESKTOP_TERMINAL_CLIENT - }, - { timeoutMs: ACTIVE_AGENT_SEND_RPC_TIMEOUT_MS } + return reportNoteSendFailure( + await sendPromptWithGuardedPasteAndEnter(runtimeTarget, terminal.handle, trimmedPrompt, { + allowLegacyFallback: false + }), + noteTarget ) - return send.accepted ? { status: 'sent' } : { status: 'not-writable' } - } catch (error) { - if (isRuntimeTerminalUnavailable(error)) { - return { status: 'no-active-terminal' } - } - if (isRuntimeTerminalNotWritable(error)) { - return { status: 'not-writable' } - } - throw error - } -} - -async function sendPromptWithGuardedPasteAndEnter( - runtimeTarget: ReturnType, - terminalHandle: string, - prompt: string, - options: { allowLegacyFallback: boolean } -): Promise { - const initialAgentStatus = await getTerminalAgentSendReadiness(runtimeTarget, terminalHandle, { - allowLegacyFallback: options.allowLegacyFallback - }) - // Why: the readiness probe and write guard can observe different transient - // title/process snapshots; the guard owns the bounded no-agent recheck. - if ( - initialAgentStatus.status !== 'sendable' && - !(initialAgentStatus.status === 'no-agent' && initialAgentStatus.supportsGuardedSend) - ) { - return { status: initialAgentStatus.status } } - const pastePayload = `${BRACKETED_PASTE_BEGIN}${sanitizeTerminalPasteText(prompt)}${BRACKETED_PASTE_END}` - try { - const { send } = await callRuntimeRpc<{ send: RuntimeTerminalSend }>( - runtimeTarget, - 'terminal.send', - { - terminal: terminalHandle, - text: pastePayload, - requireAgentStatus: 'sendable', - client: ORCA_DESKTOP_TERMINAL_CLIENT - }, - { timeoutMs: ACTIVE_AGENT_SEND_RPC_TIMEOUT_MS } - ) - if (!send.accepted) { - if (send.refusedReason === 'permission') { - return { status: 'permission' } - } - if (send.refusedReason === 'no-agent') { - return { status: 'no-agent' } - } - return { status: 'not-writable' } - } - } catch (error) { - if (isRuntimeTerminalUnavailable(error)) { - return { status: 'no-active-terminal' } - } - if (isRuntimeTerminalNotWritable(error)) { - return { status: 'not-writable' } - } - throw error - } - - await new Promise((resolve) => setTimeout(resolve, POST_PASTE_SUBMIT_DELAY_MS)) - - try { - const submitAgentStatus = await getTerminalAgentSendReadiness(runtimeTarget, terminalHandle, { - allowLegacyFallback: options.allowLegacyFallback - }) - if ( - submitAgentStatus.status !== 'sendable' && - !(submitAgentStatus.status === 'no-agent' && submitAgentStatus.supportsGuardedSend) - ) { - return { status: 'partial-submit-failed' } - } - } catch (error) { - if (isRuntimeTerminalUnavailable(error)) { - return { status: 'partial-submit-failed' } - } - throw error - } - - try { - const { send } = await callRuntimeRpc<{ send: RuntimeTerminalSend }>( - runtimeTarget, - 'terminal.send', - { - terminal: terminalHandle, - enter: true, - requireAgentStatus: 'sendable', - client: ORCA_DESKTOP_TERMINAL_CLIENT - }, - { timeoutMs: ACTIVE_AGENT_SEND_RPC_TIMEOUT_MS } - ) - return send.accepted ? { status: 'sent' } : { status: 'partial-submit-failed' } - } catch (error) { - if (isRuntimeTerminalUnavailable(error) || isRuntimeTerminalNotWritable(error)) { - return { status: 'partial-submit-failed' } - } - throw error - } + return reportNoteSendFailure( + await sendPromptWithLegacyCombinedSend(runtimeTarget, terminal.handle, trimmedPrompt), + noteTarget + ) } async function sendPromptToExplicitAgentTarget( diff --git a/src/renderer/src/lib/active-agent-note-target.ts b/src/renderer/src/lib/active-agent-note-target.ts index dbacc97e4c1..2b114166d1f 100644 --- a/src/renderer/src/lib/active-agent-note-target.ts +++ b/src/renderer/src/lib/active-agent-note-target.ts @@ -1,4 +1,5 @@ import type { RuntimeTerminalListResult } from '../../../shared/runtime-types' +import { toHostSessionTabId } from '../../../shared/terminal-surface-id' import { AGENT_STATUS_STALE_AFTER_MS, type AgentStatusEntry @@ -174,9 +175,11 @@ export async function findActiveRuntimeTerminal( }, { timeoutMs } ) + // Why: paired renderer tabs wrap the host id with `web-terminal-*`. + const runtimeTabId = toHostSessionTabId(noteTarget.tabId) return ( terminals.find( - (terminal) => terminal.tabId === noteTarget.tabId && terminal.leafId === noteTarget.leafId + (terminal) => terminal.tabId === runtimeTabId && terminal.leafId === noteTarget.leafId ) ?? null ) } diff --git a/src/renderer/src/lib/active-agent-terminal-send-readiness.ts b/src/renderer/src/lib/active-agent-terminal-send-readiness.ts index fa049398e8b..b432366527f 100644 --- a/src/renderer/src/lib/active-agent-terminal-send-readiness.ts +++ b/src/renderer/src/lib/active-agent-terminal-send-readiness.ts @@ -1,6 +1,12 @@ import type { RuntimeTerminalAgentStatus } from '../../../shared/runtime-types' +import { hasRuntimeRpcErrorCode } from '../../../shared/runtime-rpc-error-code' +import type { ActiveAgentNotesSendFailureCode } from './active-agent-note-send-result' import { callRuntimeRpc, RuntimeRpcCallError } from '@/runtime/runtime-rpc-client' import type { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { + runtimeFailureCode, + TERMINAL_RUNTIME_FAILURE_CODES +} from './active-agent-note-send-diagnostics' export const ACTIVE_AGENT_SEND_RPC_TIMEOUT_MS = 15000 @@ -14,6 +20,7 @@ export type TerminalAgentSendReadiness = export type TerminalAgentSendReadinessResult = { status: TerminalAgentSendReadiness supportsGuardedSend: boolean + code?: ActiveAgentNotesSendFailureCode } export async function getTerminalAgentSendReadiness( @@ -44,13 +51,14 @@ export async function getTerminalAgentSendReadiness( } // Why: active-focused sends still wait for tui-idle, preserving old // runtime compatibility without immediate selected-target risk. - return { - status: await getLegacyTerminalAgentSendStatus(runtimeTarget, terminalHandle), - supportsGuardedSend: false - } + return await getLegacyTerminalAgentSendStatus(runtimeTarget, terminalHandle) } if (isRuntimeTerminalUnavailable(error)) { - return { status: 'no-active-terminal', supportsGuardedSend: false } + return { + status: 'no-active-terminal', + supportsGuardedSend: false, + code: runtimeTerminalUnavailableCode(error) + } } throw error } @@ -59,7 +67,7 @@ export async function getTerminalAgentSendReadiness( async function getLegacyTerminalAgentSendStatus( runtimeTarget: ReturnType, terminalHandle: string -): Promise { +): Promise { try { const { isRunningAgent } = await callRuntimeRpc<{ isRunningAgent: boolean }>( runtimeTarget, @@ -67,31 +75,38 @@ async function getLegacyTerminalAgentSendStatus( { terminal: terminalHandle }, { timeoutMs: ACTIVE_AGENT_SEND_RPC_TIMEOUT_MS } ) - return isRunningAgent ? 'sendable' : 'no-agent' + return { + status: isRunningAgent ? 'sendable' : 'no-agent', + supportsGuardedSend: false + } } catch (error) { if (isRuntimeTerminalUnavailable(error)) { - return 'no-active-terminal' + return { + status: 'no-active-terminal', + supportsGuardedSend: false, + code: runtimeTerminalUnavailableCode(error) + } } throw error } } +function runtimeTerminalUnavailableCode(error: unknown): ActiveAgentNotesSendFailureCode { + return runtimeFailureCode(error) ?? 'runtime-unverifiable' +} + export function isRuntimeTimeout(error: unknown): boolean { + if (hasRuntimeRpcErrorCode(error, 'runtime_timeout')) { + return true + } const message = error instanceof Error ? error.message : String(error) return message.includes('timeout') } export function isRuntimeTerminalUnavailable(error: unknown): boolean { - const message = error instanceof Error ? error.message : String(error) - return ( - message.includes('terminal_handle_stale') || - message.includes('terminal_exited') || - message.includes('terminal_gone') || - message.includes('no_active_terminal') - ) + return TERMINAL_RUNTIME_FAILURE_CODES.some((code) => hasRuntimeRpcErrorCode(error, code)) } export function isRuntimeTerminalNotWritable(error: unknown): boolean { - const message = error instanceof Error ? error.message : String(error) - return message.includes('terminal_not_writable') + return hasRuntimeRpcErrorCode(error, 'terminal_not_writable') } diff --git a/src/renderer/src/lib/lazy-chunk-recovery-reload.test.ts b/src/renderer/src/lib/lazy-chunk-recovery-reload.test.ts index 9cdf4fa728d..8c113363861 100644 --- a/src/renderer/src/lib/lazy-chunk-recovery-reload.test.ts +++ b/src/renderer/src/lib/lazy-chunk-recovery-reload.test.ts @@ -6,6 +6,7 @@ import { requestLazyChunkRecoveryReload } from './lazy-chunk-recovery-reload' describe('requestLazyChunkRecoveryReload', () => { afterEach(() => { vi.restoreAllMocks() + vi.unstubAllGlobals() }) it('refuses the reload when the staged checkpoint never reaches disk', async () => { @@ -36,4 +37,32 @@ describe('requestLazyChunkRecoveryReload', () => { expect(order).toEqual(['flushed', 'reload']) }) + + it('joins the preload checkpoint before navigating when no override is supplied', async () => { + const order: string[] = [] + let flush: () => void = () => undefined + const awaitBeforeUnloadCheckpoint = vi.fn( + () => + new Promise((resolve) => { + flush = () => { + order.push('flushed') + resolve() + } + }) + ) + vi.stubGlobal('api', { app: { awaitBeforeUnloadCheckpoint } }) + const reload = vi.spyOn(window.location, 'reload').mockImplementation(() => { + order.push('reload') + window.dispatchEvent(new Event(ORCA_RENDERER_UNLOAD_PREVENTED_EVENT)) + }) + + const outcome = requestLazyChunkRecoveryReload(window) + await vi.waitFor(() => expect(awaitBeforeUnloadCheckpoint).toHaveBeenCalledTimes(1)) + expect(reload).not.toHaveBeenCalled() + + flush() + + await expect(outcome).resolves.toBe('unload-vetoed') + expect(order).toEqual(['flushed', 'reload']) + }) }) diff --git a/src/renderer/src/lib/open-markdown-in-floating-workspace.test.ts b/src/renderer/src/lib/open-markdown-in-floating-workspace.test.ts new file mode 100644 index 00000000000..62fc278fc7a --- /dev/null +++ b/src/renderer/src/lib/open-markdown-in-floating-workspace.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it, vi } from 'vitest' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' +import type { MarkdownDocument } from '../../../shared/filesystem-entry-types' +import type { EditorFilesSlice } from '@/store/slices/editor/types/editor-files-slice' +import { openMarkdownDocumentInFloatingWorkspace } from './open-markdown-in-floating-workspace' + +function openFileMock(): ReturnType> { + return vi.fn(() => 'file-1') +} + +function markdownDocument(overrides: Partial = {}): MarkdownDocument { + return { + filePath: '/Users/me/notes/README.md', + relativePath: 'README.md', + basename: 'README.md', + name: 'README', + ...overrides + } +} + +describe('openMarkdownDocumentInFloatingWorkspace', () => { + it('opens the document as a permanent floating-workspace edit tab', () => { + const openFile = openFileMock() + + const fileId = openMarkdownDocumentInFloatingWorkspace(openFile, markdownDocument()) + + expect(fileId).toBe('file-1') + expect(openFile).toHaveBeenCalledTimes(1) + expect(openFile.mock.calls[0][0]).toEqual({ + filePath: '/Users/me/notes/README.md', + relativePath: 'README.md', + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + language: 'markdown', + mode: 'edit', + runtimeEnvironmentId: null + }) + expect(openFile.mock.calls[0][1]).toEqual({ + preview: false, + targetGroupId: undefined, + suppressActiveRuntimeFallback: true + }) + }) + + it('pins the open to this machine instead of the active runtime', () => { + const openFile = openFileMock() + + openMarkdownDocumentInFloatingWorkspace(openFile, markdownDocument()) + + // Why: the caller already resolved an absolute local path, so a null runtime plus the + // fallback suppression is what keeps the read off a remote SSH host the user is focused on. + // Dropping either one silently reads the file on the wrong machine. + expect(openFile.mock.calls[0][0].runtimeEnvironmentId).toBeNull() + expect(openFile.mock.calls[0][1]?.suppressActiveRuntimeFallback).toBe(true) + }) + + it('derives the language from the relative path', () => { + const openFile = openFileMock() + + openMarkdownDocumentInFloatingWorkspace( + openFile, + markdownDocument({ + filePath: '/Users/me/notes/plan.mdx', + relativePath: 'plan.mdx', + basename: 'plan.mdx', + name: 'plan' + }) + ) + + expect(openFile.mock.calls[0][0].language).toBe('markdown') + }) + + it('forwards a requested target group', () => { + const openFile = openFileMock() + + openMarkdownDocumentInFloatingWorkspace(openFile, markdownDocument(), { + targetGroupId: 'group-2' + }) + + expect(openFile.mock.calls[0][1]?.targetGroupId).toBe('group-2') + }) +}) diff --git a/src/renderer/src/lib/open-markdown-in-floating-workspace.ts b/src/renderer/src/lib/open-markdown-in-floating-workspace.ts new file mode 100644 index 00000000000..3b1bd4cd08b --- /dev/null +++ b/src/renderer/src/lib/open-markdown-in-floating-workspace.ts @@ -0,0 +1,32 @@ +import type { MarkdownDocument } from '../../../shared/filesystem-entry-types' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' +import type { EditorFilesSlice } from '@/store/slices/editor/types/editor-files-slice' +import { detectLanguage } from './language-detect' + +/** + * Opens a markdown file that belongs to no workspace as a floating-workspace editor tab. + * + * Why local-only: every caller resolves an absolute path on this machine (a native picker or + * the OS shell), so routing it through the active runtime would read it on the wrong host. + */ +export function openMarkdownDocumentInFloatingWorkspace( + openFile: EditorFilesSlice['openFile'], + document: MarkdownDocument, + options: { targetGroupId?: string } = {} +): string { + return openFile( + { + filePath: document.filePath, + relativePath: document.relativePath, + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + language: detectLanguage(document.relativePath), + mode: 'edit', + runtimeEnvironmentId: null + }, + { + preview: false, + targetGroupId: options.targetGroupId, + suppressActiveRuntimeFallback: true + } + ) +} diff --git a/src/renderer/src/lib/palette-match/palette-match-budget.ts b/src/renderer/src/lib/palette-match/palette-match-budget.ts index c51144fb39d..864473a65e6 100644 --- a/src/renderer/src/lib/palette-match/palette-match-budget.ts +++ b/src/renderer/src/lib/palette-match/palette-match-budget.ts @@ -1,8 +1,14 @@ /** * Checked-in performance budget for the Cmd+J matcher, measured against the * synthetic corpus in `palette-match-performance.test.ts`. These are ceilings for - * catching order-of-magnitude regressions, not targets — the measured numbers on - * a developer machine sit roughly an order of magnitude under each one. + * catching order-of-magnitude regressions, not targets. + * + * The wall-clock ceilings are asserted against the *fastest* sample of a batch, + * never the slowest: a vitest worker sharing cores with the rest of the suite + * gets preempted mid-measurement, so the slowest sample measures the machine + * while the fastest still approximates the matcher. Fan-out regressions are + * caught by `fieldMatchesPerCandidate` instead, which counts work rather than + * time and so does not depend on machine speed at all. * * Raising any value requires a fresh measurement recorded in the PR. */ @@ -11,10 +17,17 @@ export const PALETTE_MATCH_BUDGET = { candidateCount: 800, /** Unique tokens in the worst supported query. */ tokenCount: 16, - /** p95 milliseconds to normalize every document once (cold open). */ - coldBuildP95Ms: 900, - /** p95 milliseconds to match the whole corpus against one prepared query. */ - warmMatchP95Ms: 220, + /** + * Ceiling on `matchPaletteField` calls per candidate for the worst query. + * Deterministic — it counts work, not time — so it catches a fan-out + * regression (re-matching every field per evidence unit, say) on any machine. + * Measured 45: 15 fields across the 3 tokens scanned before the first miss. + */ + fieldMatchesPerCandidate: 60, + /** Milliseconds to normalize every document once (cold open), fastest sample. */ + coldBuildMs: 900, + /** Milliseconds to match the whole corpus against one prepared query, fastest sample. */ + warmMatchMs: 220, /** * Megabytes of indexed text and offset tables the normalized documents retain. * Measured deterministically rather than from `heapUsed`, which is polluted by diff --git a/src/renderer/src/lib/palette-match/palette-match-performance.test.ts b/src/renderer/src/lib/palette-match/palette-match-performance.test.ts index aa1e6e12047..13fbced916f 100644 --- a/src/renderer/src/lib/palette-match/palette-match-performance.test.ts +++ b/src/renderer/src/lib/palette-match/palette-match-performance.test.ts @@ -1,8 +1,11 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { PALETTE_MATCH_BUDGET } from './palette-match-budget' import { matchPaletteDocument } from './match-document' +import * as matchFieldModule from './match-field' import { preparePaletteQuery } from './palette-query' import { buildWorktreePaletteDocuments } from '../worktree-palette-document' +import type { PaletteDocument } from './palette-document' +import type { PaletteQueryToken } from './palette-query' import type { Repo } from '../../../../shared/repo-types' import type { Worktree } from '../../../../shared/worktree/types' @@ -89,49 +92,75 @@ const WORST_QUERY = Array.from({ length: tokenCount }, (_, index) => index === 0 ? 'scan' : index === 1 ? 'daily' : `token${index}` ).join(' ') -function percentile95(samples: number[]): number { - const sorted = [...samples].sort((a, b) => a - b) - return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95))] +function prepareWorstQuery(): { tokens: readonly PaletteQueryToken[]; normalized: string } { + const prepared = preparePaletteQuery(WORST_QUERY) + if (prepared.state !== 'ready') { + throw new Error(`Expected a ready query, got ${prepared.state}`) + } + return { tokens: prepared.tokens, normalized: prepared.normalized } +} + +const preparedQuery = prepareWorstQuery() + +function matchEveryDocument(documents: ReadonlyMap): void { + for (const document of documents.values()) { + matchPaletteDocument({ + document, + tokens: preparedQuery.tokens, + normalizedQuery: preparedQuery.normalized + }) + } +} + +/** + * Why the fastest sample and not p95: this runs in a vitest worker competing for + * cores with the rest of the suite, so a slow sample records a preemption rather + * than the matcher. The fastest sample is the least contaminated estimate of + * intrinsic cost — measured stable within 1.6x on a fully saturated machine, + * while the slowest of the same batch swung by 17x. + */ +function fastestSample(samples: readonly number[]): number { + return Math.min(...samples) +} + +function timeRepeatedly(work: () => void, rounds: number): number[] { + const samples: number[] = [] + for (let round = 0; round < rounds; round += 1) { + const start = performance.now() + work() + samples.push(performance.now() - start) + } + return samples } describe('palette matcher performance budget', () => { it('normalizes a cold corpus within budget', () => { - const samples: number[] = [] - for (let run = 0; run < 5; run += 1) { - const start = performance.now() - buildWorktreePaletteDocuments(worktrees, sources) - samples.push(performance.now() - start) - } - expect(percentile95(samples)).toBeLessThan(PALETTE_MATCH_BUDGET.coldBuildP95Ms) + const samples = timeRepeatedly(() => buildWorktreePaletteDocuments(worktrees, sources), 5) + expect(fastestSample(samples)).toBeLessThan(PALETTE_MATCH_BUDGET.coldBuildMs) }) it('matches a 16-token query against warm documents within budget', () => { const documents = buildWorktreePaletteDocuments(worktrees, sources) - const prepared = preparePaletteQuery(WORST_QUERY) - expect(prepared.state).toBe('ready') - if (prepared.state !== 'ready') { - return - } - const matchAllDocuments = (): void => { - for (const document of documents.values()) { - matchPaletteDocument({ - document, - tokens: prepared.tokens, - normalizedQuery: prepared.normalized - }) - } - } + // Warm the matcher before timing so JIT compilation is not part of the samples. + matchEveryDocument(documents) - // Warm the matcher before timing so JIT compilation is not part of p95. - matchAllDocuments() - const samples: number[] = [] - for (let run = 0; run < 10; run += 1) { - const start = performance.now() - matchAllDocuments() - samples.push(performance.now() - start) + const samples = timeRepeatedly(() => matchEveryDocument(documents), 10) + expect(fastestSample(samples)).toBeLessThan(PALETTE_MATCH_BUDGET.warmMatchMs) + }) + + it('bounds field-match fan-out per candidate', () => { + const documents = buildWorktreePaletteDocuments(worktrees, sources) + const fieldMatch = vi.spyOn(matchFieldModule, 'matchPaletteField') + try { + matchEveryDocument(documents) + const perCandidate = fieldMatch.mock.calls.length / documents.size + // Guards the ceiling against going vacuous if the spy ever stops intercepting. + expect(perCandidate).toBeGreaterThan(0) + expect(perCandidate).toBeLessThan(PALETTE_MATCH_BUDGET.fieldMatchesPerCandidate) + } finally { + fieldMatch.mockRestore() } - expect(percentile95(samples)).toBeLessThan(PALETTE_MATCH_BUDGET.warmMatchP95Ms) }) it('keeps the retained document payload within budget', () => { diff --git a/src/renderer/src/store/slices/agent-status-ack-cleanup.test.ts b/src/renderer/src/store/slices/agent-status-ack-cleanup.test.ts index b763262a22d..4ca35b9bdc2 100644 --- a/src/renderer/src/store/slices/agent-status-ack-cleanup.test.ts +++ b/src/renderer/src/store/slices/agent-status-ack-cleanup.test.ts @@ -117,3 +117,31 @@ describe('acknowledgedAgentsByPaneKey cleanup on teardown', () => { expect(ackAt < newEntry.stateStartedAt).toBe(true) }) }) + +// Why: only the terminal-view path cleared unreadAgentCompletionPanes, so an +// Activity-page ack left the tab dot lit. +describe('acknowledgeAgents clears the unread agent-completion marker', () => { + it('drops the pane from unreadAgentCompletionPanes', () => { + const store = createTestStore() + store.getState().setAgentStatus('tab-1:0', { state: 'done', prompt: 'p', agentType: 'claude' }) + store.getState().markAgentCompletionPaneUnread('tab-1:0') + expect(store.getState().unreadAgentCompletionPanes['tab-1:0']).toBe(true) + + store.getState().acknowledgeAgents(['tab-1:0']) + + expect(store.getState().unreadAgentCompletionPanes['tab-1:0']).toBeUndefined() + }) + + it('leaves other panes and the terminal-bell unread map untouched', () => { + const store = createTestStore() + store.getState().markAgentCompletionPaneUnread('tab-1:0') + store.getState().markAgentCompletionPaneUnread('tab-2:0') + store.getState().markTerminalPaneUnread('tab-1:0') + + store.getState().acknowledgeAgents(['tab-1:0']) + + expect(store.getState().unreadAgentCompletionPanes['tab-2:0']).toBe(true) + // Why: a BEL is a separate signal; acking the agent must not silence it. + expect(store.getState().unreadTerminalPanes['tab-1:0']).toBe(true) + }) +}) diff --git a/src/renderer/src/store/slices/ui-slice-test-harness.ts b/src/renderer/src/store/slices/ui-slice-test-harness.ts index 318b24afef8..b8bbcf24b85 100644 --- a/src/renderer/src/store/slices/ui-slice-test-harness.ts +++ b/src/renderer/src/store/slices/ui-slice-test-harness.ts @@ -20,6 +20,8 @@ export function createUIStore(): StoreApi { combinedDiffFileTreeWidth: 256, rightSidebarTab: 'explorer', rightSidebarExplorerView: 'files', + // Why: acknowledgeAgents clears the agent-completion marker the terminal slice owns. + unreadAgentCompletionPanes: {}, ...createSettingsSearchState(args[0]), ...createWorktreeNavHistorySlice(...(args as Parameters)), ...createUISlice(...(args as Parameters)) diff --git a/src/renderer/src/store/slices/ui/ui-slice-agent-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-agent-actions.ts index b1401e7d416..ebd0f016b3f 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-agent-actions.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-agent-actions.ts @@ -149,9 +149,11 @@ export function createUiAgentActions( worktreeId: mode.worktreeId, prompt: mode.prompt, noteTarget: { tabId: target.tabId, leafId: target.leafId } - }).catch((error) => { - console.error('Failed to send notes to sidebar agent target:', error) - return { status: 'no-active-terminal' as const } + }).catch(() => { + console.error('Failed to send notes to sidebar agent target:', { + code: 'runtime-unverifiable' + }) + return { status: 'status-unavailable' as const, code: 'runtime-unverifiable' as const } }) const stillCurrent = (): boolean => { @@ -160,7 +162,10 @@ export function createUiAgentActions( } if (result.status !== 'sent') { - const message = activeAgentNotesSendFailureMessage(result.status, { explicitTarget: true }) + const message = activeAgentNotesSendFailureMessage(result.status, { + explicitTarget: true, + code: result.code + }) set((s) => s.agentSendPopoverTargetMode?.id === mode.id && s.agentSendPopoverTargetMode.instanceId === mode.instanceId @@ -217,7 +222,16 @@ export function createUiAgentActions( const migrationUnsupported = Object.values(s.migrationUnsupportedByPtyId ?? {}) // Why: only reallocate if an ack advances; compare prev | null = null + // Why: one ack, two records — leaving the completion marker set keeps the tab dot, + // the ⌘J row and the floating-workspace dot lit with nothing left to read. + let nextUnreadCompletions: Record | null = null for (const key of paneKeys) { + if (s.unreadAgentCompletionPanes[key]) { + if (nextUnreadCompletions === null) { + nextUnreadCompletions = { ...s.unreadAgentCompletionPanes } + } + delete nextUnreadCompletions[key] + } const prev = s.acknowledgedAgentsByPaneKey[key] ?? 0 // Why not plain Date.now(): a remote/SSH execution host can stamp a turn ahead of this clock, // and every unread rule is `ackAt < turnTimestamp`. A behind-the-turn ack can never clear the @@ -258,7 +272,13 @@ export function createUiAgentActions( next[key] = stamp } } - return next ? { acknowledgedAgentsByPaneKey: next } : s + if (!next && !nextUnreadCompletions) { + return s + } + return { + ...(next ? { acknowledgedAgentsByPaneKey: next } : {}), + ...(nextUnreadCompletions ? { unreadAgentCompletionPanes: nextUnreadCompletions } : {}) + } }) const notificationIds = [...notificationIdsToDismiss] if (notificationIds.length > 0 && typeof window !== 'undefined') { diff --git a/src/renderer/src/store/slices/worktrees-runtime-host-metadata-retirement.test.ts b/src/renderer/src/store/slices/worktrees-runtime-host-metadata-retirement.test.ts new file mode 100644 index 00000000000..eeaf49dd5e4 --- /dev/null +++ b/src/renderer/src/store/slices/worktrees-runtime-host-metadata-retirement.test.ts @@ -0,0 +1,104 @@ +// Why this file exists: a paired client's WorktreeMeta for a runtime host is exempt from +// gcStaleWorktreeMeta (it skips any row that is not local on both the repo and the meta's hostId), +// and `forgetPersistedWorktreeMetaForRemovals` used to bail for every non-SSH host. So the client +// kept a row per remote worktree it had ever seen and dropped none (#17776). +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AppState } from '../types' +import { makeWorktree } from './worktrees-slice-test-fixtures' +import { makeDetectedResult } from './worktrees-detected-listing-fixtures' +import { + createTestStore, + forgetRemovedForExecutionHostMock, + resetRemoteRuntimeMocks, + resetWorktreeSliceModuleMemory, + runtimeEnvironmentCall +} from './worktrees-slice-test-harness' + +const REPO_ID = 'repo-runtime' +const HOST_ID = 'runtime:env-1' + +const worktree = (path: string) => + makeWorktree({ id: `${REPO_ID}::${path}`, repoId: REPO_ID, path, hostId: HOST_ID }) + +const live = worktree('/home/orca/live') +const deletedOnHost = worktree('/home/orca/deleted') + +function seedClientWithBothRows(): ReturnType { + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + repos: [ + { + id: REPO_ID, + path: '/home/orca/repo', + displayName: 'Runtime Repo', + badgeColor: '#000', + addedAt: 0, + executionHostId: HOST_ID + } + ], + worktreesByRepo: { [REPO_ID]: [live, deletedOnHost] } + } as Partial) + return store +} + +beforeEach(resetWorktreeSliceModuleMemory) + +describe('runtime-host persisted metadata retirement', () => { + beforeEach(() => { + vi.clearAllMocks() + resetRemoteRuntimeMocks() + }) + + it('retires metadata for rows an authoritative runtime-host scan proved gone', async () => { + const store = seedClientWithBothRows() + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-detected', + ok: true, + result: makeDetectedResult(REPO_ID, [live]), + _meta: { runtimeId: 'runtime-remote' } + }) + + await store.getState().fetchWorktrees(REPO_ID, { executionHostId: HOST_ID }) + + expect(forgetRemovedForExecutionHostMock).toHaveBeenCalledExactlyOnceWith({ + repoId: REPO_ID, + executionHostId: HOST_ID, + worktreeIds: [deletedOnHost.id] + }) + }) + + // A non-authoritative reply is a failed listing, not a report that a checkout is gone. + it('retires nothing when the runtime host could not scan', async () => { + const store = seedClientWithBothRows() + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-detected', + ok: true, + result: makeDetectedResult(REPO_ID, [live], { + authoritative: false, + source: 'metadata-fallback' + }), + _meta: { runtimeId: 'runtime-remote' } + }) + + await store.getState().fetchWorktrees(REPO_ID, { executionHostId: HOST_ID }) + + expect(forgetRemovedForExecutionHostMock).not.toHaveBeenCalled() + }) + + // `session-fallback` claims authoritative but is the truncated, visibility-filtered `worktree.list` + // reply from a host too old for `worktree.detectedList`. Its omissions prove nothing. + it('retires nothing from a legacy session-fallback listing', async () => { + const store = seedClientWithBothRows() + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-detected', + ok: true, + result: makeDetectedResult(REPO_ID, [live], { source: 'session-fallback' }), + _meta: { runtimeId: 'runtime-remote' } + }) + + await store.getState().fetchWorktrees(REPO_ID, { executionHostId: HOST_ID }) + + expect(forgetRemovedForExecutionHostMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/store/slices/worktrees/listing/authoritative-worktree-removal-memory.ts b/src/renderer/src/store/slices/worktrees/listing/authoritative-worktree-removal-memory.ts index 9793adb0e16..e2fae6b9f77 100644 --- a/src/renderer/src/store/slices/worktrees/listing/authoritative-worktree-removal-memory.ts +++ b/src/renderer/src/store/slices/worktrees/listing/authoritative-worktree-removal-memory.ts @@ -50,16 +50,18 @@ export function resetAuthoritativelyRemovedWorktreeMemoryForTests(): void { authoritativelyRemovedWorktreeIdsByHost.clear() } -// Why: SSH WorktreeMeta is exempt from gcStaleWorktreeMeta (persistence.ts:407,415) and outlives the remote -// worktree, so a scan-proven removal must retire the metadata itself — otherwise the next launch's fallback -// re-lists the deleted row before the host connects, and the in-memory suppression above is already gone. +// Why: off-host WorktreeMeta is exempt from gcStaleWorktreeMeta -- it skips any row whose repo or hostId is +// not local -- and outlives the remote worktree, so a scan-proven removal must retire the metadata itself. +// Otherwise the next launch's fallback re-lists the deleted row before the host connects, and the in-memory +// suppression above is already gone. Runtime hosts were excluded until #17776, which is why a paired client +// accumulated a row per remote worktree it had ever seen and never dropped one. export function forgetPersistedWorktreeMetaForRemovals( repoId: string, hostId: ExecutionHostId, worktreeIds: readonly string[] ): void { const parsedHost = parseExecutionHostId(hostId) - if (worktreeIds.length === 0 || parsedHost?.kind !== 'ssh') { + if (worktreeIds.length === 0 || (parsedHost?.kind !== 'ssh' && parsedHost?.kind !== 'runtime')) { return } const forget = window.api.worktrees.forgetRemovedForExecutionHost diff --git a/src/renderer/src/store/slices/worktrees/listing/fetched-worktree-merge.ts b/src/renderer/src/store/slices/worktrees/listing/fetched-worktree-merge.ts index 13e6b1572c1..fde831ee2e5 100644 --- a/src/renderer/src/store/slices/worktrees/listing/fetched-worktree-merge.ts +++ b/src/renderer/src/store/slices/worktrees/listing/fetched-worktree-merge.ts @@ -254,7 +254,14 @@ export function mergeFetchedWorktrees( // Why: applied outside the updater so a repeated updater call cannot double-apply the removal memory. forgetAuthoritativelyRemovedWorktrees(args.hostId, authoritativelySeenIds) rememberAuthoritativelyRemovedWorktrees(args.hostId, authoritativelyRemovedIds) - forgetPersistedWorktreeMetaForRemovals(args.repoId, args.hostId, authoritativelyRemovedIds) + // Only a real scan retires persisted metadata. `session-fallback` also reports authoritative, + // but it is the truncated, visibility-filtered `worktree.list` reply from a host too old for + // `worktree.detectedList` -- its omissions are not evidence a checkout is gone. + forgetPersistedWorktreeMetaForRemovals( + args.repoId, + args.hostId, + args.refresh.result.source === 'git' ? authoritativelyRemovedIds : [] + ) } return admitted } diff --git a/src/renderer/src/web/preload-api/web-ui-api.ts b/src/renderer/src/web/preload-api/web-ui-api.ts index 8c6e4d73a95..ca67f5664a9 100644 --- a/src/renderer/src/web/preload-api/web-ui-api.ts +++ b/src/renderer/src/web/preload-api/web-ui-api.ts @@ -157,6 +157,9 @@ export function createWebUiApi(): NonNullable['ui']> { consumePendingOpenSettings: () => Promise.resolve(false), onOpenSkillShare: () => noopUnsubscribe, consumePendingSkillShare: () => Promise.resolve(null), + // Why: the web client has no OS shell handing it files, so there is never a queued open. + onOpenMarkdownFiles: () => noopUnsubscribe, + consumePendingMarkdownFileOpens: () => Promise.resolve([]), onOpenSetupGuide: () => noopUnsubscribe, onOpenFeatureTour: () => noopUnsubscribe, onOpenCrashReport: () => noopUnsubscribe, diff --git a/src/shared/child-process/child-process-import-boundary.test.ts b/src/shared/child-process/child-process-import-boundary.test.ts index 4e261de5c8a..10ca4fd8522 100644 --- a/src/shared/child-process/child-process-import-boundary.test.ts +++ b/src/shared/child-process/child-process-import-boundary.test.ts @@ -23,10 +23,19 @@ const CHILD_PROCESS_IMPORT_ALLOWLIST: readonly string[] = readFileSync( .map((line) => line.trim()) .filter((line) => line.length > 0 && !line.startsWith('#')) +/** + * The true count of files importing child_process directly. + * + * May only ever be DECREASED, and only by migrating a file off + * `node:child_process`. Raising it is never the fix. + */ +const DIRECT_IMPORTER_PIN = 160 + const IMPORT_PATTERN = /(?:from\s+['"]node:child_process['"]|from\s+['"]child_process['"]|require\(\s*['"]node:child_process['"]|require\(\s*['"]child_process['"])/ -const OWNER_DIRECTORY = 'src/shared/child-process' +// Why: trailing slash, so a sibling like src/shared/child-process-foo.ts is scanned, not exempted. +const OWNER_DIRECTORY = 'src/shared/child-process/' const SCANNED_EXTENSIONS = ['.ts', '.tsx'] const IGNORED_DIRECTORIES = new Set([ 'node_modules', @@ -110,9 +119,22 @@ describe('child_process import boundary', () => { expect(stale, 'Allowlist entry no longer imports child_process — delete the line.').toEqual([]) }) - it('never grows', () => { - // The count is asserted separately from membership so a swap (one file - // migrated, one added) still fails loudly. - expect(offenders.length).toBeLessThanOrEqual(CHILD_PROCESS_IMPORT_ALLOWLIST.length) + it('holds the offender count at the pin', () => { + // Bounding by the allowlist's own length proves nothing: the two move + // together, so a swap (one file migrated off, one new file added with its + // entry) kept the bound satisfied. The pin is a literal for that reason. + expect( + offenders.length, + `${offenders.length} files import child_process directly; the pin is ${DIRECT_IMPORTER_PIN}. ` + + 'Never raise the pin -- migrate the file to runProcess/spawnProcess from ' + + 'src/shared/child-process instead.' + ).toBeLessThanOrEqual(DIRECT_IMPORTER_PIN) + // A pin left above reality is how a ratchet rots: it re-opens room for the + // next direct import to land for free. + expect( + offenders.length, + `Only ${offenders.length} files import child_process directly. Lower DIRECT_IMPORTER_PIN to ` + + `${offenders.length} to keep the ground you just took.` + ).toBeGreaterThanOrEqual(DIRECT_IMPORTER_PIN) }) }) diff --git a/src/shared/child-process/windows-console-visibility.test.ts b/src/shared/child-process/windows-console-visibility.test.ts index e98a97cfa98..7985b0ee11a 100644 --- a/src/shared/child-process/windows-console-visibility.test.ts +++ b/src/shared/child-process/windows-console-visibility.test.ts @@ -27,6 +27,15 @@ const ALLOWLIST: readonly string[] = readAllowlist( join(__dirname, '__fixtures__', 'windows-console-visibility-allowlist.txt') ) +/** + * The true count of files spawning without `windowsHide`. + * + * May only ever be DECREASED, and only by fixing a call site. Set equality with + * the allowlist does not bound this: a swap (one file fixed and delisted, one + * new file added with its entry) satisfies both membership assertions. + */ +const UNHIDDEN_SPAWNER_PIN = 68 + const CHILD_PROCESS_IMPORT = /from\s+['"](?:node:)?child_process['"]|require\(\s*['"](?:node:)?child_process['"]/ // Includes the promisified and renamed spellings -- `execAsync`, `spawnDetached`, @@ -166,4 +175,18 @@ describe('direct child-process calls hide the Windows console', () => { // A fixed file must leave the list, or the ratchet stops ratcheting. expect(ALLOWLIST.filter((path) => !offenders.includes(path))).toEqual([]) }) + + it('holds the offender count at the pin', () => { + expect( + offenders.length, + `${offenders.length} files spawn without windowsHide; the pin is ${UNHIDDEN_SPAWNER_PIN}. ` + + 'Never raise the pin -- add the flag, or route the call through run-process.ts.' + ).toBeLessThanOrEqual(UNHIDDEN_SPAWNER_PIN) + // A pin left above reality re-opens room for the next unguarded spawn. + expect( + offenders.length, + `Only ${offenders.length} files spawn without windowsHide. Lower UNHIDDEN_SPAWNER_PIN to ` + + `${offenders.length} to keep the ground you just took.` + ).toBeGreaterThanOrEqual(UNHIDDEN_SPAWNER_PIN) + }) }) diff --git a/src/shared/detected-worktree-provider-contract.ts b/src/shared/detected-worktree-provider-contract.ts index 9f146e35778..99b0f8fdc74 100644 --- a/src/shared/detected-worktree-provider-contract.ts +++ b/src/shared/detected-worktree-provider-contract.ts @@ -41,9 +41,15 @@ export type HostQualifiedKnownWorktreeResult = executionHostId: SshExecutionHostId } +/** + * Hosts whose persisted metadata a scan can retire: exactly those `gcStaleWorktreeMeta` skips, + * because it only ever condemns rows that are local on both the repo and the meta's `hostId`. + */ +export type OffHostExecutionHostId = Extract + export type ForgetRemovedWorktreesForExecutionHostArgs = { repoId: string - executionHostId: SshExecutionHostId + executionHostId: OffHostExecutionHostId /** Ids an authoritative scan of this host proved gone — the only evidence that retires persisted metadata. */ worktreeIds: readonly string[] } diff --git a/src/shared/git-binary-compatibility.test.ts b/src/shared/git-binary-compatibility.test.ts index debab394649..3a8f562dff1 100644 --- a/src/shared/git-binary-compatibility.test.ts +++ b/src/shared/git-binary-compatibility.test.ts @@ -15,6 +15,7 @@ import { isUnsupportedWorktreeListZError } from './git-worktree-command-capabilities' import { gitCredentialPromptGuardEnv } from './git-credential-prompt-env' +import { GIT_HISTORY_COMMIT_FORMAT, parseGitHistoryLog } from './git-history-log-parser' import { githubPullRequestHeadLocalRef, gitlabMergeRequestHeadLocalRef, @@ -378,4 +379,29 @@ describeBinaryCompatibility('real Git binary compatibility', () => { runGit(['show', '--end-of-options', `${pinnedOid}:absent.txt`]) ).rejects.toBeDefined() }) + // Why pin this: an older Git echoes %(decorate:…) and exits zero, so only %D + // in the same record carries the badges (#15507). Asserts the echo and the recovery. + it('reads commit decorations on both sides of the %(decorate:...) boundary', async () => { + await writeFile(join(repoPath, 'decorated.txt'), 'decorated\n') + await runGit(['add', 'decorated.txt']) + await runGit(['commit', '-qm', 'decorated commit']) + await runGit(['tag', 'compat-decorated']) + const head = (await runGit(['rev-parse', 'HEAD'])).stdout.trim() + + const log = await runGit([ + 'log', + `--format=${GIT_HISTORY_COMMIT_FORMAT}`, + '-z', + '--decorate=full', + '-n1', + head + ]) + + expect(log.stdout.includes('%(decorate')).toBe(!supports(2, 43)) + + const [item] = parseGitHistoryLog(log.stdout) + expect(item?.id).toBe(head) + expect(item?.subject).toBe('decorated commit') + expect(item?.references?.map((ref) => ref.id)).toContain('refs/tags/compat-decorated') + }) }) diff --git a/src/shared/git-history-log-parser.ts b/src/shared/git-history-log-parser.ts index 8f34002f0cc..ddc354513b9 100644 --- a/src/shared/git-history-log-parser.ts +++ b/src/shared/git-history-log-parser.ts @@ -2,9 +2,15 @@ import type { GitHistoryItem, GitHistoryItemRef } from './git-history-types' import { iterateNulDelimitedFields } from './nul-delimited-fields' const GIT_HISTORY_DECORATION_SEPARATOR = '\x1f' +const GIT_HISTORY_LEGACY_DECORATION_SEPARATOR = ',' +// Why %D too: %(decorate:…) is Git 2.43+, and older Git echoes it verbatim and exits zero. +// Callers must pass --decorate=full; both fields emit short names otherwise, which parse to no refs. export const GIT_HISTORY_COMMIT_FORMAT = - '%H%n%aN%n%aE%n%at%n%ct%n%P%n%(decorate:prefix=,suffix=,separator=%x1f)%n%B' + '%H%n%aN%n%aE%n%at%n%ct%n%P%n%(decorate:prefix=,suffix=,separator=%x1f)%n%D%n%B' + +// Why exact-match: no ref name may contain the \x1f an old Git echoes here. +const UNEXPANDED_DECORATE_PLACEHOLDER = `%(decorate:prefix=,suffix=,separator=${GIT_HISTORY_DECORATION_SEPARATOR})` export function shortGitHash(hash: string): string { return hash.slice(0, 7) @@ -15,17 +21,18 @@ function commitSubject(message: string): string { return firstLine || '(no commit message)' } -function parseGitDecorationRefs(raw: string, revision: string): GitHistoryItemRef[] { +function parseGitDecorationRefs( + raw: string, + revision: string, + separator: string +): GitHistoryItemRef[] { if (!raw.trim()) { return [] } const refs: GitHistoryItemRef[] = [] - // Why: Git permits commas in ref names, so Orca's git log format uses a - // control-character separator that Git ref names cannot contain. - const parts = raw.includes(GIT_HISTORY_DECORATION_SEPARATOR) - ? raw.split(GIT_HISTORY_DECORATION_SEPARATOR) - : raw.split(',') + // Why passed in: a lone decoration carries no separator, so sniffing `raw` split `feat,one`. + const parts = raw.split(separator) for (const part of parts) { const ref = part.trim() @@ -115,8 +122,10 @@ export function parseGitHistoryLog(stdout: string): GitHistoryItem[] { const authorEmail = lines[2] ?? '' const authorDateSeconds = Number.parseInt(lines[3] ?? '', 10) const parents = (lines[5] ?? '').trim() - const decorations = lines[6] ?? '' - const message = lines.slice(7).join('\n').replace(/\n$/, '') + const decorateField = lines[6] ?? '' + const isLegacyGit = decorateField === UNEXPANDED_DECORATE_PLACEHOLDER + const decorations = isLegacyGit ? (lines[7] ?? '') : decorateField + const message = lines.slice(8).join('\n').replace(/\n$/, '') items.push({ id: hash, @@ -127,7 +136,11 @@ export function parseGitHistoryLog(stdout: string): GitHistoryItem[] { authorEmail: authorEmail || undefined, displayId: shortGitHash(hash), timestamp: Number.isFinite(authorDateSeconds) ? authorDateSeconds * 1000 : undefined, - references: parseGitDecorationRefs(decorations, hash) + references: parseGitDecorationRefs( + decorations, + hash, + isLegacyGit ? GIT_HISTORY_LEGACY_DECORATION_SEPARATOR : GIT_HISTORY_DECORATION_SEPARATOR + ) }) } return items diff --git a/src/shared/git-history.test.ts b/src/shared/git-history.test.ts index 54aac202c71..617fa33c2ef 100644 --- a/src/shared/git-history.test.ts +++ b/src/shared/git-history.test.ts @@ -16,6 +16,7 @@ function logRecord({ hash, parents = [], decorations = '', + legacyDecorations = '', message, author = 'Ada Lovelace', timestamp = 1_700_000_000 @@ -23,6 +24,7 @@ function logRecord({ hash: string parents?: string[] decorations?: string + legacyDecorations?: string message: string author?: string timestamp?: number @@ -35,6 +37,7 @@ function logRecord({ String(timestamp), parents.join(' '), decorations, + legacyDecorations, message ].join('\n')}\0` } @@ -94,8 +97,12 @@ describe('git history parsing', () => { const stdout = logRecord({ hash: HEAD_OID, parents: [BASE_OID], - decorations: - 'HEAD -> refs/heads/feature, refs/remotes/origin/HEAD -> refs/remotes/origin/feature, refs/remotes/origin/feature, tag: refs/tags/v1.0.0', + decorations: [ + 'HEAD -> refs/heads/feature', + 'refs/remotes/origin/HEAD -> refs/remotes/origin/feature', + 'refs/remotes/origin/feature', + 'tag: refs/tags/v1.0.0' + ].join(DECORATION_SEPARATOR), message: 'feat: add graph\n\nbody line' }) @@ -117,6 +124,39 @@ describe('git history parsing', () => { ]) }) + it('falls back to %D decorations when Git predates the %(decorate:…) placeholder', () => { + // Why: Git < 2.43 echoes the placeholder and exits zero (#15507). + const stdout = logRecord({ + hash: HEAD_OID, + decorations: `%(decorate:prefix=,suffix=,separator=${DECORATION_SEPARATOR})`, + legacyDecorations: 'HEAD -> refs/heads/feature, tag: refs/tags/v1.0.0', + message: 'feat: add graph' + }) + + const [item] = parseGitHistoryLog(stdout) + + expect(item?.subject).toBe('feat: add graph') + expect(item?.references?.map((ref) => [ref.id, ref.name, ref.category])).toEqual([ + ['refs/heads/feature', 'feature', 'branches'], + ['refs/tags/v1.0.0', 'v1.0.0', 'tags'] + ]) + }) + + it('keeps a comma inside a lone decoration, which carries no separator', () => { + // Why: a lone decoration carries no separator, so sniffing for \x1f split it in two. + const stdout = logRecord({ + hash: HEAD_OID, + decorations: 'HEAD -> refs/heads/feat,one', + message: 'initial' + }) + + const [item] = parseGitHistoryLog(stdout) + + expect(item?.references?.map((ref) => [ref.id, ref.name])).toEqual([ + ['refs/heads/feat,one', 'feat,one'] + ]) + }) + it('preserves commas inside branch and tag decoration names', () => { const stdout = logRecord({ hash: HEAD_OID, diff --git a/src/shared/github/project-group-sort.ts b/src/shared/github/project-group-sort.ts index 707fbfb35e4..0b91d92b267 100644 --- a/src/shared/github/project-group-sort.ts +++ b/src/shared/github/project-group-sort.ts @@ -24,6 +24,29 @@ export type ProjectGroup = { const EMPTY_GROUP_KEY = '__empty__' +type ProjectFieldValue = GitHubProjectRow['fieldValuesByFieldId'][string] + +/** False for anything that renders as an empty cell — absent, or present with a blank payload. */ +function hasNonEmptyFieldValue(value: ProjectFieldValue | undefined): boolean { + if (!value) { + return false + } + switch (value.kind) { + case 'users': + return Boolean(value.users[0]?.login) + case 'labels': + return Boolean(value.labels[0]?.name) + case 'text': + return value.text.trim().length > 0 + case 'date': + return value.date.trim().length > 0 + case 'iteration': + case 'number': + case 'single-select': + return true + } +} + // Why: use a finite sentinel instead of Infinity so subtractions in the sort // comparator stay finite. `Infinity - Infinity` is NaN, which makes // Array.sort's behavior implementation-defined and skips later tie-breaks. @@ -35,7 +58,7 @@ function getFieldValueForGrouping( field: GitHubProjectField ): { key: string; label: string; orderHint: number; iteration: ProjectGroup['iteration'] } { const value = row.fieldValuesByFieldId[field.id] - if (!value) { + if (!hasNonEmptyFieldValue(value)) { return { key: EMPTY_GROUP_KEY, label: labelForEmpty(field), @@ -144,14 +167,11 @@ function compareSort(a: GitHubProjectRow, b: GitHubProjectRow, sort: GitHubProje const field = sort.field const aValue = a.fieldValuesByFieldId[field.id] const bValue = b.fieldValuesByFieldId[field.id] - if (!aValue && !bValue) { - return 0 - } - if (!aValue) { - return 1 - } - if (!bValue) { - return -1 + // Why: return before the trailing DESC flip so empty sorts last in both directions. + const aFilled = hasNonEmptyFieldValue(aValue) + const bFilled = hasNonEmptyFieldValue(bValue) + if (!aFilled || !bFilled) { + return aFilled === bFilled ? 0 : aFilled ? -1 : 1 } let cmp = 0 @@ -182,29 +202,9 @@ function compareSort(a: GitHubProjectRow, b: GitHubProjectRow, sort: GitHubProje } else if (aValue.kind === 'text' && bValue.kind === 'text') { cmp = aValue.text.localeCompare(bValue.text) } else if (aValue.kind === 'users' && bValue.kind === 'users') { - const aLogin = aValue.users[0]?.login ?? '' - const bLogin = bValue.users[0]?.login ?? '' - if (!aLogin && !bLogin) { - cmp = 0 - } else if (!aLogin) { - cmp = 1 - } else if (!bLogin) { - cmp = -1 - } else { - cmp = aLogin.localeCompare(bLogin) - } + cmp = (aValue.users[0]?.login ?? '').localeCompare(bValue.users[0]?.login ?? '') } else if (aValue.kind === 'labels' && bValue.kind === 'labels') { - const aName = aValue.labels[0]?.name ?? '' - const bName = bValue.labels[0]?.name ?? '' - if (!aName && !bName) { - cmp = 0 - } else if (!aName) { - cmp = 1 - } else if (!bName) { - cmp = -1 - } else { - cmp = aName.localeCompare(bName) - } + cmp = (aValue.labels[0]?.name ?? '').localeCompare(bValue.labels[0]?.name ?? '') } else { // Why: unknown sort-field kind — ignore this sort field and fall through // to tie-breaks (and eventually row.position).