diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index 9bc7d415d7b..6f888c3a512 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -922,9 +922,7 @@ jobs: run: | $env:SKIP_BUILD = '1' $env:ORCA_E2E_FORWARD_APP_LOGS = '1' - pnpm run --if-present test:e2e:workspace-session-golden pnpm run --if-present test:e2e:windows-fresh-startup-golden - pnpm run --if-present test:e2e:source-control-golden - name: Upload Playwright traces if: failure() @@ -940,6 +938,9 @@ jobs: if: needs.cut.outputs.should_release == 'true' name: skill sharing release gate ${{ matrix.platform }} runs-on: ${{ matrix.os }} + # The full suite is release-blocking on macOS. Windows still produces the + # same evidence, but intermittent filesystem contention cannot block signing. + continue-on-error: ${{ matrix.platform == 'windows' }} timeout-minutes: 20 strategy: fail-fast: false diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 1a31af9dfaf..13633ed8e01 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -105,6 +105,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, @@ -376,12 +381,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', @@ -468,6 +485,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/__fixtures__/websocket-server-wildcard-bind-allowlist.txt b/config/scripts/__fixtures__/websocket-server-wildcard-bind-allowlist.txt new file mode 100644 index 00000000000..4b76622a9f2 --- /dev/null +++ b/config/scripts/__fixtures__/websocket-server-wildcard-bind-allowlist.txt @@ -0,0 +1,14 @@ +# Files allowed to construct a `ws` server that binds a port without pinning `host`. +# +# `ws` accepts `{ port }` alone and silently binds the wildcard address. A server +# reached over 127.0.0.1 must pin `host: '127.0.0.1'`, or a foreign loopback +# listener can hold the same port and answer in its place -- which is how +# relay-control-client.test.ts came to fail with a real HTTP 401 in a test that +# was simulating silence. +# +# This list only shrinks. Adding a line also requires raising the pin in +# websocket-server-loopback-bind.test.ts, which is deliberate friction. + +# Deliberate, not drift: this mock is dialled by a phone on the LAN, so it has to +# be reachable on a real interface. A loopback bind would make it unreachable. +mobile/scripts/mock-server.ts diff --git a/config/scripts/call-site-option-keys.ts b/config/scripts/call-site-option-keys.ts new file mode 100644 index 00000000000..dff3a971c45 --- /dev/null +++ b/config/scripts/call-site-option-keys.ts @@ -0,0 +1,204 @@ +/** + * Read the top-level option keys of a call's object-literal argument out of raw + * source text. + * + * Text rather than an AST because typescript@7 no longer ships the classic + * compiler API and every installed parser is a transitive dependency. The + * tradeoff is handled by refusing to guess: any shape this cannot read comes + * back as `unreadable` with a reason, and callers must treat that as a failure + * rather than as an absence of keys. + */ + +export type CallOptionKeys = + | { readonly readable: true; readonly keys: readonly string[] } + | { readonly readable: false; readonly reason: string } + +type ScanState = 'code' | 'line' | 'block' | 'single' | 'double' | 'template' + +function closesString(state: ScanState, current: string): boolean { + return ( + (state === 'single' && current === "'") || + (state === 'double' && current === '"') || + (state === 'template' && current === '`') + ) +} + +function opensNonCode(current: string, next: string | undefined): ScanState | null { + if (current === '/' && next === '/') { + return 'line' + } + if (current === '/' && next === '*') { + return 'block' + } + if (current === "'") { + return 'single' + } + if (current === '"') { + return 'double' + } + if (current === '`') { + return 'template' + } + return null +} + +/** + * Text between an open paren and its match, tracking strings and comments so a + * brace inside either cannot unbalance the count. Null when it never closes. + */ +function balancedArguments(text: string, openIndex: number): string | null { + let depth = 0 + let state: ScanState = 'code' + for (let index = openIndex; index < text.length; index++) { + const current = text[index] + const next = text[index + 1] + if (state === 'code') { + const opened = opensNonCode(current, next) + if (opened) { + state = opened + if (opened === 'line' || opened === 'block') { + index++ + } + } else if (current === '(' || current === '{' || current === '[') { + depth++ + } else if (current === ')' || current === '}' || current === ']') { + depth-- + if (depth === 0) { + return text.slice(openIndex + 1, index) + } + if (depth < 0) { + return null + } + } + continue + } + if (state === 'line') { + if (current === '\n') { + state = 'code' + } + continue + } + if (state === 'block') { + if (current === '*' && next === '/') { + state = 'code' + index++ + } + continue + } + if (current === '\\') { + index++ + continue + } + // Brace tracking inside `${}` would need its own depth; templates never + // appear as options, so report one as unreadable instead of guessing. + if (state === 'template' && current === '$' && next === '{') { + return null + } + if (closesString(state, current)) { + state = 'code' + } + } + return null +} + +/** Keys at depth 0 of an object literal body, with anything non-identifier kept verbatim. */ +function objectLiteralKeys(body: string): string[] { + const keys: string[] = [] + let depth = 0 + let state: ScanState = 'code' + let inValue = false + let token = '' + const flush = (): void => { + const name = token.trim() + token = '' + if (name && depth === 0) { + keys.push(name) + } + } + for (let index = 0; index < body.length; index++) { + const current = body[index] + const next = body[index + 1] + if (state === 'code') { + const opened = opensNonCode(current, next) + if (opened) { + state = opened + if (opened === 'line' || opened === 'block') { + index++ + } + } else if (current === '(' || current === '{' || current === '[') { + depth++ + if (!inValue) { + token += current + } + } else if (current === ')' || current === '}' || current === ']') { + depth-- + if (!inValue) { + token += current + } + } else if (current === ':' && depth === 0 && !inValue) { + flush() + inValue = true + } else if (current === ',' && depth === 0) { + // A shorthand or a spread ends here having never seen a colon. + if (inValue) { + inValue = false + token = '' + } else { + flush() + } + } else if (!inValue) { + token += current + } + continue + } + if (state === 'line') { + if (current === '\n') { + state = 'code' + } + continue + } + if (state === 'block') { + if (current === '*' && next === '/') { + state = 'code' + index++ + } + continue + } + if (current === '\\') { + index++ + continue + } + if (closesString(state, current)) { + state = 'code' + } + } + if (!inValue) { + flush() + } + return keys +} + +/** + * Option keys of the call whose argument list opens at `parenIndex`, or the + * reason the shape could not be read. Spreads and computed keys land in the + * latter: either can carry a key this would otherwise report as absent. + */ +export function readCallOptionKeys(text: string, parenIndex: number): CallOptionKeys { + const args = balancedArguments(text, parenIndex) + if (args === null) { + return { readable: false, reason: 'argument list never closes' } + } + if (!args.trim()) { + return { readable: false, reason: 'called with no options argument' } + } + const trimmed = args.trim() + if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) { + return { readable: false, reason: 'options are not an object literal' } + } + const keys = objectLiteralKeys(trimmed.slice(1, -1)) + const unreadable = keys.find((key) => !/^[A-Za-z_$][\w$]*$/.test(key)) + if (unreadable !== undefined) { + return { readable: false, reason: `unreadable option key \`${unreadable}\`` } + } + return { readable: true, keys } +} 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/package-electron-runtime-contract.test.mjs b/config/scripts/package-electron-runtime-contract.test.mjs index 9f62802c84f..950d5ed258a 100644 --- a/config/scripts/package-electron-runtime-contract.test.mjs +++ b/config/scripts/package-electron-runtime-contract.test.mjs @@ -655,6 +655,8 @@ describe('Electron runtime package contract', () => { expect(releaseWindowsRunStep.run).toContain( 'pnpm run --if-present test:e2e:windows-fresh-startup-golden' ) + expect(releaseWindowsRunStep.run).not.toContain('test:e2e:workspace-session-golden') + expect(releaseWindowsRunStep.run).not.toContain('test:e2e:source-control-golden') expect(releaseEvidenceJob['continue-on-error']).toBe(true) expect( releaseEvidenceJob.strategy.matrix.include.map(({ platform }) => platform).sort() diff --git a/config/scripts/skill-sharing-release-workflow.test.mjs b/config/scripts/skill-sharing-release-workflow.test.mjs index 2978b058305..8b72880e3bb 100644 --- a/config/scripts/skill-sharing-release-workflow.test.mjs +++ b/config/scripts/skill-sharing-release-workflow.test.mjs @@ -31,7 +31,7 @@ describe('skill-sharing release workflow', () => { expect(macBuild.needs).toContain('release-preflight') }) - it('blocks publication on native Windows, macOS, and the Linux floor', () => { + it('blocks on macOS and the Linux floor while keeping Windows diagnostic', () => { const platform = workflow.jobs['skill-sharing-release-gate'] const linux = workflow.jobs['skill-sharing-linux-floor-release-gate'] const publishNeeds = workflow.jobs['publish-release'].needs @@ -40,6 +40,7 @@ describe('skill-sharing release workflow', () => { { os: 'macos-15', platform: 'mac' }, { os: 'windows-2022', platform: 'windows' } ]) + expect(platform['continue-on-error']).toBe("${{ matrix.platform == 'windows' }}") expect(linux.container).toBe('ubuntu:20.04') expect(publishNeeds).toContain('skill-sharing-release-gate') expect(publishNeeds).toContain('skill-sharing-linux-floor-release-gate') 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/config/scripts/websocket-server-bind-scan.ts b/config/scripts/websocket-server-bind-scan.ts new file mode 100644 index 00000000000..9054f8cc38e --- /dev/null +++ b/config/scripts/websocket-server-bind-scan.ts @@ -0,0 +1,172 @@ +import { readFileSync, readdirSync } from 'node:fs' +import { join, relative } from 'node:path' +import { readCallOptionKeys } from './call-site-option-keys' + +/** + * Locate every `new WebSocketServer(...)` in the tree and say, for each, whether + * it pins a bind address. + * + * `ws` accepts `{ port }` alone and silently binds the wildcard address, so a + * server the caller then dials on 127.0.0.1 sits at a port a foreign loopback + * listener can also hold -- and the more specific listener wins the connection, + * answering in that server's place. + * + * Anything unreadable is reported as `opaque` rather than skipped. A matcher + * that silently exempts the shapes it fails to parse is worse than no matcher, + * because it reads as coverage. + */ + +export type BindSite = { path: string; line: number } +export type OpaqueSite = BindSite & { reason: string } + +export type WebSocketServerBindScan = { + filesScanned: number + /** Every construction recognized, however it was then classified. */ + constructions: number + /** Binds a port with no `host`: reachable at an address the dialer never named. */ + wildcardBound: BindSite[] + /** Shape that could not be read; never treated as safe. */ + opaque: OpaqueSite[] + /** Binds a port and pins `host`. */ + loopbackBound: BindSite[] + /** No `port`: attaches to a server that owns the bind itself. */ + attached: BindSite[] +} + +const IGNORED_DIRECTORIES = new Set([ + 'node_modules', + 'dist', + 'out', + 'build', + '.git', + '__fixtures__', + 'coverage', + // Full snapshots of older releases; their bind sites are not this tree's to fix. + '.cross-version-checkouts' +]) +const SCANNED_EXTENSIONS = /\.(?:ts|tsx|mts|cts)$/ +const SCANNED_ROOTS = ['src', 'mobile', 'config', 'tests'] +const WS_IMPORT_HINT = /from\s*['"]ws['"]/ + +function collectSourceFiles(root: string, found: string[] = []): string[] { + let entries: ReturnType> + try { + entries = readdirSync(root, { withFileTypes: true }) + } catch { + return found + } + for (const entry of entries) { + if (IGNORED_DIRECTORIES.has(entry.name)) { + continue + } + const full = join(root, entry.name) + if (entry.isDirectory()) { + collectSourceFiles(full, found) + } else if (SCANNED_EXTENSIONS.test(entry.name)) { + found.push(full) + } + } + return found +} + +/** Local names bound to ws's server class, following `as` aliases and namespace imports. */ +function webSocketServerNames(text: string): { direct: Set; namespaces: Set } { + const direct = new Set() + const namespaces = new Set() + // One statement at a time: a pattern reaching for `from 'ws'` would swallow + // every import above it and lose the specifier names in the blob. + for (const match of text.matchAll(/\bimport\b([\s\S]*?)\bfrom\s*(['"])([^'"]+)\2/g)) { + if (match[3] !== 'ws') { + continue + } + const clause = match[1] + if (/^\s*type\b/.test(clause)) { + continue + } + const namespace = clause.match(/\*\s+as\s+([A-Za-z_$][\w$]*)/) + if (namespace) { + namespaces.add(namespace[1]) + } + const named = clause.match(/\{([\s\S]*)\}/) + if (!named) { + continue + } + for (const specifier of named[1].split(',')) { + const trimmed = specifier.trim() + if (!trimmed || /^type\s/.test(trimmed)) { + continue + } + const parts = trimmed.split(/\s+as\s+/) + // `Server` is ws's own alias for WebSocketServer. + if (parts[0].trim() === 'WebSocketServer' || parts[0].trim() === 'Server') { + direct.add((parts[1] ?? parts[0]).trim()) + } + } + } + return { direct, namespaces } +} + +function classify( + scan: WebSocketServerBindScan, + site: BindSite, + text: string, + paren: number +): void { + const options = readCallOptionKeys(text, paren) + if (!options.readable) { + scan.opaque.push({ ...site, reason: options.reason }) + return + } + if (!options.keys.includes('port')) { + scan.attached.push(site) + return + } + if (!options.keys.includes('host')) { + scan.wildcardBound.push(site) + return + } + scan.loopbackBound.push(site) +} + +export function scanWebSocketServerBinds(repoRoot: string): WebSocketServerBindScan { + const files = SCANNED_ROOTS.flatMap((directory) => collectSourceFiles(join(repoRoot, directory))) + const scan: WebSocketServerBindScan = { + filesScanned: files.length, + constructions: 0, + wildcardBound: [], + opaque: [], + loopbackBound: [], + attached: [] + } + for (const file of files) { + const text = readFileSync(file, 'utf8') + // Filter on the import, not on the class name: `Server as Wss` never spells + // WebSocketServer, and keying on that name silently skipped the whole alias. + if (!WS_IMPORT_HINT.test(text)) { + continue + } + const { direct, namespaces } = webSocketServerNames(text) + if (!direct.size && !namespaces.size) { + continue + } + const path = relative(repoRoot, file).split('\\').join('/') + const patterns = [ + ...[...direct].map((name) => new RegExp(`\\bnew\\s+${name}\\s*\\(`, 'g')), + ...[...namespaces].map( + (name) => new RegExp(`\\bnew\\s+${name}\\.(?:WebSocketServer|Server)\\s*\\(`, 'g') + ) + ] + for (const pattern of patterns) { + for (const match of text.matchAll(pattern)) { + scan.constructions++ + const line = text.slice(0, match.index).split('\n').length + classify(scan, { path, line }, text, match.index + match[0].length - 1) + } + } + } + return scan +} + +export function formatSites(sites: readonly BindSite[]): string[] { + return sites.map((site) => `${site.path}:${site.line}`) +} diff --git a/config/scripts/websocket-server-loopback-bind.test.ts b/config/scripts/websocket-server-loopback-bind.test.ts new file mode 100644 index 00000000000..9f32f8eda1a --- /dev/null +++ b/config/scripts/websocket-server-loopback-bind.test.ts @@ -0,0 +1,106 @@ +import { readFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import { formatSites, scanWebSocketServerBinds } from './websocket-server-bind-scan' + +/** + * Hold the bind address at the tree level rather than per call site. + * + * Every one of the ~30 `.listen(0, ...)` calls in this repo already passes + * '127.0.0.1'; 7 of 7 `new WebSocketServer({ port })` calls did not. Authors know + * the convention -- `ws` just never asks, because `{ port }` alone binds the + * wildcard without a word. That silence is what this test replaces. + * + * The allowlist only shrinks. A new wildcard bind fails here even where it looks + * harmless today, because harmless-looking is exactly what the seven were. + */ +/** The ratchet, held as data so it reads as the list it is. */ +const WILDCARD_BIND_ALLOWLIST: readonly string[] = readFileSync( + join(__dirname, '__fixtures__', 'websocket-server-wildcard-bind-allowlist.txt'), + 'utf8' +) + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith('#')) + +/** + * The true count of constructions that bind a port without pinning a host. + * + * May only ever be DECREASED, and only by pinning a host. Raising it is never + * the fix. + */ +const WILDCARD_BIND_PIN = 1 + +/** + * A floor under the constructions the scanner still recognizes. + * + * This is the guard against the scanner going blind: an import pattern it stops + * following reports zero offenders and reads exactly like a clean tree. During + * development a single wrong regex dropped this from 24 to 3. + */ +const RECOGNIZED_CONSTRUCTION_FLOOR = 20 + +describe('WebSocketServer loopback bind boundary', () => { + const repoRoot = resolve(__dirname, '..', '..') + const scan = scanWebSocketServerBinds(repoRoot) + const offenders = scan.wildcardBound.map((site) => site.path) + + it('scans a plausible number of files', () => { + // A broken root or extension list would make the guard silently vacuous. + expect(scan.filesScanned).toBeGreaterThan(5_000) + }) + + it('still recognizes the known construction sites', () => { + expect( + scan.constructions, + `Only ${scan.constructions} WebSocketServer constructions were recognized; the floor is ` + + `${RECOGNIZED_CONSTRUCTION_FLOOR}. The scanner has probably stopped following an import ` + + 'shape rather than the tree having lost that many servers.' + ).toBeGreaterThanOrEqual(RECOGNIZED_CONSTRUCTION_FLOOR) + }) + + it('can read the options of every construction it found', () => { + // An unreadable shape is never assumed safe: it could be hiding a host, or + // hiding the absence of one. Rewrite it as a plain object literal. + expect( + scan.opaque.map((site) => `${site.path}:${site.line} -- ${site.reason}`), + 'WebSocketServer options that this guard cannot read.' + ).toEqual([]) + }) + + it('has no wildcard-bound server outside the allowlist', () => { + const unlisted = scan.wildcardBound.filter( + (site) => !WILDCARD_BIND_ALLOWLIST.includes(site.path) + ) + expect( + formatSites(unlisted), + "New WebSocketServer that binds a port without a host. Pass host: '127.0.0.1' so a foreign " + + 'loopback listener cannot claim the port and answer in its place.' + ).toEqual([]) + }) + + it('has no stale allowlist entry', () => { + // Why this direction matters too: an entry left behind after the file was + // fixed hides the next regression in that same path. + const stale = WILDCARD_BIND_ALLOWLIST.filter((path) => !offenders.includes(path)) + expect(stale, 'Allowlist entry no longer binds the wildcard — delete the line.').toEqual([]) + }) + + it('holds the wildcard-bind count at the pin', () => { + // Bounding by the allowlist's own length would prove nothing: the two move + // together, so appending a line to silence a failure would keep the bound + // satisfied. The pin is a literal so that widening takes a second edit. + expect( + scan.wildcardBound.length, + `${scan.wildcardBound.length} constructions bind the wildcard; the pin is ` + + `${WILDCARD_BIND_PIN}. Never raise the pin -- pass host: '127.0.0.1' instead.` + ).toBeLessThanOrEqual(WILDCARD_BIND_PIN) + // A pin left above reality is how a ratchet rots: it re-opens room for the + // next wildcard bind to land for free. + expect( + scan.wildcardBound.length, + `Only ${scan.wildcardBound.length} constructions bind the wildcard. Lower ` + + `WILDCARD_BIND_PIN to ${scan.wildcardBound.length} to keep the ground you just took.` + ).toBeGreaterThanOrEqual(WILDCARD_BIND_PIN) + }) +}) 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/docs/site/package.json b/docs/site/package.json index 9b5311fa04a..50ea76760fc 100644 --- a/docs/site/package.json +++ b/docs/site/package.json @@ -18,7 +18,7 @@ "fumadocs-mdx": "^14.3.1", "fumadocs-ui": "^16.8.4", "lucide-react": "^1.6.0", - "next": "16.2.1", + "next": "16.3.4", "react": "19.2.4", "react-dom": "19.2.4", "tailwind-merge": "^3.5.0", @@ -32,10 +32,10 @@ "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", - "eslint-config-next": "16.2.1", + "eslint-config-next": "16.3.4", "tailwindcss": "^4", "typescript": "^5", - "vercel": "50.37.0" + "vercel": "59.11.1" }, "engines": { "node": "22.x" @@ -46,6 +46,13 @@ "esbuild", "sharp", "unrs-resolver" - ] + ], + "overrides": { + "@vercel/fun>tar": "7.5.22", + "@vercel/fun>@tootallnate/once": "2.0.1", + "@vercel/node>undici": "5.29.0", + "@vercel/python-analysis>js-yaml": "4.3.2", + "@vercel/python-analysis>minimatch": "10.2.6" + } } } diff --git a/docs/site/pnpm-lock.yaml b/docs/site/pnpm-lock.yaml index 7cfd0ceee6f..4320f1bd617 100644 --- a/docs/site/pnpm-lock.yaml +++ b/docs/site/pnpm-lock.yaml @@ -4,6 +4,13 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + '@vercel/fun>tar': 7.5.22 + '@vercel/fun>@tootallnate/once': 2.0.1 + '@vercel/node>undici': 5.29.0 + '@vercel/python-analysis>js-yaml': 4.3.2 + '@vercel/python-analysis>minimatch': 10.2.6 + importers: .: @@ -13,19 +20,19 @@ importers: version: 2.1.1 fumadocs-core: specifier: ^16.8.4 - version: 16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.26.0(react@19.2.4))(next@16.2.1(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.3) + version: 16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.26.0(react@19.2.4))(next@16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.3) fumadocs-mdx: specifier: ^14.3.1 - version: 14.3.2(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.17)(fumadocs-core@16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.26.0(react@19.2.4))(next@16.2.1(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.3))(next@16.2.1(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) + version: 14.3.2(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.17)(fumadocs-core@16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.26.0(react@19.2.4))(next@16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.3))(next@16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) fumadocs-ui: specifier: ^16.8.4 - version: 16.12.1(@types/mdx@2.0.14)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(fumadocs-core@16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.26.0(react@19.2.4))(next@16.2.1(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.3))(next@16.2.1(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tailwindcss@4.3.3) + version: 16.12.1(@types/mdx@2.0.14)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(fumadocs-core@16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.26.0(react@19.2.4))(next@16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.3))(next@16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tailwindcss@4.3.3) lucide-react: specifier: ^1.6.0 version: 1.26.0(react@19.2.4) next: - specifier: 16.2.1 - version: 16.2.1(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: 16.3.4 + version: 16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: specifier: 19.2.4 version: 19.2.4 @@ -61,8 +68,8 @@ importers: specifier: ^9 version: 9.39.5(jiti@2.7.0) eslint-config-next: - specifier: 16.2.1 - version: 16.2.1(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + specifier: 16.3.4 + version: 16.3.4(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) tailwindcss: specifier: ^4 version: 4.3.3 @@ -70,8 +77,8 @@ importers: specifier: ^5 version: 5.9.3 vercel: - specifier: 50.37.0 - version: 50.37.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.2)(typescript@5.9.3) + specifier: 59.11.1 + version: 59.11.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) packages: @@ -175,8 +182,8 @@ packages: '@emnapi/runtime@1.10.0': resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} - '@emnapi/runtime@1.11.2': - resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} '@emnapi/wasi-threads@1.2.1': resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} @@ -499,6 +506,12 @@ packages: peerDependencies: eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + '@eslint-community/regexpp@4.12.2': resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} @@ -588,154 +601,152 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} - '@iarna/toml@2.2.5': - resolution: {integrity: sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==} - '@img/colour@1.1.0': resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} - '@img/sharp-darwin-arm64@0.34.5': - resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-darwin-arm64@0.35.4': + resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==} + engines: {node: '>=20.9.0'} cpu: [arm64] os: [darwin] - '@img/sharp-darwin-x64@0.34.5': - resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-darwin-x64@0.35.4': + resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==} + engines: {node: '>=20.9.0'} cpu: [x64] os: [darwin] - '@img/sharp-libvips-darwin-arm64@1.2.4': - resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + '@img/sharp-freebsd-wasm32@0.35.4': + resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==} + engines: {node: '>=20.9.0'} + os: [freebsd] + + '@img/sharp-libvips-darwin-arm64@1.3.3': + resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==} cpu: [arm64] os: [darwin] - '@img/sharp-libvips-darwin-x64@1.2.4': - resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + '@img/sharp-libvips-darwin-x64@1.3.3': + resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==} cpu: [x64] os: [darwin] - '@img/sharp-libvips-linux-arm64@1.2.4': - resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + '@img/sharp-libvips-linux-arm64@1.3.3': + resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==} cpu: [arm64] os: [linux] - '@img/sharp-libvips-linux-arm@1.2.4': - resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + '@img/sharp-libvips-linux-arm@1.3.3': + resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==} cpu: [arm] os: [linux] - '@img/sharp-libvips-linux-ppc64@1.2.4': - resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + '@img/sharp-libvips-linux-ppc64@1.3.3': + resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==} cpu: [ppc64] os: [linux] - '@img/sharp-libvips-linux-riscv64@1.2.4': - resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + '@img/sharp-libvips-linux-riscv64@1.3.3': + resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==} cpu: [riscv64] os: [linux] - '@img/sharp-libvips-linux-s390x@1.2.4': - resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + '@img/sharp-libvips-linux-s390x@1.3.3': + resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==} cpu: [s390x] os: [linux] - '@img/sharp-libvips-linux-x64@1.2.4': - resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + '@img/sharp-libvips-linux-x64@1.3.3': + resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==} cpu: [x64] os: [linux] - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': - resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': + resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==} cpu: [arm64] os: [linux] - '@img/sharp-libvips-linuxmusl-x64@1.2.4': - resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + '@img/sharp-libvips-linuxmusl-x64@1.3.3': + resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==} cpu: [x64] os: [linux] - '@img/sharp-linux-arm64@0.34.5': - resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-arm64@0.35.4': + resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==} + engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] - '@img/sharp-linux-arm@0.34.5': - resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-arm@0.35.4': + resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==} + engines: {node: '>=20.9.0'} cpu: [arm] os: [linux] - '@img/sharp-linux-ppc64@0.34.5': - resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-ppc64@0.35.4': + resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==} + engines: {node: '>=20.9.0'} cpu: [ppc64] os: [linux] - '@img/sharp-linux-riscv64@0.34.5': - resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-riscv64@0.35.4': + resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==} + engines: {node: '>=20.9.0'} cpu: [riscv64] os: [linux] - '@img/sharp-linux-s390x@0.34.5': - resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-s390x@0.35.4': + resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==} + engines: {node: '>=20.9.0'} cpu: [s390x] os: [linux] - '@img/sharp-linux-x64@0.34.5': - resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-x64@0.35.4': + resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==} + engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] - '@img/sharp-linuxmusl-arm64@0.34.5': - resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linuxmusl-arm64@0.35.4': + resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==} + engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] - '@img/sharp-linuxmusl-x64@0.34.5': - resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linuxmusl-x64@0.35.4': + resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==} + engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] - '@img/sharp-wasm32@0.34.5': - resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-wasm32@0.35.4': + resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.4': + resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==} + engines: {node: '>=20.9.0'} cpu: [wasm32] - '@img/sharp-win32-arm64@0.34.5': - resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-win32-arm64@0.35.4': + resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==} + engines: {node: '>=20.9.0'} cpu: [arm64] os: [win32] - '@img/sharp-win32-ia32@0.34.5': - resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-win32-ia32@0.35.4': + resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==} + engines: {node: ^20.9.0} cpu: [ia32] os: [win32] - '@img/sharp-win32-x64@0.34.5': - resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-win32-x64@0.35.4': + resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==} + engines: {node: '>=20.9.0'} cpu: [x64] os: [win32] - '@isaacs/balanced-match@4.0.1': - resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} - engines: {node: 20 || >=22} - - '@isaacs/brace-expansion@5.0.1': - resolution: {integrity: sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==} - engines: {node: 20 || >=22} - '@isaacs/fs-minipass@4.0.1': resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} engines: {node: '>=18.0.0'} @@ -764,62 +775,138 @@ packages: '@mdx-js/mdx@3.1.1': resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==} + '@napi-rs/keyring-darwin-arm64@1.2.0': + resolution: {integrity: sha512-CA83rDeyONDADO25JLZsh3eHY8yTEtm/RS6ecPsY+1v+dSawzT9GywBMu2r6uOp1IEhQs/xAfxgybGAFr17lSA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/keyring-darwin-x64@1.2.0': + resolution: {integrity: sha512-dBHjtKRCj4ByfnfqIKIJLo3wueQNJhLRyuxtX/rR4K/XtcS7VLlRD01XXizjpre54vpmObj63w+ZpHG+mGM8uA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/keyring-freebsd-x64@1.2.0': + resolution: {integrity: sha512-DPZFr11pNJSnaoh0dzSUNF+T6ORhy3CkzUT3uGixbA71cAOPJ24iG8e8QrLOkuC/StWrAku3gBnth2XMWOcR3Q==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@napi-rs/keyring-linux-arm-gnueabihf@1.2.0': + resolution: {integrity: sha512-8xv6DyEMlvRdqJzp4F39RLUmmTQsLcGYYv/3eIfZNZN1O5257tHxTrFYqAsny659rJJK2EKeSa7PhrSibQqRWQ==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/keyring-linux-arm64-gnu@1.2.0': + resolution: {integrity: sha512-Pu2V6Py+PBt7inryEecirl+t+ti8bhZphjP+W68iVaXHUxLdWmkgL9KI1VkbRHbx5k8K5Tew9OP218YfmVguIA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/keyring-linux-arm64-musl@1.2.0': + resolution: {integrity: sha512-8TDymrpC4P1a9iDEaegT7RnrkmrJN5eNZh3Im3UEV5PPYGtrb82CRxsuFohthCWQW81O483u1bu+25+XA4nKUw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/keyring-linux-riscv64-gnu@1.2.0': + resolution: {integrity: sha512-awsB5XI1MYL7fwfjMDGmKOWvNgJEO7mM7iVEMS0fO39f0kVJnOSjlu7RHcXAF0LOx+0VfF3oxbWqJmZbvRCRHw==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + + '@napi-rs/keyring-linux-x64-gnu@1.2.0': + resolution: {integrity: sha512-8E+7z4tbxSJXxIBqA+vfB1CGajpCDRyTyqXkBig5NtASrv4YXcntSo96Iah2QDR5zD3dSTsmbqJudcj9rKKuHQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/keyring-linux-x64-musl@1.2.0': + resolution: {integrity: sha512-8RZ8yVEnmWr/3BxKgBSzmgntI7lNEsY7xouNfOsQkuVAiCNmxzJwETspzK3PQ2FHtDxgz5vHQDEBVGMyM4hUHA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/keyring-win32-arm64-msvc@1.2.0': + resolution: {integrity: sha512-AoqaDZpQ6KPE19VBLpxyORcp+yWmHI9Xs9Oo0PJ4mfHma4nFSLVdhAubJCxdlNptHe5va7ghGCHj3L9Akiv4cQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/keyring-win32-ia32-msvc@1.2.0': + resolution: {integrity: sha512-EYL+EEI6bCsYi3LfwcQdnX3P/R76ENKNn+3PmpGheBsUFLuh0gQuP7aMVHM4rTw6UVe+L3vCLZSptq/oeacz0A==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@napi-rs/keyring-win32-x64-msvc@1.2.0': + resolution: {integrity: sha512-xFlx/TsmqmCwNU9v+AVnEJgoEAlBYgzFF5Ihz1rMpPAt4qQWWkMd4sCyM1gMJ1A/GnRqRegDiQpwaxGUHFtFbA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/keyring@1.2.0': + resolution: {integrity: sha512-d0d4Oyxm+v980PEq1ZH2PmS6cvpMIRc17eYpiU47KgW+lzxklMu6+HOEOPmxrpnF/XQZ0+Q78I2mgMhbIIo/dg==} + engines: {node: '>= 10'} + '@napi-rs/wasm-runtime@1.1.6': resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} peerDependencies: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 - '@next/env@16.2.1': - resolution: {integrity: sha512-n8P/HCkIWW+gVal2Z8XqXJ6aB3J0tuM29OcHpCsobWlChH/SITBs1DFBk/HajgrwDkqqBXPbuUuzgDvUekREPg==} + '@next/env@16.3.4': + resolution: {integrity: sha512-cjWZnUUa6jZq2kFaNe/ZyJdZonOZ/QoN0Zka2nz/FLOrfx14pQuM9c5RaSVkWMqgdt4ksgPAMWPyHSs/CyV48Q==} - '@next/eslint-plugin-next@16.2.1': - resolution: {integrity: sha512-r0epZGo24eT4g08jJlg2OEryBphXqO8aL18oajoTKLzHJ6jVr6P6FI58DLMug04MwD3j8Fj0YK0slyzneKVyzA==} + '@next/eslint-plugin-next@16.3.4': + resolution: {integrity: sha512-szW9y2Aumu4z88YXfTzcFsgUAg2k64uzbtcO5L9f1AKS4w/GUKJcbFllRflROVyNPgJtGOnvNxiyp3v6b+prIA==} - '@next/swc-darwin-arm64@16.2.1': - resolution: {integrity: sha512-BwZ8w8YTaSEr2HIuXLMLxIdElNMPvY9fLqb20LX9A9OMGtJilhHLbCL3ggyd0TwjmMcTxi0XXt+ur1vWUoxj2Q==} + '@next/swc-darwin-arm64@16.3.4': + resolution: {integrity: sha512-iBr3I5LZNk5/bgl5//iTgD2tcym14MX0Xo7fD//u9dYAEgGzza1y9oywluPtf74YnOswVdH1908aK9xVz7zQTw==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@16.2.1': - resolution: {integrity: sha512-/vrcE6iQSJq3uL3VGVHiXeaKbn8Es10DGTGRJnRZlkNQQk3kaNtAJg8Y6xuAlrx/6INKVjkfi5rY0iEXorZ6uA==} + '@next/swc-darwin-x64@16.3.4': + resolution: {integrity: sha512-2dpiSyl2Jw/NrBPaU2MAKGSa+2MR82pJIn4Sm5Rjr+gxAeuh0z158Su3Z2O8zn7UNNq+ej4bToed6RcRN/Lydg==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@16.2.1': - resolution: {integrity: sha512-uLn+0BK+C31LTVbQ/QU+UaVrV0rRSJQ8RfniQAHPghDdgE+SlroYqcmFnO5iNjNfVWCyKZHYrs3Nl0mUzWxbBw==} + '@next/swc-linux-arm64-gnu@16.3.4': + resolution: {integrity: sha512-+t+U8HZT+fApePCS5h89CSH3datz29MkzyfCn+6fpsZBG/oiEOhINcb9rtkv6sdpToLGFn2e6146NzaKCXkqrA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-arm64-musl@16.2.1': - resolution: {integrity: sha512-ssKq6iMRnHdnycGp9hCuGnXJZ0YPr4/wNwrfE5DbmvEcgl9+yv97/Kq3TPVDfYome1SW5geciLB9aiEqKXQjlQ==} + '@next/swc-linux-arm64-musl@16.3.4': + resolution: {integrity: sha512-mx03GNs1ocQA5JQ4FxDMmIsNkdrZh8cuezKCrId28e5/gIPU/l7Kcy2+vmCCzdjnnmXJy+iOAu+7K0QppO6Urg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-x64-gnu@16.2.1': - resolution: {integrity: sha512-HQm7SrHRELJ30T1TSmT706IWovFFSRGxfgUkyWJZF/RKBMdbdRWJuFrcpDdE5vy9UXjFOx6L3mRdqH04Mmx0hg==} + '@next/swc-linux-x64-gnu@16.3.4': + resolution: {integrity: sha512-YIhGY6fSMfha52bnVxnzc9zaVBzJg+cqQTOD8tXIBSx4fuv0pVMxQTE0PaS59YhnMOiYiG09IMwxJAf/CFm/Dw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-linux-x64-musl@16.2.1': - resolution: {integrity: sha512-aV2iUaC/5HGEpbBkE+4B8aHIudoOy5DYekAKOMSHoIYQ66y/wIVeaRx8MS2ZMdxe/HIXlMho4ubdZs/J8441Tg==} + '@next/swc-linux-x64-musl@16.3.4': + resolution: {integrity: sha512-+eaaX6axpDb0yF1GCpiERe6njplvdC+nks/fKfcHu3XPGRrald8P3/X7yv7QLdjA51knnxwl9pxdIJsg+w1L+Q==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-win32-arm64-msvc@16.2.1': - resolution: {integrity: sha512-IXdNgiDHaSk0ZUJ+xp0OQTdTgnpx1RCfRTalhn3cjOP+IddTMINwA7DXZrwTmGDO8SUr5q2hdP/du4DcrB1GxA==} + '@next/swc-win32-arm64-msvc@16.3.4': + resolution: {integrity: sha512-0jcXW7Xs/uzICrmgV3MhDYDeRy++1CqnpDIerlPIqYO4bhzB4WNbX/aRnQclustsAyTkFKB0z6rbcjmNg5tR8A==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@16.2.1': - resolution: {integrity: sha512-qvU+3a39Hay+ieIztkGSbF7+mccbbg1Tk25hc4JDylf8IHjYmY/Zm64Qq1602yPyQqvie+vf5T/uPwNxDNIoeg==} + '@next/swc-win32-x64-msvc@16.3.4': + resolution: {integrity: sha512-vvBzwu1pYQCp92maZCFCIw/XgOTMR5tur9GjakwIo2cmwRTMKajRZZDS9+e4KsUZWKu1E007WUeAFXRRjZeuzw==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -844,9 +931,131 @@ packages: resolution: {integrity: sha512-a61ljmRVVyG5MC/698C8/FfFDw5a8LOIvyOLW5fztgUXqUpc1jOfQzOitSCbge657OgXXThmY3Tk8fpiDb4UcA==} engines: {node: '>= 20.0.0'} + '@oxc-parser/binding-android-arm-eabi@0.121.0': + resolution: {integrity: sha512-n07FQcySwOlzap424/PLMtOkbS7xOu8nsJduKL8P3COGHKgKoDYXwoAHCbChfgFpHnviehrLWIPX0lKGtbEk/A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxc-parser/binding-android-arm64@0.121.0': + resolution: {integrity: sha512-/Dd1xIXboYAicw+twT2utxPD7bL8qh7d3ej0qvaYIMj3/EgIrGR+tSnjCUkiCT6g6uTC0neSS4JY8LxhdSU/sA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxc-parser/binding-darwin-arm64@0.121.0': + resolution: {integrity: sha512-A0jNEvv7QMtCO1yk205t3DWU9sWUjQ2KNF0hSVO5W9R9r/R1BIvzG01UQAfmtC0dQm7sCrs5puixurKSfr2bRQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxc-parser/binding-darwin-x64@0.121.0': + resolution: {integrity: sha512-SsHzipdxTKUs3I9EOAPmnIimEeJOemqRlRDOp9LIj+96wtxZejF51gNibmoGq8KoqbT1ssAI5po/E3J+vEtXGA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxc-parser/binding-freebsd-x64@0.121.0': + resolution: {integrity: sha512-v1APOTkCp+RWOIDAHRoaeW/UoaHF15a60E8eUL6kUQXh+i4K7PBwq2Wi7jm8p0ymID5/m/oC1w3W31Z/+r7HQw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxc-parser/binding-linux-arm-gnueabihf@0.121.0': + resolution: {integrity: sha512-PmqPQuqHZyFVWA4ycr0eu4VnTMmq9laOHZd+8R359w6kzuNZPvmmunmNJ8ybkm769A0nCoVp3TJ6dUz7B3FYIQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm-musleabihf@0.121.0': + resolution: {integrity: sha512-vF24htj+MOH+Q7y9A8NuC6pUZu8t/C2Fr/kDOi2OcNf28oogr2xadBPXAbml802E8wRAVfbta6YLDQTearz+jw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm64-gnu@0.121.0': + resolution: {integrity: sha512-wjH8cIG2Lu/3d64iZpbYr73hREMgKAfu7fqpXjgM2S16y2zhTfDIp8EQjxO8vlDtKP5Rc7waZW72lh8nZtWrpA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxc-parser/binding-linux-arm64-musl@0.121.0': + resolution: {integrity: sha512-qT663J/W8yQFw3dtscbEi9LKJevr20V7uWs2MPGTnvNZ3rm8anhhE16gXGpxDOHeg9raySaSHKhd4IGa3YZvuw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxc-parser/binding-linux-ppc64-gnu@0.121.0': + resolution: {integrity: sha512-mYNe4NhVvDBbPkAP8JaVS8lC1dsoJZWH5WCjpw5E+sjhk1R08wt3NnXYUzum7tIiWPfgQxbCMcoxgeemFASbRw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@oxc-parser/binding-linux-riscv64-gnu@0.121.0': + resolution: {integrity: sha512-+QiFoGxhAbaI/amqX567784cDyyuZIpinBrJNxUzb+/L2aBRX67mN6Jv40pqduHf15yYByI+K5gUEygCuv0z9w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxc-parser/binding-linux-riscv64-musl@0.121.0': + resolution: {integrity: sha512-9ykEgyTa5JD/Uhv2sttbKnCfl2PieUfOjyxJC/oDL2UO0qtXOtjPLl7H8Kaj5G7p3hIvFgu3YWvAxvE0sqY+hQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxc-parser/binding-linux-s390x-gnu@0.121.0': + resolution: {integrity: sha512-DB1EW5VHZdc1lIRjOI3bW/wV6R6y0xlfvdVrqj6kKi7Ayu2U3UqUBdq9KviVkcUGd5Oq+dROqvUEEFRXGAM7EQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@oxc-parser/binding-linux-x64-gnu@0.121.0': + resolution: {integrity: sha512-s4lfobX9p4kPTclvMiH3gcQUd88VlnkMTF6n2MTMDAyX5FPNRhhRSFZK05Ykhf8Zy5NibV4PbGR6DnK7FGNN6A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxc-parser/binding-linux-x64-musl@0.121.0': + resolution: {integrity: sha512-P9KlyTpuBuMi3NRGpJO8MicuGZfOoqZVRP1WjOecwx8yk4L/+mrCRNc5egSi0byhuReblBF2oVoDSMgV9Bj4Hw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxc-parser/binding-openharmony-arm64@0.121.0': + resolution: {integrity: sha512-R+4jrWOfF2OAPPhj3Eb3U5CaKNAH9/btMveMULIrcNW/hjfysFQlF8wE0GaVBr81dWz8JLgQlsxwctoL78JwXw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxc-parser/binding-wasm32-wasi@0.121.0': + resolution: {integrity: sha512-5TFISkPTymKvsmIlKasPVTPuWxzCcrT8pM+p77+mtQbIZDd1UC8zww4CJcRI46kolmgrEX6QpKO8AvWMVZ+ifw==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@oxc-parser/binding-win32-arm64-msvc@0.121.0': + resolution: {integrity: sha512-V0pxh4mql4XTt3aiEtRNUeBAUFOw5jzZNxPABLaOKAWrVzSr9+XUaB095lY7jqMf5t8vkfh8NManGB28zanYKw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxc-parser/binding-win32-ia32-msvc@0.121.0': + resolution: {integrity: sha512-4Ob1qvYMPnlF2N9rdmKdkQFdrq16QVcQwBsO8yiPZXof0fHKFF+LmQV501XFbi7lHyrKm8rlJRfQ/M8bZZPVLw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxc-parser/binding-win32-x64-msvc@0.121.0': + resolution: {integrity: sha512-BOp1KCzdboB1tPqoCPXgntgFs0jjeSyOXHzgxVFR7B/qfr3F8r4YDacHkTOUNXtDgM8YwKnkf3rE5gwALYX7NA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@oxc-project/types@0.110.0': resolution: {integrity: sha512-6Ct21OIlrEnFEJk5LT4e63pk3btsI6/TusD/GStLi7wYlGJNOl1GI9qvXAnRAxQU9zqA2Oz+UwhfTOU2rPZVow==} + '@oxc-project/types@0.121.0': + resolution: {integrity: sha512-CGtOARQb9tyv7ECgdAlFxi0Fv7lmzvmlm2rpD/RdijOO9rfk/JvB1CjT8EnoD+tjna/IYgKKw3IV7objRb+aYw==} + '@oxc-transform/binding-android-arm-eabi@0.111.0': resolution: {integrity: sha512-NdFLicvorfHYu0g2ftjVJaH7+Dz27AQUNJOq8t/ofRUoWmczOodgUCHx8C1M1htCN4ZmhS/FzfSy6yd/UngJGg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1455,8 +1664,8 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@swc/helpers@0.5.15': - resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + '@swc/helpers@0.5.23': + resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} '@tailwindcss/node@4.3.3': resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==} @@ -1546,13 +1755,10 @@ packages: '@tailwindcss/postcss@4.3.3': resolution: {integrity: sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==} - '@tootallnate/once@2.0.0': - resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} + '@tootallnate/once@2.0.1': + resolution: {integrity: sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==} engines: {node: '>= 10'} - '@tootallnate/quickjs-emscripten@0.23.0': - resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==} - '@ts-morph/common@0.11.1': resolution: {integrity: sha512-7hWZS0NRpEsNV8vWJzg7FEz6V8MaLNeJOmwmghqUXTpzk16V1LLZhdo+4QvE/+zv4cVci0OviuJFnqhEfoV3+g==} @@ -1778,105 +1984,186 @@ packages: cpu: [x64] os: [win32] - '@vercel/backends@0.0.51': - resolution: {integrity: sha512-rGuyw79vubB9VyXhb5eMvm3uNe7D3avDHNm4zXbF99m54346KNOvRp0jJ39rBgh6I4wQ1SUal/vUYcs+nDI3DQ==} + '@vercel/backends@7.0.0': + resolution: {integrity: sha512-he5zmmtKzzaoizZhPXHxd9bOge3pOL90uz33b9IzmGnImWBlkSOPtGQr5r+NX2ad8HS5HWBdUchwAEcnxYz66w==} peerDependencies: - typescript: ^4.0.0 || ^5.0.0 + '@vercel/build-utils': 14.9.0 - '@vercel/blob@2.3.0': - resolution: {integrity: sha512-oYWiJbWRQ7gz9Mj0X/NHFJ3OcLMOBzq/2b3j6zeNrQmtFo6dHwU8FAwNpxVIYddVMd+g8eqEi7iRueYx8FtM0Q==} + '@vercel/blob@2.8.0': + resolution: {integrity: sha512-Nu+HWKpkgovCh/ezlG7wCVwF7RErTzLzZMbGKFBdGBCbTKyK+s5VXPLl+0+TpNEQPH8AVaGzOpIsXUOtkqylCQ==} engines: {node: '>=20.0.0'} - '@vercel/build-utils@13.10.0': - resolution: {integrity: sha512-i+fF1EvEzZhrifP1qUYklTmrUTmndPfsvWGLsv9TaDm5WqX8VOp8irUAhOwc5gc5RHEOs4Ox0GtiPhJ7oZ59cw==} + '@vercel/build-utils@14.9.0': + resolution: {integrity: sha512-czxOQSyZgFYZoD72gRSkRSokGghDyh5pMBPiuy0bjq2I4t7vjiENF1FN0NI/em5S/ITh2hKN1kzXHFwQy9jg6g==} - '@vercel/cervel@0.0.38': - resolution: {integrity: sha512-1/802XtFsfOmZH7hNTQqlMv/8rSNwTOkJPY0uU1CgXk7yYMG9nq2uOTF1eumx+0TwMc8cO9X9azOi35lGP++yw==} + '@vercel/cervel@0.1.59': + resolution: {integrity: sha512-zFpD1AWHher/SYpwJAZTnw9ncf3qVdPWGG0fTUz0dTlO7nkdV67zXLej46y+PrbML1LtHXMqBQRauM9Y8uH+wg==} hasBin: true - peerDependencies: - typescript: ^4.0.0 || ^5.0.0 - '@vercel/detect-agent@1.2.1': - resolution: {integrity: sha512-U/BJCltQSTFTHwaiCQQTQG3GonTbRoEewjV+OU2mMjcHLAoPOh6CP1SXA2XNmqiqI3c82nkRNJ7piZ14RqmTXw==} + '@vercel/cli-auth@0.3.5': + resolution: {integrity: sha512-DSkTWamJrhgCUrymOSCWysbiVeLsvPINhoKVTw+mypoyIJxKQxDgQaafvZ0OYGS2qg8wVUY30HgDugzaEdwo6Q==} + + '@vercel/cli-config@0.2.4': + resolution: {integrity: sha512-kZ5SojbrV06GHoU6QIWGwDXLov+s9rWZ7QqdqKfJfBGCNUieGfgaCjeeenNy8Y+QC0bwC0dZ2B4l5Hvdmrgpdw==} + + '@vercel/cli-exec@1.0.1': + resolution: {integrity: sha512-g9XerViJ/paZujufXYcu5XYI2vU2rtB4sgdpjUHde5RnOkdmpu0ngH46LCFGHoPXO/C+qDPSczIHIRN+8Q2YKQ==} + engines: {node: '>= 18'} + + '@vercel/container@7.0.0': + resolution: {integrity: sha512-VhQAJv8j6/ufedWYAbwjJ94p3R0MfNYiJmamr68NeFVGJlv6sFNm1SJb1Rzl+6udK44BcQp3M64qKFO4ARNLFA==} + peerDependencies: + '@vercel/build-utils': 14.9.0 + + '@vercel/detect-agent@1.2.5': + resolution: {integrity: sha512-0krENrjuitlW8s6TJu0MlqCevyCU7K7JK63jZAf7xZ6n17tx+vUEwzHT3sTxawtwZxaW21hu+oFUpoOrm49FsQ==} engines: {node: '>=14'} - '@vercel/elysia@0.1.53': - resolution: {integrity: sha512-SdQP06NbODpTOBc6NRV9y/5vpV4wfBZFpWZiJ8oUj5F9POPR7tz+FempLi/YfTv5OuYiA76K3mugNFFEOtpyrg==} + '@vercel/elysia@7.0.0': + resolution: {integrity: sha512-EZgu9HXQVf1af7sElg3tws+0TTT/k1DgPyDftJnXfk1L3W8M31pqbBY3a6I1hcnWxNaNceF2VtSWMPNrmsTOVQ==} + peerDependencies: + '@vercel/build-utils': 14.9.0 - '@vercel/error-utils@2.0.3': - resolution: {integrity: sha512-CqC01WZxbLUxoiVdh9B/poPbNpY9U+tO1N9oWHwTl5YAZxcqXmmWJ8KNMFItJCUUWdY3J3xv8LvAuQv2KZ5YdQ==} + '@vercel/error-utils@2.2.1': + resolution: {integrity: sha512-9DhP8jP7raLML4hGsBemxX5fXuQnu5xxMV+HjGygGbzEmVK/+KyJ3QP2Cw7PdF0uXdb9N0Qa4c3tRGH34ZX6vw==} - '@vercel/express@0.1.63': - resolution: {integrity: sha512-hUQk+rVo+26NH8DAqXrxiC9j2ajvMlz6iwY+KQYFwHxh9mI+NmeBRD8ELJdl54Z5tEeec8BQ/9giKmbu+NpzkA==} + '@vercel/express@7.0.0': + resolution: {integrity: sha512-dhGOtQk3HJXQBeFyfiDP0/nIWqwzrvN02rr/tF8zEk45Z6xqLlSkvH47lVPlvVQdoPy93MOlwKA7/gYapi1chg==} + peerDependencies: + '@vercel/build-utils': 14.9.0 - '@vercel/fastify@0.1.56': - resolution: {integrity: sha512-4LXxtieB+UVPLeGgw0q48g4PX+uquBIJPVlgs1ABySI5X4IVJXm3KTAABuaZ0mJ/yAryIc4FXD3pYzU7hSCfQg==} + '@vercel/fastify@7.0.0': + resolution: {integrity: sha512-tcB2pd0DdQls884nS70bkZgdrYRFceST1zZO/073o1iJG0VVoFcIPuDdePGlIIetegGJ+S68Wc47gr4TU/UQcQ==} + peerDependencies: + '@vercel/build-utils': 14.9.0 '@vercel/fun@1.3.0': resolution: {integrity: sha512-8erw9uPe0dFg45THkNxmjtvMX143SkZebmjgSVbcM3XCkXu3RIiBaJMcMNG8aaS+rnTuw8+d4De9HVT0M/r3wg==} engines: {node: '>= 18'} - '@vercel/gatsby-plugin-vercel-analytics@1.0.11': - resolution: {integrity: sha512-iTEA0vY6RBPuEzkwUTVzSHDATo1aF6bdLLspI68mQ/BTbi5UQEGjpjyzdKOVcSYApDtFU6M6vypZ1t4vIEnHvw==} + '@vercel/gatsby-plugin-vercel-analytics@1.0.12': + resolution: {integrity: sha512-Ejlhwxr7EBYJxtwYnlh6Vm6A2dPsUSPxMdYrfv0koZkgAzVwo7cG4aDQiy7iASMaGzwuI/PAnwlY2sJBF5b4OA==} - '@vercel/gatsby-plugin-vercel-builder@2.1.4': - resolution: {integrity: sha512-PONQs8DAc/P/tWGHGluDWE4aD0WfjDkod9sr8ksJJUpvkhanPpTQAFj8CTCbnr9Tu/9FWa1ZdwB4Q2+pXCWTEA==} + '@vercel/gatsby-plugin-vercel-builder@2.2.52': + resolution: {integrity: sha512-PYl9TBsYsP0a7qB4kgrF0a5YBUt7caiZwrxd4dl/uPdWHZlogTrnw2Cf2GS/kgij4rsew5jhc9xMnE4eHvQ1zg==} - '@vercel/go@3.4.6': - resolution: {integrity: sha512-ig91qRY+f4lLXWoRvnhEvu5DcT7LXtALBo/jJqxvlyOsHRBP4mdAvUgg9sygu2NOeMSV/cy249yJqQniP32HWw==} + '@vercel/go@10.0.0': + resolution: {integrity: sha512-G2tHOrb64snuckAdfq+OjMqE8fKIB4rq3FpbmaQ1vjvnyK/PqyWnvX9gCoIigThM49P0RkGQVp76DCt1RFGh4Q==} + peerDependencies: + '@vercel/build-utils': 14.9.0 - '@vercel/h3@0.1.62': - resolution: {integrity: sha512-0gzAyyf3rZf6ZG0ZTE33TczpX5ioR3dF8p4i78wVH+xEmaQ4u1o8yvVMK49CcMoQ1qjJVdpwQfI41BAfKQk7ig==} + '@vercel/h3@7.0.0': + resolution: {integrity: sha512-GYhMAK/XgDAQmgXSaiokz2kjc8QeE4azNg9aajcAm2q16Yd+t4a+VZutHppfk4I5SaLf5sKAGhGB8hkCHkbTPA==} + peerDependencies: + '@vercel/build-utils': 14.9.0 - '@vercel/hono@0.2.56': - resolution: {integrity: sha512-fB7zOx9B+eYzUDBmwZHqKn3kVQ2XYUjTdMQb1+FxWmSKr5T/uIRuX8ayWnD1lT5Bmrt5534nB6wVfZRljmWWzA==} + '@vercel/hono@7.0.0': + resolution: {integrity: sha512-Bn+illFuXukoI0l2z9Y75IS0c8mnLwy0zf5D0AIC2vegjPy5PnoYZ8c7mnEh88On/2ahMR3KEfocWnDssbpGHQ==} + peerDependencies: + '@vercel/build-utils': 14.9.0 - '@vercel/hydrogen@1.3.6': - resolution: {integrity: sha512-Ec8dKEjGIM4BfThcRLtQs5zaJ4+iJbgLZwkytwi7Blk8VrK6W2F1dtLDmVQYZdVnQcnmHmTx8mxUuMkfP06Mnw==} + '@vercel/hydrogen@8.0.0': + resolution: {integrity: sha512-Z2CKkTKn2SsqD5ftXXizPJ2HCmUoWvvEL9RnSd/eup1IPpOMK42MjIGyh+aPiiwzOwcPmoANPCmMufqrUOjHzw==} + peerDependencies: + '@vercel/build-utils': 14.9.0 - '@vercel/koa@0.1.36': - resolution: {integrity: sha512-ht1w0Qjrb9uMGbfz7aFabs1CCOG24XH5tLYN+sb4sYKHdioKusbpa4u9O76WrAwJXtnyHc48hJE4eyb6lCTndQ==} + '@vercel/koa@7.0.0': + resolution: {integrity: sha512-UgfQVMc2+hjfoMrGmAFhHmGIf1uDwNL+3Y2B+Ad9Gn4D2wz2gn2Pl5ywy23VOmfSD/aTerbLigTWKT+y9fb9Tg==} + peerDependencies: + '@vercel/build-utils': 14.9.0 - '@vercel/nestjs@0.2.57': - resolution: {integrity: sha512-qXE4Z0BZTj4KEpDJtZp0/2x1aOWf97tUHoatCG+ovTRLIdtSLhyZUFDcoqv/T/WZ4MbO24JqX2sqLmuiTUxIow==} + '@vercel/nestjs@7.0.0': + resolution: {integrity: sha512-suD7cuigAQlLA/RLAly8234gXXgIC/XG7AGBa1h2Y4vuIwGP43EBHZ4IuojdN6YCKwbD4/2eUMRa/qBs9OpsNQ==} + peerDependencies: + '@vercel/build-utils': 14.9.0 - '@vercel/next@4.16.3': - resolution: {integrity: sha512-yVLrMxMI+Taq47C94lWVUf981WerJ+COrJbgltHcMY8HDdXdgthYe06+na3dni31AL6BYShS8VLpE54QsAdM9Q==} + '@vercel/next@11.0.0': + resolution: {integrity: sha512-eTaJA+6iKLfhRwOl44mAuNj35jnuA5uExpDvy8vN+oWW0F5Ycjc2yoLHffbUO+9x3SWmo9CV5hpkGKfSSrJakg==} + peerDependencies: + '@vercel/build-utils': 14.9.0 - '@vercel/nft@1.5.0': - resolution: {integrity: sha512-IWTDeIoWhQ7ZtRO/JRKH+jhmeQvZYhtGPmzw/QGDY+wDCQqfm25P9yIdoAFagu4fWsK4IwZXDFIjrmp5rRm/sA==} + '@vercel/nft@1.10.0': + resolution: {integrity: sha512-iLOW4fcsgkipfOh2Bw3wB38YDfxTlxr7+j4uFeui2OswkNT28jIitS/aMce7tS0mef1YPQ8zLIDYr3a0aahNrA==} engines: {node: '>=20'} hasBin: true - '@vercel/node@5.6.20': - resolution: {integrity: sha512-n9ZMFzuRauAQNhwVvmsS/prG0We7RyDP2j/tPQoxcnOq5vP1DK6A+r7dG46sRTytgxVBgVJtU3486RAOvhOgcg==} + '@vercel/node@12.0.0': + resolution: {integrity: sha512-F3tbqSdN1Nap1zeZcdfScatAVFUrLLYXNBsHf9wutOMyOjuW7M32NEEp1eXhjHXdrIg9SyChooqvJPmt3iepSw==} + peerDependencies: + '@vercel/build-utils': 14.9.0 - '@vercel/prepare-flags-definitions@0.2.1': - resolution: {integrity: sha512-ouXTsqn7I9xZ1KKezgvn/w3tZeQHL/tc52j9GHiOYi6kT8xgdbT8s2x8C9BQr44iceX0hfhtZwk9q7NuI2Tqbw==} + '@vercel/oidc@3.2.0': + resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==} + engines: {node: '>= 20'} - '@vercel/python-analysis@0.11.0': - resolution: {integrity: sha512-gsoj+nscmNm0xDh+tRhECRhit2VlAVaD7jc9h93sN6rDEBDxPo7eLEgIJFzVDaAItxERZ9Od2IK/04fB9vFy+g==} + '@vercel/oidc@3.8.5': + resolution: {integrity: sha512-RwXYtnt6za+5UO4IaLywN/6B95AlLqynPRUWRJxeJ/qufwkcLUbZNUxYtzT0uMpuraWhlNcGqPNGkTnZr4BGBw==} + engines: {node: '>= 20'} - '@vercel/python@6.28.0': - resolution: {integrity: sha512-/Ley7HPz/AXhxO7unK5+4hEtsMUxXa02SJ8Tiaz//nEtRQMUcVREIyAUkOOfecjiCpg2hMHSVyCtABFjhzAbgA==} + '@vercel/prepare-flags-definitions@0.3.0': + resolution: {integrity: sha512-/0nuDFwYje0nqZnVKSd2VfJy2wOPQwbkas1qO1JQgtb0sLl+EeSCW4O9hrvq55pN50PNlAZ/APSeWHIAT9ZGHg==} - '@vercel/redwood@2.4.12': - resolution: {integrity: sha512-8kJ7eEerI4iMpKVRxQCsnxiIwRVsWtyirEbYb4erCqqsJymTu/xrjhsfAUuzeP8qkuYGP82MJVK+hpKlFtsjGw==} + '@vercel/python-analysis@0.14.0': + resolution: {integrity: sha512-qyVxbaU14gAi/AsaR8syZLukUsOp69Jkm1xn80rZPy4k9+zRUTIfqs0AOk7L8wwBxDDB6LLjcBUAmafhbJQnUQ==} - '@vercel/remix-builder@5.7.2': - resolution: {integrity: sha512-bfStsDBQramYbWugelfyp1szTuDOLQ+ZELvgA9cpYc4FucgCrDy/bpvMMvsjiDACB9PoDzLrG//ZBgsic9yAhg==} + '@vercel/python@13.0.0': + resolution: {integrity: sha512-KUqi9G5jx26m10kbpzgd8q2jGlijgX4aawH5aD2J8T7pu4q0dZGTw0DlczdrSFnEDYd8yM3zghGjXjtvcrKeaQ==} + peerDependencies: + '@vercel/build-utils': 14.9.0 - '@vercel/ruby@2.3.2': - resolution: {integrity: sha512-okIgMmPEePyDR9TZYaKM4oftcxVHM5Dbdl7V/tIdh3lq8MGLi7HR5vvQglmZUwZOeovE6MVtezxl960EOzeIiQ==} + '@vercel/redwood@9.0.0': + resolution: {integrity: sha512-y4YdEsqjGVHFcLTPKZWppTvcgXbq9edxxXl+KBEJvtJpqY7s4ZjFd+BzF8YF6KTC7x18rYOiugBSrbyLyctRtQ==} + peerDependencies: + '@vercel/build-utils': 14.9.0 - '@vercel/rust@1.0.5': - resolution: {integrity: sha512-Y03g59nv1uT6Da+PvB/50WqJSHlaFZ9MSkG00R82dUcTySslMbQdOeaXymZtabrmU8zQYhWDb1/CwBki8sWnaQ==} + '@vercel/remix-builder@12.0.0': + resolution: {integrity: sha512-SQqmOQSQn44Migiu/xglGZ2EjlE4YfEEi8GpOHfN2iVKZhGqyeA5spTzVuxWAHjxeYbaOarHW7qM/giaFc0e2A==} + peerDependencies: + '@vercel/build-utils': 14.9.0 - '@vercel/static-build@2.9.4': - resolution: {integrity: sha512-TqObjKTlr9nGkOzAUFweshKdbqOFKj8hS1xE499A7wYqlZWcORfcn+Yqe9ifXi628KA9+K+sLPaXJN/D4krr7A==} + '@vercel/ruby@9.0.0': + resolution: {integrity: sha512-kTmJZWkZpSEcK1t0FuM1Py1PTLDTgGbmhwXt1B1Tv/ZpJU2UfLK9rxzNUZemy2jdxSRtFWL+D/Ur0j1sF1W7Hg==} + peerDependencies: + '@vercel/build-utils': 14.9.0 - '@vercel/static-config@3.2.0': - resolution: {integrity: sha512-UpOEIgWxWx0M+mDe1IMdHS6JuWM/L5nNIJ4ixX8v9JgBAejymo88OkgnmfLCNMem0Wd+b5vcQPWLdZybCndlsA==} + '@vercel/rust@8.0.0': + resolution: {integrity: sha512-Ut16g8BZkwedJ8NN+LlPZPbiy4sC4efYYl2f440A6dg7obW5t4xjt9M1wTkooIgT0w2/j/FVfDdmQNJGfpPCgA==} + peerDependencies: + '@vercel/build-utils': 14.9.0 + + '@vercel/sandbox@3.1.0': + resolution: {integrity: sha512-z124E4rsmNpwGtTnLImiYzEXk972bWniQyhlvkEt35UtwKTdfBAFXcNG2OvqYqwzAsdQaP3B7teQ2pqA9B5Viw==} + + '@vercel/static-build@9.0.0': + resolution: {integrity: sha512-JrYaWxWmq83vQofB669VTp9egqoJN4wn3JgduvgCj3mNsdRj7R2wvyMPdGJWNm6t5Jsq3YMikik9VVmxZpctRQ==} + peerDependencies: + '@vercel/build-utils': 14.9.0 + + '@vercel/static-config@3.4.3': + resolution: {integrity: sha512-BY1sL8rNJvIm3TK/8TQ+Q6jIx7NpO5xckQzv7s6c1ypHvRnTl+vf6rmgY5WFXmoYDeGm3tMy4jiy/5M/5jGr/g==} + + '@vercel/vc-native-darwin-arm64@59.11.1': + resolution: {integrity: sha512-Lpxu0C2h3HThwfa7hrQXtzvy0uMAg2XAgMAyS0xwe10LQ5+OiX3nNFQCb4sGKhCBHvtiHv14iSCZRgQo1HtNFQ==} + cpu: [arm64] + os: [darwin] + + '@vercel/vc-native-darwin-x64@59.11.1': + resolution: {integrity: sha512-aAmQaFTC37ZNFWwf+WZ+zSzCnbKXR6UkQC77Zx2sbc/yGsjraBzf44Nza9fL1lLfVsIYs+wixmLKhvig1AQ06A==} + cpu: [x64] + os: [darwin] + + '@vercel/vc-native-linux-arm64@59.11.1': + resolution: {integrity: sha512-npU8GcrkwqyOotX2txj5TRIC9gof7uFr1Lp5fhzlw7qLoZNTzP/uNu//erNsSi4/LjIGBAD0YAnWVv6XkvlT1Q==} + cpu: [arm64] + os: [linux] + + '@vercel/vc-native-linux-x64@59.11.1': + resolution: {integrity: sha512-CV0nod07WyVceW9KucckRZSi4AVuQYz/T+lKjSmnNOFXz9TPi6i0X1R8ObcDfSzi0o6kgVhUGZS2+DXDiUTHqQ==} + cpu: [x64] + os: [linux] + + '@workflow/serde@4.1.0-beta.2': + resolution: {integrity: sha512-8kkeoQKLDaKXefjV5dbhBj2aErfKp1Mc4pb6tj8144cF+Em5SPbyMbyLCHp+BVrFfFVCBluCtMx+jjvaFVZGww==} abbrev@3.0.1: resolution: {integrity: sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==} @@ -1963,10 +2250,6 @@ packages: ast-types-flow@0.0.8: resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} - ast-types@0.13.4: - resolution: {integrity: sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==} - engines: {node: '>=4'} - astring@1.9.0: resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==} hasBin: true @@ -1992,9 +2275,6 @@ packages: async-sema@3.1.1: resolution: {integrity: sha512-tLRNUXati5MFePdAk8dw7Qt7DpxPB60ofAgn8WRhW6a2rcimZnYBP9oxHiv0OHy+Wz7kPMG+t4LGdt31+4EmGg==} - asynckit@0.4.0: - resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - available-typed-arrays@1.0.7: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} @@ -2007,6 +2287,14 @@ packages: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} + b4a@1.8.1: + resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==} + peerDependencies: + react-native-b4a: '*' + peerDependenciesMeta: + react-native-b4a: + optional: true + bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} @@ -2017,15 +2305,19 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + bare-events@2.9.2: + resolution: {integrity: sha512-AIPKioV7/Y/8KfZ3AAhjPJxLLbY49S64Ym5DakZlUg75qQiTgUq9hEJoEwa4eUezPUlXRy/i5NpsKvo9jgKmoA==} + peerDependencies: + bare-abort-controller: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + baseline-browser-mapping@2.11.1: resolution: {integrity: sha512-HYXq73DDpCtNzOmrFsm9eSwCvWCql0RzqjpDzXN9EadiLJ4DNat0nsZ/Bzmy+Ud12mb4/zKDY0cQ805ZzN+i0A==} engines: {node: '>=6.0.0'} hasBin: true - basic-ftp@5.3.1: - resolution: {integrity: sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==} - engines: {node: '>=10.0.0'} - bindings@1.5.0: resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} @@ -2132,10 +2424,6 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - combined-stream@1.0.8: - resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} - engines: {node: '>= 0.8'} - comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} @@ -2160,9 +2448,6 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - cookie-es@2.0.1: - resolution: {integrity: sha512-aVf4A4hI2w70LnF7GG+7xDQUkliwiXWXFvTjkip4+b64ygDQ2sJPRSKFDHbxn8o0xu9QzPkMuuiWIXyFSE2slA==} - cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -2173,10 +2458,6 @@ packages: damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} - data-uri-to-buffer@6.0.2: - resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==} - engines: {node: '>= 14'} - data-view-buffer@1.0.2: resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} engines: {node: '>= 0.4'} @@ -2225,18 +2506,14 @@ packages: resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} engines: {node: '>= 0.4'} + define-lazy-prop@2.0.0: + resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==} + engines: {node: '>=8'} + define-properties@1.2.1: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} - degenerator@5.0.1: - resolution: {integrity: sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==} - engines: {node: '>= 14'} - - delayed-stream@1.0.0: - resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} - engines: {node: '>=0.4.0'} - depd@1.1.2: resolution: {integrity: sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==} engines: {node: '>= 0.6'} @@ -2311,6 +2588,9 @@ packages: es-module-lexer@1.4.1: resolution: {integrity: sha512-cXLGjP0c4T3flZJKQSuziYoq7MlT+rnvfZjfp7h+I7K9BNX54kP9nyWvdbwjQ4u1iWbOL4u96fgeZLToQlZC7w==} + es-module-lexer@1.5.0: + resolution: {integrity: sha512-pqrTKmwEIgafsYZAGw9kszYzmagcE/n4dbgwGWLEXg7J4QFJVQRBld8j3Q3GNez79jzxZshq0bcT962QHOghjw==} + es-object-atoms@1.1.2: resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} engines: {node: '>= 0.4'} @@ -2355,13 +2635,8 @@ packages: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} - escodegen@2.1.0: - resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==} - engines: {node: '>=6.0'} - hasBin: true - - eslint-config-next@16.2.1: - resolution: {integrity: sha512-qhabwjQZ1Mk53XzXvmogf8KQ0tG0CQXF0CZ56+2/lVhmObgmaqj7x5A1DSrWdZd3kwI7GTPGUjFne+krRxYmFg==} + eslint-config-next@16.3.4: + resolution: {integrity: sha512-35/8RM10huEL9vlr8hUZMERMENHBrnyHN3ZZkF9efSgzGaqK34jIqry44A956//zriUhUAUW0XSkcolhrryqAA==} peerDependencies: eslint: '>=9.0.0' typescript: '>=3.3.1' @@ -2464,11 +2739,6 @@ packages: resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - esprima@4.0.1: - resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} - engines: {node: '>=4'} - hasBin: true - esquery@1.7.0: resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} engines: {node: '>=0.10'} @@ -2519,6 +2789,9 @@ packages: events-intercept@2.0.0: resolution: {integrity: sha512-blk1va0zol9QOrdZt0rFXo5KMkNPVSp92Eju/Qz8THwKWKRKeE0T8Br/1aW6+Edkyq9xHYgYxn2QtOnUKPUp+Q==} + events-universal@1.0.1: + resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + execa@3.2.0: resolution: {integrity: sha512-kJJfVbI/lZE1PZYDI5VPxp8zXPO9rtxOkhpZ0jMKha56AI9y2gGVC6bkukStQf0ka5Rh15BA5m7cCCH4jmHqkw==} engines: {node: ^8.12.0 || >=9.7.0} @@ -2533,6 +2806,9 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + fast-glob@3.3.1: resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==} engines: {node: '>=8.6.0'} @@ -2588,10 +2864,6 @@ packages: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} - form-data@4.0.6: - resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} - engines: {node: '>= 6'} - framer-motion@12.42.2: resolution: {integrity: sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw==} peerDependencies: @@ -2756,6 +3028,10 @@ packages: resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} engines: {node: '>=6'} + get-port@5.1.1: + resolution: {integrity: sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==} + engines: {node: '>=8'} + get-proto@1.0.1: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} @@ -2775,10 +3051,6 @@ packages: get-tsconfig@4.14.0: resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} - get-uri@6.0.5: - resolution: {integrity: sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==} - engines: {node: '>= 14'} - github-slugger@2.0.0: resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} @@ -2880,10 +3152,6 @@ packages: resolution: {integrity: sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw==} engines: {node: '>= 0.6'} - http-proxy-agent@7.0.2: - resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} - engines: {node: '>= 14'} - https-proxy-agent@7.0.6: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} @@ -2926,10 +3194,6 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} - ip-address@10.2.0: - resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} - engines: {node: '>= 12'} - is-alphabetical@2.0.1: resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} @@ -2978,6 +3242,11 @@ packages: is-decimal@2.0.1: resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + is-docker@2.2.1: + resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} + engines: {node: '>=8'} + hasBin: true + is-document.all@1.0.0: resolution: {integrity: sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==} engines: {node: '>= 0.4'} @@ -3064,6 +3333,10 @@ packages: resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} engines: {node: '>= 0.4'} + is-wsl@2.2.0: + resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} + engines: {node: '>=8'} + isarray@2.0.5: resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} @@ -3081,15 +3354,14 @@ packages: jose@5.9.6: resolution: {integrity: sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ==} + jose@6.2.3: + resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@4.1.1: - resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} - hasBin: true - - js-yaml@4.3.0: - resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} + js-yaml@4.3.2: + resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} hasBin: true jsesc@3.1.0: @@ -3121,9 +3393,15 @@ packages: engines: {node: '>=6'} hasBin: true + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + jsonfile@6.2.1: resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + jsonlines@0.1.1: + resolution: {integrity: sha512-ekDrAGso79Cvf+dtm+mL8OBI2bmAOt3gssYs833De/C9NmIpWDWyUO4zPgB5x2/OhY366dkhgfPMYfwZF7yOZA==} + jsx-ast-utils@3.3.5: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} @@ -3226,6 +3504,9 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.5.2: resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} engines: {node: 20 || >=22} @@ -3237,10 +3518,6 @@ packages: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} engines: {node: '>=10'} - lru-cache@7.18.3: - resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} - engines: {node: '>=12'} - lucide-react@1.26.0: resolution: {integrity: sha512-raglYVR2+VkMfJL158krjVmE+rV5ST2lzA/KQm1FRSjMHT4MnWaegHxoVEpmc2So3nOEhp9oGejJwAPX8MoAjg==} peerDependencies: @@ -3445,12 +3722,8 @@ packages: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} - minimatch@10.1.1: - resolution: {integrity: sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==} - engines: {node: 20 || >=22} - - minimatch@10.2.5: - resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} engines: {node: 18 || 20 || >=22} minimatch@3.1.5: @@ -3505,8 +3778,8 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - nanoid@3.3.16: - resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -3518,18 +3791,14 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - netmask@2.1.1: - resolution: {integrity: sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==} - engines: {node: '>= 0.4.0'} - next-themes@0.4.6: resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} peerDependencies: react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc - next@16.2.1: - resolution: {integrity: sha512-VaChzNL7o9rbfdt60HUj8tev4m6d7iC1igAy157526+cJlXOQu5LzsBXNT+xaJnTP/k+utSX5vMv7m0G+zKH+Q==} + next@16.3.4: + resolution: {integrity: sha512-/Ztf6CeRH+ejEXUrYtqI4gkS66eFIHuSwqi60RgcpWKodxFZx2/dqVCMKBwILfAHXQ+F1b1vAudgj3mnxqtoIA==} engines: {node: '>=20.9.0'} hasBin: true peerDependencies: @@ -3649,6 +3918,10 @@ packages: oniguruma-to-es@4.3.6: resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==} + open@8.4.0: + resolution: {integrity: sha512-XgFPPM+B28FtCCgSb9I+s9szOC1vZRSwgWsRUA5ylIxRTgKozqjOCrVOqGsYABPYK5qnfqClxZTFBa8PKt2v6Q==} + engines: {node: '>=12'} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -3661,6 +3934,10 @@ packages: resolution: {integrity: sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==} engines: {node: '>= 0.4'} + oxc-parser@0.121.0: + resolution: {integrity: sha512-ek9o58+SCv6AV7nchiAcUJy1DNE2CC5WRdBcO0mF+W4oRjNQfPO7b3pLjTHSFECpHkKGOZSQxx3hk8viIL5YCg==} + engines: {node: ^20.19.0 || >=22.12.0} + oxc-transform@0.111.0: resolution: {integrity: sha512-oa5KKSDNLHZGaiqIGAbCWXeN9IJUAz9MElWcQX90epDxdKc9Hrt/BsLj3K4gDqfAYa5dwdH+ZCFJG9hR74fiGg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3677,14 +3954,6 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} - pac-proxy-agent@7.2.0: - resolution: {integrity: sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==} - engines: {node: '>= 14'} - - pac-resolver@7.0.1: - resolution: {integrity: sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==} - engines: {node: '>= 14'} - parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -3751,12 +4020,12 @@ packages: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} - postcss@8.4.31: - resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} + postcss@8.5.23: + resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==} engines: {node: ^10 || ^12 || >=14} - postcss@8.5.22: - resolution: {integrity: sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==} + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} engines: {node: ^10 || ^12 || >=14} prelude-ls@1.2.1: @@ -3776,13 +4045,6 @@ packages: property-information@7.2.0: resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} - proxy-agent@6.4.0: - resolution: {integrity: sha512-u0piLU+nCOHMgGjRbimiXmA9kM/L9EHh3zL81xCdp7m+Y2pHIsnmbdDoEDoAz5geaonNR6q6+yOPQs6n4T6sBQ==} - engines: {node: '>= 14'} - - proxy-from-env@1.1.0: - resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} - pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} @@ -3957,6 +4219,10 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + sandbox@4.1.0: + resolution: {integrity: sha512-kzDiAyvrGHGdrQ/7mT6Md18K9OUVgZW/KUKO/wBJ/gHouDh6oJPWcGWfOV5i7CSep2map3Pl7vV9gszm3Cvu7Q==} + hasBin: true + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -3992,9 +4258,14 @@ packages: setprototypeof@1.1.1: resolution: {integrity: sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==} - sharp@0.34.5: - resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + sharp@0.35.4: + resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==} + engines: {node: '>=20.9.0'} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} @@ -4031,30 +4302,14 @@ packages: resolution: {integrity: sha512-MY2/qGx4enyjprQnFaZsHib3Yadh3IXyV2C321GY0pjGfVBu4un0uDJkwgdxqO+Rdx8JMT8IfJIRwbYVz3Ob3Q==} engines: {node: '>=14'} - smart-buffer@4.2.0: - resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} - engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} - smol-toml@1.5.2: resolution: {integrity: sha512-QlaZEqcAH3/RtNyet1IPIYPsEWAaYyXXv1Krsi+1L/QHppjX4Ifm8MQsBISz9vE8cHicIq3clogsheili5vhaQ==} engines: {node: '>= 18'} - socks-proxy-agent@8.0.5: - resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==} - engines: {node: '>= 14'} - - socks@2.8.9: - resolution: {integrity: sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==} - engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} - source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} - source-map@0.6.1: - resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} - engines: {node: '>=0.10.0'} - source-map@0.7.6: resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} engines: {node: '>= 12'} @@ -4062,8 +4317,8 @@ packages: space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} - srvx@0.8.9: - resolution: {integrity: sha512-wYc3VLZHRzwYrWJhkEqkhLb31TI0SOkfYZDkUhXdp3NoCnNS0FqajiQszZZjfow/VYEuc6Q5sZh9nM6kPy2NBQ==} + srvx@0.11.16: + resolution: {integrity: sha512-bp07zRuycfTY43IjAvvTFnmnJi8ikW0VFiHwOhhYcVW/L4xQ1XY4PAd4Nuum1rsA17C39zL7x+CDhrn5AL32Rw==} engines: {node: '>=20.16.0'} hasBin: true @@ -4088,6 +4343,9 @@ packages: resolution: {integrity: sha512-HAGUASw8NT0k8JvIVutB2Y/9iBk7gpgEyAudXwNJmZERdMITGdajOa4VJfD/kNiA3TppQpTP4J+CtcHwdzKBAw==} deprecated: Deprecated. Use node:stream/promises and node:stream/consumers instead. + streamx@2.28.1: + resolution: {integrity: sha512-zEzXb0s5Cds7tqMH6rhZ05lcJydCWiQPEwiNngVqzsxCc962vLY4Uw+mW7od8kDH258k2Uz/JrOkdIAAhSh9VA==} + string.prototype.includes@2.0.1: resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==} engines: {node: '>= 0.4'} @@ -4163,14 +4421,15 @@ packages: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} - tar@7.5.21: - resolution: {integrity: sha512-XdhtCvlMywwxpCW8YEq3lOXBJpUPTR2OHHcwLPO3HwsJqOHa2Ok/oJ7ruGzp+JrKoRPVCzJwAdEjqLW/vNRPHA==} + tar-stream@3.1.7: + resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==} + + tar@7.5.22: + resolution: {integrity: sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==} engines: {node: '>=18'} - tar@7.5.7: - resolution: {integrity: sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==} - engines: {node: '>=18'} - deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + text-decoder@1.2.7: + resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} throttleit@2.1.0: resolution: {integrity: sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==} @@ -4283,14 +4542,18 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - undici@5.28.4: - resolution: {integrity: sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g==} + undici@5.29.0: + resolution: {integrity: sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==} engines: {node: '>=14.0'} - undici@6.27.0: - resolution: {integrity: sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==} + undici@6.28.0: + resolution: {integrity: sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==} engines: {node: '>=18.17'} + undici@7.29.0: + resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==} + engines: {node: '>=20.18.1'} + unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} @@ -4355,8 +4618,12 @@ packages: '@types/react': optional: true - vercel@50.37.0: - resolution: {integrity: sha512-GVeZ/5vw1dZXw2S3aEVmo05BbfspKc+gb3+h6hTz0Dm1IW3lCpAI7A/FXor8XDMHy64PhOjYCwpEu6Qnr3Cz+Q==} + uuid@14.0.1: + resolution: {integrity: sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==} + hasBin: true + + vercel@59.11.1: + resolution: {integrity: sha512-vGmxxo6NcqfMcHMJ2rtTsOLTdYjb/Jp49eAMh/zpTvvDS7BCqQdCLpeMTxxZ+5yezQWDKvYstzueRicEuNfubg==} engines: {node: '>= 18'} hasBin: true @@ -4409,6 +4676,18 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + xdg-app-paths@5.1.0: resolution: {integrity: sha512-RAQ3WkPf4KTU1A8RtFx3gWywzVKe00tfOPFfl2NDGqbIFENQO4kqAJp7mhQjNj/33W5x5hiWWUdyfPq/5SU3QA==} engines: {node: '>=6'} @@ -4456,6 +4735,9 @@ packages: zod@3.22.4: resolution: {integrity: sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==} + zod@4.1.11: + resolution: {integrity: sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==} + zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} @@ -4591,7 +4873,7 @@ snapshots: tslib: 2.8.1 optional: true - '@emnapi/runtime@1.11.2': + '@emnapi/runtime@1.11.3': dependencies: tslib: 2.8.1 optional: true @@ -4762,6 +5044,11 @@ snapshots: eslint: 9.39.5(jiti@2.7.0) eslint-visitor-keys: 3.4.3 + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.5(jiti@2.7.0))': + dependencies: + eslint: 9.39.5(jiti@2.7.0) + eslint-visitor-keys: 3.4.3 + '@eslint-community/regexpp@4.12.2': {} '@eslint/config-array@0.21.2': @@ -4788,7 +5075,7 @@ snapshots: globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.1 - js-yaml: 4.3.0 + js-yaml: 4.3.2 minimatch: 3.1.5 strip-json-comments: 3.1.1 transitivePeerDependencies: @@ -4849,110 +5136,112 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} - '@iarna/toml@2.2.5': {} - '@img/colour@1.1.0': optional: true - '@img/sharp-darwin-arm64@0.34.5': + '@img/sharp-darwin-arm64@0.35.4': optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-arm64': 1.3.3 optional: true - '@img/sharp-darwin-x64@0.34.5': + '@img/sharp-darwin-x64@0.35.4': optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.3.3 optional: true - '@img/sharp-libvips-darwin-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-darwin-x64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-arm@1.2.4': - optional: true - - '@img/sharp-libvips-linux-ppc64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-riscv64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-s390x@1.2.4': - optional: true - - '@img/sharp-libvips-linux-x64@1.2.4': - optional: true - - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-linuxmusl-x64@1.2.4': - optional: true - - '@img/sharp-linux-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.2.4 - optional: true - - '@img/sharp-linux-arm@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.2.4 - optional: true - - '@img/sharp-linux-ppc64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.2.4 - optional: true - - '@img/sharp-linux-riscv64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-riscv64': 1.2.4 - optional: true - - '@img/sharp-linux-s390x@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.2.4 - optional: true - - '@img/sharp-linux-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.2.4 - optional: true - - '@img/sharp-linuxmusl-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 - optional: true - - '@img/sharp-linuxmusl-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 - optional: true - - '@img/sharp-wasm32@0.34.5': + '@img/sharp-freebsd-wasm32@0.35.4': dependencies: - '@emnapi/runtime': 1.11.2 + '@img/sharp-wasm32': 0.35.4 optional: true - '@img/sharp-win32-arm64@0.34.5': + '@img/sharp-libvips-darwin-arm64@1.3.3': optional: true - '@img/sharp-win32-ia32@0.34.5': + '@img/sharp-libvips-darwin-x64@1.3.3': optional: true - '@img/sharp-win32-x64@0.34.5': + '@img/sharp-libvips-linux-arm64@1.3.3': optional: true - '@isaacs/balanced-match@4.0.1': {} + '@img/sharp-libvips-linux-arm@1.3.3': + optional: true - '@isaacs/brace-expansion@5.0.1': + '@img/sharp-libvips-linux-ppc64@1.3.3': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.3.3': + optional: true + + '@img/sharp-libvips-linux-s390x@1.3.3': + optional: true + + '@img/sharp-libvips-linux-x64@1.3.3': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.3.3': + optional: true + + '@img/sharp-linux-arm64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.3.3 + optional: true + + '@img/sharp-linux-arm@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.3.3 + optional: true + + '@img/sharp-linux-ppc64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.3.3 + optional: true + + '@img/sharp-linux-riscv64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.3 + optional: true + + '@img/sharp-linux-s390x@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.3.3 + optional: true + + '@img/sharp-linux-x64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.3.3 + optional: true + + '@img/sharp-linuxmusl-arm64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 + optional: true + + '@img/sharp-linuxmusl-x64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 + optional: true + + '@img/sharp-wasm32@0.35.4': dependencies: - '@isaacs/balanced-match': 4.0.1 + '@emnapi/runtime': 1.11.3 + optional: true + + '@img/sharp-webcontainers-wasm32@0.35.4': + dependencies: + '@img/sharp-wasm32': 0.35.4 + optional: true + + '@img/sharp-win32-arm64@0.35.4': + optional: true + + '@img/sharp-win32-ia32@0.35.4': + optional: true + + '@img/sharp-win32-x64@0.35.4': + optional: true '@isaacs/fs-minipass@4.0.1': dependencies: @@ -4985,7 +5274,7 @@ snapshots: node-fetch: 2.7.0 nopt: 8.1.0 semver: 7.8.5 - tar: 7.5.21 + tar: 7.5.22 transitivePeerDependencies: - encoding - supports-color @@ -5020,6 +5309,57 @@ snapshots: transitivePeerDependencies: - supports-color + '@napi-rs/keyring-darwin-arm64@1.2.0': + optional: true + + '@napi-rs/keyring-darwin-x64@1.2.0': + optional: true + + '@napi-rs/keyring-freebsd-x64@1.2.0': + optional: true + + '@napi-rs/keyring-linux-arm-gnueabihf@1.2.0': + optional: true + + '@napi-rs/keyring-linux-arm64-gnu@1.2.0': + optional: true + + '@napi-rs/keyring-linux-arm64-musl@1.2.0': + optional: true + + '@napi-rs/keyring-linux-riscv64-gnu@1.2.0': + optional: true + + '@napi-rs/keyring-linux-x64-gnu@1.2.0': + optional: true + + '@napi-rs/keyring-linux-x64-musl@1.2.0': + optional: true + + '@napi-rs/keyring-win32-arm64-msvc@1.2.0': + optional: true + + '@napi-rs/keyring-win32-ia32-msvc@1.2.0': + optional: true + + '@napi-rs/keyring-win32-x64-msvc@1.2.0': + optional: true + + '@napi-rs/keyring@1.2.0': + optionalDependencies: + '@napi-rs/keyring-darwin-arm64': 1.2.0 + '@napi-rs/keyring-darwin-x64': 1.2.0 + '@napi-rs/keyring-freebsd-x64': 1.2.0 + '@napi-rs/keyring-linux-arm-gnueabihf': 1.2.0 + '@napi-rs/keyring-linux-arm64-gnu': 1.2.0 + '@napi-rs/keyring-linux-arm64-musl': 1.2.0 + '@napi-rs/keyring-linux-riscv64-gnu': 1.2.0 + '@napi-rs/keyring-linux-x64-gnu': 1.2.0 + '@napi-rs/keyring-linux-x64-musl': 1.2.0 + '@napi-rs/keyring-win32-arm64-msvc': 1.2.0 + '@napi-rs/keyring-win32-ia32-msvc': 1.2.0 + '@napi-rs/keyring-win32-x64-msvc': 1.2.0 + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 @@ -5027,41 +5367,44 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true - '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.2)': + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)': dependencies: '@emnapi/core': 1.10.0 - '@emnapi/runtime': 1.11.2 + '@emnapi/runtime': 1.11.3 '@tybys/wasm-util': 0.10.3 optional: true - '@next/env@16.2.1': {} + '@next/env@16.3.4': {} - '@next/eslint-plugin-next@16.2.1': + '@next/eslint-plugin-next@16.3.4(eslint@9.39.5(jiti@2.7.0))': dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.5(jiti@2.7.0)) fast-glob: 3.3.1 + transitivePeerDependencies: + - eslint - '@next/swc-darwin-arm64@16.2.1': + '@next/swc-darwin-arm64@16.3.4': optional: true - '@next/swc-darwin-x64@16.2.1': + '@next/swc-darwin-x64@16.3.4': optional: true - '@next/swc-linux-arm64-gnu@16.2.1': + '@next/swc-linux-arm64-gnu@16.3.4': optional: true - '@next/swc-linux-arm64-musl@16.2.1': + '@next/swc-linux-arm64-musl@16.3.4': optional: true - '@next/swc-linux-x64-gnu@16.2.1': + '@next/swc-linux-x64-gnu@16.3.4': optional: true - '@next/swc-linux-x64-musl@16.2.1': + '@next/swc-linux-x64-musl@16.3.4': optional: true - '@next/swc-win32-arm64-msvc@16.2.1': + '@next/swc-win32-arm64-msvc@16.3.4': optional: true - '@next/swc-win32-x64-msvc@16.2.1': + '@next/swc-win32-x64-msvc@16.3.4': optional: true '@nodelib/fs.scandir@2.1.5': @@ -5080,8 +5423,75 @@ snapshots: '@orama/orama@3.1.18': {} + '@oxc-parser/binding-android-arm-eabi@0.121.0': + optional: true + + '@oxc-parser/binding-android-arm64@0.121.0': + optional: true + + '@oxc-parser/binding-darwin-arm64@0.121.0': + optional: true + + '@oxc-parser/binding-darwin-x64@0.121.0': + optional: true + + '@oxc-parser/binding-freebsd-x64@0.121.0': + optional: true + + '@oxc-parser/binding-linux-arm-gnueabihf@0.121.0': + optional: true + + '@oxc-parser/binding-linux-arm-musleabihf@0.121.0': + optional: true + + '@oxc-parser/binding-linux-arm64-gnu@0.121.0': + optional: true + + '@oxc-parser/binding-linux-arm64-musl@0.121.0': + optional: true + + '@oxc-parser/binding-linux-ppc64-gnu@0.121.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-gnu@0.121.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-musl@0.121.0': + optional: true + + '@oxc-parser/binding-linux-s390x-gnu@0.121.0': + optional: true + + '@oxc-parser/binding-linux-x64-gnu@0.121.0': + optional: true + + '@oxc-parser/binding-linux-x64-musl@0.121.0': + optional: true + + '@oxc-parser/binding-openharmony-arm64@0.121.0': + optional: true + + '@oxc-parser/binding-wasm32-wasi@0.121.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)': + dependencies: + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + optional: true + + '@oxc-parser/binding-win32-arm64-msvc@0.121.0': + optional: true + + '@oxc-parser/binding-win32-ia32-msvc@0.121.0': + optional: true + + '@oxc-parser/binding-win32-x64-msvc@0.121.0': + optional: true + '@oxc-project/types@0.110.0': {} + '@oxc-project/types@0.121.0': {} + '@oxc-transform/binding-android-arm-eabi@0.111.0': optional: true @@ -5130,9 +5540,9 @@ snapshots: '@oxc-transform/binding-openharmony-arm64@0.111.0': optional: true - '@oxc-transform/binding-wasm32-wasi@0.111.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.2)': + '@oxc-transform/binding-wasm32-wasi@0.111.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)': dependencies: - '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.2) + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' @@ -5532,9 +5942,9 @@ snapshots: '@rolldown/binding-openharmony-arm64@1.0.0-rc.1': optional: true - '@rolldown/binding-wasm32-wasi@1.0.0-rc.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.2)': + '@rolldown/binding-wasm32-wasi@1.0.0-rc.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)': dependencies: - '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.2) + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' @@ -5600,7 +6010,7 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@swc/helpers@0.5.15': + '@swc/helpers@0.5.23': dependencies: tslib: 2.8.1 @@ -5670,12 +6080,10 @@ snapshots: '@alloc/quick-lru': 5.2.0 '@tailwindcss/node': 4.3.3 '@tailwindcss/oxide': 4.3.3 - postcss: 8.5.22 + postcss: 8.5.26 tailwindcss: 4.3.3 - '@tootallnate/once@2.0.0': {} - - '@tootallnate/quickjs-emscripten@0.23.0': {} + '@tootallnate/once@2.0.1': {} '@ts-morph/common@0.11.1': dependencies: @@ -5802,7 +6210,7 @@ snapshots: '@typescript-eslint/types': 8.65.0 '@typescript-eslint/visitor-keys': 8.65.0 debug: 4.4.3 - minimatch: 10.2.5 + minimatch: 10.2.6 semver: 7.8.5 tinyglobby: 0.2.17 ts-api-utils: 2.5.0(typescript@5.9.3) @@ -5898,19 +6306,21 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.12.2': optional: true - '@vercel/backends@0.0.51(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.2)(typescript@5.9.3)': + '@vercel/backends@7.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0)': dependencies: - '@vercel/build-utils': 13.10.0 - '@vercel/nft': 1.5.0 + '@vercel/build-utils': 14.9.0 + '@vercel/nft': 1.10.0 + '@vercel/static-config': 3.4.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) execa: 3.2.0 fs-extra: 11.1.0 - oxc-transform: 0.111.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.2) + get-port: 5.1.1 + oxc-transform: 0.111.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) path-to-regexp: 8.3.0 resolve.exports: 2.0.3 - rolldown: 1.0.0-rc.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.2) - srvx: 0.8.9 + rolldown: 1.0.0-rc.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) + srvx: 0.11.16 + ts-morph: 12.0.0 tsx: 4.21.0 - typescript: 5.9.3 zod: 3.22.4 transitivePeerDependencies: - '@emnapi/core' @@ -5919,22 +6329,59 @@ snapshots: - rollup - supports-color - '@vercel/blob@2.3.0': + '@vercel/blob@2.8.0': dependencies: + '@vercel/oidc': 3.8.5 async-retry: 1.3.3 is-buffer: 2.0.5 is-node-process: 1.2.0 throttleit: 2.1.0 - undici: 6.27.0 + undici: 6.28.0 - '@vercel/build-utils@13.10.0': + '@vercel/build-utils@14.9.0': dependencies: - '@vercel/python-analysis': 0.11.0 + cjs-module-lexer: 1.2.3 + es-module-lexer: 1.5.0 - '@vercel/cervel@0.0.38(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.2)(typescript@5.9.3)': + '@vercel/cervel@0.1.59(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0)': dependencies: - '@vercel/backends': 0.0.51(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.2)(typescript@5.9.3) - typescript: 5.9.3 + '@vercel/backends': 7.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + - '@vercel/build-utils' + - encoding + - rollup + - supports-color + + '@vercel/cli-auth@0.3.5': + dependencies: + '@napi-rs/keyring': 1.2.0 + '@vercel/cli-config': 0.2.4 + async-listen: 3.0.0 + open: 8.4.0 + zod: 4.1.11 + + '@vercel/cli-config@0.2.4': + dependencies: + xdg-app-paths: 5.1.0 + zod: 4.1.11 + + '@vercel/cli-exec@1.0.1': + dependencies: + execa: 5.1.1 + + '@vercel/container@7.0.0(@vercel/build-utils@14.9.0)': + dependencies: + '@vercel/build-utils': 14.9.0 + + '@vercel/detect-agent@1.2.5': {} + + '@vercel/elysia@7.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0)': + dependencies: + '@vercel/build-utils': 14.9.0 + '@vercel/node': 12.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0) + '@vercel/static-config': 3.4.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' @@ -5942,25 +6389,15 @@ snapshots: - rollup - supports-color - '@vercel/detect-agent@1.2.1': {} + '@vercel/error-utils@2.2.1': {} - '@vercel/elysia@0.1.53': + '@vercel/express@7.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0)': dependencies: - '@vercel/node': 5.6.20 - '@vercel/static-config': 3.2.0 - transitivePeerDependencies: - - encoding - - rollup - - supports-color - - '@vercel/error-utils@2.0.3': {} - - '@vercel/express@0.1.63(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.2)(typescript@5.9.3)': - dependencies: - '@vercel/cervel': 0.0.38(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.2)(typescript@5.9.3) - '@vercel/nft': 1.5.0 - '@vercel/node': 5.6.20 - '@vercel/static-config': 3.2.0 + '@vercel/build-utils': 14.9.0 + '@vercel/cervel': 0.1.59(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0) + '@vercel/nft': 1.10.0 + '@vercel/node': 12.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0) + '@vercel/static-config': 3.4.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) fs-extra: 11.1.0 path-to-regexp: 8.3.0 ts-morph: 12.0.0 @@ -5971,20 +6408,22 @@ snapshots: - encoding - rollup - supports-color - - typescript - '@vercel/fastify@0.1.56': + '@vercel/fastify@7.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0)': dependencies: - '@vercel/node': 5.6.20 - '@vercel/static-config': 3.2.0 + '@vercel/build-utils': 14.9.0 + '@vercel/node': 12.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0) + '@vercel/static-config': 3.4.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' - encoding - rollup - supports-color '@vercel/fun@1.3.0': dependencies: - '@tootallnate/once': 2.0.0 + '@tootallnate/once': 2.0.1 async-listen: 1.2.0 debug: 4.3.4 generic-pool: 3.4.2 @@ -5996,7 +6435,7 @@ snapshots: semver: 7.5.4 stat-mode: 0.3.0 stream-to-promise: 2.2.0 - tar: 7.5.7 + tar: 7.5.22 tinyexec: 0.3.2 tree-kill: 1.2.2 uid-promise: 1.0.0 @@ -6006,75 +6445,94 @@ snapshots: - encoding - supports-color - '@vercel/gatsby-plugin-vercel-analytics@1.0.11': + '@vercel/gatsby-plugin-vercel-analytics@1.0.12': dependencies: web-vitals: 0.2.4 - '@vercel/gatsby-plugin-vercel-builder@2.1.4': + '@vercel/gatsby-plugin-vercel-builder@2.2.52': dependencies: '@sinclair/typebox': 0.25.24 - '@vercel/build-utils': 13.10.0 + '@vercel/build-utils': 14.9.0 esbuild: 0.27.0 etag: 1.8.1 fs-extra: 11.1.0 - '@vercel/go@3.4.6': {} - - '@vercel/h3@0.1.62': + '@vercel/go@10.0.0(@vercel/build-utils@14.9.0)': dependencies: - '@vercel/node': 5.6.20 - '@vercel/static-config': 3.2.0 + '@vercel/build-utils': 14.9.0 + + '@vercel/h3@7.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0)': + dependencies: + '@vercel/build-utils': 14.9.0 + '@vercel/node': 12.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0) + '@vercel/static-config': 3.4.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' - encoding - rollup - supports-color - '@vercel/hono@0.2.56': + '@vercel/hono@7.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0)': dependencies: - '@vercel/nft': 1.5.0 - '@vercel/node': 5.6.20 - '@vercel/static-config': 3.2.0 + '@vercel/build-utils': 14.9.0 + '@vercel/nft': 1.10.0 + '@vercel/node': 12.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0) + '@vercel/static-config': 3.4.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) fs-extra: 11.1.0 path-to-regexp: 8.3.0 ts-morph: 12.0.0 zod: 3.22.4 transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' - encoding - rollup - supports-color - '@vercel/hydrogen@1.3.6': + '@vercel/hydrogen@8.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0)': dependencies: - '@vercel/static-config': 3.2.0 + '@vercel/build-utils': 14.9.0 + '@vercel/static-config': 3.4.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) ts-morph: 12.0.0 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' - '@vercel/koa@0.1.36': + '@vercel/koa@7.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0)': dependencies: - '@vercel/node': 5.6.20 - '@vercel/static-config': 3.2.0 + '@vercel/build-utils': 14.9.0 + '@vercel/node': 12.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0) + '@vercel/static-config': 3.4.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + - encoding + - rollup + - supports-color + + '@vercel/nestjs@7.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0)': + dependencies: + '@vercel/build-utils': 14.9.0 + '@vercel/node': 12.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0) + '@vercel/static-config': 3.4.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + - encoding + - rollup + - supports-color + + '@vercel/next@11.0.0(@vercel/build-utils@14.9.0)': + dependencies: + '@vercel/build-utils': 14.9.0 + '@vercel/nft': 1.10.0 transitivePeerDependencies: - encoding - rollup - supports-color - '@vercel/nestjs@0.2.57': - dependencies: - '@vercel/node': 5.6.20 - '@vercel/static-config': 3.2.0 - transitivePeerDependencies: - - encoding - - rollup - - supports-color - - '@vercel/next@4.16.3': - dependencies: - '@vercel/nft': 1.5.0 - transitivePeerDependencies: - - encoding - - rollup - - supports-color - - '@vercel/nft@1.5.0': + '@vercel/nft@1.10.0': dependencies: '@mapbox/node-pre-gyp': 2.0.3 '@rollup/pluginutils': 5.4.0 @@ -6093,16 +6551,16 @@ snapshots: - rollup - supports-color - '@vercel/node@5.6.20': + '@vercel/node@12.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0)': dependencies: '@edge-runtime/node-utils': 2.3.0 '@edge-runtime/primitives': 4.1.0 '@edge-runtime/vm': 3.2.0 '@types/node': 20.11.0 - '@vercel/build-utils': 13.10.0 - '@vercel/error-utils': 2.0.3 - '@vercel/nft': 1.5.0 - '@vercel/static-config': 3.2.0 + '@vercel/build-utils': 14.9.0 + '@vercel/error-utils': 2.2.1 + '@vercel/nft': 1.10.0 + '@vercel/static-config': 3.4.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) async-listen: 3.0.0 cjs-module-lexer: 1.2.3 edge-runtime: 2.5.9 @@ -6116,71 +6574,132 @@ snapshots: ts-morph: 12.0.0 tsx: 4.21.0 typescript: 5.9.3 - undici: 5.28.4 + undici: 5.29.0 transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' - encoding - rollup - supports-color - '@vercel/prepare-flags-definitions@0.2.1': {} + '@vercel/oidc@3.2.0': {} - '@vercel/python-analysis@0.11.0': + '@vercel/oidc@3.8.5': + dependencies: + '@vercel/cli-config': 0.2.4 + '@vercel/cli-exec': 1.0.1 + jose: 5.9.6 + + '@vercel/prepare-flags-definitions@0.3.0': {} + + '@vercel/python-analysis@0.14.0': dependencies: '@bytecodealliance/preview2-shim': 0.17.6 '@renovatebot/pep440': 4.2.1 fs-extra: 11.1.1 - js-yaml: 4.1.1 - minimatch: 10.1.1 + js-yaml: 4.3.2 + minimatch: 10.2.6 smol-toml: 1.5.2 zod: 3.22.4 - '@vercel/python@6.28.0': + '@vercel/python@13.0.0(@vercel/build-utils@14.9.0)': dependencies: - '@vercel/python-analysis': 0.11.0 + '@vercel/build-utils': 14.9.0 + '@vercel/python-analysis': 0.14.0 - '@vercel/redwood@2.4.12': + '@vercel/redwood@9.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0)': dependencies: - '@vercel/nft': 1.5.0 - '@vercel/static-config': 3.2.0 + '@vercel/build-utils': 14.9.0 + '@vercel/nft': 1.10.0 + '@vercel/static-config': 3.4.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) semver: 6.3.1 ts-morph: 12.0.0 transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' - encoding - rollup - supports-color - '@vercel/remix-builder@5.7.2': + '@vercel/remix-builder@12.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0)': dependencies: - '@vercel/error-utils': 2.0.3 - '@vercel/nft': 1.5.0 - '@vercel/static-config': 3.2.0 + '@vercel/build-utils': 14.9.0 + '@vercel/error-utils': 2.2.1 + '@vercel/nft': 1.10.0 + '@vercel/static-config': 3.4.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) path-to-regexp: 6.1.0 path-to-regexp-updated: path-to-regexp@6.3.0 ts-morph: 12.0.0 transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' - encoding - rollup - supports-color - '@vercel/ruby@2.3.2': {} - - '@vercel/rust@1.0.5': + '@vercel/ruby@9.0.0(@vercel/build-utils@14.9.0)': dependencies: - '@iarna/toml': 2.2.5 + '@vercel/build-utils': 14.9.0 + + '@vercel/rust@8.0.0(@vercel/build-utils@14.9.0)': + dependencies: + '@vercel/build-utils': 14.9.0 execa: 5.1.1 + get-port: 5.1.1 + smol-toml: 1.5.2 - '@vercel/static-build@2.9.4': + '@vercel/sandbox@3.1.0': dependencies: - '@vercel/gatsby-plugin-vercel-analytics': 1.0.11 - '@vercel/gatsby-plugin-vercel-builder': 2.1.4 - '@vercel/static-config': 3.2.0 - ts-morph: 12.0.0 + '@vercel/oidc': 3.2.0 + '@workflow/serde': 4.1.0-beta.2 + async-retry: 1.3.3 + jose: 6.2.3 + jsonlines: 0.1.1 + lru-cache: 10.4.3 + ms: 2.1.3 + picocolors: 1.1.1 + tar-stream: 3.1.7 + undici: 7.29.0 + xdg-app-paths: 5.1.0 + zod: 4.4.3 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a - '@vercel/static-config@3.2.0': + '@vercel/static-build@9.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0)': + dependencies: + '@vercel/build-utils': 14.9.0 + '@vercel/gatsby-plugin-vercel-analytics': 1.0.12 + '@vercel/gatsby-plugin-vercel-builder': 2.2.52 + '@vercel/static-config': 3.4.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) + ts-morph: 12.0.0 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + '@vercel/static-config@3.4.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)': dependencies: ajv: 8.6.3 json-schema-to-ts: 1.6.4 + oxc-parser: 0.121.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) ts-morph: 12.0.0 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + '@vercel/vc-native-darwin-arm64@59.11.1': + optional: true + + '@vercel/vc-native-darwin-x64@59.11.1': + optional: true + + '@vercel/vc-native-linux-arm64@59.11.1': + optional: true + + '@vercel/vc-native-linux-x64@59.11.1': + optional: true + + '@workflow/serde@4.1.0-beta.2': {} abbrev@3.0.1: {} @@ -6295,10 +6814,6 @@ snapshots: ast-types-flow@0.0.8: {} - ast-types@0.13.4: - dependencies: - tslib: 2.8.1 - astring@1.9.0: {} async-function@1.0.0: {} @@ -6315,8 +6830,6 @@ snapshots: async-sema@3.1.1: {} - asynckit@0.4.0: {} - available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.1.0 @@ -6325,15 +6838,17 @@ snapshots: axobject-query@4.1.0: {} + b4a@1.8.1: {} + bail@2.0.2: {} balanced-match@1.0.2: {} balanced-match@4.0.4: {} - baseline-browser-mapping@2.11.1: {} + bare-events@2.9.2: {} - basic-ftp@5.3.1: {} + baseline-browser-mapping@2.11.1: {} bindings@1.5.0: dependencies: @@ -6432,10 +6947,6 @@ snapshots: color-name@1.1.4: {} - combined-stream@1.0.8: - dependencies: - delayed-stream: 1.0.0 - comma-separated-tokens@2.0.3: {} compute-scroll-into-view@3.1.1: {} @@ -6450,8 +6961,6 @@ snapshots: convert-source-map@2.0.0: {} - cookie-es@2.0.1: {} - cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -6462,8 +6971,6 @@ snapshots: damerau-levenshtein@1.0.8: {} - data-uri-to-buffer@6.0.2: {} - data-view-buffer@1.0.2: dependencies: call-bound: 1.0.4 @@ -6506,20 +7013,14 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + define-lazy-prop@2.0.0: {} + define-properties@1.2.1: dependencies: define-data-property: 1.1.4 has-property-descriptors: 1.0.2 object-keys: 1.1.1 - degenerator@5.0.1: - dependencies: - ast-types: 0.13.4 - escodegen: 2.1.0 - esprima: 4.0.1 - - delayed-stream@1.0.0: {} - depd@1.1.2: {} dequal@2.0.3: {} @@ -6662,6 +7163,8 @@ snapshots: es-module-lexer@1.4.1: {} + es-module-lexer@1.5.0: {} + es-object-atoms@1.1.2: dependencies: es-errors: 1.3.0 @@ -6764,17 +7267,9 @@ snapshots: escape-string-regexp@5.0.0: {} - escodegen@2.1.0: + eslint-config-next@16.3.4(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3): dependencies: - esprima: 4.0.1 - estraverse: 5.3.0 - esutils: 2.0.3 - optionalDependencies: - source-map: 0.6.1 - - eslint-config-next@16.2.1(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3): - dependencies: - '@next/eslint-plugin-next': 16.2.1 + '@next/eslint-plugin-next': 16.3.4(eslint@9.39.5(jiti@2.7.0)) eslint: 9.39.5(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0)) @@ -6965,8 +7460,6 @@ snapshots: acorn-jsx: 5.3.2(acorn@8.17.0) eslint-visitor-keys: 4.2.1 - esprima@4.0.1: {} - esquery@1.7.0: dependencies: estraverse: 5.3.0 @@ -7022,6 +7515,12 @@ snapshots: events-intercept@2.0.0: {} + events-universal@1.0.1: + dependencies: + bare-events: 2.9.2 + transitivePeerDependencies: + - bare-abort-controller + execa@3.2.0: dependencies: cross-spawn: 7.0.6 @@ -7051,6 +7550,8 @@ snapshots: fast-deep-equal@3.1.3: {} + fast-fifo@1.3.2: {} + fast-glob@3.3.1: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -7109,14 +7610,6 @@ snapshots: dependencies: is-callable: 1.2.7 - form-data@4.0.6: - dependencies: - asynckit: 0.4.0 - combined-stream: 1.0.8 - es-set-tostringtag: 2.1.0 - hasown: 2.0.4 - mime-types: 2.1.35 - framer-motion@12.42.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: motion-dom: 12.42.2 @@ -7141,7 +7634,7 @@ snapshots: fsevents@2.3.3: optional: true - fumadocs-core@16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.26.0(react@19.2.4))(next@16.2.1(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.3): + fumadocs-core@16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.26.0(react@19.2.4))(next@16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.3): dependencies: '@orama/orama': 3.1.18 estree-util-value-to-estree: 3.5.0 @@ -7168,22 +7661,22 @@ snapshots: '@types/mdast': 4.0.4 '@types/react': 19.2.17 lucide-react: 1.26.0(react@19.2.4) - next: 16.2.1(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next: 16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: 19.2.4 react-dom: 19.2.4(react@19.2.4) zod: 4.4.3 transitivePeerDependencies: - supports-color - fumadocs-mdx@14.3.2(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.17)(fumadocs-core@16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.26.0(react@19.2.4))(next@16.2.1(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.3))(next@16.2.1(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4): + fumadocs-mdx@14.3.2(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.17)(fumadocs-core@16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.26.0(react@19.2.4))(next@16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.3))(next@16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4): dependencies: '@mdx-js/mdx': 3.1.1 '@standard-schema/spec': 1.1.0 chokidar: 5.0.0 esbuild: 0.28.1 estree-util-value-to-estree: 3.5.0 - fumadocs-core: 16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.26.0(react@19.2.4))(next@16.2.1(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.3) - js-yaml: 4.3.0 + fumadocs-core: 16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.26.0(react@19.2.4))(next@16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.3) + js-yaml: 4.3.2 mdast-util-mdx: 3.0.0 mdast-util-to-markdown: 2.1.2 picocolors: 1.1.1 @@ -7199,12 +7692,12 @@ snapshots: '@types/mdast': 4.0.4 '@types/mdx': 2.0.14 '@types/react': 19.2.17 - next: 16.2.1(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next: 16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: 19.2.4 transitivePeerDependencies: - supports-color - fumadocs-ui@16.12.1(@types/mdx@2.0.14)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(fumadocs-core@16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.26.0(react@19.2.4))(next@16.2.1(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.3))(next@16.2.1(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tailwindcss@4.3.3): + fumadocs-ui@16.12.1(@types/mdx@2.0.14)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(fumadocs-core@16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.26.0(react@19.2.4))(next@16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.3))(next@16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tailwindcss@4.3.3): dependencies: '@fuma-translate/react': 1.0.2(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@fumadocs/tailwind': 0.1.1(tailwindcss@4.3.3) @@ -7220,7 +7713,7 @@ snapshots: '@radix-ui/react-tabs': 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) class-variance-authority: 0.7.1 cnfast: 0.0.8 - fumadocs-core: 16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.26.0(react@19.2.4))(next@16.2.1(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.3) + fumadocs-core: 16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.26.0(react@19.2.4))(next@16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.3) lucide-react: 1.26.0(react@19.2.4) motion: 12.42.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) next-themes: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -7234,7 +7727,7 @@ snapshots: optionalDependencies: '@types/mdx': 2.0.14 '@types/react': 19.2.17 - next: 16.2.1(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next: 16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) transitivePeerDependencies: - '@emotion/is-prop-valid' - '@types/react-dom' @@ -7277,6 +7770,8 @@ snapshots: get-nonce@1.0.1: {} + get-port@5.1.1: {} + get-proto@1.0.1: dependencies: dunder-proto: 1.0.1 @@ -7298,14 +7793,6 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 - get-uri@6.0.5: - dependencies: - basic-ftp: 5.3.1 - data-uri-to-buffer: 6.0.2 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - github-slugger@2.0.0: {} glob-parent@5.1.2: @@ -7318,7 +7805,7 @@ snapshots: glob@13.0.6: dependencies: - minimatch: 10.2.5 + minimatch: 10.2.6 minipass: 7.1.3 path-scurry: 2.0.2 @@ -7481,13 +7968,6 @@ snapshots: statuses: 1.5.0 toidentifier: 1.0.0 - http-proxy-agent@7.0.2: - dependencies: - agent-base: 7.1.4 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 @@ -7524,8 +8004,6 @@ snapshots: hasown: 2.0.4 side-channel: 1.1.1 - ip-address@10.2.0: {} - is-alphabetical@2.0.1: {} is-alphanumerical@2.0.1: @@ -7581,6 +8059,8 @@ snapshots: is-decimal@2.0.1: {} + is-docker@2.2.1: {} + is-document.all@1.0.0: dependencies: call-bound: 1.0.4 @@ -7661,6 +8141,10 @@ snapshots: call-bound: 1.0.4 get-intrinsic: 1.3.0 + is-wsl@2.2.0: + dependencies: + is-docker: 2.2.1 + isarray@2.0.5: {} isexe@2.0.0: {} @@ -7678,13 +8162,11 @@ snapshots: jose@5.9.6: {} + jose@6.2.3: {} + js-tokens@4.0.0: {} - js-yaml@4.1.1: - dependencies: - argparse: 2.0.1 - - js-yaml@4.3.0: + js-yaml@4.3.2: dependencies: argparse: 2.0.1 @@ -7709,12 +8191,16 @@ snapshots: json5@2.2.3: {} + jsonc-parser@3.3.1: {} + jsonfile@6.2.1: dependencies: universalify: 2.0.1 optionalDependencies: graceful-fs: 4.2.11 + jsonlines@0.1.1: {} + jsx-ast-utils@3.3.5: dependencies: array-includes: 3.1.9 @@ -7798,6 +8284,8 @@ snapshots: dependencies: js-tokens: 4.0.0 + lru-cache@10.4.3: {} + lru-cache@11.5.2: {} lru-cache@5.1.1: @@ -7808,8 +8296,6 @@ snapshots: dependencies: yallist: 4.0.0 - lru-cache@7.18.3: {} - lucide-react@1.26.0(react@19.2.4): dependencies: react: 19.2.4 @@ -8276,11 +8762,7 @@ snapshots: mimic-fn@2.1.0: {} - minimatch@10.1.1: - dependencies: - '@isaacs/brace-expansion': 5.0.1 - - minimatch@10.2.5: + minimatch@10.2.6: dependencies: brace-expansion: 5.0.8 @@ -8320,41 +8802,40 @@ snapshots: ms@2.1.3: {} - nanoid@3.3.16: {} + nanoid@3.3.18: {} napi-postinstall@0.3.4: {} natural-compare@1.4.0: {} - netmask@2.1.1: {} - next-themes@0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - next@16.2.1(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + next@16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: - '@next/env': 16.2.1 - '@swc/helpers': 0.5.15 + '@next/env': 16.3.4 + '@swc/helpers': 0.5.23 baseline-browser-mapping: 2.11.1 caniuse-lite: 1.0.30001806 - postcss: 8.4.31 + postcss: 8.5.23 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) styled-jsx: 5.1.6(@babel/core@7.29.7)(react@19.2.4) optionalDependencies: - '@next/swc-darwin-arm64': 16.2.1 - '@next/swc-darwin-x64': 16.2.1 - '@next/swc-linux-arm64-gnu': 16.2.1 - '@next/swc-linux-arm64-musl': 16.2.1 - '@next/swc-linux-x64-gnu': 16.2.1 - '@next/swc-linux-x64-musl': 16.2.1 - '@next/swc-win32-arm64-msvc': 16.2.1 - '@next/swc-win32-x64-msvc': 16.2.1 - sharp: 0.34.5 + '@next/swc-darwin-arm64': 16.3.4 + '@next/swc-darwin-x64': 16.3.4 + '@next/swc-linux-arm64-gnu': 16.3.4 + '@next/swc-linux-arm64-musl': 16.3.4 + '@next/swc-linux-x64-gnu': 16.3.4 + '@next/swc-linux-x64-musl': 16.3.4 + '@next/swc-win32-arm64-msvc': 16.3.4 + '@next/swc-win32-x64-msvc': 16.3.4 + sharp: 0.35.4(@types/node@20.19.43) transitivePeerDependencies: - '@babel/core' + - '@types/node' - babel-plugin-macros node-exports-info@1.6.2: @@ -8452,6 +8933,12 @@ snapshots: regex: 6.1.0 regex-recursion: 6.0.2 + open@8.4.0: + dependencies: + define-lazy-prop: 2.0.0 + is-docker: 2.2.1 + is-wsl: 2.2.0 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -8470,7 +8957,35 @@ snapshots: object-keys: 1.1.1 safe-push-apply: 1.0.0 - oxc-transform@0.111.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.2): + oxc-parser@0.121.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3): + dependencies: + '@oxc-project/types': 0.121.0 + optionalDependencies: + '@oxc-parser/binding-android-arm-eabi': 0.121.0 + '@oxc-parser/binding-android-arm64': 0.121.0 + '@oxc-parser/binding-darwin-arm64': 0.121.0 + '@oxc-parser/binding-darwin-x64': 0.121.0 + '@oxc-parser/binding-freebsd-x64': 0.121.0 + '@oxc-parser/binding-linux-arm-gnueabihf': 0.121.0 + '@oxc-parser/binding-linux-arm-musleabihf': 0.121.0 + '@oxc-parser/binding-linux-arm64-gnu': 0.121.0 + '@oxc-parser/binding-linux-arm64-musl': 0.121.0 + '@oxc-parser/binding-linux-ppc64-gnu': 0.121.0 + '@oxc-parser/binding-linux-riscv64-gnu': 0.121.0 + '@oxc-parser/binding-linux-riscv64-musl': 0.121.0 + '@oxc-parser/binding-linux-s390x-gnu': 0.121.0 + '@oxc-parser/binding-linux-x64-gnu': 0.121.0 + '@oxc-parser/binding-linux-x64-musl': 0.121.0 + '@oxc-parser/binding-openharmony-arm64': 0.121.0 + '@oxc-parser/binding-wasm32-wasi': 0.121.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) + '@oxc-parser/binding-win32-arm64-msvc': 0.121.0 + '@oxc-parser/binding-win32-ia32-msvc': 0.121.0 + '@oxc-parser/binding-win32-x64-msvc': 0.121.0 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + oxc-transform@0.111.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3): optionalDependencies: '@oxc-transform/binding-android-arm-eabi': 0.111.0 '@oxc-transform/binding-android-arm64': 0.111.0 @@ -8488,7 +9003,7 @@ snapshots: '@oxc-transform/binding-linux-x64-gnu': 0.111.0 '@oxc-transform/binding-linux-x64-musl': 0.111.0 '@oxc-transform/binding-openharmony-arm64': 0.111.0 - '@oxc-transform/binding-wasm32-wasi': 0.111.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.2) + '@oxc-transform/binding-wasm32-wasi': 0.111.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) '@oxc-transform/binding-win32-arm64-msvc': 0.111.0 '@oxc-transform/binding-win32-ia32-msvc': 0.111.0 '@oxc-transform/binding-win32-x64-msvc': 0.111.0 @@ -8506,24 +9021,6 @@ snapshots: dependencies: p-limit: 3.1.0 - pac-proxy-agent@7.2.0: - dependencies: - '@tootallnate/quickjs-emscripten': 0.23.0 - agent-base: 7.1.4 - debug: 4.4.3 - get-uri: 6.0.5 - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 - pac-resolver: 7.0.1 - socks-proxy-agent: 8.0.5 - transitivePeerDependencies: - - supports-color - - pac-resolver@7.0.1: - dependencies: - degenerator: 5.0.1 - netmask: 2.1.1 - parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -8577,15 +9074,15 @@ snapshots: possible-typed-array-names@1.1.0: {} - postcss@8.4.31: + postcss@8.5.23: dependencies: - nanoid: 3.3.16 + nanoid: 3.3.18 picocolors: 1.1.1 source-map-js: 1.2.1 - postcss@8.5.22: + postcss@8.5.26: dependencies: - nanoid: 3.3.16 + nanoid: 3.3.18 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -8605,21 +9102,6 @@ snapshots: property-information@7.2.0: {} - proxy-agent@6.4.0: - dependencies: - agent-base: 7.1.4 - debug: 4.4.3 - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 - lru-cache: 7.18.3 - pac-proxy-agent: 7.2.0 - proxy-from-env: 1.1.0 - socks-proxy-agent: 8.0.5 - transitivePeerDependencies: - - supports-color - - proxy-from-env@1.1.0: {} - pump@3.0.4: dependencies: end-of-stream: 1.4.5 @@ -8822,7 +9304,7 @@ snapshots: reusify@1.1.0: {} - rolldown@1.0.0-rc.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.2): + rolldown@1.0.0-rc.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3): dependencies: '@oxc-project/types': 0.110.0 '@rolldown/pluginutils': 1.0.0-rc.1 @@ -8837,7 +9319,7 @@ snapshots: '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.1 '@rolldown/binding-linux-x64-musl': 1.0.0-rc.1 '@rolldown/binding-openharmony-arm64': 1.0.0-rc.1 - '@rolldown/binding-wasm32-wasi': 1.0.0-rc.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.2) + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.1 '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.1 transitivePeerDependencies: @@ -8869,6 +9351,20 @@ snapshots: safer-buffer@2.1.2: {} + sandbox@4.1.0: + dependencies: + '@vercel/sandbox': 3.1.0 + async-retry: 1.3.3 + debug: 4.4.3 + ws: 8.21.3 + zod: 4.4.3 + transitivePeerDependencies: + - bare-abort-controller + - bufferutil + - react-native-b4a + - supports-color + - utf-8-validate + scheduler@0.27.0: {} scroll-into-view-if-needed@3.1.0: @@ -8907,36 +9403,38 @@ snapshots: setprototypeof@1.1.1: {} - sharp@0.34.5: + sharp@0.35.4(@types/node@20.19.43): dependencies: '@img/colour': 1.1.0 detect-libc: 2.1.2 semver: 7.8.5 optionalDependencies: - '@img/sharp-darwin-arm64': 0.34.5 - '@img/sharp-darwin-x64': 0.34.5 - '@img/sharp-libvips-darwin-arm64': 1.2.4 - '@img/sharp-libvips-darwin-x64': 1.2.4 - '@img/sharp-libvips-linux-arm': 1.2.4 - '@img/sharp-libvips-linux-arm64': 1.2.4 - '@img/sharp-libvips-linux-ppc64': 1.2.4 - '@img/sharp-libvips-linux-riscv64': 1.2.4 - '@img/sharp-libvips-linux-s390x': 1.2.4 - '@img/sharp-libvips-linux-x64': 1.2.4 - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 - '@img/sharp-linux-arm': 0.34.5 - '@img/sharp-linux-arm64': 0.34.5 - '@img/sharp-linux-ppc64': 0.34.5 - '@img/sharp-linux-riscv64': 0.34.5 - '@img/sharp-linux-s390x': 0.34.5 - '@img/sharp-linux-x64': 0.34.5 - '@img/sharp-linuxmusl-arm64': 0.34.5 - '@img/sharp-linuxmusl-x64': 0.34.5 - '@img/sharp-wasm32': 0.34.5 - '@img/sharp-win32-arm64': 0.34.5 - '@img/sharp-win32-ia32': 0.34.5 - '@img/sharp-win32-x64': 0.34.5 + '@img/sharp-darwin-arm64': 0.35.4 + '@img/sharp-darwin-x64': 0.35.4 + '@img/sharp-freebsd-wasm32': 0.35.4 + '@img/sharp-libvips-darwin-arm64': 1.3.3 + '@img/sharp-libvips-darwin-x64': 1.3.3 + '@img/sharp-libvips-linux-arm': 1.3.3 + '@img/sharp-libvips-linux-arm64': 1.3.3 + '@img/sharp-libvips-linux-ppc64': 1.3.3 + '@img/sharp-libvips-linux-riscv64': 1.3.3 + '@img/sharp-libvips-linux-s390x': 1.3.3 + '@img/sharp-libvips-linux-x64': 1.3.3 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 + '@img/sharp-linux-arm': 0.35.4 + '@img/sharp-linux-arm64': 0.35.4 + '@img/sharp-linux-ppc64': 0.35.4 + '@img/sharp-linux-riscv64': 0.35.4 + '@img/sharp-linux-s390x': 0.35.4 + '@img/sharp-linux-x64': 0.35.4 + '@img/sharp-linuxmusl-arm64': 0.35.4 + '@img/sharp-linuxmusl-x64': 0.35.4 + '@img/sharp-webcontainers-wasm32': 0.35.4 + '@img/sharp-win32-arm64': 0.35.4 + '@img/sharp-win32-ia32': 0.35.4 + '@img/sharp-win32-x64': 0.35.4 + '@types/node': 20.19.43 optional: true shebang-command@2.0.0: @@ -8988,35 +9486,15 @@ snapshots: signal-exit@4.0.2: {} - smart-buffer@4.2.0: {} - smol-toml@1.5.2: {} - socks-proxy-agent@8.0.5: - dependencies: - agent-base: 7.1.4 - debug: 4.4.3 - socks: 2.8.9 - transitivePeerDependencies: - - supports-color - - socks@2.8.9: - dependencies: - ip-address: 10.2.0 - smart-buffer: 4.2.0 - source-map-js@1.2.1: {} - source-map@0.6.1: - optional: true - source-map@0.7.6: {} space-separated-tokens@2.0.2: {} - srvx@0.8.9: - dependencies: - cookie-es: 2.0.1 + srvx@0.11.16: {} stable-hash@0.0.5: {} @@ -9039,6 +9517,15 @@ snapshots: end-of-stream: 1.1.0 stream-to-array: 2.3.0 + streamx@2.28.1: + dependencies: + events-universal: 1.0.1 + fast-fifo: 1.3.2 + text-decoder: 1.2.7 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + string.prototype.includes@2.0.1: dependencies: call-bind: 1.0.9 @@ -9128,7 +9615,16 @@ snapshots: tapable@2.3.3: {} - tar@7.5.21: + tar-stream@3.1.7: + dependencies: + b4a: 1.8.1 + fast-fifo: 1.3.2 + streamx: 2.28.1 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + tar@7.5.22: dependencies: '@isaacs/fs-minipass': 4.0.1 chownr: 3.0.0 @@ -9136,13 +9632,11 @@ snapshots: minizlib: 3.1.0 yallist: 5.0.0 - tar@7.5.7: + text-decoder@1.2.7: dependencies: - '@isaacs/fs-minipass': 4.0.1 - chownr: 3.0.0 - minipass: 7.1.3 - minizlib: 3.1.0 - yallist: 5.0.0 + b4a: 1.8.1 + transitivePeerDependencies: + - react-native-b4a throttleit@2.1.0: {} @@ -9265,11 +9759,13 @@ snapshots: undici-types@6.21.0: {} - undici@5.28.4: + undici@5.29.0: dependencies: '@fastify/busboy': 2.1.1 - undici@6.27.0: {} + undici@6.28.0: {} + + undici@7.29.0: {} unified@11.0.5: dependencies: @@ -9369,44 +9865,62 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 - vercel@50.37.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.2)(typescript@5.9.3): + uuid@14.0.1: {} + + vercel@59.11.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3): dependencies: - '@vercel/backends': 0.0.51(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.2)(typescript@5.9.3) - '@vercel/blob': 2.3.0 - '@vercel/build-utils': 13.10.0 - '@vercel/detect-agent': 1.2.1 - '@vercel/elysia': 0.1.53 - '@vercel/express': 0.1.63(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.2)(typescript@5.9.3) - '@vercel/fastify': 0.1.56 + '@vercel/backends': 7.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0) + '@vercel/blob': 2.8.0 + '@vercel/build-utils': 14.9.0 + '@vercel/cli-auth': 0.3.5 + '@vercel/cli-config': 0.2.4 + '@vercel/container': 7.0.0(@vercel/build-utils@14.9.0) + '@vercel/detect-agent': 1.2.5 + '@vercel/elysia': 7.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0) + '@vercel/express': 7.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0) + '@vercel/fastify': 7.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0) '@vercel/fun': 1.3.0 - '@vercel/go': 3.4.6 - '@vercel/h3': 0.1.62 - '@vercel/hono': 0.2.56 - '@vercel/hydrogen': 1.3.6 - '@vercel/koa': 0.1.36 - '@vercel/nestjs': 0.2.57 - '@vercel/next': 4.16.3 - '@vercel/node': 5.6.20 - '@vercel/prepare-flags-definitions': 0.2.1 - '@vercel/python': 6.28.0 - '@vercel/redwood': 2.4.12 - '@vercel/remix-builder': 5.7.2 - '@vercel/ruby': 2.3.2 - '@vercel/rust': 1.0.5 - '@vercel/static-build': 2.9.4 + '@vercel/go': 10.0.0(@vercel/build-utils@14.9.0) + '@vercel/h3': 7.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0) + '@vercel/hono': 7.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0) + '@vercel/hydrogen': 8.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0) + '@vercel/koa': 7.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0) + '@vercel/nestjs': 7.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0) + '@vercel/next': 11.0.0(@vercel/build-utils@14.9.0) + '@vercel/node': 12.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0) + '@vercel/prepare-flags-definitions': 0.3.0 + '@vercel/python': 13.0.0(@vercel/build-utils@14.9.0) + '@vercel/python-analysis': 0.14.0 + '@vercel/redwood': 9.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0) + '@vercel/remix-builder': 12.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0) + '@vercel/ruby': 9.0.0(@vercel/build-utils@14.9.0) + '@vercel/rust': 8.0.0(@vercel/build-utils@14.9.0) + '@vercel/static-build': 9.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0) chokidar: 4.0.0 esbuild: 0.27.0 - form-data: 4.0.6 jose: 5.9.6 + jsonc-parser: 3.3.1 luxon: 3.7.2 - proxy-agent: 6.4.0 + sandbox: 4.1.0 + smol-toml: 1.5.2 + undici: 5.29.0 + uuid: 14.0.1 + zod: 4.1.11 + optionalDependencies: + '@vercel/vc-native-darwin-arm64': 59.11.1 + '@vercel/vc-native-darwin-x64': 59.11.1 + '@vercel/vc-native-linux-arm64': 59.11.1 + '@vercel/vc-native-linux-x64': 59.11.1 transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' + - bare-abort-controller + - bufferutil - encoding + - react-native-b4a - rollup - supports-color - - typescript + - utf-8-validate vfile-location@5.0.3: dependencies: @@ -9483,6 +9997,8 @@ snapshots: wrappy@1.0.2: {} + ws@8.21.3: {} + xdg-app-paths@5.1.0: dependencies: xdg-portable: 7.3.0 @@ -9521,6 +10037,8 @@ snapshots: zod@3.22.4: {} + zod@4.1.11: {} + zod@4.4.3: {} zwitch@2.0.4: {} diff --git a/mobile/pnpm-lock.yaml b/mobile/pnpm-lock.yaml index 6473659419d..60ccee97d2f 100644 --- a/mobile/pnpm-lock.yaml +++ b/mobile/pnpm-lock.yaml @@ -93,7 +93,7 @@ importers: version: 55.0.27(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)(typescript@6.0.3) expo-router: specifier: ^55.0.18 - version: 55.0.18(2f99795f9796def5cdb1516a493dc117) + version: 55.0.18(4a60a26fd685ffdcc7f556016ae4bc5e) expo-secure-store: specifier: ^55.0.18 version: 55.0.18(expo@55.0.30) @@ -178,7 +178,7 @@ importers: version: 0.25.4 expo-module-scripts: specifier: ^55.0.2 - version: 55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(eslint@9.39.4)(expo@55.0.30)(jest@29.7.0(@types/node@26.1.2))(prettier@2.8.8)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-refresh@0.14.2)(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8) + version: 55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(eslint@9.39.4)(expo@55.0.30)(jest@29.7.0(@types/node@26.4.0))(prettier@2.8.8)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-refresh@0.14.2)(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8) happy-dom: specifier: ^20.11.8 version: 20.11.8 @@ -199,10 +199,10 @@ importers: version: 6.0.3 vite: specifier: ^8.0.16 - version: 8.1.0(@types/node@26.1.2)(esbuild@0.25.4)(terser@5.49.1)(tsx@4.22.4)(yaml@2.9.0) + version: 8.1.0(@types/node@26.4.0)(esbuild@0.25.4)(terser@5.51.2)(tsx@4.22.4)(yaml@2.9.0) vitest: specifier: ^4.1.11 - version: 4.1.11(@types/node@26.1.2)(happy-dom@20.11.8)(jsdom@20.0.3)(vite@8.1.0(@types/node@26.1.2)(esbuild@0.25.4)(terser@5.49.1)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.11(@types/node@26.4.0)(happy-dom@20.11.8)(jsdom@20.0.3)(vite@8.1.0(@types/node@26.4.0)(esbuild@0.25.4)(terser@5.51.2)(tsx@4.22.4)(yaml@2.9.0)) packages: @@ -2897,6 +2897,9 @@ packages: '@types/node@26.1.2': resolution: {integrity: sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==} + '@types/node@26.4.0': + resolution: {integrity: sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ==} + '@types/react-native@0.73.0': resolution: {integrity: sha512-6ZRPQrYM72qYKGWidEttRe6M5DZBEV5F+MHMHqd4TTYx0tfkcdrUFGdef6CCxY0jXU7wldvd/zA/b0A/kTeJmA==} deprecated: This is a stub types definition. react-native provides its own type definitions, so you do not need this installed. @@ -3356,8 +3359,8 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - baseline-browser-mapping@2.10.27: - resolution: {integrity: sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==} + baseline-browser-mapping@2.11.20: + resolution: {integrity: sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==} engines: {node: '>=6.0.0'} hasBin: true @@ -3398,8 +3401,8 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} - browserslist@4.28.2: - resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + browserslist@4.28.8: + resolution: {integrity: sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true @@ -3448,8 +3451,8 @@ packages: resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} engines: {node: '>=10'} - caniuse-lite@1.0.30001792: - resolution: {integrity: sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==} + caniuse-lite@1.0.30001810: + resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==} chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} @@ -3941,8 +3944,8 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - electron-to-chromium@1.5.352: - resolution: {integrity: sha512-9wHk8x6dyuimoe18EdiDPWKExNdxYqo4fn4FwOVVper6RxT3cmpBwBkWWfSOCYJjQdIco/nPhJhNLmn4Ufg1Yg==} + electron-to-chromium@1.5.416: + resolution: {integrity: sha512-K6bvB2BjnNrugtIih6ewlbBI9DXa976jIdiIlRLHhBoEI9a4JaQjjHyF+A1IQI543aQYR4LnmOrT/K5fZj0aPA==} emittery@0.13.1: resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} @@ -5228,6 +5231,10 @@ packages: resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} hasBin: true + js-yaml@4.3.2: + resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} + hasBin: true + jsc-safe-url@0.2.4: resolution: {integrity: sha512-0wM3YBWtYePOjfyXQH5MWQ8H7sdk5EXSwZvmSLKk2RboVQ2Bu239jycHDz5J/8Blf3K0Qnoy2b6xD+z10MFB+Q==} @@ -5492,8 +5499,8 @@ packages: resolution: {integrity: sha512-tnn0J5wzgTgTx2OJy3Cwr1y79bJz4eNgFQd+2HENOs5Vz6QOMnt05z7J+BedIo9wIbpEa0iN9U1nerxyvMRE9g==} engines: {node: '>=20.19.4'} - metro-babel-transformer@0.84.4: - resolution: {integrity: sha512-rvCfz8snl9h20VcvpOHxZuHP1SlAkv4HXbzw7nyyVwu6Eqo5PRerbakQ9XmUCOsRy70spJ37O+G1TK8oMzo48g==} + metro-babel-transformer@0.84.5: + resolution: {integrity: sha512-2WbHILKMiJUzfdjmGOQOqU1bWi9//gqiclc/tkk/AIsrrVw3efhZ1uhkOwMTxUEPOzqoo091H0olLmVZH5FHGQ==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} metro-cache-key@0.83.7: @@ -5504,8 +5511,8 @@ packages: resolution: {integrity: sha512-I38PtcjT4crS5HY9UQ8i6z8S7tJ2WewtPGr/OwS6FcLKfy5T/1hlTaFw+wozUZkEtNpR6Gc0oJuvuKCbSoSN5A==} engines: {node: '>=20.19.4'} - metro-cache-key@0.84.4: - resolution: {integrity: sha512-wVO79aGrkYImpnaVS4+d5RrRBRPX31QtvKB3wKGBuiNSznduZTQHzsrJZRroFJSwnygrzdsGUtDQPuqqFjFdvw==} + metro-cache-key@0.84.5: + resolution: {integrity: sha512-3dPB2TnvGjjf0/9O7AXVQURKXuQNauTZE7WpTGTlR017Gh/B5y0m/2wcqxfveUguHSpu89KhVxCAlr2k/H7uhQ==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} metro-cache@0.83.7: @@ -5516,8 +5523,8 @@ packages: resolution: {integrity: sha512-aogMG5WbKzW5000otNjYrS9hIoORzkCI1faPJK+vxQLaf2BorJKBBFe/jl2Tfsi1mZUglS2EBuBt8B3JB7MYDQ==} engines: {node: '>=20.19.4'} - metro-cache@0.84.4: - resolution: {integrity: sha512-gpcFQdSLUwUCk71saKoE64jLFbx2nwTfVCcPSULMNT8QYq0p1eZZE29Jvd0HtT/UlhC3ZOutLxJME5xqD2JUZg==} + metro-cache@0.84.5: + resolution: {integrity: sha512-WHS0n2OxQqtwEjSeQFPePNrMvEFhmQcUQM9cRJMHByWoi/GMWFBEWOf7hVkAM/0KRutAXNbDlSu/cZB6CyxgQQ==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} metro-config@0.83.7: @@ -5528,8 +5535,8 @@ packages: resolution: {integrity: sha512-crNbNy+/B4tCne2+HjUshwvC57gNBQj+V9fFSy3lHH2RlKcLHib3Mil/SfTX8Pfh2fCY5pPuSu2OKa7YiadB+Q==} engines: {node: '>=20.19.4'} - metro-config@0.84.4: - resolution: {integrity: sha512-PMotGDjXcXLWo2TMRH+VR99phFNgYTwqh4OoieIKK3yTJa1Jmkl+fZJxDO0jfBvNF+WESHciHvpNuBtXaF3B0Q==} + metro-config@0.84.5: + resolution: {integrity: sha512-zie+uN6oohscowi2S7ByU+wUw6CrT4ZxW9uAbONOObSxx86RGmnIAmjXHLkfmcdYoY7jzOPEbqcI6oeVmqyBQA==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} metro-core@0.83.7: @@ -5540,8 +5547,8 @@ packages: resolution: {integrity: sha512-NTyOUOQaQKvQgJG9VI2ymN6KTM7gHEqkVFhkPc9bK4BsHSTz/EaXuFBXtC+wtwINLeH9tbusL/jfsSmdEmuU7A==} engines: {node: '>=20.19.4'} - metro-core@0.84.4: - resolution: {integrity: sha512-HONpWC5LGXZn3ffkd4Hu6AIrfE7j4Z0g0wMo/goV24WOB3lhuFZ40KgvaDiSw8iyQHloMYay5N/wPX+z8oN/PQ==} + metro-core@0.84.5: + resolution: {integrity: sha512-xwm605hCi5Y6eJTTb8ZWo6pkUcoBEIyiQOfkZh5GwtDwUrP9SNhTQZhzJHrBCwwxlf3Ptl/pxWJgQ1rsNYMnrA==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} metro-file-map@0.83.7: @@ -5552,8 +5559,8 @@ packages: resolution: {integrity: sha512-+W++EUuzEXIfWQEFTWQMVThzhWbnJL4gRNJ9WSHzIAM5pT7gsprUxo9+2hrfirURm/TLvrKwhq37oCECJcDSyQ==} engines: {node: '>=20.19.4'} - metro-file-map@0.84.4: - resolution: {integrity: sha512-KSVDi/u60hKPx++NLu3MTIvyjzNoJnFAF8PQFxaj1jiSka/wjw+Ua6sNuJ0TDHQv+7AAoFQxeMgaRAe8Yic5wQ==} + metro-file-map@0.84.5: + resolution: {integrity: sha512-mlm/JL8toSbSc2akpKIGmzvrVRSCgZ5vkbycI34oMLoOnLGuLyC8WTyVJ6P0hZG/usDaGwZSl/s9BCRriqjGJA==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} metro-minify-terser@0.83.7: @@ -5564,8 +5571,8 @@ packages: resolution: {integrity: sha512-7tU0J5/c7LZaZJwTlOb1xq0NepTFvGzRxigDhZOq4jSE6g0BRHmBqe7XvLH3WcRiJNGy+eshcZr34RpMjb6mmg==} engines: {node: '>=20.19.4'} - metro-minify-terser@0.84.4: - resolution: {integrity: sha512-5qpbaVOMC7CPitIpuewzVeGw7E+C3ykbv2mqTjQLl85Z3annSVGlSCTcsZjqXZzjupfK4Ztj3dDc4kc44NZwtQ==} + metro-minify-terser@0.84.5: + resolution: {integrity: sha512-BJoFwCEDsYnagPqarayInv2+diCDNDdLlaof/p6s9w4gh+gc9HXYM+pDvsKGKKUumpZswNF3Z/ftTMqKl/5IBg==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} metro-resolver@0.83.7: @@ -5576,8 +5583,8 @@ packages: resolution: {integrity: sha512-piU0NVTI9i37YztDVF5rtn9uxP3NebVT0xZM9NKJ9z0jbigrctUQksi3NoYIeJMvL6Wn2dgAehpcmmnfn+gUwA==} engines: {node: '>=20.19.4'} - metro-resolver@0.84.4: - resolution: {integrity: sha512-1qLgbxQ5ZGhhutuPot1Yp348ofDsATL2WkrHF65TobqTT9K3P9qJXw38bomk7ncp5B7OYMfWwtyBZo1lCV792A==} + metro-resolver@0.84.5: + resolution: {integrity: sha512-VSSnepg1k6LyCwtb6eirWdAWlpKwBG8Rdtsr1mU38rMelFyWgh3/QuMSiZIZAIjwg/fsa8GhW5/FO54CAUPCEA==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} metro-runtime@0.83.7: @@ -5588,8 +5595,8 @@ packages: resolution: {integrity: sha512-f7FfeM0pamq8vrvs8aO9KvIUabbUKe0WkHFpLt6Q9yIIIsORqNFwlgJeHGraOFPU7Cxqj5yLXkJu5bT1uwDXvw==} engines: {node: '>=20.19.4'} - metro-runtime@0.84.4: - resolution: {integrity: sha512-Jibypds4g7AhzdRKY+kDoj51s5EXMwgyp5ddtlreDAsWefMdOx+agWqgm0H2XSZ/ueanHHVM89fnf5OJnlxa8Q==} + metro-runtime@0.84.5: + resolution: {integrity: sha512-U1m2+d1Pr+JO2/iVXBB2OfXXityz7tqwIorxfrT15IEgaHvpJBq/OHiqnOWPKJbUl3JcxjcdviZZOKk85oK4Qg==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} metro-source-map@0.83.7: @@ -5600,8 +5607,8 @@ packages: resolution: {integrity: sha512-60Uor7bM+KsVewLkLCcZfkPFCbqjPDdoSmeBuT3+ye+ac80BuLWbbR8DpyxWpUqQeZPdaAT5ZqFgDIs8BNEcVA==} engines: {node: '>=20.19.4'} - metro-source-map@0.84.4: - resolution: {integrity: sha512-jbWkPxIesVuo1IWkvezmMJld6iu8nD62GsrZiV6jP37AOdbo4OBq1FJ+qkOg8sV05wAHB//jAbziuW0SlJfW4g==} + metro-source-map@0.84.5: + resolution: {integrity: sha512-2BtV5L9uPc49F13Gn5wiP6bX/EncqzqTIk2VL/0F/96Vo0YEOjluT/qktQjFODfqGFsucwnh5mPEAl/2jVEfeg==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} metro-symbolicate@0.83.7: @@ -5614,8 +5621,8 @@ packages: engines: {node: '>=20.19.4'} hasBin: true - metro-symbolicate@0.84.4: - resolution: {integrity: sha512-OnfpacxUqGPZQ27t8qK9mFa7uqHIlVWeqRqkCbvMvreEBiamEeOn8krKtcwgP5M4cYDPwuSmCTopHMVthqG4zA==} + metro-symbolicate@0.84.5: + resolution: {integrity: sha512-rQ40zYDAkaWBN9yvjUuAD0ZpzBMZSoKyGYXnb5JrfbKjun7fTvfoLHL3KXFYenBTYZkQtlp4cKSCv/1utxFyOw==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} hasBin: true @@ -5627,8 +5634,8 @@ packages: resolution: {integrity: sha512-9JRPkvi+m0QH2Y/w5RjCF9mHqOUNFpMFLDDLhKUjhcKESh8Wm3HKDdHXHVldTN1lTToCIabv81nF0zyJzKEJ5g==} engines: {node: '>=20.19.4'} - metro-transform-plugins@0.84.4: - resolution: {integrity: sha512-kehr6HbAecqD0/a3xLXobELdPaAmRAl8bel0qagPF4vhZtux93nS8S4eq2kgKt6J2GnQpVjSoW1PXdst04mwow==} + metro-transform-plugins@0.84.5: + resolution: {integrity: sha512-+InaSVGaOyt0DyRo4Y/zIdPI6CZwnbNho5LAL23tgmuGwv7fyfkF7kKfPjZcfxXBcoYdTLLFnCfCH/dHSiCqNg==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} metro-transform-worker@0.83.7: @@ -5639,8 +5646,8 @@ packages: resolution: {integrity: sha512-Pa2hOfhUmWpI/dmkhsLq8uGyFHK2opoEK7j/YCiRtqNSe0YzzxYguXTNgg8AMiuZfc9LPcB1AhGENS10O5wcIw==} engines: {node: '>=20.19.4'} - metro-transform-worker@0.84.4: - resolution: {integrity: sha512-W1IYMvvXTu4MxYr7d9h7CeG2vpIr3bmLLIavkPY4O1ilzDrvS8z/NEe6y+pC44Ff7raMXQgYSfdqDUwN/i39gg==} + metro-transform-worker@0.84.5: + resolution: {integrity: sha512-ui1Z8x4s5RL36gMmKLaMMO7O9NNDHNdthEZSCDQHAau3JcAsTaFOK6I+2q4I/kW5u8hSEjJk9L45TXSVJw6g1A==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} metro@0.83.7: @@ -5653,8 +5660,8 @@ packages: engines: {node: '>=20.19.4'} hasBin: true - metro@0.84.4: - resolution: {integrity: sha512-8ETTubqfD6ornDy2zYDvRcKnVDOXdFJsjetYDBsY4oAsb6NJkiwFR+FaMESyGppFmQUyBQA4H4sFGxzcQSGtFA==} + metro@0.84.5: + resolution: {integrity: sha512-r1liLkyFZMVSEMNjU1CJU5pRzs3NdkxHqXS60O25c0rCIqAR+cGk7rPydw/g0WAIKVXojIBIF45yYBPagJGcgw==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} hasBin: true @@ -5763,8 +5770,9 @@ packages: node-int64@0.4.0: resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} - node-releases@2.0.38: - resolution: {integrity: sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==} + node-releases@2.0.54: + resolution: {integrity: sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==} + engines: {node: '>=18'} normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} @@ -5795,8 +5803,8 @@ packages: resolution: {integrity: sha512-pk7el+eTOzfSKMAY4QBiiwKzegXn633JQj13y+pW5E5IdS+yV2CfDJ9Hf20K/LKy8nCrP9dAmd7XOk52OJxifA==} engines: {node: '>=20.19.4'} - ob1@0.84.4: - resolution: {integrity: sha512-eJXMpz4aQHXF/YBB9ddqZDIS+ooO91hObo9FoW/xBkr54/zCwYYCDqT/O54vNo8kOkWs5Ou/y28NgdrV0edQNA==} + ob1@0.84.5: + resolution: {integrity: sha512-aH9RkoZc7w/90HBamFxTw8ZLFr05wXS+iOnvmrgo53Ep8Pyrm5FieQSaPIVROkfFVQISeD/zo92fes26TOwe+A==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} object-assign@4.1.1: @@ -6677,6 +6685,11 @@ packages: engines: {node: '>=10'} hasBin: true + terser@5.51.2: + resolution: {integrity: sha512-bWnjSNscmuI+GJze6ZupnHP8G/cTcsJF+bXCeQknk2SHQsgbNJnLrqiH9jZ2W4STPVXH2mDKKRX3iwPhc9Cn/Q==} + engines: {node: '>=10'} + hasBin: true + test-exclude@6.0.0: resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} engines: {node: '>=8'} @@ -6862,8 +6875,8 @@ packages: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} - update-browserslist-db@1.2.3: - resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + update-browserslist-db@1.3.2: + resolution: {integrity: sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==} hasBin: true peerDependencies: browserslist: '>= 4.21.0' @@ -7301,7 +7314,7 @@ snapshots: dependencies: '@babel/compat-data': 7.29.3 '@babel/helper-validator-option': 7.27.1 - browserslist: 4.28.2 + browserslist: 4.28.8 lru-cache: 5.1.1 semver: 6.3.1 @@ -7309,7 +7322,7 @@ snapshots: dependencies: '@babel/compat-data': 7.29.7 '@babel/helper-validator-option': 7.29.7 - browserslist: 4.28.2 + browserslist: 4.28.8 lru-cache: 5.1.1 semver: 6.3.1 @@ -8694,7 +8707,7 @@ snapshots: globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.1 - js-yaml: 4.3.1 + js-yaml: 4.3.2 minimatch: 3.1.5 strip-json-comments: 3.1.1 transitivePeerDependencies: @@ -8773,7 +8786,7 @@ snapshots: ws: 8.21.3 zod: 3.25.76 optionalDependencies: - expo-router: 55.0.18(2f99795f9796def5cdb1516a493dc117) + expo-router: 55.0.18(4a60a26fd685ffdcc7f556016ae4bc5e) react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) transitivePeerDependencies: - '@expo/dom-webview' @@ -8985,7 +8998,7 @@ snapshots: '@expo/json-file': 10.0.16 '@expo/metro': 55.1.2 '@expo/spawn-async': 1.8.0 - browserslist: 4.28.2 + browserslist: 4.28.8 chalk: 4.1.2 debug: 4.4.3 getenv: 2.0.0 @@ -9116,7 +9129,7 @@ snapshots: react: 19.2.8 optionalDependencies: '@expo/metro-runtime': 55.0.10(@expo/dom-webview@55.0.5)(expo@55.0.30)(react-dom@19.2.8(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) - expo-router: 55.0.18(2f99795f9796def5cdb1516a493dc117) + expo-router: 55.0.18(4a60a26fd685ffdcc7f556016ae4bc5e) react-dom: 19.2.8(react@19.2.8) transitivePeerDependencies: - supports-color @@ -9201,14 +9214,14 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 26.1.2 + '@types/node': 26.4.0 ansi-escapes: 4.3.2 chalk: 4.1.2 ci-info: 3.9.0 exit: 0.1.2 graceful-fs: 4.2.11 jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@26.1.2) + jest-config: 29.7.0(@types/node@26.4.0) jest-haste-map: 29.7.0 jest-message-util: 29.7.0 jest-regex-util: 29.6.3 @@ -9281,7 +9294,7 @@ snapshots: '@jest/transform': 29.7.0 '@jest/types': 29.6.3 '@jridgewell/trace-mapping': 0.3.31 - '@types/node': 26.1.2 + '@types/node': 26.4.0 chalk: 4.1.2 collect-v8-coverage: 1.0.3 exit: 0.1.2 @@ -9975,8 +9988,8 @@ snapshots: dependencies: '@react-native/js-polyfills': 0.85.2 '@react-native/metro-babel-transformer': 0.85.2(@babel/core@7.29.7) - metro-config: 0.84.4 - metro-runtime: 0.84.4 + metro-config: 0.84.5 + metro-runtime: 0.84.5 transitivePeerDependencies: - '@babel/core' - bufferutil @@ -10126,7 +10139,7 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@testing-library/react-native@13.3.3(jest@29.7.0(@types/node@26.1.2))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8)': + '@testing-library/react-native@13.3.3(jest@29.7.0(@types/node@26.4.0))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: jest-matcher-utils: 30.3.0 picocolors: 1.1.1 @@ -10136,7 +10149,7 @@ snapshots: react-test-renderer: 19.2.8(react@19.2.8) redent: 3.0.0 optionalDependencies: - jest: 29.7.0(@types/node@26.1.2) + jest: 29.7.0(@types/node@26.4.0) '@tootallnate/once@2.0.1': {} @@ -10345,6 +10358,10 @@ snapshots: dependencies: undici-types: 8.3.0 + '@types/node@26.4.0': + dependencies: + undici-types: 8.3.0 + '@types/react-native@0.73.0(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8)': dependencies: react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) @@ -10525,13 +10542,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.11(vite@8.1.0(@types/node@26.1.2)(esbuild@0.25.4)(terser@5.49.1)(tsx@4.22.4)(yaml@2.9.0))': + '@vitest/mocker@4.1.11(vite@8.1.0(@types/node@26.4.0)(esbuild@0.25.4)(terser@5.51.2)(tsx@4.22.4)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.11 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.1.0(@types/node@26.1.2)(esbuild@0.25.4)(terser@5.49.1)(tsx@4.22.4)(yaml@2.9.0) + vite: 8.1.0(@types/node@26.4.0)(esbuild@0.25.4)(terser@5.51.2)(tsx@4.22.4)(yaml@2.9.0) '@vitest/pretty-format@4.1.11': dependencies: @@ -10934,7 +10951,7 @@ snapshots: base64-js@1.5.1: {} - baseline-browser-mapping@2.10.27: {} + baseline-browser-mapping@2.11.20: {} better-opn@3.0.2: dependencies: @@ -10972,13 +10989,13 @@ snapshots: dependencies: fill-range: 7.1.1 - browserslist@4.28.2: + browserslist@4.28.8: dependencies: - baseline-browser-mapping: 2.10.27 - caniuse-lite: 1.0.30001792 - electron-to-chromium: 1.5.352 - node-releases: 2.0.38 - update-browserslist-db: 1.2.3(browserslist@4.28.2) + baseline-browser-mapping: 2.11.20 + caniuse-lite: 1.0.30001810 + electron-to-chromium: 1.5.416 + node-releases: 2.0.54 + update-browserslist-db: 1.3.2(browserslist@4.28.8) bs-logger@0.2.6: dependencies: @@ -11024,7 +11041,7 @@ snapshots: camelcase@6.3.0: {} - caniuse-lite@1.0.30001792: {} + caniuse-lite@1.0.30001810: {} chai@6.2.2: {} @@ -11174,7 +11191,7 @@ snapshots: core-js-compat@3.49.0: dependencies: - browserslist: 4.28.2 + browserslist: 4.28.8 cose-base@1.0.3: dependencies: @@ -11184,13 +11201,13 @@ snapshots: dependencies: layout-base: 2.0.1 - create-jest@29.7.0(@types/node@26.1.2): + create-jest@29.7.0(@types/node@26.4.0): dependencies: '@jest/types': 29.6.3 chalk: 4.1.2 exit: 0.1.2 graceful-fs: 4.2.11 - jest-config: 29.7.0(@types/node@26.1.2) + jest-config: 29.7.0(@types/node@26.4.0) jest-util: 29.7.0 prompts: 2.4.2 transitivePeerDependencies: @@ -11554,7 +11571,7 @@ snapshots: ee-first@1.1.1: {} - electron-to-chromium@1.5.352: {} + electron-to-chromium@1.5.416: {} emittery@0.13.1: {} @@ -12169,7 +12186,7 @@ snapshots: expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) expo-json-utils: 55.0.2 - expo-module-scripts@55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(eslint@9.39.4)(expo@55.0.30)(jest@29.7.0(@types/node@26.1.2))(prettier@2.8.8)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-refresh@0.14.2)(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8): + expo-module-scripts@55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(eslint@9.39.4)(expo@55.0.30)(jest@29.7.0(@types/node@26.4.0))(prettier@2.8.8)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-refresh@0.14.2)(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8): dependencies: '@babel/cli': 7.28.6(@babel/core@7.29.7) '@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.29.7) @@ -12177,7 +12194,7 @@ snapshots: '@babel/preset-typescript': 7.28.5(@babel/core@7.29.7) '@expo/npm-proofread': 1.0.1 '@expo/spawn-async': 1.7.2 - '@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@26.1.2))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8) + '@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@26.4.0))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8) '@tsconfig/node18': 18.2.6 '@types/jest': 29.5.14 babel-plugin-dynamic-import-node: 2.3.3 @@ -12185,11 +12202,11 @@ snapshots: commander: 12.1.0 eslint-config-universe: 15.0.4(eslint@9.39.4)(prettier@2.8.8)(typescript@5.9.3) glob: 13.0.6 - jest-expo: 55.0.17(@babel/core@7.29.7)(expo@55.0.30)(jest@29.7.0(@types/node@26.1.2))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)(typescript@5.9.3) + jest-expo: 55.0.17(@babel/core@7.29.7)(expo@55.0.30)(jest@29.7.0(@types/node@26.4.0))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)(typescript@5.9.3) jest-snapshot-prettier: prettier@2.8.8 - jest-watch-typeahead: 2.2.1(jest@29.7.0(@types/node@26.1.2)) + jest-watch-typeahead: 2.2.1(jest@29.7.0(@types/node@26.4.0)) resolve-workspace-root: 2.0.1 - ts-jest: 29.0.5(@babel/core@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(jest@29.7.0(@types/node@26.1.2))(typescript@5.9.3) + ts-jest: 29.0.5(@babel/core@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(jest@29.7.0(@types/node@26.4.0))(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - '@babel/core' @@ -12252,7 +12269,7 @@ snapshots: - supports-color - typescript - expo-router@55.0.18(2f99795f9796def5cdb1516a493dc117): + expo-router@55.0.18(4a60a26fd685ffdcc7f556016ae4bc5e): dependencies: '@expo/log-box': 55.0.13(@expo/dom-webview@55.0.5)(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) '@expo/metro-runtime': 55.0.10(@expo/dom-webview@55.0.5)(expo@55.0.30)(react-dom@19.2.8(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) @@ -12289,7 +12306,7 @@ snapshots: use-latest-callback: 0.2.6(react@19.2.8) vaul: 1.1.2(@types/react@19.2.14)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) optionalDependencies: - '@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@26.1.2))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8) + '@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@26.4.0))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8) react-dom: 19.2.8(react@19.2.8) react-native-gesture-handler: 2.31.2(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) react-native-reanimated: 4.3.4(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) @@ -12950,7 +12967,7 @@ snapshots: '@jest/expect': 29.7.0 '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 26.1.2 + '@types/node': 26.4.0 chalk: 4.1.2 co: 4.6.0 dedent: 1.7.2 @@ -12970,16 +12987,16 @@ snapshots: - babel-plugin-macros - supports-color - jest-cli@29.7.0(@types/node@26.1.2): + jest-cli@29.7.0(@types/node@26.4.0): dependencies: '@jest/core': 29.7.0 '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 chalk: 4.1.2 - create-jest: 29.7.0(@types/node@26.1.2) + create-jest: 29.7.0(@types/node@26.4.0) exit: 0.1.2 import-local: 3.2.0 - jest-config: 29.7.0(@types/node@26.1.2) + jest-config: 29.7.0(@types/node@26.4.0) jest-util: 29.7.0 jest-validate: 29.7.0 yargs: 17.7.3 @@ -12989,7 +13006,7 @@ snapshots: - supports-color - ts-node - jest-config@29.7.0(@types/node@26.1.2): + jest-config@29.7.0(@types/node@26.4.0): dependencies: '@babel/core': 7.29.7 '@jest/test-sequencer': 29.7.0 @@ -13014,7 +13031,7 @@ snapshots: slash: 3.0.0 strip-json-comments: 3.1.1 optionalDependencies: - '@types/node': 26.1.2 + '@types/node': 26.4.0 transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -13069,7 +13086,7 @@ snapshots: jest-mock: 29.7.0 jest-util: 29.7.0 - jest-expo@55.0.17(@babel/core@7.29.7)(expo@55.0.30)(jest@29.7.0(@types/node@26.1.2))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)(typescript@5.9.3): + jest-expo@55.0.17(@babel/core@7.29.7)(expo@55.0.30)(jest@29.7.0(@types/node@26.4.0))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)(typescript@5.9.3): dependencies: '@expo/config': 55.0.16(typescript@5.9.3) '@expo/json-file': 10.0.14 @@ -13080,7 +13097,7 @@ snapshots: jest-environment-jsdom: 29.7.0 jest-snapshot: 29.7.0 jest-watch-select-projects: 2.0.0 - jest-watch-typeahead: 2.2.1(jest@29.7.0(@types/node@26.1.2)) + jest-watch-typeahead: 2.2.1(jest@29.7.0(@types/node@26.4.0)) json5: 2.2.3 lodash: 4.18.1 react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) @@ -13184,7 +13201,7 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 26.1.2 + '@types/node': 26.4.0 chalk: 4.1.2 emittery: 0.13.1 graceful-fs: 4.2.11 @@ -13212,7 +13229,7 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 26.1.2 + '@types/node': 26.4.0 chalk: 4.1.2 cjs-module-lexer: 1.4.3 collect-v8-coverage: 1.0.3 @@ -13279,11 +13296,11 @@ snapshots: chalk: 3.0.0 prompts: 2.4.2 - jest-watch-typeahead@2.2.1(jest@29.7.0(@types/node@26.1.2)): + jest-watch-typeahead@2.2.1(jest@29.7.0(@types/node@26.4.0)): dependencies: ansi-escapes: 6.2.1 chalk: 4.1.2 - jest: 29.7.0(@types/node@26.1.2) + jest: 29.7.0(@types/node@26.4.0) jest-regex-util: 29.6.3 jest-watcher: 29.7.0 slash: 5.1.0 @@ -13308,12 +13325,12 @@ snapshots: merge-stream: 2.0.0 supports-color: 8.1.1 - jest@29.7.0(@types/node@26.1.2): + jest@29.7.0(@types/node@26.4.0): dependencies: '@jest/core': 29.7.0 '@jest/types': 29.6.3 import-local: 3.2.0 - jest-cli: 29.7.0(@types/node@26.1.2) + jest-cli: 29.7.0(@types/node@26.4.0) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -13333,6 +13350,10 @@ snapshots: dependencies: argparse: 2.0.1 + js-yaml@4.3.2: + dependencies: + argparse: 2.0.1 + jsc-safe-url@0.2.4: {} jsdom@20.0.3: @@ -13604,12 +13625,12 @@ snapshots: transitivePeerDependencies: - supports-color - metro-babel-transformer@0.84.4: + metro-babel-transformer@0.84.5: dependencies: '@babel/core': 7.29.7 flow-enums-runtime: 0.0.6 hermes-parser: 0.35.0 - metro-cache-key: 0.84.4 + metro-cache-key: 0.84.5 nullthrows: 1.1.1 transitivePeerDependencies: - supports-color @@ -13622,7 +13643,7 @@ snapshots: dependencies: flow-enums-runtime: 0.0.6 - metro-cache-key@0.84.4: + metro-cache-key@0.84.5: dependencies: flow-enums-runtime: 0.0.6 @@ -13644,12 +13665,12 @@ snapshots: transitivePeerDependencies: - supports-color - metro-cache@0.84.4: + metro-cache@0.84.5: dependencies: exponential-backoff: 3.1.3 flow-enums-runtime: 0.0.6 https-proxy-agent: 7.0.6 - metro-core: 0.84.4 + metro-core: 0.84.5 transitivePeerDependencies: - supports-color @@ -13683,15 +13704,15 @@ snapshots: - supports-color - utf-8-validate - metro-config@0.84.4: + metro-config@0.84.5: dependencies: connect: 3.7.0 flow-enums-runtime: 0.0.6 jest-validate: 29.7.0 - metro: 0.84.4 - metro-cache: 0.84.4 - metro-core: 0.84.4 - metro-runtime: 0.84.4 + metro: 0.84.5 + metro-cache: 0.84.5 + metro-core: 0.84.5 + metro-runtime: 0.84.5 yaml: 2.9.0 transitivePeerDependencies: - bufferutil @@ -13710,11 +13731,11 @@ snapshots: lodash.throttle: 4.1.1 metro-resolver: 0.83.8 - metro-core@0.84.4: + metro-core@0.84.5: dependencies: flow-enums-runtime: 0.0.6 lodash.throttle: 4.1.1 - metro-resolver: 0.84.4 + metro-resolver: 0.84.5 metro-file-map@0.83.7: dependencies: @@ -13744,7 +13765,7 @@ snapshots: transitivePeerDependencies: - supports-color - metro-file-map@0.84.4: + metro-file-map@0.84.5: dependencies: debug: 4.4.3 fb-watchman: 2.0.2 @@ -13768,10 +13789,10 @@ snapshots: flow-enums-runtime: 0.0.6 terser: 5.49.1 - metro-minify-terser@0.84.4: + metro-minify-terser@0.84.5: dependencies: flow-enums-runtime: 0.0.6 - terser: 5.49.1 + terser: 5.51.2 metro-resolver@0.83.7: dependencies: @@ -13781,7 +13802,7 @@ snapshots: dependencies: flow-enums-runtime: 0.0.6 - metro-resolver@0.84.4: + metro-resolver@0.84.5: dependencies: flow-enums-runtime: 0.0.6 @@ -13795,7 +13816,7 @@ snapshots: '@babel/runtime': 7.29.7 flow-enums-runtime: 0.0.6 - metro-runtime@0.84.4: + metro-runtime@0.84.5: dependencies: '@babel/runtime': 7.29.7 flow-enums-runtime: 0.0.6 @@ -13828,15 +13849,15 @@ snapshots: transitivePeerDependencies: - supports-color - metro-source-map@0.84.4: + metro-source-map@0.84.5: dependencies: '@babel/traverse': 7.29.8 '@babel/types': 7.29.8 flow-enums-runtime: 0.0.6 invariant: 2.2.4 - metro-symbolicate: 0.84.4 + metro-symbolicate: 0.84.5 nullthrows: 1.1.1 - ob1: 0.84.4 + ob1: 0.84.5 source-map: 0.5.7 vlq: 1.0.1 transitivePeerDependencies: @@ -13864,11 +13885,11 @@ snapshots: transitivePeerDependencies: - supports-color - metro-symbolicate@0.84.4: + metro-symbolicate@0.84.5: dependencies: flow-enums-runtime: 0.0.6 invariant: 2.2.4 - metro-source-map: 0.84.4 + metro-source-map: 0.84.5 nullthrows: 1.1.1 source-map: 0.5.7 vlq: 1.0.1 @@ -13897,7 +13918,7 @@ snapshots: transitivePeerDependencies: - supports-color - metro-transform-plugins@0.84.4: + metro-transform-plugins@0.84.5: dependencies: '@babel/core': 7.29.7 '@babel/generator': 7.29.8 @@ -13948,20 +13969,20 @@ snapshots: - supports-color - utf-8-validate - metro-transform-worker@0.84.4: + metro-transform-worker@0.84.5: dependencies: '@babel/core': 7.29.7 '@babel/generator': 7.29.8 '@babel/parser': 7.29.8 '@babel/types': 7.29.8 flow-enums-runtime: 0.0.6 - metro: 0.84.4 - metro-babel-transformer: 0.84.4 - metro-cache: 0.84.4 - metro-cache-key: 0.84.4 - metro-minify-terser: 0.84.4 - metro-source-map: 0.84.4 - metro-transform-plugins: 0.84.4 + metro: 0.84.5 + metro-babel-transformer: 0.84.5 + metro-cache: 0.84.5 + metro-cache-key: 0.84.5 + metro-minify-terser: 0.84.5 + metro-source-map: 0.84.5 + metro-transform-plugins: 0.84.5 nullthrows: 1.1.1 transitivePeerDependencies: - bufferutil @@ -14059,7 +14080,7 @@ snapshots: - supports-color - utf-8-validate - metro@0.84.4: + metro@0.84.5: dependencies: '@babel/code-frame': 7.29.7 '@babel/core': 7.29.7 @@ -14076,23 +14097,22 @@ snapshots: flow-enums-runtime: 0.0.6 graceful-fs: 4.2.11 hermes-parser: 0.35.0 - image-size: 1.2.1 invariant: 2.2.4 jest-worker: 29.7.0 jsc-safe-url: 0.2.4 lodash.throttle: 4.1.1 - metro-babel-transformer: 0.84.4 - metro-cache: 0.84.4 - metro-cache-key: 0.84.4 - metro-config: 0.84.4 - metro-core: 0.84.4 - metro-file-map: 0.84.4 - metro-resolver: 0.84.4 - metro-runtime: 0.84.4 - metro-source-map: 0.84.4 - metro-symbolicate: 0.84.4 - metro-transform-plugins: 0.84.4 - metro-transform-worker: 0.84.4 + metro-babel-transformer: 0.84.5 + metro-cache: 0.84.5 + metro-cache-key: 0.84.5 + metro-config: 0.84.5 + metro-core: 0.84.5 + metro-file-map: 0.84.5 + metro-resolver: 0.84.5 + metro-runtime: 0.84.5 + metro-source-map: 0.84.5 + metro-symbolicate: 0.84.5 + metro-transform-plugins: 0.84.5 + metro-transform-worker: 0.84.5 mime-types: 3.0.2 nullthrows: 1.1.1 serialize-error: 2.1.0 @@ -14175,7 +14195,7 @@ snapshots: node-int64@0.4.0: {} - node-releases@2.0.38: {} + node-releases@2.0.54: {} normalize-path@3.0.0: {} @@ -14206,7 +14226,7 @@ snapshots: dependencies: flow-enums-runtime: 0.0.6 - ob1@0.84.4: + ob1@0.84.5: dependencies: flow-enums-runtime: 0.0.6 @@ -15212,6 +15232,13 @@ snapshots: commander: 2.20.3 source-map-support: 0.5.21 + terser@5.51.2: + dependencies: + '@jridgewell/source-map': 0.3.11 + acorn: 8.15.0 + commander: 2.20.3 + source-map-support: 0.5.21 + test-exclude@6.0.0: dependencies: '@istanbuljs/schema': 0.1.6 @@ -15267,11 +15294,11 @@ snapshots: ts-dedent@2.3.0: {} - ts-jest@29.0.5(@babel/core@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(jest@29.7.0(@types/node@26.1.2))(typescript@5.9.3): + ts-jest@29.0.5(@babel/core@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(jest@29.7.0(@types/node@26.4.0))(typescript@5.9.3): dependencies: bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 - jest: 29.7.0(@types/node@26.1.2) + jest: 29.7.0(@types/node@26.4.0) jest-util: 29.7.0 json5: 2.2.3 lodash.memoize: 4.1.2 @@ -15381,9 +15408,9 @@ snapshots: unpipe@1.0.0: {} - update-browserslist-db@1.2.3(browserslist@4.28.2): + update-browserslist-db@1.3.2(browserslist@4.28.8): dependencies: - browserslist: 4.28.2 + browserslist: 4.28.8 escalade: 3.2.0 picocolors: 1.1.1 @@ -15442,7 +15469,7 @@ snapshots: - '@types/react' - '@types/react-dom' - vite@8.1.0(@types/node@26.1.2)(esbuild@0.25.4)(terser@5.49.1)(tsx@4.22.4)(yaml@2.9.0): + vite@8.1.0(@types/node@26.4.0)(esbuild@0.25.4)(terser@5.51.2)(tsx@4.22.4)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -15450,17 +15477,17 @@ snapshots: rolldown: 1.1.3 tinyglobby: 0.2.17 optionalDependencies: - '@types/node': 26.1.2 + '@types/node': 26.4.0 esbuild: 0.25.4 fsevents: 2.3.3 - terser: 5.49.1 + terser: 5.51.2 tsx: 4.22.4 yaml: 2.9.0 - vitest@4.1.11(@types/node@26.1.2)(happy-dom@20.11.8)(jsdom@20.0.3)(vite@8.1.0(@types/node@26.1.2)(esbuild@0.25.4)(terser@5.49.1)(tsx@4.22.4)(yaml@2.9.0)): + vitest@4.1.11(@types/node@26.4.0)(happy-dom@20.11.8)(jsdom@20.0.3)(vite@8.1.0(@types/node@26.4.0)(esbuild@0.25.4)(terser@5.51.2)(tsx@4.22.4)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(vite@8.1.0(@types/node@26.1.2)(esbuild@0.25.4)(terser@5.49.1)(tsx@4.22.4)(yaml@2.9.0)) + '@vitest/mocker': 4.1.11(vite@8.1.0(@types/node@26.4.0)(esbuild@0.25.4)(terser@5.51.2)(tsx@4.22.4)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.11 '@vitest/runner': 4.1.11 '@vitest/snapshot': 4.1.11 @@ -15477,10 +15504,10 @@ snapshots: tinyexec: 1.1.2 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.1.0(@types/node@26.1.2)(esbuild@0.25.4)(terser@5.49.1)(tsx@4.22.4)(yaml@2.9.0) + vite: 8.1.0(@types/node@26.4.0)(esbuild@0.25.4)(terser@5.51.2)(tsx@4.22.4)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 26.1.2 + '@types/node': 26.4.0 happy-dom: 20.11.8 jsdom: 20.0.3 transitivePeerDependencies: 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/mobile/src/transport/rpc-client-live-recovery.test.ts b/mobile/src/transport/rpc-client-live-recovery.test.ts index 471a53be740..bef276f918d 100644 --- a/mobile/src/transport/rpc-client-live-recovery.test.ts +++ b/mobile/src/transport/rpc-client-live-recovery.test.ts @@ -59,7 +59,8 @@ function e2eeDecrypt(encrypted: string, sharedKey: Uint8Array): string | null { // fail with EADDRINUSE; the full scenario restarts on the captured port // because the client keeps reconnecting to its original URL. function startServer(port = 0): Promise { - const wss = new WebSocketServer({ port }) + // host must match the 127.0.0.1 clients dial: a wildcard bind lets a foreign loopback listener claim the port and answer here. + const wss = new WebSocketServer({ host: '127.0.0.1', port }) wss.on('connection', (ws: ServerSocket) => { let sharedKey: Uint8Array | null = null let authenticated = false diff --git a/src/main/agent-awake-service.ts b/src/main/agent-awake-service.ts index 29e866d27f2..b79612e2b9c 100644 --- a/src/main/agent-awake-service.ts +++ b/src/main/agent-awake-service.ts @@ -117,6 +117,11 @@ export class AgentAwakeService { } } + /** Agents this runtime has seen working recently, independent of the awake setting. */ + getWorkingAgentCount(): number { + return this.getEligibleRunningStatusCount() + } + subscribe(listener: (status: ComputerAwakeStatus) => void): () => void { this.statusListeners.add(listener) return () => this.statusListeners.delete(listener) diff --git a/src/main/agent-hooks/server-replay-evidence-clock.test.ts b/src/main/agent-hooks/server-replay-evidence-clock.test.ts new file mode 100644 index 00000000000..12475a35d64 --- /dev/null +++ b/src/main/agent-hooks/server-replay-evidence-clock.test.ts @@ -0,0 +1,102 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AgentHookServer, _internals } from './server' +import { createHookListenerState } from '../../shared/agent-hook-listener/listener-state' +import { normalizeHookPayload } from '../../shared/agent-hook-listener' +import type { EnrichedAgentHookEventPayload } from './server/server-types' +import { buildBody, PANE } from './server.test-fixtures' + +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: () => ({}) })) + +const CONNECTION = 'conn-1' +const T0 = 1_800_000_000_000 + +function ingest( + server: AgentHookServer, + payload: Record, + options: { isReplay?: boolean } = {} +): void { + const event = normalizeHookPayload( + createHookListenerState(), + 'claude', + buildBody(payload), + 'production' + ) + if (!event) { + throw new Error('normalizeHookPayload rejected a known-good Claude fixture') + } + server.ingestRemote({ ...event, ...(options.isReplay ? { isReplay: true } : {}) }, CONNECTION) +} + +describe('the observation clock a relay replay must not restamp', () => { + let server: AgentHookServer + let emitted: EnrichedAgentHookEventPayload[] + + beforeEach(() => { + _internals.resetCachesForTests() + vi.useFakeTimers() + vi.setSystemTime(T0) + server = new AgentHookServer() + emitted = [] + server.setListener((payload) => { + emitted.push(payload) + }) + }) + + afterEach(() => { + server.setListener(null) + vi.useRealTimers() + vi.restoreAllMocks() + }) + + const lastForPane = (): EnrichedAgentHookEventPayload => + emitted.toReversed().find((event) => event.paneKey === PANE)! + + it('holds the observation time across a reconnect replay while delivery order advances', () => { + ingest(server, { hook_event_name: 'UserPromptSubmit', prompt: 'do the thing' }) + expect(lastForPane().evidenceObservedAt).toBe(T0) + + vi.setSystemTime(T0 + 25 * 60 * 1000) + // A lost transport clears the row; the age of the evidence it restates is not a claim. + server.clearStatusEntriesForConnection(CONNECTION) + ingest( + server, + { hook_event_name: 'UserPromptSubmit', prompt: 'do the thing' }, + { isReplay: true } + ) + + const replayed = lastForPane() + expect(replayed.payload.state).toBe('working') + // Delivery order must still clear the connection watermark, or the renderer drops the row. + expect(replayed.receivedAt).toBeGreaterThan(T0 + 25 * 60 * 1000 - 1) + expect(replayed.evidenceObservedAt).toBe(T0) + }) + + it('lets a live event restamp the observation time after a replay', () => { + ingest(server, { hook_event_name: 'UserPromptSubmit', prompt: 'do the thing' }) + vi.setSystemTime(T0 + 25 * 60 * 1000) + server.clearStatusEntriesForConnection(CONNECTION) + ingest( + server, + { hook_event_name: 'UserPromptSubmit', prompt: 'do the thing' }, + { isReplay: true } + ) + + vi.setSystemTime(T0 + 26 * 60 * 1000) + ingest(server, { hook_event_name: 'PreToolUse', tool_name: 'Edit' }) + expect(lastForPane().evidenceObservedAt).toBe(T0 + 26 * 60 * 1000) + }) + + it('gives a torn-down pane no inherited observation time', () => { + ingest(server, { hook_event_name: 'UserPromptSubmit', prompt: 'do the thing' }) + server.clearPaneState(PANE) + + vi.setSystemTime(T0 + 25 * 60 * 1000) + ingest( + server, + { hook_event_name: 'UserPromptSubmit', prompt: 'a new session' }, + { isReplay: true } + ) + expect(lastForPane().evidenceObservedAt).toBe(T0 + 25 * 60 * 1000) + }) +}) diff --git a/src/main/agent-hooks/server/server-state.ts b/src/main/agent-hooks/server/server-state.ts index 956be136ca6..7dc8125576e 100644 --- a/src/main/agent-hooks/server/server-state.ts +++ b/src/main/agent-hooks/server/server-state.ts @@ -97,6 +97,10 @@ export abstract class AgentHookServerState { protected closedAgentStatusPaneKeys = new Set() protected restartedStatusLaunchTokenHashByPaneKey = new Map() protected connectionTimestampWatermarkById = new Map() + // Why: survives the row itself. A transport clear deletes the pane's status row on purpose + // (absence, not completion), but the *age* of the evidence a later replay restates is not a + // claim about the pane and must not be lost with it. Bounded like its sibling maps. + protected evidenceObservedAtByPaneKey = new Map() // Why: skip disk writes when the JSON exactly matches the last write; guards against re-firing trailing timers when nothing changed. protected lastWrittenJson: string | null = null // Why: main is the pane authority for local/WSL/SSH panes — hook HTTP, relay, and its own diff --git a/src/main/agent-hooks/server/server-status-application.ts b/src/main/agent-hooks/server/server-status-application.ts index 7fbb6a96bc1..f7e1126d11b 100644 --- a/src/main/agent-hooks/server/server-status-application.ts +++ b/src/main/agent-hooks/server/server-status-application.ts @@ -13,6 +13,9 @@ import type { EnrichedAgentHookEventPayload } from './server-types' import { agentTypeToPromptSentAgentKind } from './server-status-identity' import { AgentHookServerStatusDisposition } from './server-status-disposition' +/** Bounds the retained observation clock; eviction only degrades a replay to `now`. */ +const MAX_REMEMBERED_EVIDENCE_OBSERVATIONS = 1024 + export abstract class AgentHookServerStatusApplication extends AgentHookServerStatusDisposition { protected attachStatusTiming( payload: AgentHookEventPayload, @@ -41,10 +44,38 @@ export abstract class AgentHookServerStatusApplication extends AgentHookServerSt return { ...payload, receivedAt: now, + evidenceObservedAt: this.resolveEvidenceObservedAt(payload, previous, now), stateStartedAt } } + /** + * A replay restates evidence already observed; it is not a new observation. Keeping + * `receivedAt` at `now` preserves delivery order (the connection-clear watermark and the + * renderer's four `<` drops all depend on it), while this clock records when the evidence + * was actually seen — so the staleness window measures age, not reconnect count. + * Without a remembered time the honest answer is `now`, which is today's behaviour. + */ + private resolveEvidenceObservedAt( + payload: AgentHookEventPayload, + previous: EnrichedAgentHookEventPayload | undefined, + now: number + ): number { + const remembered = + previous?.evidenceObservedAt ?? this.evidenceObservedAtByPaneKey.get(payload.paneKey) + const observedAt = payload.isReplay === true && remembered !== undefined ? remembered : now + this.evidenceObservedAtByPaneKey.delete(payload.paneKey) + this.evidenceObservedAtByPaneKey.set(payload.paneKey, observedAt) + while (this.evidenceObservedAtByPaneKey.size > MAX_REMEMBERED_EVIDENCE_OBSERVATIONS) { + const oldest = this.evidenceObservedAtByPaneKey.keys().next().value + if (typeof oldest !== 'string') { + break + } + this.evidenceObservedAtByPaneKey.delete(oldest) + } + return observedAt + } + protected hashPromptForTelemetryDedupe(prompt: string): string { return createHash('sha256') .update(this.promptSentHashSalt) diff --git a/src/main/agent-hooks/server/server-tab-cleanup.ts b/src/main/agent-hooks/server/server-tab-cleanup.ts index 4abacfc81d0..3ce2c4fce0a 100644 --- a/src/main/agent-hooks/server/server-tab-cleanup.ts +++ b/src/main/agent-hooks/server/server-tab-cleanup.ts @@ -94,6 +94,8 @@ export abstract class AgentHookServerTabCleanup extends AgentHookServerCleanup { this.currentAuthorityObservations.delete(resolvedPaneKey) this.promptSentDedupeByPaneKey.delete(resolvedPaneKey) this.restartedStatusLaunchTokenHashByPaneKey.delete(resolvedPaneKey) + // Why: the pane itself is gone, so its observation clock describes nothing a later pane owns. + this.evidenceObservedAtByPaneKey.delete(resolvedPaneKey) let clearedAlias = false for (const [legacyPaneKey, alias] of this.legacyPaneKeyAliases) { if (alias.stablePaneKey === resolvedPaneKey) { @@ -105,6 +107,7 @@ export abstract class AgentHookServerTabCleanup extends AgentHookServerCleanup { this.currentAuthorityObservations.delete(legacyPaneKey) this.promptSentDedupeByPaneKey.delete(legacyPaneKey) this.restartedStatusLaunchTokenHashByPaneKey.delete(legacyPaneKey) + this.evidenceObservedAtByPaneKey.delete(legacyPaneKey) clearedAlias = true } } diff --git a/src/main/agent-hooks/server/server-types.ts b/src/main/agent-hooks/server/server-types.ts index 913bcd7067e..c151c70d34b 100644 --- a/src/main/agent-hooks/server/server-types.ts +++ b/src/main/agent-hooks/server/server-types.ts @@ -11,6 +11,11 @@ import type { LegacyPaneKeyAliasEntry } from '../../../shared/persisted-state-ty // Why: server-side enrichment — receivedAt = latest event arrival, stateStartedAt = when the current state first appeared; extra fields ride the shared map untouched (it only writes/clears). export type EnrichedAgentHookEventPayload = AgentHookEventPayload & { receivedAt: number + /** When this evidence was first observed, as distinct from `receivedAt`. A relay reconnect + * replays cached rows and `receivedAt` must restamp to clear the connection watermark, so + * only this clock can answer how old the evidence itself is. Persisted so it survives a + * main restart; absent means "never separately observed" and consumers use `receivedAt`. */ + evidenceObservedAt?: number stateStartedAt: number /** Provenance/ordering stamped by this server as the pane authority (STA-4293). Read by nothing yet. */ observation?: AgentStatusObservation diff --git a/src/main/cli/cli-command-installation.ts b/src/main/cli/cli-command-installation.ts index 5b277bd8c62..fbabc3bf6dd 100644 --- a/src/main/cli/cli-command-installation.ts +++ b/src/main/cli/cli-command-installation.ts @@ -35,7 +35,9 @@ export class CliCommandInstallation extends CliCommandInspection { const inspected = await this.inspectStableSymlink(commandPath, launcherPath) if (inspected.status.state === 'conflict') { - throw new Error(`Refusing to replace non-Orca command at ${commandPath}. Remove it and register again if it is no longer needed.`) + throw new Error( + `Refusing to replace non-Orca command at ${commandPath}. Remove it and register again if it is no longer needed.` + ) } if (inspected.status.state === 'installed') { return @@ -54,7 +56,9 @@ export class CliCommandInstallation extends CliCommandInspection { if (!(await capturedExpectedEntry(quarantine, inspected))) { await this.restoreQuarantinedCommand(quarantine, commandPath) - throw new Error(`Refusing to replace non-Orca command at ${commandPath}. Remove it and register again if it is no longer needed.`) + throw new Error( + `Refusing to replace non-Orca command at ${commandPath}. Remove it and register again if it is no longer needed.` + ) } try { diff --git a/src/main/cli/cli-installer.ts b/src/main/cli/cli-installer.ts index 3c832df5078..d95e1649ac0 100644 --- a/src/main/cli/cli-installer.ts +++ b/src/main/cli/cli-installer.ts @@ -116,7 +116,9 @@ export class CliInstaller extends CliPathRegistration { throw new Error(initialStatus.detail ?? 'CLI registration is unavailable on this build.') } if (initialStatus.state === 'conflict') { - throw new Error(`Refusing to replace non-Orca command at ${initialStatus.commandPath}. Remove it and register again if it is no longer needed.`) + throw new Error( + `Refusing to replace non-Orca command at ${initialStatus.commandPath}. Remove it and register again if it is no longer needed.` + ) } const extractedRoot = await this.ensureLinuxAppImagePayload() const status = extractedRoot @@ -126,7 +128,9 @@ export class CliInstaller extends CliPathRegistration { throw new Error(status.detail ?? 'CLI registration is unavailable on this build.') } if (status.state === 'conflict') { - throw new Error(`Refusing to replace non-Orca command at ${status.commandPath}. Remove it and register again if it is no longer needed.`) + throw new Error( + `Refusing to replace non-Orca command at ${status.commandPath}. Remove it and register again if it is no longer needed.` + ) } // eslint-disable-next-line unicorn/prefer-ternary -- Why: the install path performs async side effects and is easier to audit as an explicit branch than as an awaited ternary. diff --git a/src/main/cli/wsl-cli-installer.ts b/src/main/cli/wsl-cli-installer.ts index f8362fddc66..484ed4f9bc3 100644 --- a/src/main/cli/wsl-cli-installer.ts +++ b/src/main/cli/wsl-cli-installer.ts @@ -207,7 +207,9 @@ export class WslCliInstaller { throw new Error(status.detail ?? 'WSL CLI registration is unavailable.') } if (status.state === 'conflict') { - throw new Error(`Refusing to replace non-Orca command at ${status.commandPath}. Remove it and register again if it is no longer needed.`) + throw new Error( + `Refusing to replace non-Orca command at ${status.commandPath}. Remove it and register again if it is no longer needed.` + ) } await this.run( diff --git a/src/main/daemon/daemon-adoption-telemetry-event.test.ts b/src/main/daemon/daemon-adoption-telemetry-event.test.ts new file mode 100644 index 00000000000..5a5cd7406c4 --- /dev/null +++ b/src/main/daemon/daemon-adoption-telemetry-event.test.ts @@ -0,0 +1,168 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ParsedDaemonPid } from './daemon-pid-file-parse' +import { validate } from '../telemetry/validator' + +const { trackMock, accessSyncMock, existsSyncMock, readFileSyncMock, getVersionMock } = vi.hoisted( + () => ({ + trackMock: vi.fn(), + accessSyncMock: vi.fn(), + existsSyncMock: vi.fn(() => true), + readFileSyncMock: vi.fn(), + getVersionMock: vi.fn(() => '1.4.191') + }) +) +vi.mock('../telemetry/client', () => ({ track: trackMock })) +vi.mock('node:fs', async (importOriginal) => ({ + ...(await importOriginal>()), + accessSync: accessSyncMock, + existsSync: existsSyncMock, + readFileSync: readFileSyncMock +})) +vi.mock('node:os', async (importOriginal) => ({ + ...(await importOriginal>()), + homedir: () => '/Users/alice' +})) +vi.mock('../../shared/app-environment', () => ({ + getAppEnvironment: () => ({ getVersion: getVersionMock }) +})) + +import { + classifyDaemonAdoptionOrigin, + trackDaemonAdopted, + trackDaemonPtyCwdDeniedIfDiverged +} from './daemon-adoption-telemetry-event' + +const stalePidRecord: ParsedDaemonPid = { + pid: 1530, + startedAtMs: 1, + entryPath: '/x/daemon-entry.js', + appVersion: '1.4.187', + launchNonce: 'n', + linuxStartTicks: null, + bootId: null, + spawnerExecPath: + '/Users/alice/Library/Caches/com.stablyai.orca.ShipIt/u/Orca.app/Contents/MacOS/Orca' +} +const origin = { app_version_match: 'different', spawner_path_class: 'updater-cache' } as const +const PID_PATH = '/fake/daemon.pid' + +beforeEach(() => { + trackMock.mockReset() + accessSyncMock.mockReset() + existsSyncMock.mockReset().mockReturnValue(true) + readFileSyncMock.mockReset().mockReturnValue(JSON.stringify(stalePidRecord)) + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('classifyDaemonAdoptionOrigin', () => { + it('compares the recorded app version and classifies the spawner path', () => { + expect(classifyDaemonAdoptionOrigin(stalePidRecord)).toEqual(origin) + expect(classifyDaemonAdoptionOrigin({ ...stalePidRecord, appVersion: '1.4.191' })).toEqual({ + app_version_match: 'same', + spawner_path_class: 'updater-cache' + }) + expect(classifyDaemonAdoptionOrigin(null)).toEqual({ + app_version_match: 'unknown', + spawner_path_class: 'unknown' + }) + }) +}) + +describe('trackDaemonAdopted', () => { + it('emits a validator-accepted payload', () => { + trackDaemonAdopted(stalePidRecord, 'intact', 7) + expect(trackMock).toHaveBeenCalledTimes(1) + const [name, props] = trackMock.mock.calls[0] + expect(name).toBe('daemon_adopted') + expect(props).toEqual({ + ...origin, + tcc_attribution: 'intact', + live_session_count_bucket: '6+' + }) + expect(validate('daemon_adopted', props).ok).toBe(true) + }) + + it('swallows a throwing telemetry client', () => { + trackMock.mockImplementationOnce(() => { + throw new Error('posthog exploded') + }) + expect(() => trackDaemonAdopted(null, 'unknown', null)).not.toThrow() + }) +}) + +describe('trackDaemonPtyCwdDeniedIfDiverged', () => { + it('emits only when the daemon was denied and the app can read the same cwd', () => { + trackDaemonPtyCwdDeniedIfDiverged('/Users/alice/Documents/repo', false, PID_PATH) + expect(accessSyncMock).toHaveBeenCalledWith('/Users/alice/Documents/repo', expect.any(Number)) + expect(trackMock).toHaveBeenCalledTimes(1) + const [name, props] = trackMock.mock.calls[0] + expect(name).toBe('daemon_pty_cwd_denied') + expect(props).toEqual({ cwd_class: 'documents', ...origin }) + expect(validate('daemon_pty_cwd_denied', props).ok).toBe(true) + }) + + // False positives would drown the signal this event exists to measure, so every + // non-divergent shape must stay silent. + it('stays silent when the daemon could read the cwd or did not report', () => { + trackDaemonPtyCwdDeniedIfDiverged('/Users/alice/Documents/repo', true, PID_PATH) + trackDaemonPtyCwdDeniedIfDiverged('/Users/alice/Documents/repo', undefined, PID_PATH) + trackDaemonPtyCwdDeniedIfDiverged(undefined, false, PID_PATH) + expect(accessSyncMock).not.toHaveBeenCalled() + expect(trackMock).not.toHaveBeenCalled() + }) + + it('stays silent when the app cannot read the cwd either (no divergence)', () => { + accessSyncMock.mockImplementation(() => { + throw Object.assign(new Error('EACCES'), { code: 'EACCES' }) + }) + trackDaemonPtyCwdDeniedIfDiverged('/Users/alice/Documents/repo', false, PID_PATH) + expect(trackMock).not.toHaveBeenCalled() + }) + + it('attributes the denial to the daemon recorded right now, not a startup snapshot', () => { + readFileSyncMock.mockReturnValue( + JSON.stringify({ + ...stalePidRecord, + appVersion: '1.4.191', + spawnerExecPath: '/Applications/Orca.app/Contents/MacOS/Orca' + }) + ) + trackDaemonPtyCwdDeniedIfDiverged('/Users/alice/Documents/repo', false, PID_PATH) + expect(readFileSyncMock).toHaveBeenCalledWith(PID_PATH, 'utf8') + expect(trackMock.mock.calls[0][1]).toEqual({ + cwd_class: 'documents', + app_version_match: 'same', + spawner_path_class: 'applications' + }) + }) + + it('swallows a throwing app environment or pid-record read instead of failing the spawn', () => { + getVersionMock.mockImplementationOnce(() => { + throw new Error('AppEnvironment not initialized') + }) + expect(() => + trackDaemonPtyCwdDeniedIfDiverged('/Users/alice/Documents/repo', false, PID_PATH) + ).not.toThrow() + expect(trackMock).not.toHaveBeenCalled() + }) + + it('stays silent off macOS', () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('linux') + trackDaemonPtyCwdDeniedIfDiverged('/home/alice/Documents/repo', false, PID_PATH) + expect(accessSyncMock).not.toHaveBeenCalled() + expect(trackMock).not.toHaveBeenCalled() + }) + + it('swallows a throwing telemetry client', () => { + trackMock.mockImplementationOnce(() => { + throw new Error('posthog exploded') + }) + expect(() => + trackDaemonPtyCwdDeniedIfDiverged('/Users/alice/Documents/repo', false, PID_PATH) + ).not.toThrow() + }) +}) diff --git a/src/main/daemon/daemon-adoption-telemetry-event.ts b/src/main/daemon/daemon-adoption-telemetry-event.ts new file mode 100644 index 00000000000..47f554bf9bb --- /dev/null +++ b/src/main/daemon/daemon-adoption-telemetry-event.ts @@ -0,0 +1,81 @@ +// App-side emitters for `daemon_adopted` and `daemon_pty_cwd_denied` (#17696). Both sit on the +// daemon launch / PTY spawn path, so every failure dies here — telemetry can never cost a terminal. + +import { accessSync, constants as fsConstants, existsSync } from 'node:fs' +import { homedir } from 'node:os' +import { getAppEnvironment } from '../../shared/app-environment' +import { + classifyDaemonPtyCwd, + classifyDaemonSpawnerPath, + type DaemonAdoptedAppVersionMatch, + type DaemonSpawnerPathClass +} from '../../shared/daemon-adoption-telemetry' +import { bucketDaemonLiveSessionCount } from '../../shared/daemon-lifecycle-telemetry' +import type { EventProps } from '../../shared/telemetry-events' +import { track } from '../telemetry/client' +import { readDaemonPidRecord } from './daemon-endpoint-incarnation' +import type { ParsedDaemonPid } from './daemon-pid-file-parse' +import type { MacDaemonTccAttributionHealth } from './daemon-tcc-attribution' + +export type DaemonAdoptionOrigin = Pick< + EventProps<'daemon_pty_cwd_denied'>, + 'app_version_match' | 'spawner_path_class' +> + +/** Classifies the adopted daemon's pid record against the running app; enum-only by construction. */ +export function classifyDaemonAdoptionOrigin( + pidRecord: ParsedDaemonPid | null +): DaemonAdoptionOrigin { + const appVersionMatch: DaemonAdoptedAppVersionMatch = !pidRecord?.appVersion + ? 'unknown' + : pidRecord.appVersion === getAppEnvironment().getVersion() + ? 'same' + : 'different' + const spawnerPathClass: DaemonSpawnerPathClass = classifyDaemonSpawnerPath( + pidRecord?.spawnerExecPath ?? null, + existsSync + ) + return { app_version_match: appVersionMatch, spawner_path_class: spawnerPathClass } +} + +// Adopted a daemon that a previous app launch forked (macOS only; that is where attribution matters). +export function trackDaemonAdopted( + pidRecord: ParsedDaemonPid | null, + tccAttribution: MacDaemonTccAttributionHealth, + liveSessionCount: number | null +): void { + try { + track('daemon_adopted', { + ...classifyDaemonAdoptionOrigin(pidRecord), + tcc_attribution: tccAttribution, + live_session_count_bucket: bucketDaemonLiveSessionCount(liveSessionCount) + }) + } catch { + // Telemetry is best-effort; a dropped event must not fail daemon adoption. + } +} + +/** + * Emits only on proven divergence: the daemon reported the cwd unreadable AND this process can + * read it. A cwd neither can read (chmod, ENOENT, unmounted volume) is not the #17696 shape. + */ +export function trackDaemonPtyCwdDeniedIfDiverged( + cwd: string | undefined, + cwdReadableByDaemon: boolean | undefined, + pidPath: string | null +): void { + try { + if (process.platform !== 'darwin' || !cwd || cwdReadableByDaemon !== false) { + return + } + accessSync(cwd, fsConstants.R_OK | fsConstants.X_OK) + // Why read now, not the adapter's startup snapshot: a respawn swaps the daemon under a + // long-lived adapter, and the denial must be attributed to the daemon that just spawned. + track('daemon_pty_cwd_denied', { + cwd_class: classifyDaemonPtyCwd(cwd, homedir()), + ...classifyDaemonAdoptionOrigin(readDaemonPidRecord(pidPath)) + }) + } catch { + // Either the app cannot read it (no divergence) or telemetry failed; neither may reach the caller. + } +} diff --git a/src/main/daemon/daemon-create-or-attach-result.ts b/src/main/daemon/daemon-create-or-attach-result.ts index d6668342487..92a7e451a10 100644 --- a/src/main/daemon/daemon-create-or-attach-result.ts +++ b/src/main/daemon/daemon-create-or-attach-result.ts @@ -14,6 +14,12 @@ export type DaemonCreateOrAttachResult = { wslDistro?: string | null agentSessionEnsure?: AgentSessionClaimedSpawnResult incarnationId?: PtyIncarnationId + /** + * Whether the daemon process itself could read the requested cwd at spawn. Only the daemon's own + * verdict counts: macOS TCC scopes folder access per process tree, so the app's view of the same + * path proves nothing about the daemon's (#17696). Omitted by daemons predating this field. + */ + cwdReadableByDaemon?: boolean } export function getDaemonSessionResultMetadata(session: { 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/daemon/daemon-init-dependency-mocks.ts b/src/main/daemon/daemon-init-dependency-mocks.ts index d920a13866e..d7217d4df63 100644 --- a/src/main/daemon/daemon-init-dependency-mocks.ts +++ b/src/main/daemon/daemon-init-dependency-mocks.ts @@ -50,7 +50,8 @@ export function createDaemonInitModuleFactories(state: DaemonInitMockState) { unbindLocalProviderListenersMock, rebindLocalProviderListenersMock, trackDaemonReplacedMock, - trackDaemonRetiredMock + trackDaemonRetiredMock, + trackDaemonAdoptedMock } = state // Why: both fakes are annotated with constructor types so the exported factories widen to @@ -82,6 +83,9 @@ export function createDaemonInitModuleFactories(state: DaemonInitMockState) { if (result.mode) { this.handle.mode = result.mode } + if (result.adopted) { + this.handle.adopted = true + } return { socketPath: result.socketPath, tokenPath: result.tokenPath @@ -199,6 +203,9 @@ export function createDaemonInitModuleFactories(state: DaemonInitMockState) { trackDaemonReplaced: trackDaemonReplacedMock, trackDaemonRetired: trackDaemonRetiredMock }), + daemonAdoptionTelemetryEvent: () => ({ + trackDaemonAdopted: trackDaemonAdoptedMock + }), daemonSpawner: () => ({ DaemonSpawner: MockDaemonSpawner, getDaemonSocketPath: (_dir: string, version?: number) => diff --git a/src/main/daemon/daemon-init-fresh-import.ts b/src/main/daemon/daemon-init-fresh-import.ts index e1cc0416337..39f3625c063 100644 --- a/src/main/daemon/daemon-init-fresh-import.ts +++ b/src/main/daemon/daemon-init-fresh-import.ts @@ -41,7 +41,8 @@ export async function importFreshDaemonInit(state: DaemonInitMockState) { unbindLocalProviderListenersMock, rebindLocalProviderListenersMock, trackDaemonReplacedMock, - trackDaemonRetiredMock + trackDaemonRetiredMock, + trackDaemonAdoptedMock } = state vi.resetModules() @@ -64,6 +65,7 @@ export async function importFreshDaemonInit(state: DaemonInitMockState) { rebindLocalProviderListenersMock.mockClear() trackDaemonReplacedMock.mockClear() trackDaemonRetiredMock.mockClear() + trackDaemonAdoptedMock.mockClear() checkDaemonHealthMock.mockClear() checkDaemonHealthMock.mockResolvedValue('healthy') healthCheckDaemonMock.mockClear() diff --git a/src/main/daemon/daemon-init-mock-types.ts b/src/main/daemon/daemon-init-mock-types.ts index 8c8b805740f..341fcd26df7 100644 --- a/src/main/daemon/daemon-init-mock-types.ts +++ b/src/main/daemon/daemon-init-mock-types.ts @@ -47,6 +47,7 @@ export type MockAdapterConstructor = new (opts: MockAdapter['options']) => MockA /** Handle the fake spawner hands back from ensureRunning/getHandle. */ export type MockSpawnerHandle = { mode?: 'degraded-new-pty-fallback' + adopted?: true releaseAdoptionLease?: () => void shutdown: () => Promise } @@ -95,6 +96,7 @@ export type EnsureRunningOverride = () => Promise<{ socketPath: string tokenPath: string mode?: 'degraded-new-pty-fallback' + adopted?: true }> /** Every stub daemon-init's suites share, plus the control knobs they mutate per test. */ @@ -143,6 +145,7 @@ export type DaemonInitMockState = { rebindLocalProviderListenersMock: Mock<(...args: unknown[]) => void> trackDaemonReplacedMock: Mock<(...args: unknown[]) => void> trackDaemonRetiredMock: Mock<(...args: unknown[]) => void> + trackDaemonAdoptedMock: Mock<(...args: unknown[]) => void> } /** net.connect stubs the suites install in beforeEach. */ diff --git a/src/main/daemon/daemon-init-provider-installation.test.ts b/src/main/daemon/daemon-init-provider-installation.test.ts index ceb7e9dfa69..423ee9ee34f 100644 --- a/src/main/daemon/daemon-init-provider-installation.test.ts +++ b/src/main/daemon/daemon-init-provider-installation.test.ts @@ -2,6 +2,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { isPackagedMock, + getMacDaemonTccAttributionHealthMock, + trackDaemonAdoptedMock, probeSocketExistsMock, readFileSyncMock, unlinkSyncMock, @@ -42,6 +44,7 @@ vi.mock('./daemon-process-start-time', () => moduleFactories.daemonProcessStartT vi.mock('./daemon-pid-file-parse', () => moduleFactories.daemonPidFileParse()) vi.mock('./client', () => moduleFactories.client()) vi.mock('./daemon-lifecycle-event', () => moduleFactories.daemonLifecycleEvent()) +vi.mock('./daemon-adoption-telemetry-event', () => moduleFactories.daemonAdoptionTelemetryEvent()) vi.mock('./daemon-spawner', () => moduleFactories.daemonSpawner()) vi.mock('./daemon-pty-adapter', () => moduleFactories.daemonPtyAdapter()) vi.mock('../ipc/pty', () => moduleFactories.ipcPty()) @@ -228,6 +231,48 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { expect(adapterInstances[1].disconnectOnly).toHaveBeenCalledOnce() }) + // #17696: adopting a daemon from an earlier app launch is invisible to daemon_lifecycle, so + // it gets its own event — macOS only, and only for adopted (not freshly forked) daemons. + it('reports a macOS daemon adoption with its TCC attribution and live session bucket', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + const mod = await importFresh() + ensureRunningOverrides.push(async () => ({ + socketPath: '/fake/adopted-socket', + tokenPath: '/fake/adopted-token', + adopted: true + })) + getMacDaemonTccAttributionHealthMock.mockResolvedValueOnce('severed') + defaultListSessionsSessions.push({ sessionId: 'wt-1@@a' }, { sessionId: 'wt-1@@b' }) + + await mod.initDaemonPtyProvider() + await vi.waitFor(() => expect(trackDaemonAdoptedMock).toHaveBeenCalledOnce()) + + // null pid record: the harness has no pid file, which the emitter classifies as 'unknown'. + expect(trackDaemonAdoptedMock).toHaveBeenCalledWith(null, 'severed', 2) + vi.restoreAllMocks() + }) + + it('does not report adoption for a freshly forked daemon or off macOS', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + const mod = await importFresh() + await mod.initDaemonPtyProvider() + await new Promise((resolve) => setImmediate(resolve)) + expect(trackDaemonAdoptedMock).not.toHaveBeenCalled() + vi.restoreAllMocks() + + vi.spyOn(process, 'platform', 'get').mockReturnValue('linux') + const linuxMod = await importFresh() + ensureRunningOverrides.push(async () => ({ + socketPath: '/fake/adopted-socket', + tokenPath: '/fake/adopted-token', + adopted: true + })) + await linuxMod.initDaemonPtyProvider() + await new Promise((resolve) => setImmediate(resolve)) + expect(trackDaemonAdoptedMock).not.toHaveBeenCalled() + vi.restoreAllMocks() + }) + it('routes fresh PTYs to the local fallback when a preserved daemon cannot spawn new PTYs', async () => { const mod = await importFresh() ensureRunningOverrides.push(async () => ({ diff --git a/src/main/daemon/daemon-init-test-harness.ts b/src/main/daemon/daemon-init-test-harness.ts index 6c38720a40b..6060ed9b759 100644 --- a/src/main/daemon/daemon-init-test-harness.ts +++ b/src/main/daemon/daemon-init-test-harness.ts @@ -156,6 +156,7 @@ function createDaemonInitMockState(): DaemonInitMockState { const rebindLocalProviderListenersMock = vi.fn() const trackDaemonReplacedMock = vi.fn() const trackDaemonRetiredMock = vi.fn() + const trackDaemonAdoptedMock = vi.fn() return { getPathMock, @@ -197,7 +198,8 @@ function createDaemonInitMockState(): DaemonInitMockState { unbindLocalProviderListenersMock, rebindLocalProviderListenersMock, trackDaemonReplacedMock, - trackDaemonRetiredMock + trackDaemonRetiredMock, + trackDaemonAdoptedMock } } diff --git a/src/main/daemon/daemon-out-of-process-launcher.ts b/src/main/daemon/daemon-out-of-process-launcher.ts index b59535a249a..21ff31918ac 100644 --- a/src/main/daemon/daemon-out-of-process-launcher.ts +++ b/src/main/daemon/daemon-out-of-process-launcher.ts @@ -41,6 +41,7 @@ function createPreservedDaemonHandle( mode?: 'degraded-new-pty-fallback' ): DaemonProcessHandle { const handle: DaemonProcessHandle = { + adopted: true, shutdown: async () => { await cleanupDaemonForProtocol(runtimeDir, protocolVersion) } diff --git a/src/main/daemon/daemon-provider-init.ts b/src/main/daemon/daemon-provider-init.ts index 1881e276e97..fa794257bda 100644 --- a/src/main/daemon/daemon-provider-init.ts +++ b/src/main/daemon/daemon-provider-init.ts @@ -24,7 +24,10 @@ import { import type { DaemonProvider } from './daemon-provider-routing' import { installDaemonProvider } from './daemon-provider-state' import { DegradedDaemonPtyProvider } from './degraded-daemon-pty-provider' +import { trackDaemonAdopted } from './daemon-adoption-telemetry-event' +import { readDaemonPidRecord } from './daemon-endpoint-incarnation' import { trackDaemonRetired } from './daemon-lifecycle-event' +import { getMacDaemonTccAttributionHealth } from './daemon-tcc-attribution' import { DaemonPtyAdapter } from './daemon-pty-adapter' import type { DaemonRespawnReason } from './daemon-pty-runtime-state' import { DaemonPtyRouter } from './daemon-pty-router' @@ -156,9 +159,37 @@ export async function initDaemonPtyProvider( logDaemonMilestone('daemon-init-done', { legacyAdapters: legacyAdapters.length }) + if (process.platform === 'darwin' && newSpawner.getHandle()?.adopted) { + void reportDaemonAdoption(runtimeDir, info.socketPath, info.tokenPath, newAdapter) + } await reconcileSeededClaudeLivePtys(routedAdapter) } +// Why off the init path: this is measurement of an adopted daemon (#17696), and neither its probes nor their failure may delay or fail startup. +async function reportDaemonAdoption( + runtimeDir: string, + socketPath: string, + tokenPath: string, + adapter: DaemonPtyAdapter +): Promise { + try { + const [tccAttribution, liveSessionCount] = await Promise.all([ + getMacDaemonTccAttributionHealth(runtimeDir, socketPath, tokenPath), + adapter.listSessions().then( + (sessions) => sessions.length, + () => null + ) + ]) + trackDaemonAdopted( + readDaemonPidRecord(getDaemonPidPath(runtimeDir)), + tccAttribution, + liveSessionCount + ) + } catch { + // Best-effort measurement only. + } +} + // Why: release gate ids only for daemon-confirmed-dead sessions; keep seeds on listing failure since releasing early can rotate a live CLI's refresh token. async function reconcileSeededClaudeLivePtys(provider: DaemonProvider): Promise { if (!hasSeededUnconfirmedClaudePtys()) { diff --git a/src/main/daemon/daemon-pty-session-spawn.ts b/src/main/daemon/daemon-pty-session-spawn.ts index 62c073f403e..235b286b0bc 100644 --- a/src/main/daemon/daemon-pty-session-spawn.ts +++ b/src/main/daemon/daemon-pty-session-spawn.ts @@ -4,6 +4,7 @@ import type { HistoryRecoveryContext, PendingDaemonSpawnOperation } from './daemon-pty-runtime-state' +import { trackDaemonPtyCwdDeniedIfDiverged } from './daemon-adoption-telemetry-event' import { STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION } from './daemon-protocol-version' import { TerminalKilledError } from './daemon-pty-lifecycle-errors' import { DaemonPtySpawnResult } from './daemon-pty-spawn-result' @@ -246,6 +247,9 @@ export abstract class DaemonPtySessionSpawn extends DaemonPtySpawnResult { } activeSpawnContext = context const result = await this.createOrAttachSpawn(context, context.historySeedSegments) + if (result.isNew && !attachOnly) { + trackDaemonPtyCwdDeniedIfDiverged(effectiveCwd, result.cwdReadableByDaemon, this.pidPath) + } return this.finishSpawn(context, result) } diff --git a/src/main/daemon/daemon-spawner.ts b/src/main/daemon/daemon-spawner.ts index a0376ef0fc0..8c50b764b05 100644 --- a/src/main/daemon/daemon-spawner.ts +++ b/src/main/daemon/daemon-spawner.ts @@ -31,6 +31,8 @@ export type DaemonPidFile = { export type DaemonProcessHandle = { mode?: 'degraded-new-pty-fallback' + /** Set when the launcher kept a daemon some earlier app launch forked, rather than forking one. */ + adopted?: true releaseAdoptionLease?(): void shutdown(): Promise } diff --git a/src/main/daemon/daemon-terminal-admission.ts b/src/main/daemon/daemon-terminal-admission.ts index b47b497fe43..83dadf5f5d2 100644 --- a/src/main/daemon/daemon-terminal-admission.ts +++ b/src/main/daemon/daemon-terminal-admission.ts @@ -161,7 +161,10 @@ export class DaemonTerminalAdmission { ...(result.launchAgent ? { launchAgent: result.launchAgent } : {}), wslDistro: result.wslDistro, ...(result.historySeeded !== undefined ? { historySeeded: result.historySeeded } : {}), - ...(result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {}) + ...(result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {}), + ...(result.cwdReadableByDaemon !== undefined + ? { cwdReadableByDaemon: result.cwdReadableByDaemon } + : {}) } } diff --git a/src/main/daemon/terminal-attach-cancellation.ts b/src/main/daemon/terminal-attach-cancellation.ts new file mode 100644 index 00000000000..52f773b1bf2 --- /dev/null +++ b/src/main/daemon/terminal-attach-cancellation.ts @@ -0,0 +1,20 @@ +import { TerminalAttachCanceledError } from './daemon-errors' + +/** Never resolves; only rejects, so it can bound a wait without settling it. */ +export function rejectOnAbort( + signal: AbortSignal | undefined, + sessionId: string +): Promise { + if (!signal) { + return new Promise(() => {}) + } + return new Promise((_resolve, reject) => { + if (signal.aborted) { + reject(new TerminalAttachCanceledError(sessionId)) + return + } + signal.addEventListener('abort', () => reject(new TerminalAttachCanceledError(sessionId)), { + once: true + }) + }) +} diff --git a/src/main/daemon/terminal-host-create-contract.ts b/src/main/daemon/terminal-host-create-contract.ts index aaaffaeb8e8..42f5bf457f4 100644 --- a/src/main/daemon/terminal-host-create-contract.ts +++ b/src/main/daemon/terminal-host-create-contract.ts @@ -54,4 +54,6 @@ export type CreateOrAttachResult = { attachToken: symbol incarnationId: PtyIncarnationId agentSessionEnsure?: AgentSessionClaimedSpawnResult + /** Daemon-process verdict on the spawn cwd; only set on a fresh spawn that was given a cwd. */ + cwdReadableByDaemon?: boolean } diff --git a/src/main/daemon/terminal-host-cwd-readability.test.ts b/src/main/daemon/terminal-host-cwd-readability.test.ts new file mode 100644 index 00000000000..aa9e08379d7 --- /dev/null +++ b/src/main/daemon/terminal-host-cwd-readability.test.ts @@ -0,0 +1,75 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { SubprocessHandle } from './session-subprocess-handle' +import { TerminalHost, type TerminalHostOptions } from './terminal-host' + +vi.mock('../pty-descendant-termination', () => ({ killWithDescendantSweep: vi.fn() })) + +function createMockSubprocess(): SubprocessHandle { + let onExitCb: ((code: number) => void) | null = null + return { + pid: 99999, + getForegroundProcess: vi.fn(() => null), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(() => { + setTimeout(() => onExitCb?.(0), 5) + }), + terminateOwnedTree: () => 'unavailable' as const, + forceKill: vi.fn(() => onExitCb?.(137)), + signal: vi.fn(), + onData() {}, + onExit(cb) { + onExitCb = cb + }, + dispose: vi.fn() + } +} + +// #17696: only the daemon process can say whether TCC lets it read the cwd, so its verdict +// rides on the create result. A non-permission failure must never read as denial. +describe('TerminalHost cwd readability verdict', () => { + let host: TerminalHost + let platformDescriptor: PropertyDescriptor | undefined + + beforeEach(() => { + platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) + const spawnSubprocess: TerminalHostOptions['spawnSubprocess'] = () => createMockSubprocess() + host = new TerminalHost({ spawnSubprocess }) + }) + + afterEach(async () => { + await host.dispose() + if (platformDescriptor) { + Object.defineProperty(process, 'platform', platformDescriptor) + } + }) + + const create = (sessionId: string, cwd?: string) => + host.createOrAttach({ + sessionId, + cols: 80, + rows: 24, + ...(cwd ? { cwd } : {}), + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + + it('reports a readable cwd as readable', async () => { + expect((await create('readable', process.cwd())).cwdReadableByDaemon).toBe(true) + }) + + it('reports a missing cwd as readable — absence is not a permission denial', async () => { + expect((await create('missing', '/definitely/not/a/real/dir')).cwdReadableByDaemon).toBe(true) + }) + + it('omits the verdict when no cwd was requested', async () => { + expect((await create('no-cwd')).cwdReadableByDaemon).toBeUndefined() + }) + + it('omits the verdict on attach to an existing session', async () => { + await create('attach', process.cwd()) + const attached = await create('attach', process.cwd()) + expect(attached.isNew).toBe(false) + expect(attached.cwdReadableByDaemon).toBeUndefined() + }) +}) diff --git a/src/main/daemon/terminal-host-session-create.ts b/src/main/daemon/terminal-host-session-create.ts index e1c8a22e750..8f6833c3d9f 100644 --- a/src/main/daemon/terminal-host-session-create.ts +++ b/src/main/daemon/terminal-host-session-create.ts @@ -1,3 +1,4 @@ +import { accessSync, constants as fsConstants } from 'node:fs' import { buildStartupCommandSubmission } from '../../shared/startup-command-submission' import { resolvePtyOwnerBackend } from '../../shared/pty-owner-backend' import { getDaemonSessionResultMetadata } from './daemon-create-or-attach-result' @@ -11,11 +12,14 @@ import type { TerminalHostTombstones } from './terminal-host-tombstones' import type { TerminalSessionTeardown } from './terminal-session-teardown' import { resolveDaemonSessionScrollbackRows } from './daemon-session-scrollback-window' import { TerminalAttachCanceledError } from './daemon-errors' +import { rejectOnAbort } from './terminal-attach-cancellation' import { SessionNotFoundError } from './types' import { resolveWslSessionContext } from './wsl-session-context' type TerminalHostSessionCreateDependencies = { sessions: Map + /** Re-checks the host's shutdown fence and this request's cancellation after any await. */ + assertCreateAllowed: () => void sessionTeardown: TerminalSessionTeardown killedTombstones: TerminalHostTombstones spawnSubprocess: TerminalHostOptions['spawnSubprocess'] @@ -30,12 +34,29 @@ export async function createOrAttachTerminalSession( deps: TerminalHostSessionCreateDependencies ): Promise { opts.onSessionResolved?.(opts.sessionId) - const existing = deps.sessions.get(opts.sessionId) + let existing = deps.sessions.get(opts.sessionId) // Why: descendant capture must finish before attach or recreation, or the // caller could receive a doomed session while teardown owns its process. if (deps.sessionTeardown.get(opts.sessionId) || existing?.isTerminating) { - throw new SessionNotFoundError(opts.sessionId) + // An attach must not adopt a doomed session; its caller retires the pane and respawns. + if (opts.attachOnly) { + throw new SessionNotFoundError(opts.sessionId) + } + // A create can wait teardown out instead, and must: a pane respawning onto its own stable id + // reaches this a beat after the attach that retired it, and refusing surfaced the raw + // SessionNotFoundError to the user. Windows makes it the common case, where the plain-shell + // sweep holds the claim across an OS identity probe and taskkill (#18046). + await Promise.race([ + deps.sessionTeardown.settle(opts.sessionId), + rejectOnAbort(opts.cancelSignal, opts.sessionId) + ]) + deps.assertCreateAllowed() + existing = deps.sessions.get(opts.sessionId) + // Unkillable child, or a fresh teardown claimed it while we waited: still nobody's to recreate. + if (existing?.isAlive && existing.isTerminating) { + throw new SessionNotFoundError(opts.sessionId) + } } // Why no ownership settle here: attach is synchronous by contract. A viewer @@ -88,6 +109,8 @@ async function spawnAndPublishSession( ctx: { size: { cols: number; rows: number }; wslDistro: string | undefined } ): Promise { const { size, wslDistro } = ctx + // Why before the fork: the shell's own cwd may already have fallen back, so probe the requested path. + const cwdReadableByDaemon = opts.cwd && !wslDistro ? isCwdReadableByThisProcess(opts.cwd) : null const subprocess = await deps.spawnSubprocess({ sessionId: opts.sessionId, cols: size.cols, @@ -184,6 +207,20 @@ async function spawnAndPublishSession( shellState: session.shellState, incarnationId: session.incarnationId, ...getDaemonSessionResultMetadata(session), + ...(cwdReadableByDaemon !== null ? { cwdReadableByDaemon } : {}), attachToken: token } } + +// Why R_OK|X_OK: listing a directory needs read, and entering it needs search — both are what +// TCC withholds. A non-permission failure (ENOENT, ENOTDIR) reads as readable so it can never +// masquerade as a permission denial. +function isCwdReadableByThisProcess(cwd: string): boolean { + try { + accessSync(cwd, fsConstants.R_OK | fsConstants.X_OK) + return true + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + return code !== 'EACCES' && code !== 'EPERM' + } +} diff --git a/src/main/daemon/terminal-host-teardown-recreate.test.ts b/src/main/daemon/terminal-host-teardown-recreate.test.ts new file mode 100644 index 00000000000..7bfb97bdb7d --- /dev/null +++ b/src/main/daemon/terminal-host-teardown-recreate.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it, vi, type Mock } from 'vitest' +import type { SubprocessHandle } from './session-subprocess-handle' +import { TerminalHost, type TerminalHostOptions } from './terminal-host' + +// Why mocked: the win32 plain-shell teardown sweeps for real, and an unmocked run would put a +// live process-table probe -- and, on a recycled pid, a taskkill /T /F -- behind these tests. +const killWithDescendantSweepMock = vi.hoisted(() => vi.fn()) +vi.mock('../pty-descendant-termination', () => ({ + killWithDescendantSweep: killWithDescendantSweepMock +})) + +type SpawnSubprocess = TerminalHostOptions['spawnSubprocess'] +type ExitableSubprocess = SubprocessHandle & { exit: (code: number) => void } + +/** Shells that report their exit only after `exitDelayMs`, holding the teardown claim open the + * way a real one does while the Windows sweep probes and taskkills its tree. Collected so a test + * can retire an intentionally unkillable child instead of leaking its exit waiter. */ +function spawnSubprocessWithSlowExit(exitDelayMs: number): { + spawnSubprocess: Mock + handles: ExitableSubprocess[] +} { + const handles: ExitableSubprocess[] = [] + const spawnSubprocess = vi.fn(() => { + let onExit: ((code: number) => void) | undefined + const handle = { + pid: 4242, + exit: (code: number) => onExit?.(code), + getForegroundProcess: vi.fn(() => null), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(() => { + setTimeout(() => onExit?.(0), exitDelayMs).unref?.() + }), + terminateOwnedTree: () => 'unavailable' as const, + forceKill: vi.fn(() => { + setTimeout(() => onExit?.(137), exitDelayMs).unref?.() + }), + signal: vi.fn(), + onData: vi.fn(), + onExit: vi.fn((callback) => { + onExit = callback + }), + dispose: vi.fn() + } as unknown as ExitableSubprocess + handles.push(handle) + return handle + }) + return { spawnSubprocess, handles } +} + +const streamClient = (): { onData: Mock; onExit: Mock } => ({ + onData: vi.fn(), + onExit: vi.fn() +}) + +describe('TerminalHost recreate during teardown', () => { + it('recreates a session whose id is still being torn down', async () => { + const { spawnSubprocess } = spawnSubprocessWithSlowExit(40) + const host = new TerminalHost({ spawnSubprocess }) + const sessionId = 'wt-1@@respawning-pane' + await host.createOrAttach({ sessionId, cols: 80, rows: 24, streamClient: streamClient() }) + + // The pane closes and immediately respawns onto its own stable id (#18046). + const killed = host.kill(sessionId, { immediate: true }) + const recreated = await host.createOrAttach({ + sessionId, + cols: 80, + rows: 24, + streamClient: streamClient() + }) + + expect(recreated.isNew).toBe(true) + expect(spawnSubprocess).toHaveBeenCalledTimes(2) + await killed + await host.dispose() + }) + + it('still refuses an attach-only respawn onto a session being torn down', async () => { + const { spawnSubprocess } = spawnSubprocessWithSlowExit(40) + const host = new TerminalHost({ spawnSubprocess }) + const sessionId = 'wt-1@@attaching-pane' + await host.createOrAttach({ sessionId, cols: 80, rows: 24, streamClient: streamClient() }) + + const killed = host.kill(sessionId, { immediate: true }) + // Why unchanged: adopting a doomed session would hand the pane a shell teardown owns; the + // caller retires the pane binding on this error and spawns fresh. + await expect( + host.createOrAttach({ + sessionId, + cols: 80, + rows: 24, + attachOnly: true, + streamClient: streamClient() + }) + ).rejects.toThrow(`Session not found: ${sessionId}`) + expect(spawnSubprocess).toHaveBeenCalledOnce() + await killed + await host.dispose() + }) + + it('refuses a create waiting on teardown once the host is shutting down', async () => { + const { spawnSubprocess } = spawnSubprocessWithSlowExit(40) + const host = new TerminalHost({ spawnSubprocess }) + const sessionId = 'wt-1@@shutting-down-pane' + await host.createOrAttach({ sessionId, cols: 80, rows: 24, streamClient: streamClient() }) + + const killed = host.kill(sessionId, { immediate: true }) + const create = host.createOrAttach({ + sessionId, + cols: 80, + rows: 24, + streamClient: streamClient() + }) + // Why: dispose joins pending creations, so a create that waited out teardown must re-read the + // fence rather than publish a session nothing will shut down. + const disposed = host.dispose() + + await expect(create).rejects.toThrow('Terminal host is shutting down') + expect(spawnSubprocess).toHaveBeenCalledOnce() + await killed + await disposed + }) + + it('leaves a canceled create waiting on teardown instead of the full exit budget', async () => { + // Why a child that never exits on its own: the create must leave on its abort signal, not on + // the teardown settling, so the teardown deliberately outlives the assertion. + const { spawnSubprocess, handles } = spawnSubprocessWithSlowExit(30_000) + const host = new TerminalHost({ spawnSubprocess }) + const sessionId = 'wt-1@@canceled-pane' + await host.createOrAttach({ sessionId, cols: 80, rows: 24, streamClient: streamClient() }) + + const killed = host.kill(sessionId, { immediate: true }) + const canceled = new AbortController() + const create = host.createOrAttach({ + sessionId, + cols: 80, + rows: 24, + cancelSignal: canceled.signal, + isCanceled: () => canceled.signal.aborted, + streamClient: streamClient() + }) + canceled.abort() + + await expect(create).rejects.toThrow(`Attach canceled for session ${sessionId}`) + expect(spawnSubprocess).toHaveBeenCalledOnce() + + handles[0].exit(137) + await killed + await host.dispose() + }) +}) diff --git a/src/main/daemon/terminal-host.test.ts b/src/main/daemon/terminal-host.test.ts index 8755005b42e..142b9b2c5a2 100644 --- a/src/main/daemon/terminal-host.test.ts +++ b/src/main/daemon/terminal-host.test.ts @@ -473,14 +473,17 @@ describe('TerminalHost', () => { expect(lastSubprocess.forceKill).toHaveBeenCalledTimes(1) expect(lastSubprocess.dispose).not.toHaveBeenCalled() expect(host.listSessions()).toHaveLength(1) - await expect( - host.createOrAttach({ - sessionId: 'session-1', - cols: 80, - rows: 24, - streamClient: { onData: vi.fn(), onExit: vi.fn() } - }) - ).rejects.toThrow('Session not found') + // An unkillable child never releases the id: the create waits out its own budget and + // then reports absence rather than publishing a session teardown still owns. + const recreate = host.createOrAttach({ + sessionId: 'session-1', + cols: 80, + rows: 24, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + const refused = expect(recreate).rejects.toThrow('Session not found') + await vi.advanceTimersByTimeAsync(IMMEDIATE_KILL_PHYSICAL_EXIT_TIMEOUT_MS) + await refused lastSubprocess._onExitCb?.(137) expect(host.listSessions()).toHaveLength(0) @@ -525,7 +528,7 @@ describe('TerminalHost', () => { expect(lastSubprocess.dispose).toHaveBeenCalled() }) - it('rejects reattach while an agent immediate-kill snapshot is pending', async () => { + it('defers a respawn until the agent immediate-kill snapshot completes', async () => { let finishSweep!: () => void killWithDescendantSweepMock.mockImplementation( (_pid: number, finish: () => void) => @@ -544,22 +547,35 @@ describe('TerminalHost', () => { streamClient: { onData: vi.fn(), onExit: vi.fn() } }) + const retiredSubprocess = lastSubprocess const killing = host.kill('agent-reattach', { immediate: true }) - await expect( - host.createOrAttach({ + let respawned = false + const respawn = host + .createOrAttach({ sessionId: 'agent-reattach', cols: 80, rows: 24, launchAgent: 'claude', streamClient: { onData: vi.fn(), onExit: vi.fn() } }) - ).rejects.toThrow('Session not found') - expect(lastSubprocess.forceKill).not.toHaveBeenCalled() + .then((result) => { + respawned = true + return result + }) + await Promise.resolve() + await Promise.resolve() + + // Why it must not resolve yet: capture still owns the process, so publishing here would + // hand the caller a session teardown is about to kill. + expect(respawned).toBe(false) + expect(spawnFn).toHaveBeenCalledTimes(1) + expect(retiredSubprocess.forceKill).not.toHaveBeenCalled() finishSweep() - lastSubprocess._onExitCb?.(137) + retiredSubprocess._onExitCb?.(137) await killing - expect(lastSubprocess.forceKill).toHaveBeenCalledOnce() + await expect(respawn).resolves.toMatchObject({ isNew: true }) + expect(retiredSubprocess.forceKill).toHaveBeenCalledOnce() }) it('coalesces duplicate immediate kill while descendant capture is pending', async () => { @@ -606,29 +622,31 @@ describe('TerminalHost', () => { const killing = host.kill('agent-natural-exit', { immediate: true }) retiredSubprocess._onExitCb?.(0) - await expect( - host.createOrAttach({ + let respawned = false + const respawn = host + .createOrAttach({ sessionId: 'agent-natural-exit', cols: 80, rows: 24, launchAgent: 'claude', streamClient: { onData: vi.fn(), onExit: vi.fn() } }) - ).rejects.toThrow('Session not found') + .then((result) => { + respawned = true + return result + }) + await Promise.resolve() + await Promise.resolve() + + // The root is already reaped, but the scan still holds the id. + expect(respawned).toBe(false) + expect(spawnFn).toHaveBeenCalledTimes(1) completeSweep() await killing expect(retiredSubprocess.forceKill).not.toHaveBeenCalled() - await expect( - host.createOrAttach({ - sessionId: 'agent-natural-exit', - cols: 80, - rows: 24, - launchAgent: 'claude', - streamClient: { onData: vi.fn(), onExit: vi.fn() } - }) - ).resolves.toEqual(expect.objectContaining({ isNew: true })) + await expect(respawn).resolves.toEqual(expect.objectContaining({ isNew: true })) expect(spawnFn).toHaveBeenCalledTimes(2) }) diff --git a/src/main/daemon/terminal-host.ts b/src/main/daemon/terminal-host.ts index f85980b5453..f64b886f424 100644 --- a/src/main/daemon/terminal-host.ts +++ b/src/main/daemon/terminal-host.ts @@ -21,24 +21,10 @@ import { listLiveTerminalHostSessions } from './terminal-host-session-listing' import { createOrAttachTerminalSession } from './terminal-host-session-create' import { isShellProcess } from '../../shared/agent-detection' import { TerminalAttachCanceledError } from './daemon-errors' +import { rejectOnAbort } from './terminal-attach-cancellation' export type { CreateOrAttachOptions, CreateOrAttachResult } from './terminal-host-create-contract' -/** Never resolves; only rejects, so it can bound a wait without settling it. */ -function rejectOnAbort(signal: AbortSignal | undefined, sessionId: string): Promise { - if (!signal) { - return new Promise(() => {}) - } - return new Promise((_resolve, reject) => { - if (signal.aborted) { - reject(new TerminalAttachCanceledError(sessionId)) - return - } - signal.addEventListener('abort', () => reject(new TerminalAttachCanceledError(sessionId)), { - once: true - }) - }) -} export type { TerminalHostOptions } from './terminal-host-options' const DEFAULT_MAX_TOMBSTONES = 1000 @@ -106,6 +92,7 @@ export class TerminalHost { } return await createOrAttachTerminalSession(options, { sessions: this.sessions, + assertCreateAllowed: () => this.assertCreateOrAttachAllowed(options), sessionTeardown: this.sessionTeardown, killedTombstones: this.killedTombstones, spawnSubprocess: this.spawnSubprocess, diff --git a/src/main/daemon/terminal-session-teardown.ts b/src/main/daemon/terminal-session-teardown.ts index 5c4f8061247..017f841a5e0 100644 --- a/src/main/daemon/terminal-session-teardown.ts +++ b/src/main/daemon/terminal-session-teardown.ts @@ -1,7 +1,7 @@ import { killWithDescendantSweep } from '../pty-descendant-termination' import type { Session } from './session' -type AgentTeardownOperation = { +type TeardownOperation = { promise: Promise immediate: boolean rootSignalled: boolean @@ -9,10 +9,10 @@ type AgentTeardownOperation = { session: Session } -/** Owns agent teardown by session id until descendant capture and root - * signalling finish, even when the root exits and its Session is reaped. */ +/** Owns teardown by session id until descendant capture and root signalling + * finish, even when the root exits and its Session is reaped. */ export class TerminalSessionTeardown { - private operations = new Map() + private operations = new Map() constructor(private sessions: ReadonlyMap) {} @@ -20,6 +20,12 @@ export class TerminalSessionTeardown { return this.operations.get(sessionId)?.promise } + /** Resolves once this id's tracked teardown has released the process — a rejected teardown + * released it too. Callers re-read session state afterwards and decide for themselves. */ + async settle(sessionId: string): Promise { + await this.operations.get(sessionId)?.promise.catch(() => {}) + } + requestImmediate(sessionId: string): Promise | undefined { const pending = this.operations.get(sessionId) if (pending) { @@ -38,11 +44,42 @@ export class TerminalSessionTeardown { return this.killAgentSession(sessionId, session, immediate) } if (immediate) { - return this.forceKillPlainShellSession(sessionId, session) + // Why tracked like the agent path: this claims termination on the Session and then awaits + // an OS probe and taskkill, and a create landing inside that window must be able to wait it + // out rather than be told the id is absent (#18046). + return this.track(sessionId, session, immediate, () => + this.forceKillPlainShellSession(sessionId, session) + ) } session.kill() } + /** Publishes an operation for `sessionId` and retires it once the teardown settles. */ + private track( + sessionId: string, + session: Session, + immediate: boolean, + run: (entry: TeardownOperation) => Promise + ): Promise { + const entry: TeardownOperation = { + promise: Promise.resolve(), + immediate, + rootSignalled: false, + rootCompletion: Promise.resolve(), + session + } + const operation = run(entry) + entry.promise = operation + this.operations.set(sessionId, entry) + const clearOperation = (): void => { + if (this.operations.get(sessionId) === entry) { + this.operations.delete(sessionId) + } + } + void operation.then(clearOperation, clearOperation) + return operation + } + /** * Immediate teardown of a non-agent shell. On Windows, closing the ConPTY does not * reap orphaned children (node-pty `useConptyDll` skips the console-process reap), so a @@ -92,48 +129,34 @@ export class TerminalSessionTeardown { session.scheduleForceDisposeFallback() } - const entry: AgentTeardownOperation = { - promise: Promise.resolve(), - immediate, - rootSignalled: false, - rootCompletion: Promise.resolve(), - session - } - const sweep = Promise.resolve( - killWithDescendantSweep( - session.pid, - () => { - // Why: natural exit reaps the PID while ps is running. Never signal that - // stale numeric PID after the Session no longer represents a live root. - if (!session.isAlive) { - return + return this.track(sessionId, session, immediate, (entry) => { + const sweep = Promise.resolve( + killWithDescendantSweep( + session.pid, + () => { + // Why: natural exit reaps the PID while ps is running. Never signal that + // stale numeric PID after the Session no longer represents a live root. + if (!session.isAlive) { + return + } + entry.rootSignalled = true + if (entry.immediate) { + entry.rootCompletion = session.forceKillAndWaitForExit() + } else { + session.signalTerminationRoot() + } + }, + { + // Why: the descendant rows are only authoritative while this exact + // Session still owns the root PID captured by ps. + ownsRoot: () => this.sessions.get(sessionId) === session && session.isAlive, + terminateOwnedTree: () => session.terminateOwnedTree() } - entry.rootSignalled = true - if (entry.immediate) { - entry.rootCompletion = session.forceKillAndWaitForExit() - } else { - session.signalTerminationRoot() - } - }, - { - // Why: the descendant rows are only authoritative while this exact - // Session still owns the root PID captured by ps. - ownsRoot: () => this.sessions.get(sessionId) === session && session.isAlive, - terminateOwnedTree: () => session.terminateOwnedTree() - } + ) ) - ) - // Why: descendant capture completion only proves signals were requested; - // destructive callers must retain the native owner until OS-confirmed exit. - const operation = sweep.then(() => entry.rootCompletion) - entry.promise = operation - this.operations.set(sessionId, entry) - const clearOperation = (): void => { - if (this.operations.get(sessionId) === entry) { - this.operations.delete(sessionId) - } - } - void operation.then(clearOperation, clearOperation) - return operation + // Why: descendant capture completion only proves signals were requested; + // destructive callers must retain the native owner until OS-confirmed exit. + return sweep.then(() => entry.rootCompletion) + }) } } diff --git a/src/main/git/canonical-repo-key.test.ts b/src/main/git/canonical-repo-key.test.ts new file mode 100644 index 00000000000..87965bcaed6 --- /dev/null +++ b/src/main/git/canonical-repo-key.test.ts @@ -0,0 +1,78 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const gitExecFileAsyncMock = vi.hoisted(() => vi.fn()) + +vi.mock('./runner', async (importOriginal) => ({ + ...((await importOriginal()) as Record), + gitExecFileAsync: gitExecFileAsyncMock +})) + +import { + _resetCanonicalRepoKeyCacheForTests, + getCanonicalRepoKey, + readGitCommonDir +} from './canonical-repo-key' + +beforeEach(() => { + _resetCanonicalRepoKeyCacheForTests() + gitExecFileAsyncMock.mockReset() +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('readGitCommonDir', () => { + it('reads the absolute answer modern Git gives', () => { + expect(readGitCommonDir('/repo/.git\n', '/repo/worktrees/a')).toBe('/repo/.git') + }) + + it('drops the flag Git older than 2.31 echoes back, and resolves the relative answer', () => { + // Without this every repository on such a host would answer `.git` and collide. + expect(readGitCommonDir('--path-format=absolute\n.git\n', '/repo')).toBe('/repo/.git') + }) + + it('resolves a WSL answer in Git execution space, not against the UNC path', () => { + expect(readGitCommonDir('.git\n', '//wsl$/Ubuntu/home/dev/repo')).toBe('/home/dev/repo/.git') + }) + + it('tolerates CRLF and blank lines', () => { + expect(readGitCommonDir('\r\n/repo/.git\r\n', '/repo')).toBe('/repo/.git') + }) + + it('returns undefined when Git printed nothing usable', () => { + expect(readGitCommonDir('\n', '/repo')).toBeUndefined() + }) +}) + +describe('getCanonicalRepoKey', () => { + it('gives every worktree of one repository the same key', async () => { + gitExecFileAsyncMock.mockResolvedValue({ stdout: '/repo/.git\n', stderr: '' }) + + await expect(getCanonicalRepoKey('/repo')).resolves.toBe('local::/repo/.git') + await expect(getCanonicalRepoKey('/repo/worktrees/a')).resolves.toBe('local::/repo/.git') + }) + + it('scopes the key to the execution host', async () => { + gitExecFileAsyncMock.mockResolvedValue({ stdout: '/home/dev/repo/.git\n', stderr: '' }) + + await expect( + getCanonicalRepoKey('//wsl$/Ubuntu/home/dev/repo', { wslDistro: 'Ubuntu' }) + ).resolves.toBe('wsl:Ubuntu::/home/dev/repo/.git') + }) + + it('caches so repeated arming costs no subprocess', async () => { + gitExecFileAsyncMock.mockResolvedValue({ stdout: '/repo/.git\n', stderr: '' }) + + await getCanonicalRepoKey('/repo') + await getCanonicalRepoKey('/repo') + + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) + }) + + it('falls back to the caller path when Git cannot answer', async () => { + gitExecFileAsyncMock.mockRejectedValue(new Error('not a git repository')) + + await expect(getCanonicalRepoKey('/not-a-repo')).resolves.toBe('local::/not-a-repo') + }) +}) diff --git a/src/main/git/canonical-repo-key.ts b/src/main/git/canonical-repo-key.ts new file mode 100644 index 00000000000..1b06423bdc9 --- /dev/null +++ b/src/main/git/canonical-repo-key.ts @@ -0,0 +1,72 @@ +import { toWslExecutionSpace } from '../../shared/wsl-paths' +import { gitExecFileAsync } from './runner' +import { resolveRevParsePath } from './worktree-path-comparison' + +/** + * One repository on one execution host, named by its Git common dir. + * + * Shared by the fetch controller (which serializes fetches on it) and idle ref + * maintenance (which scopes all of its state to it), so both agree on what "the + * same repo" means across every worktree that points at it. + */ + +export type CanonicalRepoKeyOptions = { wslDistro?: string } + +const CACHE_MAX = 512 +const cache = new Map() + +/** + * Git < 2.31 ignores `--path-format=absolute`: it echoes the unrecognized flag, + * exits 0, and prints a relative `.git`. Taking the raw stdout there would give + * every repository on the host the same key. + */ +export function readGitCommonDir(stdout: string, repoPath: string): string | undefined { + const commonDir = stdout + .split('\n') + .map((line) => (line.endsWith('\r') ? line.slice(0, -1) : line)) + .findLast((line) => line.length > 0 && !line.startsWith('-')) + return commonDir ? resolveRevParsePath(toWslExecutionSpace(repoPath), commonDir) : undefined +} + +function remember(cacheKey: string, value: string): string { + cache.delete(cacheKey) + cache.set(cacheKey, value) + while (cache.size > CACHE_MAX) { + const oldest = cache.keys().next() + if (oldest.done) { + break + } + cache.delete(oldest.value) + } + return value +} + +/** `${runtimeKey}::${gitCommonDir}`, falling back to the caller's path. */ +export async function getCanonicalRepoKey( + repoPath: string, + options: CanonicalRepoKeyOptions = {} +): Promise { + const runtimeKey = options.wslDistro ? `wsl:${options.wslDistro}` : 'local' + const cacheKey = `${runtimeKey}::${repoPath}` + const cached = cache.get(cacheKey) + if (cached !== undefined) { + return remember(cacheKey, cached) + } + try { + const { stdout } = await gitExecFileAsync( + ['rev-parse', '--path-format=absolute', '--git-common-dir'], + { cwd: repoPath, ...options } + ) + const commonDir = readGitCommonDir(stdout, repoPath) + if (commonDir) { + return remember(cacheKey, `${runtimeKey}::${commonDir}`) + } + } catch { + // The caller path remains a safe serialization key when canonicalization fails. + } + return remember(cacheKey, cacheKey) +} + +export function _resetCanonicalRepoKeyCacheForTests(): void { + cache.clear() +} 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/fork-remote-refspec.ts b/src/main/git/fork-remote-refspec.ts index 5bf53cfe7f0..c924fdb2cb2 100644 --- a/src/main/git/fork-remote-refspec.ts +++ b/src/main/git/fork-remote-refspec.ts @@ -55,6 +55,30 @@ function refspecSource(refspec: string): string { return refspec.replace(/^\+/, '').split(':')[0]! } +/** + * True if `branchName`'s remote-tracking ref already exists locally under `remoteName`. + * Used to skip a redundant fetch on the common repeat-materialize case (the ref was + * already pulled in by an earlier mint/fetch) while still fetching it on demand the + * first time a sibling worktree widens an existing remote onto a new branch -- a bare + * refspec-config widen never itself imports anything (see `ensureRemoteTracksBranchNarrowly`). + */ +export async function forkRemoteTrackingRefExists( + execGit: GitExecFn, + repoPath: string, + remoteName: string, + branchName: string +): Promise { + try { + await execGit( + ['rev-parse', '--verify', '--quiet', `refs/remotes/${remoteName}/${branchName}`], + repoPath + ) + return true + } catch { + return false + } +} + /** * True only if `remote..url` is actually set. Deliberately plumbing (`config --get`), * not porcelain `git remote get-url` -- the latter falls back to echoing the remote *name* diff --git a/src/main/git/local-repo-ref-maintenance.test.ts b/src/main/git/local-repo-ref-maintenance.test.ts new file mode 100644 index 00000000000..f6a411733c3 --- /dev/null +++ b/src/main/git/local-repo-ref-maintenance.test.ts @@ -0,0 +1,182 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const gitExecFileAsyncMock = vi.hoisted(() => vi.fn()) +const readRepoCommonDirFromGitMock = vi.hoisted(() => vi.fn()) + +vi.mock('./runner', async (importOriginal) => ({ + ...((await importOriginal()) as Record), + gitExecFileAsync: gitExecFileAsyncMock +})) + +vi.mock('./worktree-list-reader', async (importOriginal) => ({ + ...((await importOriginal()) as Record), + readRepoCommonDirFromGit: readRepoCommonDirFromGitMock +})) + +import { _resetCanonicalRepoKeyCacheForTests } from './canonical-repo-key' +import { + _resetLocalRepoRefMaintenanceForTests, + armLocalRepoRefMaintenance, + createLocalRepoRefMaintenanceTarget, + getLocalRepoRefMaintenance, + setRepoMaintenanceActivityProbe, + withRepoRefMaintenancePaused +} from './local-repo-ref-maintenance' + +const NO_ABORT = new AbortController().signal + +function target(wslDistro?: string): ReturnType { + return createLocalRepoRefMaintenanceTarget({ + key: 'local::/repo/.git', + repoPath: wslDistro ? '//wsl$/Ubuntu/home/dev/repo' : '/repo', + ...(wslDistro ? { wslDistro } : {}) + }) +} + +beforeEach(() => { + gitExecFileAsyncMock.mockReset() + readRepoCommonDirFromGitMock.mockReset() + delete process.env.ORCA_DISABLE_REPO_REF_MAINTENANCE + _resetCanonicalRepoKeyCacheForTests() + _resetLocalRepoRefMaintenanceForTests() +}) + +afterEach(() => { + delete process.env.ORCA_DISABLE_REPO_REF_MAINTENANCE + _resetLocalRepoRefMaintenanceForTests() + vi.restoreAllMocks() +}) + +describe('local repo ref maintenance target', () => { + it('never hands the pack child an abort signal', async () => { + // Killing a `pack-refs` strands a `refs/**` lock about one time in five, and + // on Windows a force-kill inside the rewrite strands `packed-refs.lock` + // every time. The child must always be allowed to finish. + readRepoCommonDirFromGitMock.mockResolvedValue('/repo/.git') + gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }) + + await target().packRefs({ setHeld: () => {} }) + + const packCall = gitExecFileAsyncMock.mock.calls.find( + ([argv]) => (argv as string[])[0] === 'pack-refs' + ) + expect(packCall?.[1]).not.toHaveProperty('signal') + }) + + it('runs pack-refs at the background tier with a long deadline', async () => { + gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }) + + await target().packRefs({ setHeld: () => {} }) + + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + ['pack-refs', '--all', '--prune'], + expect.objectContaining({ cwd: '/repo', admissionTier: 'background', timeout: 15 * 60_000 }) + ) + }) + + it('reads either Git auto-maintenance opt-out, and unset keys as consent', async () => { + for (const stdout of [ + 'maintenance.auto false\n', + 'gc.auto 0\n', + 'gc.auto 6700\nmaintenance.auto false\n' + ]) { + gitExecFileAsyncMock.mockResolvedValue({ stdout, stderr: '' }) + await expect(target().isOptedOut?.(NO_ABORT)).resolves.toBe(true) + } + + gitExecFileAsyncMock.mockResolvedValue({ + stdout: 'maintenance.auto true\ngc.auto 6700\n', + stderr: '' + }) + await expect(target().isOptedOut?.(NO_ABORT)).resolves.toBe(false) + + // `git config --get-regexp` exits non-zero when nothing matches. + gitExecFileAsyncMock.mockRejectedValue(new Error('exit 1')) + await expect(target().isOptedOut?.(NO_ABORT)).resolves.toBe(false) + }) + + it('walks the POSIX refs directory for a native repo', async () => { + readRepoCommonDirFromGitMock.mockResolvedValue('/repo/.git') + + await expect(target().resolveRefsDirectory(NO_ABORT)).resolves.toBe('/repo/.git/refs') + }) + + it('translates a WSL repo answer back to the UNC path the main process can open', async () => { + // Git answers in its own execution space, which for WSL is a Linux path. + readRepoCommonDirFromGitMock.mockResolvedValue('/home/dev/repo/.git') + + await expect(target('Ubuntu').resolveRefsDirectory(NO_ABORT)).resolves.toBe( + '\\\\wsl.localhost\\Ubuntu\\home\\dev\\repo\\.git\\refs' + ) + }) + + it('reports an unresolvable repository rather than guessing a path', async () => { + readRepoCommonDirFromGitMock.mockResolvedValue(undefined) + + await expect(target().resolveRefsDirectory(NO_ABORT)).resolves.toBeUndefined() + }) +}) + +describe('local repo ref maintenance scheduling', () => { + it('schedules nothing when the kill switch is set', () => { + process.env.ORCA_DISABLE_REPO_REF_MAINTENANCE = '1' + const arm = vi.spyOn(getLocalRepoRefMaintenance(), 'arm') + + armLocalRepoRefMaintenance({ key: 'local::/repo/.git', repoPath: '/repo' }) + + expect(arm).not.toHaveBeenCalled() + }) + + it('arms through the shared single-flight instance otherwise', () => { + const arm = vi.spyOn(getLocalRepoRefMaintenance(), 'arm') + + armLocalRepoRefMaintenance({ key: 'local::/repo/.git', repoPath: '/repo' }) + + expect(arm).toHaveBeenCalledTimes(1) + }) + + it('is free when nothing has ever been armed', async () => { + // The common case by far: no timers, no instance, no reason to pay anything. + await expect(withRepoRefMaintenancePaused('git-fetch', async () => 'done')).resolves.toBe( + 'done' + ) + }) + + it('holds the window shut for the duration of ref-touching work', async () => { + readRepoCommonDirFromGitMock.mockResolvedValue('/repo/.git') + _resetLocalRepoRefMaintenanceForTests({ quietPeriodMs: 1, looseRefThreshold: 0 }) + setRepoMaintenanceActivityProbe(() => false) + const maintenance = getLocalRepoRefMaintenance() + const packRefs = vi.fn(async () => {}) + maintenance.arm({ + key: 'local::/repo/.git', + resolveRefsDirectory: async () => '/repo/.git/refs', + packRefs + }) + + await withRepoRefMaintenancePaused('branch-delete', async () => { + await new Promise((resolve) => setTimeout(resolve, 25)) + expect(packRefs).not.toHaveBeenCalled() + }) + + await vi.waitFor(() => expect(packRefs).toHaveBeenCalledTimes(1)) + }) + + it('routes the app activity probe into the shared instance', async () => { + readRepoCommonDirFromGitMock.mockResolvedValue('/repo/.git') + let busy = true + setRepoMaintenanceActivityProbe(() => busy) + const maintenance = getLocalRepoRefMaintenance() + const packRefs = vi.fn(async () => {}) + + maintenance.arm({ + key: 'local::/repo/.git', + resolveRefsDirectory: async () => '/repo/.git/refs', + packRefs + }) + await maintenance.whenAttemptSettled() + + expect(packRefs).not.toHaveBeenCalled() + busy = false + }) +}) diff --git a/src/main/git/local-repo-ref-maintenance.ts b/src/main/git/local-repo-ref-maintenance.ts new file mode 100644 index 00000000000..e84f7d1ae30 --- /dev/null +++ b/src/main/git/local-repo-ref-maintenance.ts @@ -0,0 +1,272 @@ +import { posix, win32 } from 'node:path' +import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' +import { RepoRefMaintenance } from '../../shared/repo-ref-maintenance' +import { + PACK_REFS_ARGS, + PACK_REFS_TIMEOUT_MS, + RefMaintenanceRepoLocked, + type PackedRefsLockReporter, + type RepoRefMaintenanceOptions, + type RepoRefMaintenanceTarget +} from '../../shared/repo-ref-maintenance-policy' +import { isWslUncPath, toWindowsWslPath } from '../../shared/wsl-paths' +import { withSpan } from '../observability/tracer' +import { PackRefsLockOwnership } from './pack-refs-lock-ownership' +import { gitExecFileAsync } from './runner' +import { readRepoCommonDirFromGit } from './worktree-list-reader' + +/** + * Main-process wiring for idle loose-ref packing on the local execution host + * (native and WSL). + * + * SSH-hosted repos are deliberately out of scope: the execution host owns + * anything that touches execution, so maintaining them means running host-side + * on the relay, which today has neither admission control nor spans. Keying all + * state by execution host is what keeps this path from reaching across. + */ + +export type RepoMaintenanceActivityProbe = () => boolean + +const REPO_BUSY_PROBE_MAX = 64 + +let activityProbe: RepoMaintenanceActivityProbe | null = null +let shared: RepoRefMaintenance | null = null +// Why keyed here rather than captured in the target: a repo can be armed from +// the fetch controller or from a user-initiated fetch, and every arming must see +// the same "this repo has work in flight" answer, not whichever closure was last. +const repoBusyProbes = new Map boolean>() + +/** Register the owner of "this repo has a fetch in flight" for `key`. */ +export function setRepoRefMaintenanceBusyProbe(key: string, probe: () => boolean): void { + repoBusyProbes.delete(key) + repoBusyProbes.set(key, probe) + while (repoBusyProbes.size > REPO_BUSY_PROBE_MAX) { + const oldest = repoBusyProbes.keys().next() + if (oldest.done) { + break + } + repoBusyProbes.delete(oldest.value) + } +} + +/** + * Register the app-wide "do not start maintenance now" signal. Owned by the + * main entry point because the inputs (live agents, battery, quit) are not + * visible from the git layer. + */ +export function setRepoMaintenanceActivityProbe(probe: RepoMaintenanceActivityProbe | null): void { + activityProbe = probe +} + +/** Support escape hatch: kills the sweep without touching the user's git config. */ +function isDisabled(): boolean { + return process.env.ORCA_DISABLE_REPO_REF_MAINTENANCE === '1' +} + +function localMaintenanceOptions(): RepoRefMaintenanceOptions { + return { + // Fail closed: without the app-level gate installed we cannot see agents, + // creates, or battery, and running blind is worse than not running. + isBusy: () => activityProbe?.() ?? true, + observe: (attempt) => + withSpan('repo.ref_maintenance', (span) => attempt(span), { + attributes: { kind: 'git', 'repo.maintenance_host': 'local' } + }), + onError: (error) => { + console.warn('[repo-ref-maintenance] attempt failed:', error) + } + } +} + +export function getLocalRepoRefMaintenance(): RepoRefMaintenance { + shared ??= new RepoRefMaintenance(localMaintenanceOptions()) + return shared +} + +/** + * Cancels every armed timer and waits out any `packed-refs` rewrite in progress. + * + * Deliberately does not kill the child. A pack orphaned by the app quitting + * finishes on its own; a pack signalled mid-prune strands a ref lock about one + * time in five, and on Windows a force-kill inside the rewrite strands + * `packed-refs.lock` every time -- which blocks every later ref deletion. + */ +export function disposeLocalRepoRefMaintenance(): Promise { + const settling = shared?.awaitPackedRefsLockRelease() ?? Promise.resolve() + shared?.dispose() + shared = null + repoBusyProbes.clear() + return settling +} + +/** + * Hold every repository open while `run` touches refs. + * + * A ref deletion needs `packed-refs.lock`, which a running pack holds only while + * it rewrites the file -- 0.03-1.37s of a 23-32s run. Waiting that out turns the + * collision into a short pause. Cancelling the pack instead would strand a + * `refs/**` lock about one time in five, which Git never clears, so the ref + * stays undeletable indefinitely. + */ +export async function withRepoRefMaintenancePaused( + reason: string, + run: () => Promise +): Promise { + // Taken unconditionally rather than only when something is already armed: a + // fetch inside `run` can arm the sweep, and one counter bump against an idle + // instance costs a microtask. This can rebuild the instance after the + // quit-time dispose; harmless, because a fresh one has no armed timers and its + // activity probe is gone, so it fails closed. + const release = await getLocalRepoRefMaintenance().pause(reason) + try { + return await run() + } finally { + release() + } +} + +/** Wait out a `packed-refs` rewrite without holding the window open. For shutdown. */ +export function awaitPackedRefsLockRelease(): Promise { + return shared ? shared.awaitPackedRefsLockRelease() : Promise.resolve() +} + +/** + * Count user-initiated ref work as activity and restart every armed countdown. + * + * Deliberately not keyed to a repo: resolving one would cost a `rev-parse` on a + * path the user is waiting on, and a manual fetch or pull says the user is at + * the keyboard, which is a reason to defer every repository. + */ +export function postponeRepoRefMaintenance(): void { + shared?.postponeAll() +} + +/** `overrides` preseeds the shared instance so a test can shorten the quiet period. */ +export function _resetLocalRepoRefMaintenanceForTests( + overrides?: Partial +): void { + shared?.dispose() + shared = overrides ? new RepoRefMaintenance({ ...localMaintenanceOptions(), ...overrides }) : null + activityProbe = null + repoBusyProbes.clear() +} + +/** + * Git reports the common dir in its own execution space, so a WSL repo answers + * with a Linux path the Windows main process cannot open. Translate it back to + * the UNC spelling for the dirent walk; the walk reads directories, not files, + * so the handful of round trips stays cheap even over the share. + */ +function refsDirectoryForMainProcess(commonDir: string, wslDistro: string | undefined): string { + if (wslDistro && !isWslUncPath(commonDir) && !isWindowsAbsolutePathLike(commonDir)) { + return win32.join(toWindowsWslPath(commonDir, wslDistro), 'refs') + } + // Decided by path syntax, not by platform: `win32.isAbsolute` accepts POSIX paths too. + return (isWindowsAbsolutePathLike(commonDir) ? win32 : posix).join(commonDir, 'refs') +} + +/** + * `maintenance.auto=false` and `gc.auto=0` are the two knobs a user reaches for + * to tell Git to stop maintaining a repository on its own. Orca sets both on its + * own fetches, but only as per-invocation `-c` flags, so this probe sees the + * user's persisted config and never Orca's own suppression. + */ +export function isGitAutoMaintenanceDisabled(configOutput: string): boolean { + return configOutput + .split('\n') + .map((line) => line.trim()) + .some((line) => line === 'maintenance.auto false' || line === 'gc.auto 0') +} + +/** + * The common dir in the spelling the main process can open. + * + * Derived from the converted refs path, not the raw one: a WSL answer arrives as + * a Linux path but converts to a UNC path with no `/` in it, so choosing the + * path flavour before conversion collapses the whole thing to `.`. + */ +function gitCommonDirForMainProcess(commonDir: string, wslDistro: string | undefined): string { + const refs = refsDirectoryForMainProcess(commonDir, wslDistro) + return (isWindowsAbsolutePathLike(refs) ? win32 : posix).dirname(refs) +} + +export type LocalRepoRefMaintenanceTargetArgs = { + /** `${runtimeKey}::${gitCommonDir}` -- already scoped to the execution host. */ + readonly key: string + readonly repoPath: string + readonly wslDistro?: string +} + +/** + * Record a write to this repo and restart its quiet-period countdown. The only + * entry point callers need: the kill switch is honoured before anything is + * scheduled, so a disabled build arms no timers at all. + */ +export function armLocalRepoRefMaintenance(args: LocalRepoRefMaintenanceTargetArgs): void { + if (isDisabled()) { + return + } + getLocalRepoRefMaintenance().arm(createLocalRepoRefMaintenanceTarget(args)) +} + +export function createLocalRepoRefMaintenanceTarget( + args: LocalRepoRefMaintenanceTargetArgs +): RepoRefMaintenanceTarget { + const gitOptions = args.wslDistro ? { wslDistro: args.wslDistro } : {} + // The engine always probes before it packs, so the pack reuses this answer + // rather than spending a second rev-parse on the same repository. + let commonDir: string | undefined + const resolveCommonDir = async (signal?: AbortSignal): Promise => { + commonDir ??= await readRepoCommonDirFromGit(args.repoPath, { + ...gitOptions, + ...(signal ? { signal } : {}) + }) + return commonDir + } + return { + key: args.key, + isBusy: () => repoBusyProbes.get(args.key)?.() ?? false, + async resolveRefsDirectory(signal: AbortSignal) { + const resolved = await resolveCommonDir(signal) + return resolved ? refsDirectoryForMainProcess(resolved, args.wslDistro) : undefined + }, + async isOptedOut(signal: AbortSignal) { + try { + const { stdout } = await gitExecFileAsync( + ['config', '--get-regexp', '^(maintenance\\.auto|gc\\.auto)$'], + { cwd: args.repoPath, ...gitOptions, admissionTier: 'background', signal } + ) + return isGitAutoMaintenanceDisabled(stdout) + } catch { + // Neither key set is the common case and exits non-zero; that is consent. + return false + } + }, + async packRefs(lock: PackedRefsLockReporter) { + const resolved = await resolveCommonDir() + const owner = resolved + ? new PackRefsLockOwnership(gitCommonDirForMainProcess(resolved, args.wslDistro)) + : null + const claim = owner ? await owner.claim() : { ok: true as const } + if (!claim.ok) { + throw new RefMaintenanceRepoLocked(claim.reason) + } + // Report the rewrite window rather than accepting a signal. A pack that is + // killed mid-prune strands a `refs/**` lock about one time in five, and + // Git never clears those; waiting out the window costs at most ~1.4s. + const watch = owner?.watchLock((held) => lock.setHeld(held)) + try { + await gitExecFileAsync([...PACK_REFS_ARGS], { + cwd: args.repoPath, + ...gitOptions, + admissionTier: 'background', + timeout: PACK_REFS_TIMEOUT_MS + }) + } finally { + watch?.stop() + lock.setHeld(false) + await owner?.release() + } + } + } +} diff --git a/src/main/git/pack-refs-lock-ownership.test.ts b/src/main/git/pack-refs-lock-ownership.test.ts new file mode 100644 index 00000000000..e181feb9976 --- /dev/null +++ b/src/main/git/pack-refs-lock-ownership.test.ts @@ -0,0 +1,192 @@ +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { PackRefsLockOwnership } from './pack-refs-lock-ownership' + +const roots: string[] = [] + +async function gitCommonDir(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-pack-refs-lock-')) + roots.push(root) + return root +} + +function paths(commonDir: string): { lock: string; marker: string } { + return { + lock: join(commonDir, 'packed-refs.lock'), + marker: join(commonDir, 'packed-refs.orca-owner') + } +} + +async function exists(path: string): Promise { + try { + await stat(path) + return true + } catch { + return false + } +} + +/** A pid that cannot be running: the kernel rejects it outright. */ +const DEAD_PID = 0x7fffffff +const ABANDONED_LOCK_AGE_MS = 15 * 60_000 +const PID_REUSE_HORIZON_MS = 24 * 60 * 60_000 + +/** `claim` takes `now`, so age cases need no sleeping and no mtime forgery. */ +function laterBy(ms: number): number { + return Date.now() + ms +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('packed-refs lock ownership', () => { + it('claims a repository with no lock and records the owner', async () => { + const commonDir = await gitCommonDir() + const { marker } = paths(commonDir) + + await expect(new PackRefsLockOwnership(commonDir).claim()).resolves.toEqual({ ok: true }) + + await expect(readFile(marker, 'utf-8')).resolves.toContain(String(process.pid)) + }) + + it('drops the owner marker on release', async () => { + const commonDir = await gitCommonDir() + const ownership = new PackRefsLockOwnership(commonDir) + await ownership.claim() + + await ownership.release() + + await expect(exists(paths(commonDir).marker)).resolves.toBe(false) + }) + + it('refuses a lock it cannot prove is its own', async () => { + const commonDir = await gitCommonDir() + // A lock with no marker belongs to the user's own git, or to another tool. + await writeFile(paths(commonDir).lock, 'someone else') + + await expect(new PackRefsLockOwnership(commonDir).claim()).resolves.toMatchObject({ ok: false }) + await expect(exists(paths(commonDir).lock)).resolves.toBe(true) + }) + + it('refuses a lock whose recorded owner is still running', async () => { + const commonDir = await gitCommonDir() + const { lock, marker } = paths(commonDir) + await writeFile(lock, 'in progress') + await writeFile(marker, JSON.stringify({ pid: process.pid })) + + await expect( + new PackRefsLockOwnership(commonDir).claim(laterBy(ABANDONED_LOCK_AGE_MS + 1)) + ).resolves.toMatchObject({ ok: false }) + await expect(exists(lock)).resolves.toBe(true) + }) + + it('reclaims the lock its own dead process left behind', async () => { + // SIGKILL and power loss bypass git's cleanup, and git never clears this itself. + const commonDir = await gitCommonDir() + const { lock, marker } = paths(commonDir) + await writeFile(lock, 'abandoned mid-rewrite') + await writeFile(marker, JSON.stringify({ pid: DEAD_PID })) + + const claimed = await new PackRefsLockOwnership(commonDir).claim( + laterBy(ABANDONED_LOCK_AGE_MS + 1) + ) + + expect(claimed).toEqual({ ok: true }) + await expect(exists(lock)).resolves.toBe(false) + await expect(readFile(marker, 'utf-8')).resolves.toContain(String(process.pid)) + }) + + it('leaves a young lock alone even when the marker names a dead process', async () => { + // A marker outlives its lock, so a foreign lock can appear after our death. + // Age is the only thing separating our wreckage from somebody's live lock. + const commonDir = await gitCommonDir() + const { lock, marker } = paths(commonDir) + await writeFile(marker, JSON.stringify({ pid: DEAD_PID })) + await writeFile(lock, 'a different git process, started just now') + + await expect(new PackRefsLockOwnership(commonDir).claim()).resolves.toMatchObject({ ok: false }) + await expect(exists(lock)).resolves.toBe(true) + }) + + it('does not wedge a repository forever when the recorded pid was recycled', async () => { + const commonDir = await gitCommonDir() + const { lock, marker } = paths(commonDir) + await writeFile(lock, 'abandoned mid-rewrite') + // Our own pid stands in for a recycled one: alive, but not the process that wrote this. + await writeFile(marker, JSON.stringify({ pid: process.pid })) + + await expect( + new PackRefsLockOwnership(commonDir).claim(laterBy(ABANDONED_LOCK_AGE_MS + 1)) + ).resolves.toMatchObject({ ok: false }) + + await expect( + new PackRefsLockOwnership(commonDir).claim(laterBy(PID_REUSE_HORIZON_MS + 1)) + ).resolves.toEqual({ ok: true }) + await expect(exists(lock)).resolves.toBe(false) + }) + + it('refuses a lock whose marker is unreadable rather than guessing', async () => { + const commonDir = await gitCommonDir() + const { lock, marker } = paths(commonDir) + await writeFile(lock, 'in progress') + await writeFile(marker, 'not json') + + await expect( + new PackRefsLockOwnership(commonDir).claim(laterBy(PID_REUSE_HORIZON_MS + 1)) + ).resolves.toMatchObject({ ok: false }) + await expect(exists(lock)).resolves.toBe(true) + }) + + it('claims cleanly when a marker outlived its lock', async () => { + const commonDir = await gitCommonDir() + await writeFile(paths(commonDir).marker, JSON.stringify({ pid: DEAD_PID })) + + await expect(new PackRefsLockOwnership(commonDir).claim()).resolves.toEqual({ ok: true }) + }) +}) + +describe('stranded per-ref locks', () => { + it('clears the empty refs/**/*.lock files its own dead process left behind', async () => { + // `tempfile.c` opens the lock O_EXCL before linking it into the list the + // signal handler walks, so a kill in that window leaves a 0-byte file that + // Git never clears -- and `update-ref -d` on that ref then fails forever. + const commonDir = await gitCommonDir() + const namespace = join(commonDir, 'refs', 'remotes', 'origin') + await mkdir(namespace, { recursive: true }) + await writeFile(join(namespace, 'main.lock'), '') + await writeFile(join(namespace, 'main'), 'a'.repeat(40)) + await writeFile(paths(commonDir).marker, JSON.stringify({ pid: DEAD_PID })) + + await new PackRefsLockOwnership(commonDir).claim(laterBy(ABANDONED_LOCK_AGE_MS + 1)) + + await expect(exists(join(namespace, 'main.lock'))).resolves.toBe(false) + // The ref itself is untouched. + await expect(exists(join(namespace, 'main'))).resolves.toBe(true) + }) + + it('leaves a non-empty ref lock alone, because a live writer is mid-write', async () => { + const commonDir = await gitCommonDir() + const namespace = join(commonDir, 'refs', 'heads') + await mkdir(namespace, { recursive: true }) + await writeFile(join(namespace, 'busy.lock'), 'b'.repeat(40)) + await writeFile(paths(commonDir).marker, JSON.stringify({ pid: DEAD_PID })) + + await new PackRefsLockOwnership(commonDir).claim(laterBy(ABANDONED_LOCK_AGE_MS + 1)) + + await expect(exists(join(namespace, 'busy.lock'))).resolves.toBe(true) + }) + + it('leaves ref locks alone when there is no marker naming a dead process', async () => { + const commonDir = await gitCommonDir() + const namespace = join(commonDir, 'refs', 'heads') + await mkdir(namespace, { recursive: true }) + await writeFile(join(namespace, 'other.lock'), '') + + await new PackRefsLockOwnership(commonDir).claim(laterBy(ABANDONED_LOCK_AGE_MS + 1)) + + await expect(exists(join(namespace, 'other.lock'))).resolves.toBe(true) + }) +}) diff --git a/src/main/git/pack-refs-lock-ownership.ts b/src/main/git/pack-refs-lock-ownership.ts new file mode 100644 index 00000000000..9e7273b143b --- /dev/null +++ b/src/main/git/pack-refs-lock-ownership.ts @@ -0,0 +1,202 @@ +import { readFile, readdir, rm, stat, writeFile } from 'node:fs/promises' +import { posix, win32 } from 'node:path' +import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' +import { + PACK_REFS_TIMEOUT_MS, + PACKED_REFS_LOCK_POLL_MS +} from '../../shared/repo-ref-maintenance-policy' + +/** No legitimate `pack-refs` outlives its own deadline, so an older lock is abandoned. */ +const ABANDONED_LOCK_AGE_MS = PACK_REFS_TIMEOUT_MS + +/** Beyond this a recorded pid may have been recycled, so it stops being evidence of life. */ +const PID_REUSE_HORIZON_MS = 24 * 60 * 60_000 + +/** The ref tree is wide but shallow; this only stops a pathological walk. */ +const REF_LOCK_SCAN_CEILING = 4096 + +/** + * Makes a `packed-refs.lock` Orca left behind attributable, and only that one. + * + * Git registers signal handlers that clean the lock up, but SIGKILL and power + * loss bypass them, and Git never removes a stale `packed-refs.lock` on its own + * -- every later ref deletion in that repository fails until someone deletes a + * file they have never heard of. Recording our pid beside the lock lets a later + * run recognise its own wreckage. + * + * Three independent conditions must all hold before anything is unlinked, + * because deleting a lock somebody else is holding is far worse than declining + * to pack: a marker must exist at all, the lock must be older than any + * `pack-refs` could legitimately run for, and the recorded process must be gone. + * A marker can outlive its lock, so age is what separates "our wreckage" from a + * foreign lock that happened to appear afterwards. + */ +export class PackRefsLockOwnership { + private readonly lockPath: string + private readonly markerPath: string + + constructor(gitCommonDir: string) { + const path = isWindowsAbsolutePathLike(gitCommonDir) ? win32 : posix + this.lockPath = path.join(gitCommonDir, 'packed-refs.lock') + this.markerPath = path.join(gitCommonDir, 'packed-refs.orca-owner') + } + + /** Refused when the lock belongs to something we cannot prove is our own wreckage. */ + async claim(now = Date.now()): Promise { + const reclaim = await this.reclaimAbandonedLock(now) + if (!reclaim.ok) { + return reclaim + } + // Per-ref strands outlive their pack and are invisible to Git, which never + // clears a `refs/**\/*.lock` it did not create in this process. + await this.reclaimStrandedRefLocks(now) + try { + await writeFile(this.markerPath, JSON.stringify({ pid: process.pid }), 'utf-8') + } catch { + // Losing the marker only costs attribution on the next run, never correctness. + } + return { ok: true } + } + + /** + * Poll `packed-refs.lock` so the scheduler knows when the exclusive rewrite + * window opens and closes. Cheap: one `stat` on a fixed path. + */ + watchLock(report: (held: boolean) => void): { stop: () => void } { + let stopped = false + let last = false + const tick = async (): Promise => { + if (stopped) { + return + } + const held = (await fileAgeMs(this.lockPath, Date.now())) !== null + if (!stopped && held !== last) { + last = held + report(held) + } + } + const timer = setInterval(() => void tick(), PACKED_REFS_LOCK_POLL_MS) + timer.unref?.() + void tick() + return { + stop: () => { + stopped = true + clearInterval(timer) + } + } + } + + async release(): Promise { + await rm(this.markerPath, { force: true }).catch(() => {}) + } + + private async reclaimAbandonedLock(now: number): Promise { + const lockAgeMs = await fileAgeMs(this.lockPath, now) + if (lockAgeMs === null) { + return { ok: true } + } + // No marker means the lock is not ours to reason about, let alone remove. + const marker = await readOwnerMarker(this.markerPath) + if (marker === null) { + return { ok: false, reason: 'held by another process' } + } + if (lockAgeMs < ABANDONED_LOCK_AGE_MS) { + // Ours, but too young to be certain the writer is gone. Worth retrying soon. + return { ok: false, reason: 'our own lock, not yet old enough to reclaim' } + } + // Past the pid-reuse horizon the pid proves nothing, and a lock this old is + // abandoned whoever wrote it -- otherwise a recycled pid would wedge the + // repository permanently. + if (isProcessAlive(marker.pid) && lockAgeMs < PID_REUSE_HORIZON_MS) { + return { ok: false, reason: 'the recorded owner is still running' } + } + await rm(this.lockPath, { force: true }).catch(() => {}) + await rm(this.markerPath, { force: true }).catch(() => {}) + return { ok: true } + } + + /** + * Clear `refs/**\/*.lock` files a dead pack of ours left behind. + * + * `tempfile.c` opens the lock `O_EXCL` before `activate_tempfile()` links it + * into the list the signal handler walks, so a kill inside that window leaves + * a 0-byte file. Afterwards `update-ref -d` and any fetch touching that ref + * fail with `cannot lock ref ... File exists`, forever. Same three conditions + * as the packed-refs lock, plus a size check: a live writer's lock is not empty. + */ + private async reclaimStrandedRefLocks(now: number): Promise { + const marker = await readOwnerMarker(this.markerPath) + if (marker === null || isProcessAlive(marker.pid)) { + return + } + const markerAgeMs = await fileAgeMs(this.markerPath, now) + if (markerAgeMs === null || markerAgeMs < ABANDONED_LOCK_AGE_MS) { + return + } + const path = isWindowsAbsolutePathLike(this.markerPath) ? win32 : posix + const pending = [path.join(path.dirname(this.markerPath), 'refs')] + let visited = 0 + while (pending.length > 0) { + const directory = pending.pop() + if (directory === undefined || (visited += 1) > REF_LOCK_SCAN_CEILING) { + return + } + let entries: { name: string; isDirectory: () => boolean }[] + try { + entries = await readdir(directory, { withFileTypes: true }) + } catch { + continue + } + for (const entry of entries) { + const full = path.join(directory, entry.name) + if (entry.isDirectory()) { + pending.push(full) + } else if (entry.name.endsWith('.lock') && (await isEmptyFile(full))) { + await rm(full, { force: true }).catch(() => {}) + } + } + } + } +} + +export type PackRefsLockClaim = { ok: true } | { ok: false; reason: string } + +/** A strand from the `O_EXCL` window is 0 bytes; a live writer's lock is not. */ +async function isEmptyFile(path: string): Promise { + try { + return (await stat(path)).size === 0 + } catch { + return false + } +} + +async function readOwnerMarker(path: string): Promise<{ pid: number } | null> { + try { + const raw = (await readFile(path, 'utf-8')).slice(0, 256) + const pid = (JSON.parse(raw) as { pid?: unknown }).pid + return typeof pid === 'number' && Number.isInteger(pid) && pid > 0 ? { pid } : null + } catch { + return null + } +} + +/** Null when the file does not exist. Uses stat: the lock holds a whole packed-refs. */ +async function fileAgeMs(path: string, now: number): Promise { + try { + return Math.max(0, now - (await stat(path)).mtimeMs) + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'ENOENT' ? null : 0 + } +} + +function isProcessAlive(pid: number): boolean { + if (pid === process.pid) { + return true + } + try { + process.kill(pid, 0) + return true + } catch (error) { + return (error as NodeJS.ErrnoException).code !== 'ESRCH' + } +} diff --git a/src/main/git/remote.ts b/src/main/git/remote.ts index c1557bdd806..2baf3b77137 100644 --- a/src/main/git/remote.ts +++ b/src/main/git/remote.ts @@ -7,6 +7,10 @@ import { gitRefTargetsBranchOnRemote } from '../../shared/git-remote-branch-name import type { GitPushTarget } from '../../shared/worktree/types' import type { GitRuntimeOptions } from './git-runtime-options' import { gitOptionsForWorktree } from './git-runtime-options' +import { + postponeRepoRefMaintenance, + withRepoRefMaintenancePaused +} from './local-repo-ref-maintenance' import { validateGitPushTarget } from './push-target-validation' import { gitExecFileAsync } from './runner' import { fetchForkRemoteWithStaleRefspecRepair } from './fork-remote-stale-branch-refspec' @@ -260,8 +264,11 @@ export async function gitPull( // Why: plain `git pull` uses the user's configured pull strategy (merge by // default) so diverged branches reconcile instead of erroring out. Conflicts // surface through the existing conflict-resolution flow. - await runWithGitWorktreeOperationLock(worktreePath, options.signal, () => - runWithGitReadCacheInvalidation(() => gitPullWithArgs(worktreePath, [], pushTarget, options)) + postponeRepoRefMaintenance() + await withRepoRefMaintenancePaused('git-pull', () => + runWithGitWorktreeOperationLock(worktreePath, options.signal, () => + runWithGitReadCacheInvalidation(() => gitPullWithArgs(worktreePath, [], pushTarget, options)) + ) ) } @@ -270,9 +277,12 @@ export async function gitFastForward( pushTarget?: GitPushTarget, options: GitRuntimeOptions = {} ): Promise { - await runWithGitWorktreeOperationLock(worktreePath, options.signal, () => - runWithGitReadCacheInvalidation(() => - gitPullWithArgs(worktreePath, ['--ff-only'], pushTarget, options) + postponeRepoRefMaintenance() + await withRepoRefMaintenancePaused('git-fast-forward', () => + runWithGitWorktreeOperationLock(worktreePath, options.signal, () => + runWithGitReadCacheInvalidation(() => + gitPullWithArgs(worktreePath, ['--ff-only'], pushTarget, options) + ) ) ) } @@ -282,22 +292,28 @@ export async function gitFetch( pushTarget?: GitPushTarget, options: GitRuntimeOptions = {} ): Promise { + // `--prune` deletes remote-tracking refs, which needs the `packed-refs` lock a + // running idle pack holds while it rewrites -- ~1.4s at most. This is the user + // clicking Fetch, so wait that window out rather than letting it fail on the lock. + postponeRepoRefMaintenance() try { - if (pushTarget) { - const target = await validateGitPushTarget(worktreePath, pushTarget, options) - const runtimeOptions = gitOptionsForWorktree(worktreePath, options) - await fetchForkRemoteWithStaleRefspecRepair( - (args, cwd) => gitExecFileAsync(args, { ...runtimeOptions, cwd }), - worktreePath, - target.remoteName, - () => - gitExecFileAsync(['fetch', '--prune', target.remoteName], runtimeOptions).then( - () => undefined - ) - ) - return - } - await gitExecFileAsync(['fetch', '--prune'], gitOptionsForWorktree(worktreePath, options)) + await withRepoRefMaintenancePaused('git-fetch', async () => { + if (pushTarget) { + const target = await validateGitPushTarget(worktreePath, pushTarget, options) + const runtimeOptions = gitOptionsForWorktree(worktreePath, options) + await fetchForkRemoteWithStaleRefspecRepair( + (args, cwd) => gitExecFileAsync(args, { ...runtimeOptions, cwd }), + worktreePath, + target.remoteName, + () => + gitExecFileAsync(['fetch', '--prune', target.remoteName], runtimeOptions).then( + () => undefined + ) + ) + return + } + await gitExecFileAsync(['fetch', '--prune'], gitOptionsForWorktree(worktreePath, options)) + }) } catch (error) { throw new Error(normalizeGitErrorMessage(error, 'fetch')) } 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/git/repo-ref-maintenance-real-git.test.ts b/src/main/git/repo-ref-maintenance-real-git.test.ts new file mode 100644 index 00000000000..30c67b0365a --- /dev/null +++ b/src/main/git/repo-ref-maintenance-real-git.test.ts @@ -0,0 +1,290 @@ +import { execFileSync } from 'node:child_process' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { countLooseRefs } from '../../shared/loose-ref-count' +import { RepoRefMaintenance } from '../../shared/repo-ref-maintenance' +import { + _resetLocalRepoRefMaintenanceForTests, + createLocalRepoRefMaintenanceTarget, + getLocalRepoRefMaintenance, + setRepoMaintenanceActivityProbe +} from './local-repo-ref-maintenance' +import { forceDeleteLocalBranch } from './worktree-branch-removal' + +const roots: string[] = [] +// Large enough that the deferral ladder (1x, 2x, 4x ... capped at 8x) outlasts +// three real `pack-refs` runs before the deferral budget is spent. +const QUIET_MS = 25 +const THRESHOLD = 20 + +function git(cwd: string, args: string[]): string { + return execFileSync('git', args, { + cwd, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'] + }).trim() +} + +/** A repo whose only loose-ref backlog is the one the test asks for. */ +async function createRepo(looseRefs: number): Promise<{ repoPath: string; refsDir: string }> { + const root = await mkdtemp(join(tmpdir(), 'orca-ref-maintenance-git-')) + roots.push(root) + const repoPath = join(root, 'repo') + execFileSync('git', ['init', '--quiet', repoPath]) + git(repoPath, ['symbolic-ref', 'HEAD', 'refs/heads/main']) + git(repoPath, ['config', 'user.email', 'test@example.com']) + git(repoPath, ['config', 'user.name', 'Test User']) + await writeFile(join(repoPath, 'file.txt'), 'one\n') + git(repoPath, ['add', 'file.txt']) + git(repoPath, ['commit', '--quiet', '-m', 'initial']) + const head = git(repoPath, ['rev-parse', 'HEAD']) + // Written directly: `update-ref` for thousands of refs is the slow part of the fixture. + const namespace = join(repoPath, '.git', 'refs', 'remotes', 'origin') + await mkdir(namespace, { recursive: true }) + for (let index = 0; index < looseRefs; index += 1) { + await writeFile(join(namespace, `branch-${index}`), `${head}\n`) + } + return { repoPath, refsDir: join(repoPath, '.git', 'refs') } +} + +function createMaintenance(onPackRefs: () => void = () => {}): { + maintenance: RepoRefMaintenance + arm: (repoPath: string) => void +} { + const maintenance = new RepoRefMaintenance({ + quietPeriodMs: QUIET_MS, + looseRefThreshold: THRESHOLD + }) + return { + maintenance, + arm: (repoPath: string) => { + const target = createLocalRepoRefMaintenanceTarget({ + key: `local::${repoPath}`, + repoPath + }) + maintenance.arm({ + ...target, + packRefs: async (signal) => { + onPackRefs() + await target.packRefs(signal) + } + }) + } + } +} + +async function settle(maintenance: RepoRefMaintenance): Promise { + await new Promise((resolve) => setTimeout(resolve, QUIET_MS * 4)) + await maintenance.whenAttemptSettled() +} + +/** Deferred repos re-arm for another quiet period, so drain rather than count rounds. */ +async function settleUntil( + maintenance: RepoRefMaintenance, + done: () => Promise +): Promise { + for (let round = 0; round < 100; round += 1) { + if (await done()) { + return + } + await settle(maintenance) + } +} + +afterEach(async () => { + _resetLocalRepoRefMaintenanceForTests() + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('idle ref maintenance against real Git', () => { + it('packs a backlogged repository down to zero loose refs', async () => { + const { repoPath, refsDir } = await createRepo(THRESHOLD + 30) + const { maintenance, arm } = createMaintenance() + + await expect(countLooseRefs(refsDir, 10_000)).resolves.toMatchObject({ + count: THRESHOLD + 31 + }) + + arm(repoPath) + await settle(maintenance) + maintenance.dispose() + + await expect(countLooseRefs(refsDir, 10_000)).resolves.toEqual({ count: 0, saturated: false }) + // The refs survived the move into packed-refs; nothing was lost. + expect(git(repoPath, ['for-each-ref', '--format=%(refname)']).split('\n')).toHaveLength( + THRESHOLD + 31 + ) + expect(git(repoPath, ['rev-parse', '--verify', 'refs/remotes/origin/branch-0'])).toMatch( + /^[0-9a-f]{40}$/ + ) + }, 30_000) + + it('leaves a healthy repository untouched', async () => { + const { repoPath, refsDir } = await createRepo(2) + let packed = 0 + const { maintenance, arm } = createMaintenance(() => { + packed += 1 + }) + + arm(repoPath) + await settle(maintenance) + maintenance.dispose() + + expect(packed).toBe(0) + await expect(countLooseRefs(refsDir, 10_000)).resolves.toMatchObject({ count: 3 }) + }, 30_000) + + it('honours maintenance.auto=false in the repository config', async () => { + const { repoPath, refsDir } = await createRepo(THRESHOLD + 30) + git(repoPath, ['config', 'maintenance.auto', 'false']) + let packed = 0 + const { maintenance, arm } = createMaintenance(() => { + packed += 1 + }) + + arm(repoPath) + await settle(maintenance) + maintenance.dispose() + + expect(packed).toBe(0) + await expect(countLooseRefs(refsDir, 10_000)).resolves.toMatchObject({ + count: THRESHOLD + 31 + }) + }, 30_000) + + it('runs one repository at a time even when several go quiet together', async () => { + const repos = await Promise.all([ + createRepo(THRESHOLD + 5), + createRepo(THRESHOLD + 5), + createRepo(THRESHOLD + 5) + ]) + let concurrent = 0 + let peak = 0 + const maintenance = new RepoRefMaintenance({ + quietPeriodMs: QUIET_MS, + looseRefThreshold: THRESHOLD + }) + for (const { repoPath } of repos) { + const target = createLocalRepoRefMaintenanceTarget({ + key: `local::${repoPath}`, + repoPath + }) + maintenance.arm({ + ...target, + packRefs: async (signal) => { + concurrent += 1 + peak = Math.max(peak, concurrent) + try { + await target.packRefs(signal) + } finally { + concurrent -= 1 + } + } + }) + } + + const allPacked = async (): Promise => { + const counts = await Promise.all(repos.map(({ refsDir }) => countLooseRefs(refsDir, 10_000))) + return counts.every((scan) => scan.count === 0) + } + await settleUntil(maintenance, allPacked) + maintenance.dispose() + + expect(peak).toBe(1) + for (const { refsDir } of repos) { + await expect(countLooseRefs(refsDir, 10_000)).resolves.toEqual({ + count: 0, + saturated: false + }) + } + }, 60_000) +}) + +describe('yielding the repository to work that deletes refs', () => { + it('waits for the packed-refs lock and succeeds while the prune continues', async () => { + // The pack is never killed. `packed-refs.lock` is held for ~1.4s of a 30s + // run; the rest is the prune, during which a concurrent `update-ref -d` + // succeeds on its own because per-ref locks last microseconds. Signalling + // the child there strands a `refs/**` lock Git never clears. + const { repoPath } = await createRepo(0) + git(repoPath, ['branch', 'doomed']) + const head = git(repoPath, ['rev-parse', 'refs/heads/doomed']) + + let packing = false + let releaseLock: (() => void) | undefined + _resetLocalRepoRefMaintenanceForTests({ quietPeriodMs: QUIET_MS, looseRefThreshold: 1 }) + setRepoMaintenanceActivityProbe(() => false) + getLocalRepoRefMaintenance().arm({ + key: `local::${repoPath}`, + resolveRefsDirectory: async () => join(repoPath, '.git', 'refs'), + packRefs: async (lock) => { + packing = true + lock.setHeld(true) + // Stands in for the rewrite window, then the long prune that follows it. + await new Promise((resolve) => { + releaseLock = () => { + lock.setHeld(false) + resolve() + } + }) + } + }) + for (let attempt = 0; attempt < 200 && !packing; attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, QUIET_MS)) + } + expect(packing).toBe(true) + + // The real deletion path, which routes through withRepoRefMaintenancePaused. + let deleted = false + const deletion = forceDeleteLocalBranch(repoPath, 'doomed', head).then(() => { + deleted = true + }) + + // It must still be waiting: the rewrite window is open. + await new Promise((resolve) => setTimeout(resolve, QUIET_MS * 4)) + expect(deleted).toBe(false) + expect(git(repoPath, ['branch', '--list', 'doomed'])).toContain('doomed') + + // Releasing the window is enough -- the pack is never cancelled. + releaseLock?.() + await deletion + expect(deleted).toBe(true) + expect(git(repoPath, ['branch', '--list', 'doomed'])).toBe('') + }, 30_000) + + it('does not block the caller once the rewrite window has closed', async () => { + // The prune phase is concurrency-safe, so a caller arriving during it pays + // nothing at all. + const { repoPath } = await createRepo(0) + git(repoPath, ['branch', 'doomed']) + const head = git(repoPath, ['rev-parse', 'refs/heads/doomed']) + + let pruning = false + let finishPrune: (() => void) | undefined + _resetLocalRepoRefMaintenanceForTests({ quietPeriodMs: QUIET_MS, looseRefThreshold: 1 }) + setRepoMaintenanceActivityProbe(() => false) + getLocalRepoRefMaintenance().arm({ + key: `local::${repoPath}`, + resolveRefsDirectory: async () => join(repoPath, '.git', 'refs'), + packRefs: async (lock) => { + lock.setHeld(true) + lock.setHeld(false) + pruning = true + await new Promise((resolve) => { + finishPrune = resolve + }) + } + }) + for (let attempt = 0; attempt < 200 && !pruning; attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, QUIET_MS)) + } + + const startedAt = Date.now() + await expect(forceDeleteLocalBranch(repoPath, 'doomed', head)).resolves.toBeUndefined() + expect(Date.now() - startedAt).toBeLessThan(2_000) + + finishPrune?.() + }, 30_000) +}) diff --git a/src/main/git/upstream-deferred-fork-remote-real.test.ts b/src/main/git/upstream-deferred-fork-remote-real.test.ts new file mode 100644 index 00000000000..451f7bed8b3 --- /dev/null +++ b/src/main/git/upstream-deferred-fork-remote-real.test.ts @@ -0,0 +1,59 @@ +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 type { GitPushTarget } from '../../shared/worktree/types' +import { getUpstreamStatus } from './upstream' + +// Why: on-demand remote materialization (#17828) defers `git remote add` for a +// fork PR to first push/pull/fetch/fast-forward, so an unpublished review's +// status must be read against a pushTarget whose remote was never created. +// This exercises the real `rev-parse --verify --quiet` failure path -- a +// fake/mocked git can't reproduce its exact exit-code/stderr shape, which is +// exactly what `getPublishTargetStatus`'s missing-ref fallback depends on. +describe('getUpstreamStatus with a deferred (not-yet-materialized) fork remote', () => { + const tempPaths: string[] = [] + + afterEach(() => { + for (const path of tempPaths.splice(0)) { + rmSync(path, { recursive: true, force: true }) + } + }) + + it('reports the graceful "publish" state instead of 0 ahead/0 behind', async () => { + const repoPath = mkdtempSync(join(tmpdir(), 'orca-deferred-fork-remote-')) + 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', '-m', 'base') + git('branch', '-M', 'contributor/fix') + + // Simulates a fork-PR review worktree right after create: pushTarget + // metadata is persisted, but `pr-contributor-orca` was never added as a + // remote because materialization is deferred to first use. + const pushTarget: GitPushTarget = { + remoteName: 'pr-contributor-orca', + branchName: 'contributor/fix', + remoteUrl: 'git@github.com:contributor/orca.git' + } + + const status = await getUpstreamStatus(repoPath, pushTarget) + + expect(status).toEqual({ + hasUpstream: false, + upstreamName: 'pr-contributor-orca/contributor/fix', + ahead: 0, + behind: 0, + hasConfiguredPushTarget: true + }) + }) +}) diff --git a/src/main/git/worktree-add.ts b/src/main/git/worktree-add.ts index 3cd761f4d13..ea6ec704b46 100644 --- a/src/main/git/worktree-add.ts +++ b/src/main/git/worktree-add.ts @@ -4,6 +4,7 @@ import type { LocalBaseRefUpdateSuggestion } from '../../shared/worktree/base-ref-drift-types' import { windowsLongPathGitArgs } from '../../shared/windows-long-path-git-args' +import { withRepoRefMaintenancePaused } from './local-repo-ref-maintenance' import { gitExecFileAsync } from './runner' import { runWithGitReadCacheInvalidation } from './status' import { invalidateWslLinkedWorktreeGitRouting } from './wsl-linked-worktree-git-routing' @@ -149,15 +150,17 @@ export async function addWorktree( options: AddWorktreeOptions = {} ): Promise { try { - return await runWithGitReadCacheInvalidation(() => - performAddWorktree( - repoPath, - worktreePath, - branch, - baseBranch, - refreshLocalBaseRef, - noCheckout, - options + return await withRepoRefMaintenancePaused('worktree-add', () => + runWithGitReadCacheInvalidation(() => + performAddWorktree( + repoPath, + worktreePath, + branch, + baseBranch, + refreshLocalBaseRef, + noCheckout, + options + ) ) ) } finally { diff --git a/src/main/git/worktree-base-divergence-real-git.test.ts b/src/main/git/worktree-base-divergence-real-git.test.ts new file mode 100644 index 00000000000..e17192cf914 --- /dev/null +++ b/src/main/git/worktree-base-divergence-real-git.test.ts @@ -0,0 +1,99 @@ +import { execFileSync } from 'node:child_process' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + measureRetargetDivergence, + RETARGET_MAX_COMMIT_DIVERGENCE +} from './worktree-base-divergence' + +const tempRoots: string[] = [] + +function git(cwd: string, args: string[]): string { + return execFileSync('git', args, { + cwd, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'] + }).trim() +} + +async function createRepo(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-base-divergence-')) + tempRoots.push(root) + const repoPath = join(root, 'repo') + execFileSync('git', ['init', '--quiet', repoPath]) + git(repoPath, ['symbolic-ref', 'HEAD', 'refs/heads/main']) + git(repoPath, ['config', 'user.email', 'test@example.com']) + git(repoPath, ['config', 'user.name', 'Test User']) + await writeFile(join(repoPath, 'version.txt'), 'one\n') + git(repoPath, ['add', 'version.txt']) + git(repoPath, ['commit', '--quiet', '-m', 'initial']) + return repoPath +} + +function commitEmpty(repoPath: string, count: number): void { + for (let index = 0; index < count; index += 1) { + git(repoPath, ['commit', '--quiet', '--allow-empty', '-m', `commit ${index}`]) + } +} + +afterEach(async () => { + await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('measureRetargetDivergence with real Git', () => { + it('allows the drift between a local branch and its remote-tracking copy', async () => { + const repoPath = await createRepo() + git(repoPath, ['update-ref', 'refs/remotes/origin/main', 'HEAD']) + commitEmpty(repoPath, 5) + git(repoPath, ['update-ref', 'refs/remotes/origin/main', 'HEAD']) + git(repoPath, ['reset', '--hard', '--quiet', 'HEAD~3']) + + await expect( + measureRetargetDivergence(repoPath, 'refs/heads/main', 'refs/remotes/origin/main') + ).resolves.toBe('within') + }) + + it('counts drift in both directions', async () => { + const repoPath = await createRepo() + const forkPoint = git(repoPath, ['rev-parse', 'HEAD']) + commitEmpty(repoPath, RETARGET_MAX_COMMIT_DIVERGENCE) + git(repoPath, ['update-ref', 'refs/remotes/origin/main', 'HEAD']) + git(repoPath, ['reset', '--hard', '--quiet', forkPoint]) + commitEmpty(repoPath, 1) + + // 100 ahead + 1 behind is over the cap even though neither side alone exceeds it. + await expect( + measureRetargetDivergence(repoPath, 'refs/heads/main', 'refs/remotes/origin/main') + ).resolves.toBe('exceeded') + }) + + it('refuses a base that has drifted past the cap', async () => { + const repoPath = await createRepo() + git(repoPath, ['update-ref', 'refs/remotes/origin/main', 'HEAD']) + commitEmpty(repoPath, RETARGET_MAX_COMMIT_DIVERGENCE + 1) + + await expect( + measureRetargetDivergence(repoPath, 'refs/remotes/origin/main', 'refs/heads/main') + ).resolves.toBe('exceeded') + }) + + it('refuses unrelated histories, which share no commits at all', async () => { + const repoPath = await createRepo() + git(repoPath, ['checkout', '--quiet', '--orphan', 'unrelated']) + git(repoPath, ['commit', '--quiet', '--allow-empty', '-m', 'unrelated root']) + + await expect( + measureRetargetDivergence(repoPath, 'refs/heads/main', 'refs/heads/unrelated') + ).resolves.toBe('exceeded') + }) + + it('reports an unreadable ref as unverifiable, not as excess drift', async () => { + const repoPath = await createRepo() + + await expect( + measureRetargetDivergence(repoPath, 'refs/heads/main', 'refs/heads/missing') + ).resolves.toBe('unknown') + }) +}) diff --git a/src/main/git/worktree-base-divergence.test.ts b/src/main/git/worktree-base-divergence.test.ts new file mode 100644 index 00000000000..88eec6a6b11 --- /dev/null +++ b/src/main/git/worktree-base-divergence.test.ts @@ -0,0 +1,181 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ gitExecFileAsync: vi.fn() })) + +vi.mock('./runner', () => ({ gitExecFileAsync: mocks.gitExecFileAsync })) + +import { GIT_READ_TIMEOUT_MS } from './command-runner/git-command-timeout' +import { WSL_GIT_READ_ENVIRONMENT_WAIT_MS } from './wsl-git-read-environment' +import { + measureRetargetDivergence, + RETARGET_DIVERGENCE_BUDGET_MS +} from './worktree-base-divergence' + +type ExecOptions = { cwd: string; timeout?: number; wslDistro?: string; signal?: AbortSignal } + +function callOptions(): ExecOptions[] { + return mocks.gitExecFileAsync.mock.calls.map((call) => call[1] as ExecOptions) +} + +function subcommands(): string[] { + return mocks.gitExecFileAsync.mock.calls.map((call) => (call[0] as string[])[0]!) +} + +function answerProbes(count: string, mergeBase = 'abc123\n') { + mocks.gitExecFileAsync.mockImplementation(async (args: string[]) => + args[0] === 'merge-base' ? { stdout: mergeBase } : { stdout: count } + ) +} + +function exitCodeError(code: number): Error & { code: number } { + return Object.assign(new Error('git exited'), { code }) +} + +beforeEach(() => { + mocks.gitExecFileAsync.mockReset() +}) + +describe('measureRetargetDivergence deadlines', () => { + it('puts every probe under one shared budget, not a budget each', async () => { + answerProbes('3\n') + + await expect( + measureRetargetDivergence('/repo', 'refs/heads/main', 'refs/remotes/origin/main') + ).resolves.toBe('within') + + expect(subcommands()).toEqual(['rev-list', 'rev-list', 'merge-base']) + const signals = callOptions().map((options) => options.signal) + // One signal object across all three: the counts and merge-base are staged, so per-probe + // budgets would let the check cost the sum of them. + expect(new Set(signals).size).toBe(1) + expect(signals[0]).toBeInstanceOf(AbortSignal) + }) + + it('also gives each probe a command timeout well below git default read deadline', async () => { + answerProbes('3\n') + + await measureRetargetDivergence('/repo', 'refs/heads/main', 'refs/remotes/origin/main') + + // The signal covers admission queueing and the WSL environment wait, which start before a + // command timeout exists; the timeout still covers a hung spawn. + for (const options of callOptions()) { + expect(options.timeout).toBe(RETARGET_DIVERGENCE_BUDGET_MS) + } + expect(RETARGET_DIVERGENCE_BUDGET_MS).toBeLessThan(GIT_READ_TIMEOUT_MS) + }) + + it('really aborts the in-flight probes when the shared budget expires', async () => { + // A probe that behaves like a slow walk: it produces nothing on its own and only settles when + // its signal fires. If the budget never fired, or never reached the probe, this hangs and the + // test fails on its own timeout rather than passing on a signal that does nothing. + mocks.gitExecFileAsync.mockImplementation( + (_args: string[], options: ExecOptions) => + new Promise((_resolve, reject) => { + options.signal?.addEventListener( + 'abort', + () => + reject( + Object.assign(new Error('The operation was aborted.'), { name: 'AbortError' }) + ), + { once: true } + ) + }) + ) + + await expect( + measureRetargetDivergence('/repo', 'refs/heads/main', 'refs/remotes/origin/main', { + budgetMsForTest: 25 + }) + ).resolves.toBe('unknown') + }) + + it('clears the WSL read-environment wait, which starts before any command timeout', () => { + // Equal to it would make the first WSL-routed create of a session `unknown` by construction, + // and the WSL numbers meaningless. + expect(RETARGET_DIVERGENCE_BUDGET_MS).toBeGreaterThan(WSL_GIT_READ_ENVIRONMENT_WAIT_MS) + expect(RETARGET_DIVERGENCE_BUDGET_MS).toBeLessThan(GIT_READ_TIMEOUT_MS) + }) + + it('stops the probes when the create itself is cancelled, without waiting for the budget', async () => { + mocks.gitExecFileAsync.mockImplementation( + (_args: string[], options: ExecOptions) => + new Promise((_resolve, reject) => { + options.signal?.addEventListener( + 'abort', + () => + reject( + Object.assign(new Error('The operation was aborted.'), { name: 'AbortError' }) + ), + { once: true } + ) + }) + ) + const controller = new AbortController() + const pending = measureRetargetDivergence( + '/repo', + 'refs/heads/main', + 'refs/remotes/origin/main', + // A budget long enough that only the caller's cancellation can end this in time. + { signal: controller.signal, budgetMsForTest: 60_000 } + ) + controller.abort() + + await expect(pending).resolves.toBe('unknown') + }) + + it('reports a blown deadline as unverifiable rather than as excess drift', async () => { + mocks.gitExecFileAsync.mockRejectedValue(new Error('git timed out.')) + + await expect( + measureRetargetDivergence('/repo', 'refs/heads/main', 'refs/remotes/origin/main') + ).resolves.toBe('unknown') + // A count that never answered must not go on to spend a merge-base walk. + expect(subcommands()).not.toContain('merge-base') + }) + + it('separates merge-base saying no from merge-base failing', async () => { + mocks.gitExecFileAsync.mockImplementation(async (args: string[]) => { + if (args[0] === 'merge-base') { + // Exit 1 is Git's answer for unrelated histories. + throw exitCodeError(1) + } + return { stdout: '2\n' } + }) + await expect( + measureRetargetDivergence('/repo', 'refs/heads/main', 'refs/remotes/origin/main') + ).resolves.toBe('exceeded') + + mocks.gitExecFileAsync.mockReset() + mocks.gitExecFileAsync.mockImplementation(async (args: string[]) => { + if (args[0] === 'merge-base') { + // A timeout carries no exit code and must not be read as "no common ancestor". + throw new Error('git timed out.') + } + return { stdout: '2\n' } + }) + await expect( + measureRetargetDivergence('/repo', 'refs/heads/main', 'refs/remotes/origin/main') + ).resolves.toBe('unknown') + }) + + it('reports drift past the cap without spending a merge-base walk', async () => { + answerProbes('101\n') + + await expect( + measureRetargetDivergence('/repo', 'refs/heads/main', 'refs/remotes/origin/main') + ).resolves.toBe('exceeded') + expect(subcommands()).not.toContain('merge-base') + }) + + it('routes every probe to the caller-named WSL distro', async () => { + answerProbes('1\n') + + await measureRetargetDivergence('/repo', 'refs/heads/main', 'refs/remotes/origin/main', { + wslDistro: 'Ubuntu' + }) + + for (const options of callOptions()) { + expect(options).toMatchObject({ cwd: '/repo', wslDistro: 'Ubuntu' }) + } + }) +}) diff --git a/src/main/git/worktree-base-divergence.ts b/src/main/git/worktree-base-divergence.ts new file mode 100644 index 00000000000..c7c924c2f24 --- /dev/null +++ b/src/main/git/worktree-base-divergence.ts @@ -0,0 +1,165 @@ +import { WSL_GIT_READ_ENVIRONMENT_WAIT_MS } from './wsl-git-read-environment' +import { gitExecFileAsync } from './runner' + +export type RetargetDivergenceOptions = { + wslDistro?: string + /** The create's own cancellation signal. Without it a cancelled create leaves these probes + * running until the budget expires. */ + signal?: AbortSignal + /** Shortens only the end-to-end budget so a test can observe a real abort; production always + * uses the constant. Mirrors `timeoutMsForTest` on the git exec options. */ + budgetMsForTest?: number +} + +/** `unknown` is deliberately not folded into `exceeded`: "the bound says no" and "the bound could + * not be evaluated" have different causes and different fixes, and only the second one means a + * retarget that would have been cheap was skipped. `unknown` covers a blown deadline, a + * cancelled create, and an ordinary Git failure alike — it is "no answer", not "slow". */ +export type RetargetDivergence = 'within' | 'exceeded' | 'unknown' + +/** + * How far two bases may drift and still be worth retargeting a prepared checkout between. + * + * Measured on a 21,715-file repo: a local `main` and its `origin/main` were 5 commits and 74 + * files apart, while an abandoned fork's `main` — same branch name, so the same base family — + * was 8,173 commits and 21,708 files from `origin/main`, i.e. a whole-tree checkout. A commit + * count separates those by three orders of magnitude, so it is the cheap proxy for the tree diff + * the retarget reset would have to write. + */ +export const RETARGET_MAX_COMMIT_DIVERGENCE = 100 + +/** + * Headroom for the walk itself, on top of the worst pre-spawn wait. + * + * ~3x the slowest walk measured on the 12GB/80k-ref repo (180ms to reject, 57ms to allow), so a + * cold WSL environment probe cannot eat the whole budget and make the answer `unknown` by + * construction. + */ +const RETARGET_DIVERGENCE_WALK_HEADROOM_MS = 500 + +/** + * End-to-end deadline for the whole check, not per probe. + * + * Derived from the WSL read-environment wait rather than picked: `git-exec-file` awaits that probe + * before a command timeout even exists, so a budget merely equal to it would guarantee `unknown` + * on the first WSL-routed create of a session and make the WSL numbers meaningless. Deriving it + * keeps that relationship explicit instead of coincidental. + * + * Sized against what it competes with: this exists only to decide whether to skip a ~4.1s p50 cold + * `worktree add`, so when it expires the create pays the budget and then does that add anyway. The + * total stays under that add even on Windows, where a killed probe also awaits `taskkill /t`. + * + * It must be a signal, not just a per-command timeout, because a per-command timeout starts only + * after `git-exec-file` has awaited admission and the WSL read-environment probe, and because the + * counts and `merge-base` are staged — two per-probe budgets in sequence would be twice the number + * written here. + */ +export const RETARGET_DIVERGENCE_BUDGET_MS = + WSL_GIT_READ_ENVIRONMENT_WAIT_MS + RETARGET_DIVERGENCE_WALK_HEADROOM_MS + +function probeOptions( + repoPath: string, + options: RetargetDivergenceOptions, + signal: AbortSignal +): { cwd: string; wslDistro?: string; signal: AbortSignal; timeout: number } { + // Built field by field rather than spread: the caller's bag carries a test-only key that must + // never reach git's exec options. + // Both bounds: the signal covers the pre-spawn waits (admission queue, WSL environment) that a + // command timeout cannot see, and the timeout keeps the bounded tree-kill path for a hung spawn. + return { + cwd: repoPath, + ...(options.wslDistro ? { wslDistro: options.wslDistro } : {}), + signal, + timeout: RETARGET_DIVERGENCE_BUDGET_MS + } +} + +/** Commits reachable from `toRef` but not `fromRef`, capped; null when the probe was unusable. */ +async function countCommitsAhead( + repoPath: string, + fromRef: string, + toRef: string, + options: RetargetDivergenceOptions, + signal: AbortSignal +): Promise { + try { + // `--max-count` stops the walk, so an unrelated history costs a bounded number of commits + // rather than a full traversal. Both flags predate the Git 2.25 baseline. + // `--end-of-options` (Git 2.24) because a range whose left side began with `-` would + // otherwise parse as an option; callers only pass `refs/`-qualified names today, and this + // keeps that from being load-bearing. + const { stdout } = await gitExecFileAsync( + [ + 'rev-list', + '--count', + `--max-count=${RETARGET_MAX_COMMIT_DIVERGENCE + 1}`, + '--end-of-options', + `${fromRef}..${toRef}` + ], + probeOptions(repoPath, options, signal) + ) + const count = Number.parseInt(stdout.trim(), 10) + return Number.isNaN(count) ? null : count + } catch { + return null + } +} + +/** True/false when Git decided, null when the probe was unusable. */ +async function hasCommonHistory( + repoPath: string, + leftRef: string, + rightRef: string, + options: RetargetDivergenceOptions, + signal: AbortSignal +): Promise { + try { + const { stdout } = await gitExecFileAsync( + ['merge-base', '--end-of-options', leftRef, rightRef], + probeOptions(repoPath, options, signal) + ) + return stdout.trim().length > 0 + } catch (error) { + // Exit 1 is `merge-base` reporting no common ancestor, which is an answer. A timeout or abort + // carries no exit code and must not be read as one. + return (error as { code?: unknown }).code === 1 ? false : null + } +} + +/** + * Whether retargeting a checkout prepared at `preparedBase` onto `targetBase` stays cheap. + * + * Fails closed on error, slowness, and cancellation alike: only a positive `within` authorizes + * reusing the checkout, so every other outcome lands on the cold create path. + */ +export async function measureRetargetDivergence( + repoPath: string, + preparedBase: string, + targetBase: string, + options: RetargetDivergenceOptions = {} +): Promise { + const budget = AbortSignal.timeout(options.budgetMsForTest ?? RETARGET_DIVERGENCE_BUDGET_MS) + // Combined so cancelling the create stops the probes immediately rather than at the deadline. + const signal = options.signal ? AbortSignal.any([options.signal, budget]) : budget + // Both directions: commits the target adds decide what the reset writes, commits only the + // preparation has decide what it must delete. + const [ahead, behind] = await Promise.all([ + countCommitsAhead(repoPath, preparedBase, targetBase, options, signal), + countCommitsAhead(repoPath, targetBase, preparedBase, options, signal) + ]) + if (ahead === null || behind === null) { + return 'unknown' + } + if (ahead + behind > RETARGET_MAX_COMMIT_DIVERGENCE) { + return 'exceeded' + } + // Only now: `merge-base` has no `--max-count`, so on unrelated histories it would walk both of + // them in full. Reaching here already proved neither side is more than the cap ahead of the + // other, which bounds that walk — and unrelated histories of any size fail the counts first. + // Required because unrelated histories replace the whole tree however few commits they carry. + const shareHistory = await hasCommonHistory(repoPath, preparedBase, targetBase, options, signal) + if (shareHistory === null) { + return 'unknown' + } + return shareHistory ? 'within' : 'exceeded' +} diff --git a/src/main/git/worktree-base-ref-probe.ts b/src/main/git/worktree-base-ref-probe.ts index 8e44877ab76..87c761ebbcc 100644 --- a/src/main/git/worktree-base-ref-probe.ts +++ b/src/main/git/worktree-base-ref-probe.ts @@ -42,6 +42,21 @@ export async function hasWorktreeBaseCommitRef( return (await resolveWorktreeBaseCommitOid(repoPath, qualifiedRef, options)) !== null } +/** + * The qualified ref a worktree base names in this repo, or the base unchanged when nothing + * matches. Callers that key on a base must compare this, not the raw string, or `main` and + * `refs/heads/main` look like different bases. + */ +export function resolveLocalWorktreeBaseRef( + repoPath: string, + baseRef: string, + options: GitExecOptions = {} +): Promise { + return resolveWorktreeAddBaseRef(baseRef, (qualifiedRef) => + hasWorktreeBaseCommitRef(repoPath, qualifiedRef, options) + ) +} + /** * Whether a worktree base — a qualified ref, a short branch or remote name, or a * full commit id — already resolves in this repo's own object/ref store. diff --git a/src/main/git/worktree-branch-removal.ts b/src/main/git/worktree-branch-removal.ts index 5e64b4a582d..08b6b21a1e3 100644 --- a/src/main/git/worktree-branch-removal.ts +++ b/src/main/git/worktree-branch-removal.ts @@ -4,6 +4,7 @@ import { } from '../../shared/git-branch-cleanup' import type { RemoveWorktreeResult } from '../../shared/worktree/create-types' import { withLocalGitCapabilityCacheForExecution } from './git-capability-state' +import { withRepoRefMaintenancePaused } from './local-repo-ref-maintenance' import { gitExecFileAsync } from './runner' import { parseWorktreeList } from './worktree-list-parser' import type { GitWorktreeExecOptions, RemoveWorktreeOptions } from './worktree-operation-options' @@ -152,7 +153,11 @@ export async function forceDeleteLocalBranch( } // Why: stale toast actions must not delete a branch that moved; `update-ref -d` deletes only if the ref still == expectedHead. try { - await runGit(['update-ref', '-d', `refs/heads/${branchName}`, expectedHead], repoPath) + // `update-ref -d` needs the packed-refs lock a running idle pack holds while + // it rewrites; waits it out rather than cancelling the pack. + await withRepoRefMaintenancePaused('branch-delete', () => + runGit(['update-ref', '-d', `refs/heads/${branchName}`, expectedHead], repoPath) + ) } catch { throw new Error( `Local branch "${branchName}" changed after the workspace was deleted. Review it before deleting it.` diff --git a/src/main/git/worktree-create-preparation-real-git.test.ts b/src/main/git/worktree-create-preparation-real-git.test.ts index bdb1e9fcc4b..63f8021a7ae 100644 --- a/src/main/git/worktree-create-preparation-real-git.test.ts +++ b/src/main/git/worktree-create-preparation-real-git.test.ts @@ -68,6 +68,55 @@ describe('prepared worktree creation with real Git', () => { expect(await listWorktrees(repoPath, { includeCreatePreparations: true })).toHaveLength(1) }) + it('lands a cross-base retarget on exactly the requested commit', async () => { + const { repoPath, root } = await createRepo() + const preparationRoot = join(root, WORKTREE_CREATE_PREPARATION_DIRECTORY) + const preparedPath = join(preparationRoot, `${process.pid}-retarget`) + const finalPath = join(root, 'retargeted-worktree') + await mkdir(preparationRoot, { recursive: true }) + + await writeFile(join(repoPath, 'shared.txt'), 'kept\n') + git(repoPath, ['add', 'shared.txt']) + git(repoPath, ['commit', '--quiet', '-m', 'local main']) + const localMainHead = git(repoPath, ['rev-parse', 'HEAD']) + + // A remote-tracking `main` that diverged: different content, an extra file, and one deletion. + git(repoPath, ['checkout', '--quiet', '-b', 'upstream-main']) + await writeFile(join(repoPath, 'version.txt'), 'two\n') + await writeFile(join(repoPath, 'only-upstream.txt'), 'upstream\n') + git(repoPath, ['rm', '--quiet', 'shared.txt']) + git(repoPath, ['add', 'version.txt', 'only-upstream.txt']) + git(repoPath, ['commit', '--quiet', '-m', 'upstream main']) + git(repoPath, ['update-ref', 'refs/remotes/origin/main', 'HEAD']) + git(repoPath, ['checkout', '--quiet', 'main']) + git(repoPath, ['branch', '--quiet', '-D', 'upstream-main']) + + await prepareWorktreeCreateCheckout( + repoPath, + preparedPath, + 'refs/remotes/origin/main', + createWorktreePreparationLockReason('retarget-test') + ) + expect(git(preparedPath, ['rev-parse', 'HEAD'])).not.toBe(localMainHead) + + await finalizePreparedWorktree(repoPath, preparedPath, finalPath, 'feature/retargeted', 'main') + + expect(git(finalPath, ['rev-parse', 'HEAD'])).toBe(localMainHead) + // A retarget that left stale files behind would be a wrong checkout, not just a slow one. + expect(git(finalPath, ['status', '--porcelain'])).toBe('') + expect((await readFile(join(finalPath, 'version.txt'), 'utf8')).replaceAll('\r\n', '\n')).toBe( + 'one\n' + ) + expect((await readFile(join(finalPath, 'shared.txt'), 'utf8')).replaceAll('\r\n', '\n')).toBe( + 'kept\n' + ) + await expect(readFile(join(finalPath, 'only-upstream.txt'), 'utf8')).rejects.toThrow() + expect(git(finalPath, ['branch', '--show-current'])).toBe('feature/retargeted') + expect(git(finalPath, ['config', '--get', 'branch.feature/retargeted.base'])).toBe( + 'refs/heads/main' + ) + }) + it('hides the preparation, retargets an advanced base, and attaches the final branch', async () => { const { repoPath, root } = await createRepo() const preparationRoot = join(root, WORKTREE_CREATE_PREPARATION_DIRECTORY) diff --git a/src/main/git/worktree-create-preparation.ts b/src/main/git/worktree-create-preparation.ts index 3be0fee1648..b60dc01ec33 100644 --- a/src/main/git/worktree-create-preparation.ts +++ b/src/main/git/worktree-create-preparation.ts @@ -10,6 +10,7 @@ import { WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS } from './worktree' import { hasWorktreeBaseCommitRef } from './worktree-base-ref-probe' +import { withRepoRefMaintenancePaused } from './local-repo-ref-maintenance' import { gitExecFileAsync } from './runner' import { runWithGitReadCacheInvalidation } from './status' import { invalidateWslLinkedWorktreeGitRouting } from './wsl-linked-worktree-git-routing' @@ -69,46 +70,48 @@ export async function prepareWorktreeCreateCheckout( options: GitWorktreeExecOptions = {} ): Promise { try { - await runWithGitReadCacheInvalidation(async () => { - const effectiveBase = await resolveWorktreeAddBaseRef(baseBranch, (qualifiedRef) => - hasWorktreeBaseCommitRef(repoPath, qualifiedRef, options) - ) - try { - await gitExecFileAsync( - [ - ...windowsLongPathGitArgs(repoPath), - 'worktree', - 'add', - '--detach', - '--no-checkout', - worktreePath, - effectiveBase - ], - { ...gitExecOptions(repoPath, options), timeout: resolveWorktreeAddTimeoutMs() } + await withRepoRefMaintenancePaused('worktree-prepare', () => + runWithGitReadCacheInvalidation(async () => { + const effectiveBase = await resolveWorktreeAddBaseRef(baseBranch, (qualifiedRef) => + hasWorktreeBaseCommitRef(repoPath, qualifiedRef, options) ) - // The add just wrote the marker; drop any pre-create route before the reset routes Git. - invalidateWslLinkedWorktreeGitRouting(worktreePath) - // Why: reset materializes files without running user post-checkout hooks before submit. - await gitExecFileAsync( - [...windowsLongPathGitArgs(worktreePath), 'reset', '--hard', effectiveBase], - { ...gitExecOptions(worktreePath, options), timeout: resolveWorktreeAddTimeoutMs() } - ) - await gitExecFileAsync( - [ - ...windowsLongPathGitArgs(repoPath), - 'worktree', - 'lock', - '--reason', - lockReason, - worktreePath - ], - { ...gitExecOptions(repoPath, options), timeout: resolveWorktreeAddTimeoutMs() } - ) - } catch (error) { - await performDiscardPreparedWorktree(repoPath, worktreePath, options).catch(() => {}) - throw error - } - }) + try { + await gitExecFileAsync( + [ + ...windowsLongPathGitArgs(repoPath), + 'worktree', + 'add', + '--detach', + '--no-checkout', + worktreePath, + effectiveBase + ], + { ...gitExecOptions(repoPath, options), timeout: resolveWorktreeAddTimeoutMs() } + ) + // The add just wrote the marker; drop any pre-create route before the reset routes Git. + invalidateWslLinkedWorktreeGitRouting(worktreePath) + // Why: reset materializes files without running user post-checkout hooks before submit. + await gitExecFileAsync( + [...windowsLongPathGitArgs(worktreePath), 'reset', '--hard', effectiveBase], + { ...gitExecOptions(worktreePath, options), timeout: resolveWorktreeAddTimeoutMs() } + ) + await gitExecFileAsync( + [ + ...windowsLongPathGitArgs(repoPath), + 'worktree', + 'lock', + '--reason', + lockReason, + worktreePath + ], + { ...gitExecOptions(repoPath, options), timeout: resolveWorktreeAddTimeoutMs() } + ) + } catch (error) { + await performDiscardPreparedWorktree(repoPath, worktreePath, options).catch(() => {}) + throw error + } + }) + ) } finally { notifyPreparedWorktreeMutation(repoPath) } diff --git a/src/main/git/worktree-listing.ts b/src/main/git/worktree-listing.ts index 6abb5a099c6..a60819181d4 100644 --- a/src/main/git/worktree-listing.ts +++ b/src/main/git/worktree-listing.ts @@ -97,7 +97,7 @@ export async function listWorktreesStrict( return annotateSparseCheckoutStatus(repoPath, visibleWorktrees, options) } -async function annotateSparseCheckoutStatus( +export async function annotateSparseCheckoutStatus( repoPath: string, worktrees: GitWorktreeInfo[], options: GitWorktreeExecOptions = {} diff --git a/src/main/git/worktree-removal.ts b/src/main/git/worktree-removal.ts index c6743a4a7e2..afe1bf2a9c1 100644 --- a/src/main/git/worktree-removal.ts +++ b/src/main/git/worktree-removal.ts @@ -22,6 +22,7 @@ import { } from './worktree-operation-options' import { areWorktreePathsEqual } from './worktree-path-comparison' import { assertWorktreeCleanForRemoval } from './worktree-removal-preflight' +import { withRepoRefMaintenancePaused } from './local-repo-ref-maintenance' import { bumpWorktreeScanGeneration, listWorktrees } from './worktree-scan-cache' import { invalidateSparseCheckoutState } from './worktree-sparse-checkout-cache' @@ -36,8 +37,13 @@ export async function removeWorktree( options: RemoveWorktreeOptions = {} ): Promise { try { - return await runWithGitReadCacheInvalidation(() => - performRemoveWorktree(repoPath, worktreePath, force, options) + // Removal deletes branches, and a ref deletion needs the packed-refs lock a + // running idle pack holds while it rewrites. Waits that window out; the + // prune phase that follows it is concurrency-safe and is left to finish. + return await withRepoRefMaintenancePaused('worktree-remove', () => + runWithGitReadCacheInvalidation(() => + performRemoveWorktree(repoPath, worktreePath, force, options) + ) ) } finally { invalidateWslLinkedWorktreeGitRouting(worktreePath) diff --git a/src/main/git/worktree-scan-cache-annotation-reuse.test.ts b/src/main/git/worktree-scan-cache-annotation-reuse.test.ts new file mode 100644 index 00000000000..0fabe5ba049 --- /dev/null +++ b/src/main/git/worktree-scan-cache-annotation-reuse.test.ts @@ -0,0 +1,93 @@ +// The annotated listing is the graph listing plus a sparse probe: callers that read only +// `worktree.path` must skip the probe, without costing a second `git worktree list`. +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { GitWorktreeInfo } from '../../shared/worktree/types' + +const { detectSparseCheckoutMock, readWorktreeListMock, readTranslatedWorktreeGraphMock } = + vi.hoisted(() => ({ + detectSparseCheckoutMock: vi.fn(), + readWorktreeListMock: vi.fn(), + readTranslatedWorktreeGraphMock: vi.fn() + })) + +vi.mock('./worktree-sparse-state', () => ({ + detectSparseCheckout: detectSparseCheckoutMock, + resolveGitCommonDir: vi.fn() +})) +vi.mock('./worktree-list-reader', () => ({ + readCheckedOutBranchRef: vi.fn(), + readRepoCommonDirFromGit: vi.fn(), + readRepoLocation: vi.fn(), + readTranslatedWorktreeGraph: readTranslatedWorktreeGraphMock, + readWorktreeHeadOid: vi.fn(), + readWorktreeList: readWorktreeListMock +})) + +import { _resetWorktreeScanCacheForTests, listWorktreeGraph, listWorktrees } from './worktree' +import { __resetSparseCheckoutStateCacheForTests } from './worktree-sparse-checkout-cache' + +const REPO = '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo' +const ROW: GitWorktreeInfo = { + path: 'C:\\wt\\x', + head: 'a'.repeat(40), + branch: 'refs/heads/feature', + isBare: false, + isMainWorktree: false +} + +describe('graph and annotated worktree scans', () => { + beforeEach(() => { + detectSparseCheckoutMock.mockReset() + detectSparseCheckoutMock.mockResolvedValue(true) + readWorktreeListMock.mockReset() + readWorktreeListMock.mockResolvedValue([ROW]) + readTranslatedWorktreeGraphMock.mockReset() + readTranslatedWorktreeGraphMock.mockResolvedValue([ROW]) + _resetWorktreeScanCacheForTests() + __resetSparseCheckoutStateCacheForTests() + }) + + it('does not probe sparse state for a graph scan', async () => { + const rows = await listWorktreeGraph(REPO, { wslDistro: 'Ubuntu' }) + + expect(rows[0]?.path).toBe('C:\\wt\\x') + expect(rows[0]?.isSparse).toBeUndefined() + expect(detectSparseCheckoutMock).not.toHaveBeenCalled() + }) + + it('still probes sparse state for the annotated scan', async () => { + const rows = await listWorktrees(REPO, { wslDistro: 'Ubuntu' }) + + expect(rows[0]?.isSparse).toBe(true) + expect(detectSparseCheckoutMock).toHaveBeenCalledTimes(1) + }) + + it('reads the git listing once for an overlapping graph and annotated scan', async () => { + const [graphRows, annotatedRows] = await Promise.all([ + listWorktreeGraph(REPO, { wslDistro: 'Ubuntu' }), + listWorktrees(REPO, { wslDistro: 'Ubuntu' }) + ]) + + expect(readTranslatedWorktreeGraphMock).toHaveBeenCalledTimes(1) + expect(graphRows[0]?.isSparse).toBeUndefined() + expect(annotatedRows[0]?.isSparse).toBe(true) + }) + + // Sharing the listing must not make the probe-free caller wait on the probe it opted out of. + it('resolves a graph scan while the annotated scan is still probing', async () => { + let releaseProbe!: () => void + detectSparseCheckoutMock.mockImplementation( + () => + new Promise((resolve) => { + releaseProbe = () => resolve(true) + }) + ) + + const annotatedScan = listWorktrees(REPO, { wslDistro: 'Ubuntu' }) + const graphRows = await listWorktreeGraph(REPO, { wslDistro: 'Ubuntu' }) + + expect(graphRows[0]?.path).toBe('C:\\wt\\x') + releaseProbe() + expect((await annotatedScan)[0]?.isSparse).toBe(true) + }) +}) diff --git a/src/main/git/worktree-scan-cache-sharing.test.ts b/src/main/git/worktree-scan-cache-sharing.test.ts index d64ec2d4852..2a687420cd8 100644 --- a/src/main/git/worktree-scan-cache-sharing.test.ts +++ b/src/main/git/worktree-scan-cache-sharing.test.ts @@ -98,7 +98,9 @@ describe('listWorktrees in-flight sharing', () => { expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) }) - it('keeps graph and annotated scans separate despite sharing the same Git listing', async () => { + // The annotated scan is the graph scan plus a sparse probe, so the two share one `git worktree + // list` and only the annotated caller pays the probe. They ran Git twice before. + it('runs one git listing for concurrent graph and annotated scans', async () => { const resolvers: ((value: { stdout: string }) => void)[] = [] gitExecFileAsyncMock.mockImplementation( () => @@ -109,13 +111,34 @@ describe('listWorktrees in-flight sharing', () => { const graphScan = listWorktreeGraph('/repo') const annotatedScan = listWorktrees('/repo') - expect(resolvers).toHaveLength(2) + expect(resolvers).toHaveLength(1) for (const resolve of resolvers) { resolve({ stdout: 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n' }) } await Promise.all([graphScan, annotatedScan]) - expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2) + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) + }) + + // Order must not matter: whichever runs first owns the listing and the other joins it. + it('runs one git listing when the annotated scan starts first', async () => { + const resolvers: ((value: { stdout: string }) => void)[] = [] + gitExecFileAsyncMock.mockImplementation( + () => + new Promise((resolve) => { + resolvers.push(resolve) + }) + ) + + const annotatedScan = listWorktrees('/repo') + const graphScan = listWorktreeGraph('/repo') + expect(resolvers).toHaveLength(1) + + for (const resolve of resolvers) { + resolve({ stdout: 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n' }) + } + await Promise.all([annotatedScan, graphScan]) + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) }) it('keeps graph scans with an AbortSignal isolated from shared callers', async () => { @@ -352,14 +375,15 @@ describe('listWorktrees in-flight sharing', () => { expect(scanResolvers).toHaveLength(1) await moveWorktree('/repo', '/repo-old', '/repo-new') - expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 1, generations: 1 }) + // Two entries per annotated scan: its own, plus the graph listing it shares with probe-free callers. + expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 2, generations: 1 }) const freshScan = listWorktrees('/repo') - expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 2, generations: 1 }) + expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 4, generations: 1 }) scanResolvers[1]?.('worktree /repo-new\nHEAD fresh\nbranch refs/heads/main\n') expect((await freshScan)[0]?.path).toBe('/repo-new') - expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 1, generations: 1 }) + expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 2, generations: 1 }) scanResolvers[0]?.('worktree /repo\nHEAD stale\nbranch refs/heads/main\n') expect((await staleScan)[0]?.path).toBe('/repo') @@ -396,7 +420,7 @@ describe('listWorktrees in-flight sharing', () => { const newestScan = listWorktrees('/repo') expect(listCalls).toBe(3) - expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 3, generations: 1 }) + expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 6, generations: 1 }) scanResolvers[0]?.() scanResolvers[2]?.() diff --git a/src/main/git/worktree-scan-cache.ts b/src/main/git/worktree-scan-cache.ts index 327aead5f2e..59a7691fe7f 100644 --- a/src/main/git/worktree-scan-cache.ts +++ b/src/main/git/worktree-scan-cache.ts @@ -1,8 +1,8 @@ import type { GitWorktreeInfo } from '../../shared/worktree/types' import { + annotateSparseCheckoutStatus, listWorktreeGraph as listWorktreeGraphUnshared, - listWorktreesStrict as listWorktreesStrictUnshared, - listWorktreesUnshared + listWorktreesStrict as listWorktreesStrictUnshared } from './worktree-listing' import type { GitWorktreeExecOptions } from './worktree-operation-options' import { WORKTREE_LIST_TIMEOUT_MS } from './worktree-operation-options' @@ -90,6 +90,22 @@ function shareWorktreeScan( return scan } +/** + * Sparse annotation layered over the shared graph scan rather than its own `git worktree list`. + * + * Both paths soften a Git failure to `[]`, so they can share one listing; only this one pays the + * per-worktree sparse probe. That lets a caller which reads just `worktree.path` skip the probes + * without costing a second subprocess when it overlaps a badge reader — the two ran Git twice + * before. Strict stays on its own scan because it must be able to reject. + */ +async function runAnnotatedWorktreeScan( + repoPath: string, + options: GitWorktreeExecOptions +): Promise { + const worktrees = await listWorktreeGraph(repoPath, options) + return annotateSparseCheckoutStatus(repoPath, worktrees, options) +} + /** * List all worktrees for a git repo at the given path. Concurrent calls for * the same repo share one scan (unless the caller passes an AbortSignal, @@ -99,7 +115,7 @@ export function listWorktrees( repoPath: string, options: GitWorktreeExecOptions = {} ): Promise { - return shareWorktreeScan(repoPath, options, 'lenient', listWorktreesUnshared) + return shareWorktreeScan(repoPath, options, 'lenient', runAnnotatedWorktreeScan) } /** 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/created-worktree-root-prune.test.ts b/src/main/ipc/created-worktree-root-prune.test.ts index 9114e34a07c..30808d7d79e 100644 --- a/src/main/ipc/created-worktree-root-prune.test.ts +++ b/src/main/ipc/created-worktree-root-prune.test.ts @@ -2,7 +2,7 @@ import type * as NodeFsPromises from 'node:fs/promises' import { resolve } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as RepoWorktrees from '../repo-worktrees' -import { listRepoWorktrees } from '../repo-worktrees' +import { listRepoWorktreeGraph } from '../repo-worktrees' import type { Store } from '../persistence' import type { Repo } from '../../shared/repo-types' import { @@ -22,7 +22,7 @@ vi.mock('node:fs/promises', async () => { vi.mock('../repo-worktrees', async () => { const actual = await vi.importActual('../repo-worktrees') - return { ...actual, listRepoWorktrees: vi.fn() } + return { ...actual, listRepoWorktreeGraph: vi.fn() } }) const repo: Repo = { @@ -56,10 +56,10 @@ describe('recovered worktree root pruning', () => { beforeEach(() => { invalidateAuthorizedRootsCache() __resetCreatedWorktreeRootsForTests() - vi.mocked(listRepoWorktrees).mockReset() + vi.mocked(listRepoWorktreeGraph).mockReset() // The #16520 outage itself: `listWorktrees` softens every Git failure to `[]`, so the rebuild // reports success with the recovered row missing and the probe is the only remaining evidence. - vi.mocked(listRepoWorktrees).mockResolvedValue([]) + vi.mocked(listRepoWorktreeGraph).mockResolvedValue([]) statMock.mockReset() statMock.mockResolvedValue({}) }) diff --git a/src/main/ipc/filesystem-auth.test.ts b/src/main/ipc/filesystem-auth.test.ts index 41c2c44a634..fa82b7a652c 100644 --- a/src/main/ipc/filesystem-auth.test.ts +++ b/src/main/ipc/filesystem-auth.test.ts @@ -5,7 +5,7 @@ import { join, resolve } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { Store } from '../persistence' import type * as RepoWorktrees from '../repo-worktrees' -import { listRepoWorktrees } from '../repo-worktrees' +import { listRepoWorktreeGraph } from '../repo-worktrees' import type { FolderWorkspace } from '../../shared/folder-workspace-types' import type { ProjectGroup } from '../../shared/project-group-types' import type { Repo } from '../../shared/repo-types' @@ -29,7 +29,7 @@ vi.mock('../repo-worktrees', async () => { const actual = await vi.importActual('../repo-worktrees') return { ...actual, - listRepoWorktrees: vi.fn() + listRepoWorktreeGraph: vi.fn() } }) @@ -98,7 +98,7 @@ describe('filesystem auth worktree roots', () => { beforeEach(() => { invalidateAuthorizedRootsCache() __resetCreatedWorktreeRootsForTests() - vi.mocked(listRepoWorktrees).mockReset() + vi.mocked(listRepoWorktreeGraph).mockReset() }) it('rebuilds the authorized roots cache for large worktree lists', async () => { @@ -112,7 +112,7 @@ describe('filesystem auth worktree roots', () => { isMainWorktree: false }) ) - vi.mocked(listRepoWorktrees).mockResolvedValue(worktrees) + vi.mocked(listRepoWorktreeGraph).mockResolvedValue(worktrees) const store = makeStore() await rebuildAuthorizedRootsCache(store) @@ -121,7 +121,7 @@ describe('filesystem auth worktree roots', () => { await expect(resolveRegisteredWorktreePath(lastWorktreePath, store)).resolves.toBe( resolve(lastWorktreePath) ) - expect(listRepoWorktrees).toHaveBeenCalledTimes(1) + expect(listRepoWorktreeGraph).toHaveBeenCalledTimes(1) }) it("keeps a repo's roots when its listing fails mid-rebuild", async () => { @@ -129,7 +129,7 @@ describe('filesystem auth worktree roots', () => { // a worktree a create just recovered without a listing (#16520). const store = makeStore() registerCreatedWorktreeRoot(store, repo.id, '/linked/recovered') - vi.mocked(listRepoWorktrees).mockRejectedValue(new Error('git worktree list failed.')) + vi.mocked(listRepoWorktreeGraph).mockRejectedValue(new Error('git worktree list failed.')) await rebuildAuthorizedRootsCache(store) @@ -146,7 +146,7 @@ describe('filesystem auth worktree roots', () => { await mkdir(recovered) const store = makeStore() registerCreatedWorktreeRoot(store, repo.id, recovered) - vi.mocked(listRepoWorktrees).mockResolvedValue([]) + vi.mocked(listRepoWorktreeGraph).mockResolvedValue([]) await rebuildAuthorizedRootsCache(store) @@ -160,7 +160,7 @@ describe('filesystem auth worktree roots', () => { await mkdir(recovered) const store = makeStore() // Register mid-listing: the rebuild's own result was computed before this worktree existed. - vi.mocked(listRepoWorktrees).mockImplementation(async () => { + vi.mocked(listRepoWorktreeGraph).mockImplementation(async () => { registerCreatedWorktreeRoot(store, repo.id, recovered) return [] }) @@ -174,7 +174,7 @@ describe('filesystem auth worktree roots', () => { it('retires a recovered root once the listing can see it again', async () => { const store = makeStore() registerCreatedWorktreeRoot(store, repo.id, '/linked/feature') - vi.mocked(listRepoWorktrees).mockResolvedValue([ + vi.mocked(listRepoWorktreeGraph).mockResolvedValue([ { path: '/linked/feature', head: '', @@ -189,7 +189,7 @@ describe('filesystem auth worktree roots', () => { await expect(resolveRegisteredWorktreePath('/linked/feature', store)).resolves.toBe( resolve('/linked/feature') ) - vi.mocked(listRepoWorktrees).mockResolvedValue([]) + vi.mocked(listRepoWorktreeGraph).mockResolvedValue([]) await rebuildAuthorizedRootsCache(store) await expect(resolveRegisteredWorktreePath('/linked/feature', store)).rejects.toThrow( @@ -205,7 +205,7 @@ describe('filesystem auth worktree roots', () => { })) let active = 0 let maxActive = 0 - vi.mocked(listRepoWorktrees).mockImplementation(async () => { + vi.mocked(listRepoWorktreeGraph).mockImplementation(async () => { active += 1 maxActive = Math.max(maxActive, active) await new Promise((resolve) => setTimeout(resolve, 1)) @@ -215,7 +215,7 @@ describe('filesystem auth worktree roots', () => { await rebuildAuthorizedRootsCache(makeStore(repos)) - expect(listRepoWorktrees).toHaveBeenCalledTimes(repos.length) + expect(listRepoWorktreeGraph).toHaveBeenCalledTimes(repos.length) expect(maxActive).toBeLessThanOrEqual(8) }) }) @@ -392,7 +392,7 @@ describe('filesystem-auth path containment', () => { vi.resetModules() vi.doMock('../repo-worktrees', () => ({ isRepoRoot: vi.fn(), - listRepoWorktrees: vi.fn() + listRepoWorktreeGraph: vi.fn() })) vi.doMock('path', async () => { const path = await vi.importActual('node:path') diff --git a/src/main/ipc/filesystem-test-harness.ts b/src/main/ipc/filesystem-test-harness.ts index 402409defa3..47efa88d6cd 100644 --- a/src/main/ipc/filesystem-test-harness.ts +++ b/src/main/ipc/filesystem-test-harness.ts @@ -107,6 +107,7 @@ export const gitStatusModuleMock = { export const gitIgnoredPathsMock = { checkIgnoredPaths: checkIgnoredPathsMock } export const gitWorktreeMock = { + listWorktreeGraph: listWorktreesMock, listWorktrees: listWorktreesMock, listWorktreesStrict: listWorktreesMock } diff --git a/src/main/ipc/filesystem/filesystem-source-control-ai-targets.ts b/src/main/ipc/filesystem/filesystem-source-control-ai-targets.ts index 62b86e7361a..2f9e5e92c09 100644 --- a/src/main/ipc/filesystem/filesystem-source-control-ai-targets.ts +++ b/src/main/ipc/filesystem/filesystem-source-control-ai-targets.ts @@ -5,7 +5,7 @@ import type { CommitMessageAgentRuntimeTarget } from '../../text-generation/comm import type { CommitMessageGenerationTarget } from '../../text-generation/commit-message-text-generation' import { resolve } from 'node:path' import { getSshGitProvider } from '../../providers/ssh-git-dispatch' -import { listRepoWorktrees } from '../../repo-worktrees' +import { listRepoWorktreeGraph } from '../../repo-worktrees' import { resolveAuthorizedPath } from '../filesystem-auth' import { resolveRegisteredWorktreePath } from '../registered-worktree-roots-cache' import { splitWorktreeId } from '../../../shared/worktree/id' @@ -77,7 +77,7 @@ async function localRepoOwnsWorktree( return true } try { - const worktrees = await listRepoWorktrees(repo) + const worktrees = await listRepoWorktreeGraph(repo) return worktrees.some((worktree) => candidatePaths.has(comparableLocalPath(worktree.path))) } catch { return false diff --git a/src/main/ipc/filesystem/git-remote/branch-mutation-handlers.ts b/src/main/ipc/filesystem/git-remote/branch-mutation-handlers.ts index 53ba72d58a4..2c3273b8c00 100644 --- a/src/main/ipc/filesystem/git-remote/branch-mutation-handlers.ts +++ b/src/main/ipc/filesystem/git-remote/branch-mutation-handlers.ts @@ -9,6 +9,10 @@ import { import { resolveRegisteredWorktreePath } from '../../registered-worktree-roots-cache' import { getLocalGitOptionsForRegisteredWorktree } from '../../local-worktree-runtime-options' import { assertGitPushTargetShape } from '../../../../shared/git-push-target-validation' +import { + materializeWorktreePushTargetRemote, + materializeWorktreePushTargetRemoteSsh +} from '../../worktree-remote' import type { FilesystemHandlerContext } from '../filesystem-handler-context' export function registerGitRemoteBranchMutationHandlers(context: FilesystemHandlerContext): void { @@ -20,6 +24,7 @@ export function registerGitRemoteBranchMutationHandlers(context: FilesystemHandl _event, args: { worktreePath: string + worktreeId?: string publish?: boolean forceWithLease?: boolean connectionId?: string @@ -36,7 +41,18 @@ export function registerGitRemoteBranchMutationHandlers(context: FilesystemHandl if (!provider) { throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) } - return provider.pushBranch(args.worktreePath, publish, args.pushTarget, { + // Why: a fork remote deferred at create time (#17828) must exist before push. + const materializedPushTarget = args.pushTarget + ? await materializeWorktreePushTargetRemoteSsh( + provider, + args.worktreePath, + args.pushTarget, + store, + undefined, + args.worktreeId + ) + : undefined + return provider.pushBranch(args.worktreePath, publish, materializedPushTarget, { forceWithLease: args.forceWithLease === true }) } @@ -46,13 +62,23 @@ export function registerGitRemoteBranchMutationHandlers(context: FilesystemHandl args.worktreePath, worktreePath ) - if (args.pushTarget) { - await validateGitPushTarget(worktreePath, args.pushTarget, { + const materializedPushTarget = args.pushTarget + ? await materializeWorktreePushTargetRemote( + worktreePath, + args.pushTarget, + store, + undefined, + gitOptions, + args.worktreeId + ) + : undefined + if (materializedPushTarget) { + await validateGitPushTarget(worktreePath, materializedPushTarget, { ...gitOptions, admissionTier: 'interactive' }) } - await gitPush(worktreePath, publish, args.pushTarget, { + await gitPush(worktreePath, publish, materializedPushTarget, { forceWithLease: args.forceWithLease === true, ...gitOptions, admissionTier: 'interactive' @@ -64,7 +90,12 @@ export function registerGitRemoteBranchMutationHandlers(context: FilesystemHandl 'git:pull', async ( _event, - args: { worktreePath: string; connectionId?: string; pushTarget?: GitPushTarget } + args: { + worktreePath: string + worktreeId?: string + connectionId?: string + pushTarget?: GitPushTarget + } ): Promise => { if (args.connectionId) { if (args.pushTarget) { @@ -74,7 +105,17 @@ export function registerGitRemoteBranchMutationHandlers(context: FilesystemHandl if (!provider) { throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) } - return provider.pullBranch(args.worktreePath, args.pushTarget) + const materializedPushTarget = args.pushTarget + ? await materializeWorktreePushTargetRemoteSsh( + provider, + args.worktreePath, + args.pushTarget, + store, + undefined, + args.worktreeId + ) + : undefined + return provider.pullBranch(args.worktreePath, materializedPushTarget) } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const gitOptions = getLocalGitOptionsForRegisteredWorktree( @@ -82,13 +123,23 @@ export function registerGitRemoteBranchMutationHandlers(context: FilesystemHandl args.worktreePath, worktreePath ) - if (args.pushTarget) { - await validateGitPushTarget(worktreePath, args.pushTarget, { + const materializedPushTarget = args.pushTarget + ? await materializeWorktreePushTargetRemote( + worktreePath, + args.pushTarget, + store, + undefined, + gitOptions, + args.worktreeId + ) + : undefined + if (materializedPushTarget) { + await validateGitPushTarget(worktreePath, materializedPushTarget, { ...gitOptions, admissionTier: 'interactive' }) } - await gitPull(worktreePath, args.pushTarget, { + await gitPull(worktreePath, materializedPushTarget, { ...gitOptions, admissionTier: 'interactive' }) @@ -99,7 +150,12 @@ export function registerGitRemoteBranchMutationHandlers(context: FilesystemHandl 'git:fastForward', async ( _event, - args: { worktreePath: string; connectionId?: string; pushTarget?: GitPushTarget } + args: { + worktreePath: string + worktreeId?: string + connectionId?: string + pushTarget?: GitPushTarget + } ): Promise => { if (args.connectionId) { if (args.pushTarget) { @@ -109,7 +165,17 @@ export function registerGitRemoteBranchMutationHandlers(context: FilesystemHandl if (!provider) { throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) } - return provider.fastForwardBranch(args.worktreePath, args.pushTarget) + const materializedPushTarget = args.pushTarget + ? await materializeWorktreePushTargetRemoteSsh( + provider, + args.worktreePath, + args.pushTarget, + store, + undefined, + args.worktreeId + ) + : undefined + return provider.fastForwardBranch(args.worktreePath, materializedPushTarget) } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const gitOptions = getLocalGitOptionsForRegisteredWorktree( @@ -117,13 +183,23 @@ export function registerGitRemoteBranchMutationHandlers(context: FilesystemHandl args.worktreePath, worktreePath ) - if (args.pushTarget) { - await validateGitPushTarget(worktreePath, args.pushTarget, { + const materializedPushTarget = args.pushTarget + ? await materializeWorktreePushTargetRemote( + worktreePath, + args.pushTarget, + store, + undefined, + gitOptions, + args.worktreeId + ) + : undefined + if (materializedPushTarget) { + await validateGitPushTarget(worktreePath, materializedPushTarget, { ...gitOptions, admissionTier: 'interactive' }) } - await gitFastForward(worktreePath, args.pushTarget, { + await gitFastForward(worktreePath, materializedPushTarget, { ...gitOptions, admissionTier: 'interactive' }) diff --git a/src/main/ipc/filesystem/git-remote/sync-handlers.ts b/src/main/ipc/filesystem/git-remote/sync-handlers.ts index eae79c918dd..a924c393a04 100644 --- a/src/main/ipc/filesystem/git-remote/sync-handlers.ts +++ b/src/main/ipc/filesystem/git-remote/sync-handlers.ts @@ -17,6 +17,10 @@ import { resolveRegisteredWorktreePath } from '../../registered-worktree-roots-c import { getLocalGitOptionsForRegisteredWorktree } from '../../local-worktree-runtime-options' import { assertGitPushTargetShape } from '../../../../shared/git-push-target-validation' import { validateGitForkSyncExpectedUpstream } from '../../../../shared/git-fork-sync' +import { + materializeWorktreePushTargetRemote, + materializeWorktreePushTargetRemoteSsh +} from '../../worktree-remote' import type { FilesystemHandlerContext } from '../filesystem-handler-context' export function registerGitRemoteSyncHandlers(context: FilesystemHandlerContext): void { @@ -52,7 +56,12 @@ export function registerGitRemoteSyncHandlers(context: FilesystemHandlerContext) 'git:fetch', async ( _event, - args: { worktreePath: string; connectionId?: string; pushTarget?: GitPushTarget } + args: { + worktreePath: string + worktreeId?: string + connectionId?: string + pushTarget?: GitPushTarget + } ): Promise => { if (args.connectionId) { if (args.pushTarget) { @@ -62,7 +71,17 @@ export function registerGitRemoteSyncHandlers(context: FilesystemHandlerContext) if (!provider) { throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) } - return provider.fetchRemote(args.worktreePath, args.pushTarget) + const materializedPushTarget = args.pushTarget + ? await materializeWorktreePushTargetRemoteSsh( + provider, + args.worktreePath, + args.pushTarget, + store, + undefined, + args.worktreeId + ) + : undefined + return provider.fetchRemote(args.worktreePath, materializedPushTarget) } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const gitOptions = getLocalGitOptionsForRegisteredWorktree( @@ -70,13 +89,23 @@ export function registerGitRemoteSyncHandlers(context: FilesystemHandlerContext) args.worktreePath, worktreePath ) - if (args.pushTarget) { - await validateGitPushTarget(worktreePath, args.pushTarget, { + const materializedPushTarget = args.pushTarget + ? await materializeWorktreePushTargetRemote( + worktreePath, + args.pushTarget, + store, + undefined, + gitOptions, + args.worktreeId + ) + : undefined + if (materializedPushTarget) { + await validateGitPushTarget(worktreePath, materializedPushTarget, { ...gitOptions, admissionTier: 'interactive' }) } - await gitFetch(worktreePath, args.pushTarget, { + await gitFetch(worktreePath, materializedPushTarget, { ...gitOptions, admissionTier: 'interactive' }) diff --git a/src/main/ipc/hosted-review.test.ts b/src/main/ipc/hosted-review.test.ts index 7cad33211d6..3f273490d7b 100644 --- a/src/main/ipc/hosted-review.test.ts +++ b/src/main/ipc/hosted-review.test.ts @@ -17,7 +17,7 @@ const { getHostedReviewCreationEligibilityMock, getHostedReviewForBranchMock, resolveRegisteredWorktreePathMock, - listRepoWorktreesMock + listRepoWorktreeGraphMock } = vi.hoisted(() => ({ handleMock: vi.fn(), createHostedReviewMock: vi.fn(), @@ -25,7 +25,7 @@ const { getHostedReviewCreationEligibilityMock: vi.fn(), getHostedReviewForBranchMock: vi.fn(), resolveRegisteredWorktreePathMock: vi.fn(), - listRepoWorktreesMock: vi.fn() + listRepoWorktreeGraphMock: vi.fn() })) vi.mock('electron', () => ({ @@ -52,7 +52,7 @@ vi.mock('./registered-worktree-roots-cache', () => ({ })) vi.mock('../repo-worktrees', () => ({ - listRepoWorktrees: listRepoWorktreesMock + listRepoWorktreeGraph: listRepoWorktreeGraphMock })) import { registerHostedReviewHandlers } from './hosted-review' @@ -97,7 +97,7 @@ describe('registerHostedReviewHandlers', () => { getHostedReviewCreationEligibilityMock.mockReset() getHostedReviewForBranchMock.mockReset() resolveRegisteredWorktreePathMock.mockReset() - listRepoWorktreesMock.mockReset() + listRepoWorktreeGraphMock.mockReset() store.getRepo.mockReset() store.getRepos.mockReset() store.getProjects.mockReset() @@ -114,7 +114,7 @@ describe('registerHostedReviewHandlers', () => { store.getRepos.mockReturnValue([repo]) store.getProjects.mockReturnValue([]) store.getSettings.mockReturnValue({ localWindowsRuntimeDefault: { kind: 'windows-host' } }) - listRepoWorktreesMock.mockResolvedValue([{ path: worktreePath }]) + listRepoWorktreeGraphMock.mockResolvedValue([{ path: worktreePath }]) }) it('routes local WSL project review creation through main-process runtime options', async () => { @@ -143,7 +143,7 @@ describe('registerHostedReviewHandlers', () => { ]) const resolvedWorktreePath = resolve('/workspace/feature') resolveRegisteredWorktreePathMock.mockResolvedValue(resolvedWorktreePath) - listRepoWorktreesMock.mockResolvedValue([{ path: resolvedWorktreePath }]) + listRepoWorktreeGraphMock.mockResolvedValue([{ path: resolvedWorktreePath }]) createHostedReviewMock.mockResolvedValueOnce({ ok: true, number: 42, @@ -162,7 +162,7 @@ describe('registerHostedReviewHandlers', () => { title: 'Feature PR' }) - expect(listRepoWorktreesMock).toHaveBeenCalledWith(localRepo, { wslDistro: 'Ubuntu' }) + expect(listRepoWorktreeGraphMock).toHaveBeenCalledWith(localRepo, { wslDistro: 'Ubuntu' }) expect(createHostedReviewMock).toHaveBeenCalledWith( resolvedWorktreePath, expect.objectContaining({ @@ -193,7 +193,7 @@ describe('registerHostedReviewHandlers', () => { store.getRepos.mockReturnValue([localRepo]) const resolvedWorktreePath = resolve('/workspace/feature') resolveRegisteredWorktreePathMock.mockResolvedValue(resolvedWorktreePath) - listRepoWorktreesMock.mockResolvedValue([{ path: resolvedWorktreePath }]) + listRepoWorktreeGraphMock.mockResolvedValue([{ path: resolvedWorktreePath }]) createHostedReviewMock.mockResolvedValueOnce({ ok: true, number: 42, url: 'https://x/1' }) registerHostedReviewHandlers(store as never, stats as never) diff --git a/src/main/ipc/hosted-review.ts b/src/main/ipc/hosted-review.ts index c431459f4f7..f64aeb8aa4d 100644 --- a/src/main/ipc/hosted-review.ts +++ b/src/main/ipc/hosted-review.ts @@ -16,7 +16,7 @@ import { import { createStackedHostedReview } from '../source-control/stacked-hosted-review-creation' import { getHostedReviewForBranch } from '../source-control/hosted-review' import { resolveRegisteredWorktreePath } from './registered-worktree-roots-cache' -import { listRepoWorktrees } from '../repo-worktrees' +import { listRepoWorktreeGraph } from '../repo-worktrees' import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' import { getWorktreeSharedLinkPaths } from '../git/worktree-shared-directories' import { getRepoExecutionHostId } from '../../shared/execution-host' @@ -68,7 +68,7 @@ async function resolveHostedReviewWorktreePath( } if (repo.connectionId) { const remoteWorktreePath = normalizeRemoteHostedReviewPath(worktreePath) - const repoWorktrees = await listRepoWorktrees(repo) + const repoWorktrees = await listRepoWorktreeGraph(repo) if ( !repoWorktrees.some( (worktree) => normalizeRemoteHostedReviewPath(worktree.path) === remoteWorktreePath @@ -82,8 +82,8 @@ async function resolveHostedReviewWorktreePath( const localGitOptions = getLocalProjectWorktreeGitOptions(store, repo) const repoWorktrees = Object.keys(localGitOptions).length > 0 - ? await listRepoWorktrees(repo, localGitOptions) - : await listRepoWorktrees(repo) + ? await listRepoWorktreeGraph(repo, localGitOptions) + : await listRepoWorktreeGraph(repo) if (!repoWorktrees.some((worktree) => resolve(worktree.path) === resolvedWorktreePath)) { throw new Error('Access denied: worktree does not belong to repository') } diff --git a/src/main/ipc/pty/ipc/spawn-push-target-materialization-real-git.test.ts b/src/main/ipc/pty/ipc/spawn-push-target-materialization-real-git.test.ts new file mode 100644 index 00000000000..20e0e646da4 --- /dev/null +++ b/src/main/ipc/pty/ipc/spawn-push-target-materialization-real-git.test.ts @@ -0,0 +1,149 @@ +// Real-binary coverage for #17828's remaining gap: the mocked-underlying-trigger suite in +// `spawn-push-target-materialization.test.ts` proves the wiring/delegation logic, but not +// that a `pty:spawn`-originated terminal -- the desktop GUI's own terminal path, previously +// uncovered -- actually ends up with a configured upstream against real git. No mocks here: +// this exercises the real `triggerTerminalSpawnPushTargetMaterialization` and real +// `materializeWorktreePushTargetRemote`, driven only through `runPtyIpcSpawn`'s hook. +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, vi } from 'vitest' +import type { GitPushTarget } from '../../../../shared/worktree/types' +import type { Repo } from '../../../../shared/repo-types' +import type { WorktreeMeta } from '../../../../shared/worktree/meta-types' +import type { Store } from '../../../persistence' +import type { PtySpawnIpcDeps } from './spawn-types' +import { triggerPtySpawnPushTargetMaterialization } from './spawn-push-target-materialization' + +const execFileAsync = promisify(execFile) + +const REPO_ID = 'repo-1' +const FORK_REMOTE = 'pr-contributor-orca' +const TRACKED_BRANCH = 'contributor/fix' + +let scratchDir = '' +let repoPath = '' +let forkPath = '' +let worktreeId = '' +let mainBranch = '' + +async function git(args: string[], cwd: string): Promise { + const { stdout } = await execFileAsync('git', args, { cwd }) + return stdout +} + +async function setIdentity(cwd: string): Promise { + await git(['config', 'user.name', 'Orca Test'], cwd) + await git(['config', 'user.email', 'orca@example.test'], cwd) + await git(['config', 'commit.gpgSign', 'false'], cwd) +} + +beforeEach(async () => { + // realpath: macOS hands out /var/... temp paths while Git reports /private/var/... + scratchDir = await realpath(await mkdtemp(join(tmpdir(), 'orca-pty-spawn-push-target-'))) + repoPath = join(scratchDir, 'repo') + forkPath = join(scratchDir, 'fork') + worktreeId = `${REPO_ID}::${repoPath}` + + await mkdir(repoPath, { recursive: true }) + await git(['init', '-q'], repoPath) + await setIdentity(repoPath) + await writeFile(join(repoPath, 'seed.txt'), 'seed\n') + await git(['add', '-A'], repoPath) + await git(['commit', '-qm', 'seed'], repoPath) + mainBranch = (await git(['rev-parse', '--abbrev-ref', 'HEAD'], repoPath)).trim() + + await git(['clone', '-q', repoPath, forkPath], scratchDir) + await setIdentity(forkPath) + await git(['checkout', '-qb', TRACKED_BRANCH], forkPath) + await writeFile(join(forkPath, 'fix.txt'), 'fix\n') + await git(['add', '-A'], forkPath) + await git(['commit', '-qm', 'fix'], forkPath) +}) + +afterEach(async () => { + await rm(scratchDir, { recursive: true, force: true }) +}) + +function forkTarget(): GitPushTarget { + return { remoteName: FORK_REMOTE, branchName: TRACKED_BRANCH, remoteUrl: forkPath } +} + +function depsFor( + pushTarget: GitPushTarget, + setWorktreeMeta?: Store['setWorktreeMeta'] +): { + deps: PtySpawnIpcDeps + meta: Record +} { + const meta: Record = { [worktreeId]: { pushTarget } as WorktreeMeta } + const store = { + getWorktreeMeta: (id: string) => meta[id], + getRepo: (id: string) => ({ id, path: repoPath, connectionId: null }) as unknown as Repo, + getAllWorktreeMeta: () => meta, + ...(setWorktreeMeta ? { setWorktreeMeta } : {}) + } as unknown as Store + return { deps: { store } as unknown as PtySpawnIpcDeps, meta } +} + +describe('triggerPtySpawnPushTargetMaterialization (real git fixture)', () => { + it('materializes the fork remote and configures the upstream for a pty:spawn-originated terminal', async () => { + // Why: not just that materialization was *called* -- the coordinator's bar for closing + // the gap is a real, git-verified configured upstream reachable from a pty:spawn arg set. + // Pre-seeds the remote so materialize takes the short-circuit branch (worktree-remote.ts): + // real `remote add`/`fetch` against a fabricated fork is already covered against real git by + // worktree-push-target-refspec-real-git.test.ts; the top-level entry point this hook calls + // additionally validates `remoteUrl` against a GitHub URL shape, which a local fixture path + // can never satisfy. The short-circuit is also the common case in practice -- every pty:spawn + // after the worktree's first (new tab, split, reattach) -- and still drives real + // `ensureRemoteTracksBranchNarrowly` / narrow `fetch` / `--set-upstream-to` git calls. + await git(['remote', 'add', FORK_REMOTE, forkPath], repoPath) + const { deps } = depsFor(forkTarget()) + + triggerPtySpawnPushTargetMaterialization(deps, { + cols: 80, + rows: 24, + worktreeId + }) + + await vi.waitFor( + async () => { + const upstream = await git( + ['rev-parse', '--abbrev-ref', `${mainBranch}@{u}`], + repoPath + ).catch(() => '') + expect(upstream.trim()).toBe(`${FORK_REMOTE}/${TRACKED_BRANCH}`) + }, + { timeout: 5000, interval: 25 } + ) + + const remoteUrl = (await git(['remote', 'get-url', FORK_REMOTE], repoPath)).trim() + expect(remoteUrl).toBe(forkPath) + + // The tracked branch's commit must actually be present -- confirms the narrow fetch ran, + // not just that the remote config was written. + const forkHead = (await git(['rev-parse', TRACKED_BRANCH], forkPath)).trim() + const fetchedHead = ( + await git(['rev-parse', `${FORK_REMOTE}/${TRACKED_BRANCH}`], repoPath) + ).trim() + expect(fetchedHead).toBe(forkHead) + }) + + it('is a no-op once the remote was already created (repeat pty:spawn, e.g. reattach)', async () => { + const target = { ...forkTarget(), remoteCreated: true } + const { deps } = depsFor(target) + await git(['remote', 'add', FORK_REMOTE, forkPath], repoPath) + + triggerPtySpawnPushTargetMaterialization(deps, { cols: 80, rows: 24, worktreeId }) + + // Give the fire-and-forget chain a tick; there is nothing to wait for since a + // remoteCreated target must short-circuit before any git call. + await new Promise((resolve) => setImmediate(resolve)) + const upstream = await git(['rev-parse', '--abbrev-ref', `${mainBranch}@{u}`], repoPath).catch( + () => '' + ) + expect(upstream.trim()).toBe('') + }) +}) diff --git a/src/main/ipc/pty/ipc/spawn-push-target-materialization.test.ts b/src/main/ipc/pty/ipc/spawn-push-target-materialization.test.ts new file mode 100644 index 00000000000..df71cb37f8a --- /dev/null +++ b/src/main/ipc/pty/ipc/spawn-push-target-materialization.test.ts @@ -0,0 +1,145 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { GitPushTarget } from '../../../../shared/worktree/types' +import type { Repo } from '../../../../shared/repo-types' +import type { WorktreeMeta } from '../../../../shared/worktree/meta-types' +import type { Store } from '../../../persistence' +import type { PtySpawnIpcArgs, PtySpawnIpcDeps } from './spawn-types' + +const { triggerMock } = vi.hoisted(() => ({ triggerMock: vi.fn() })) +vi.mock('../../../runtime/runtime-terminal-spawn-push-target-materialization', () => ({ + triggerTerminalSpawnPushTargetMaterialization: triggerMock +})) + +import { triggerPtySpawnPushTargetMaterialization } from './spawn-push-target-materialization' + +const REPO_ID = 'repo-1' +const WORKTREE_PATH = '/repo/worktree' +const WORKTREE_ID = `${REPO_ID}::${WORKTREE_PATH}` +const FORK_TARGET: GitPushTarget = { + remoteName: 'pr-contributor-orca', + branchName: 'contributor/fix', + remoteUrl: 'git@github.com:contributor/orca.git' +} +const REPO = { id: REPO_ID, path: '/repo', connectionId: null } as unknown as Repo + +function depsWithStore(overrides: Partial = {}): PtySpawnIpcDeps { + return { + store: { + getWorktreeMeta: vi.fn().mockReturnValue({ pushTarget: FORK_TARGET } as WorktreeMeta), + getRepo: vi.fn().mockReturnValue(REPO), + ...overrides + } as unknown as Store + } as unknown as PtySpawnIpcDeps +} + +function baseArgs(overrides: Partial = {}): PtySpawnIpcArgs { + return { cols: 80, rows: 24, worktreeId: WORKTREE_ID, ...overrides } +} + +describe('triggerPtySpawnPushTargetMaterialization', () => { + let warnSpy: ReturnType + + beforeEach(() => { + triggerMock.mockReset() + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + it('is a no-op when args has no worktreeId', () => { + triggerPtySpawnPushTargetMaterialization(depsWithStore(), baseArgs({ worktreeId: undefined })) + expect(triggerMock).not.toHaveBeenCalled() + }) + + it('is a no-op when deps has no store', () => { + triggerPtySpawnPushTargetMaterialization({} as unknown as PtySpawnIpcDeps, baseArgs()) + expect(triggerMock).not.toHaveBeenCalled() + }) + + it('is a no-op for a malformed worktreeId (no separator)', () => { + triggerPtySpawnPushTargetMaterialization( + depsWithStore(), + baseArgs({ worktreeId: 'not-a-valid-id' }) + ) + expect(triggerMock).not.toHaveBeenCalled() + }) + + it('parses the worktreeId, looks up the push target and repo, and delegates', () => { + const deps = depsWithStore() + triggerPtySpawnPushTargetMaterialization(deps, baseArgs()) + + expect(deps.store!.getWorktreeMeta).toHaveBeenCalledWith(WORKTREE_ID) + expect(deps.store!.getRepo).toHaveBeenCalledWith(REPO_ID) + expect(triggerMock).toHaveBeenCalledWith( + WORKTREE_PATH, + FORK_TARGET, + REPO, + deps.store, + REPO_ID, + WORKTREE_ID + ) + }) + + it('passes null when the repo lookup misses', () => { + const deps = depsWithStore({ getRepo: vi.fn().mockReturnValue(undefined) }) + triggerPtySpawnPushTargetMaterialization(deps, baseArgs()) + + expect(triggerMock).toHaveBeenCalledWith( + WORKTREE_PATH, + FORK_TARGET, + null, + deps.store, + REPO_ID, + WORKTREE_ID + ) + }) + + // Why: many pty:spawn unit tests supply a narrow fake Store missing these methods -- + // this is the actual bug the hook must guard against (#17828), not a hypothetical. + // Optional chaining degrades the lookups to undefined/null; the underlying trigger + // itself no-ops on an undefined push target, so this never blocks or throws on spawn. + it('does not throw when the store lacks getWorktreeMeta/getRepo, delegating with undefined/null', () => { + const partialStore = {} as Store + expect(() => + triggerPtySpawnPushTargetMaterialization( + { store: partialStore } as unknown as PtySpawnIpcDeps, + baseArgs() + ) + ).not.toThrow() + expect(triggerMock).toHaveBeenCalledWith( + WORKTREE_PATH, + undefined, + null, + partialStore, + REPO_ID, + WORKTREE_ID + ) + }) + + it('warns and swallows an error thrown by the underlying trigger', () => { + triggerMock.mockImplementation(() => { + throw new Error('boom') + }) + expect(() => + triggerPtySpawnPushTargetMaterialization(depsWithStore(), baseArgs()) + ).not.toThrow() + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('failed to trigger push target materialization'), + expect.any(Error) + ) + }) + + it('strips a folder-workspace instance suffix from the worktree path before delegating', () => { + const instanceId = 'a1b2c3d4-e5f6-4789-a012-b3c4d5e6f789' + const deps = depsWithStore() + const suffixedId = `${WORKTREE_ID}::workspace:${instanceId}` + triggerPtySpawnPushTargetMaterialization(deps, baseArgs({ worktreeId: suffixedId })) + + expect(triggerMock).toHaveBeenCalledWith( + WORKTREE_PATH, + FORK_TARGET, + REPO, + deps.store, + REPO_ID, + suffixedId + ) + }) +}) diff --git a/src/main/ipc/pty/ipc/spawn-push-target-materialization.ts b/src/main/ipc/pty/ipc/spawn-push-target-materialization.ts new file mode 100644 index 00000000000..d9a30326155 --- /dev/null +++ b/src/main/ipc/pty/ipc/spawn-push-target-materialization.ts @@ -0,0 +1,40 @@ +import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree/id' +import { triggerTerminalSpawnPushTargetMaterialization } from '../../../runtime/runtime-terminal-spawn-push-target-materialization' +import type { PtySpawnIpcArgs, PtySpawnIpcDeps } from './spawn-types' + +// Why (#17828): pty:spawn is the desktop GUI's own terminal path (new tab, split, reattach) -- +// raw git commands can run here before any Orca-driven sync, so a deferred fork-PR remote must +// exist first. Mirrors the agent/background-terminal hook in +// runtime-terminal-spawn-push-target-materialization.ts, which this delegates to; fire-and-forget +// and a no-op once the remote already exists, so it is safe on every spawn including reattaches. +export function triggerPtySpawnPushTargetMaterialization( + deps: PtySpawnIpcDeps, + args: PtySpawnIpcArgs +): void { + if (!args.worktreeId || !deps.store) { + return + } + const parsed = splitWorktreeIdForFilesystem(args.worktreeId) + if (!parsed) { + return + } + // Why: never let a partial/fake Store (many pty:spawn unit tests supply a narrow one) or an + // unexpected lookup failure turn this best-effort hook into a spawn-blocking exception. + try { + const pushTarget = deps.store.getWorktreeMeta?.(args.worktreeId)?.pushTarget + const repo = deps.store.getRepo?.(parsed.repoId) ?? null + triggerTerminalSpawnPushTargetMaterialization( + parsed.worktreePath, + pushTarget, + repo, + deps.store, + parsed.repoId, + args.worktreeId + ) + } catch (error) { + console.warn( + `[pty-spawn] failed to trigger push target materialization for ${args.worktreeId}:`, + error + ) + } +} diff --git a/src/main/ipc/pty/ipc/spawn-run.ts b/src/main/ipc/pty/ipc/spawn-run.ts index 748eb5d8f62..82e2d33f383 100644 --- a/src/main/ipc/pty/ipc/spawn-run.ts +++ b/src/main/ipc/pty/ipc/spawn-run.ts @@ -7,6 +7,7 @@ import { buildPtyIpcSpawnOptions } from './spawn-options' import { executePtyIpcSpawn } from './spawn-execute' import { commitPtyIpcSpawn } from './spawn-commit' import { createPtyIpcSpawnState, type PtyIpcSpawnState } from './spawn-state' +import { triggerPtySpawnPushTargetMaterialization } from './spawn-push-target-materialization' import type { PtySpawnIpcArgs, PtySpawnIpcDeps } from './spawn-types' function releaseAbandonedAgentTeamsLeader(ctx: PtyIpcSpawnState): void { @@ -30,6 +31,7 @@ function restoreProvisionalPtySize(ctx: PtyIpcSpawnState): void { } export async function runPtyIpcSpawn(deps: PtySpawnIpcDeps, args: PtySpawnIpcArgs) { + triggerPtySpawnPushTargetMaterialization(deps, args) const ctx = createPtyIpcSpawnState(deps, args) const early = await beginPtyIpcSpawn(ctx) if (early) { diff --git a/src/main/ipc/registered-worktree-roots-cache.ts b/src/main/ipc/registered-worktree-roots-cache.ts index 9e1d20733df..ad8c40535cf 100644 --- a/src/main/ipc/registered-worktree-roots-cache.ts +++ b/src/main/ipc/registered-worktree-roots-cache.ts @@ -3,7 +3,7 @@ import { resolve } from 'node:path' import { withTimeout } from '../../shared/promise-timeout-fallback' import { getErrorCode } from '../git/worktree-operation-options' import type { Store } from '../persistence' -import { isRepoRoot, listRepoWorktrees } from '../repo-worktrees' +import { isRepoRoot, listRepoWorktreeGraph } from '../repo-worktrees' import { getLocalRepos } from './filesystem-allowed-roots' import { isDescendantOrEqual, normalizeExistingPath } from './filesystem-path-containment' @@ -54,7 +54,7 @@ export async function rebuildAuthorizedRootsCache(store: Store): Promise { try { roots.push(resolve(repo.path)) - for (const worktree of await listRepoWorktrees(repo)) { + for (const worktree of await listRepoWorktreeGraph(repo)) { roots.push(resolve(worktree.path)) } } catch (error) { diff --git a/src/main/ipc/repos-add-linked-worktree.test.ts b/src/main/ipc/repos-add-linked-worktree.test.ts index de9da98ae38..97cef8da5e0 100644 --- a/src/main/ipc/repos-add-linked-worktree.test.ts +++ b/src/main/ipc/repos-add-linked-worktree.test.ts @@ -113,7 +113,7 @@ describe('repos:add with git worktrees', () => { invalidateAuthorizedRootsCacheMock.mockReset() prepareLocalWorktreeRootForRepoMock.mockReset().mockResolvedValue(undefined) - registerRepoHandlers(mockWindow as never, mockStore as never) + registerRepoHandlers(mockWindow as never, mockStore as never, {} as never) }) it('returns the tracked main checkout instead of adding its linked worktree', async () => { diff --git a/src/main/ipc/repos-create.test.ts b/src/main/ipc/repos-create.test.ts index 86e2dc5ee10..cc9081ce2a2 100644 --- a/src/main/ipc/repos-create.test.ts +++ b/src/main/ipc/repos-create.test.ts @@ -61,8 +61,11 @@ vi.mock('fs/promises', () => ({ rm: rmMock })) +// `availableParallelism` is read at module load by the git admission scheduler, +// which this module graph reaches; a partial `os` mock breaks that import. vi.mock('os', () => ({ - homedir: homedirMock + homedir: homedirMock, + availableParallelism: () => 8 })) vi.mock('../git/runner', () => ({ @@ -148,7 +151,7 @@ describe('repos:create', () => { gitExecFileAsyncMock.mockReset().mockResolvedValue({ stdout: '', stderr: '' }) homedirMock.mockReset().mockReturnValue('/Users/alice') - registerRepoHandlers(mockWindow as never, mockStore as never) + registerRepoHandlers(mockWindow as never, mockStore as never, {} as never) }) it('registers the repos:create handler', () => { diff --git a/src/main/ipc/repos-execution-host-catalog.test.ts b/src/main/ipc/repos-execution-host-catalog.test.ts index 6d200522535..6e4de0adbdd 100644 --- a/src/main/ipc/repos-execution-host-catalog.test.ts +++ b/src/main/ipc/repos-execution-host-catalog.test.ts @@ -54,7 +54,7 @@ describe('projectGroups IPC validation', () => { mockWindow.webContents.send.mockReset() resetProjectGroupMocks(reposMocks, { isGitRepo, getGitRepoRoot }) - registerRepoHandlers(mockWindow as never, mockStore as never) + registerRepoHandlers(mockWindow as never, mockStore as never, {} as never) }) it('rejects malformed local project group create arguments before persistence', () => { diff --git a/src/main/ipc/repos-local-add-and-project-setup.test.ts b/src/main/ipc/repos-local-add-and-project-setup.test.ts index 305a900f711..0ff8b48c773 100644 --- a/src/main/ipc/repos-local-add-and-project-setup.test.ts +++ b/src/main/ipc/repos-local-add-and-project-setup.test.ts @@ -55,7 +55,7 @@ describe('repos:add + repos:clone', () => { resetLocalRepoMocks(reposMocks) mockWindow.webContents.send.mockReset() - registerRepoHandlers(mockWindow as never, mockStore as never) + registerRepoHandlers(mockWindow as never, mockStore as never, {} as never) }) it('defaults repos:add badgeColor to DEFAULT_REPO_BADGE_COLOR for folder repos', async () => { diff --git a/src/main/ipc/repos-local-clone-lifecycle.test.ts b/src/main/ipc/repos-local-clone-lifecycle.test.ts index 395bfec8001..4bd7e4f45a3 100644 --- a/src/main/ipc/repos-local-clone-lifecycle.test.ts +++ b/src/main/ipc/repos-local-clone-lifecycle.test.ts @@ -73,7 +73,7 @@ describe('repos:add + repos:clone', () => { resetLocalRepoMocks(reposMocks) mockWindow.webContents.send.mockReset() - registerRepoHandlers(mockWindow as never, mockStore as never) + registerRepoHandlers(mockWindow as never, mockStore as never, {} as never) }) afterEach(async () => { diff --git a/src/main/ipc/repos-nested-import.test.ts b/src/main/ipc/repos-nested-import.test.ts index 010be624a91..8bb62d42fbc 100644 --- a/src/main/ipc/repos-nested-import.test.ts +++ b/src/main/ipc/repos-nested-import.test.ts @@ -59,7 +59,7 @@ describe('projectGroups IPC validation', () => { mockWindow.webContents.send.mockReset() resetProjectGroupMocks(reposMocks, { isGitRepo, getGitRepoRoot }) - registerRepoHandlers(mockWindow as never, mockStore as never) + registerRepoHandlers(mockWindow as never, mockStore as never, {} as never) }) it('uses completed scan ids as an allowlist for nested imports', async () => { diff --git a/src/main/ipc/repos-nested-scan.test.ts b/src/main/ipc/repos-nested-scan.test.ts index 26650246a06..53d4e64477f 100644 --- a/src/main/ipc/repos-nested-scan.test.ts +++ b/src/main/ipc/repos-nested-scan.test.ts @@ -52,7 +52,7 @@ describe('projectGroups IPC validation', () => { mockWindow.webContents.send.mockReset() resetProjectGroupMocks(reposMocks, { isGitRepo, getGitRepoRoot }) - registerRepoHandlers(mockWindow as never, mockStore as never) + registerRepoHandlers(mockWindow as never, mockStore as never, {} as never) }) it('scans nested repositories over a connected SSH filesystem', async () => { diff --git a/src/main/ipc/repos-picker.test.ts b/src/main/ipc/repos-picker.test.ts index 0e843f31e95..14b8bbd0b68 100644 --- a/src/main/ipc/repos-picker.test.ts +++ b/src/main/ipc/repos-picker.test.ts @@ -80,7 +80,7 @@ describe('repos folder pickers', () => { removeHandlerMock.mockReset() showOpenDialogMock.mockReset() - registerRepoHandlers(mockWindow as never, mockStore as never) + registerRepoHandlers(mockWindow as never, mockStore as never, {} as never) }) it('registers the multi-folder picker with handler cleanup', () => { diff --git a/src/main/ipc/repos-remote-base-ref-queries.test.ts b/src/main/ipc/repos-remote-base-ref-queries.test.ts index 8ee9cb1cfc8..f4a43633712 100644 --- a/src/main/ipc/repos-remote-base-ref-queries.test.ts +++ b/src/main/ipc/repos-remote-base-ref-queries.test.ts @@ -51,7 +51,7 @@ describe('repos:getBaseRefDefault envelope', () => { prepareLocalWorktreeRootForRepoMock.mockReset().mockResolvedValue(undefined) // Reset exec so a newly added test doesn't inherit the previous test's exec mock. mockGitProvider.exec = vi.fn().mockResolvedValue({ stdout: '', stderr: '' }) - registerRepoHandlers(mockWindow as never, mockStore as never) + registerRepoHandlers(mockWindow as never, mockStore as never, {} as never) }) it('returns { defaultBaseRef, remoteCount: 0 } for folder-mode repos', async () => { @@ -235,7 +235,7 @@ describe('repos:searchBaseRefs SSH relay', () => { mockStore.getRepo.mockReset() prepareLocalWorktreeRootForRepoMock.mockReset().mockResolvedValue(undefined) mockGitProvider.exec = vi.fn().mockResolvedValue({ stdout: '', stderr: '' }) - registerRepoHandlers(mockWindow as never, mockStore as never) + registerRepoHandlers(mockWindow as never, mockStore as never, {} as never) }) it('returns [] for a folder-mode repo without invoking the relay', async () => { diff --git a/src/main/ipc/repos-remote-client-events.test.ts b/src/main/ipc/repos-remote-client-events.test.ts index 8d8064f49d7..af8bf4ecf28 100644 --- a/src/main/ipc/repos-remote-client-events.test.ts +++ b/src/main/ipc/repos-remote-client-events.test.ts @@ -48,7 +48,7 @@ const mainWindow = { isDestroyed: () => false, webContents: { send: vi.fn() } } async function registerHandlersWithoutNotifier(): Promise { vi.resetModules() const repos = await import('./repos') - repos.registerRepoHandlers(mainWindow as never, mockStore as never) + repos.registerRepoHandlers(mainWindow as never, mockStore as never, {} as never) return import('./repos/repos-changed-notification') } diff --git a/src/main/ipc/repos-remote-git-username.test.ts b/src/main/ipc/repos-remote-git-username.test.ts index d3f22254fd7..41d254fc16e 100644 --- a/src/main/ipc/repos-remote-git-username.test.ts +++ b/src/main/ipc/repos-remote-git-username.test.ts @@ -50,7 +50,7 @@ describe('repos:getGitUsername', () => { mockWindow.webContents.send.mockReset() prepareLocalWorktreeRootForRepoMock.mockReset().mockResolvedValue(undefined) - registerRepoHandlers(mockWindow as never, mockStore as never) + registerRepoHandlers(mockWindow as never, mockStore as never, {} as never) }) it('uses explicit SSH username config instead of remote author identity', async () => { diff --git a/src/main/ipc/repos-remote.test.ts b/src/main/ipc/repos-remote.test.ts index e5660eb8d27..3e18624828b 100644 --- a/src/main/ipc/repos-remote.test.ts +++ b/src/main/ipc/repos-remote.test.ts @@ -98,7 +98,7 @@ describe('repos:addRemote', () => { }) mockWindow.webContents.send.mockReset() - registerRepoHandlers(mockWindow as never, mockStore as never) + registerRepoHandlers(mockWindow as never, mockStore as never, {} as never) }) it('registers the repos:addRemote handler', () => { diff --git a/src/main/ipc/repos-sparse-presets.test.ts b/src/main/ipc/repos-sparse-presets.test.ts index 8de69f63255..5f7e7202e73 100644 --- a/src/main/ipc/repos-sparse-presets.test.ts +++ b/src/main/ipc/repos-sparse-presets.test.ts @@ -96,7 +96,7 @@ describe('sparse preset repo IPC handlers', () => { mockStore.saveSparsePreset.mockReset().mockImplementation((preset: SparsePreset) => preset) mockStore.removeSparsePreset.mockReset() - registerRepoHandlers(mainWindow as never, mockStore as never) + registerRepoHandlers(mainWindow as never, mockStore as never, {} as never) }) it('normalizes and de-duplicates saved sparse preset directories', () => { diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index 56e00fac46f..3c2754507b7 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -13,8 +13,13 @@ import { registerRepoFolderPickerHandlers } from './repos/repo-folder-picker-han import { registerRepoCloneHandlers } from './repos/repo-clone-lifecycle' import { registerRepoGitUsernameHandler } from './repos/repo-git-username-handler' import { registerBaseRefQueryHandlers } from './repos/base-ref-query-handlers' +import type { OrcaRuntimeService } from '../runtime/orca-runtime' -export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): void { +export function registerRepoHandlers( + mainWindow: BrowserWindow, + store: Store, + runtime: OrcaRuntimeService +): void { // Remove previously registered handlers so we can re-register on macOS app re-activation (new window). ipcMain.removeHandler('repos:list') ipcMain.removeHandler('repos:listForExecutionHost') @@ -67,7 +72,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v registerProjectHostSetupHandlers(mainWindow, store) registerRepoCreationHandlers(mainWindow, store) registerProjectGroupHandlers(mainWindow, store) - registerFolderWorkspaceHandlers(mainWindow, store) + registerFolderWorkspaceHandlers(mainWindow, store, runtime) registerNestedRepoImportHandler(mainWindow, store) registerRepoUpdateHandler(mainWindow, store) registerSparsePresetHandlers(mainWindow, store) diff --git a/src/main/ipc/repos/folder-workspace-handlers.ts b/src/main/ipc/repos/folder-workspace-handlers.ts index 3bd8caa5fd1..2c2968a3c9a 100644 --- a/src/main/ipc/repos/folder-workspace-handlers.ts +++ b/src/main/ipc/repos/folder-workspace-handlers.ts @@ -9,6 +9,7 @@ import { getFolderWorkspacePathStatusForPath } from '../../project-groups/folder-workspace-path-status' import { getSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch' +import type { OrcaRuntimeService } from '../../runtime/orca-runtime' import { notifyReposChanged } from './repos-changed-notification' import { FolderWorkspaceCreateArgs, @@ -18,7 +19,11 @@ import { parseProjectGroupIpcArgs } from './repo-ipc-arg-schemas' -export function registerFolderWorkspaceHandlers(mainWindow: BrowserWindow, store: Store): void { +export function registerFolderWorkspaceHandlers( + mainWindow: BrowserWindow, + store: Store, + runtime: OrcaRuntimeService +): void { ipcMain.handle('folderWorkspaces:list', (): FolderWorkspace[] => store.getFolderWorkspaces()) ipcMain.handle('folderWorkspaces:getPathStatus', async (_event, rawArgs: unknown) => { @@ -107,16 +112,13 @@ export function registerFolderWorkspaceHandlers(mainWindow: BrowserWindow, store } ) - ipcMain.handle('folderWorkspaces:delete', (_event, rawArgs: unknown): boolean => { + ipcMain.handle('folderWorkspaces:delete', async (_event, rawArgs: unknown): Promise => { const args = parseProjectGroupIpcArgs( FolderWorkspaceSelectorArgs, rawArgs, 'invalid_folder_workspace_delete_args' ) - const deleted = store.removeFolderWorkspace(args.folderWorkspaceId) - if (deleted) { - notifyReposChanged(mainWindow) - } - return deleted + // Why: the runtime owns PTY/browser/session teardown and notifies on success. + return (await runtime.deleteFolderWorkspace(args.folderWorkspaceId)).deleted }) } diff --git a/src/main/ipc/ssh-connection-handlers.ts b/src/main/ipc/ssh-connection-handlers.ts index 8c3bf4bbc6d..93fd6ff0e33 100644 --- a/src/main/ipc/ssh-connection-handlers.ts +++ b/src/main/ipc/ssh-connection-handlers.ts @@ -1,5 +1,5 @@ import { ipcMain } from 'electron' -import type { SshTarget } from '../../shared/ssh-types' +import type { SshTarget, SshTerminateSessionsResult } from '../../shared/ssh-types' import { SSH_TERMINATE_RECONNECT_REQUIRED } from '../../shared/constants' import { isSshPtyNotFoundError } from '../providers/ssh-pty-errors' import { toAppSshPtyId, toRelaySshPtyId } from '../providers/ssh-pty-id' @@ -60,14 +60,21 @@ async function doResetRelay(targetId: string, target: SshTarget): Promise assertSshConnectsNotFenced() conn = await connectionManager!.connect(target) } + let relayStopAcknowledged = false try { await forceStopRelayForTarget(conn, targetId) + relayStopAcknowledged = true } finally { const ptyIds = new Set(getPtyIdsForConnection(targetId)) for (const lease of persistedStore!.getSshRemotePtyLeases(targetId)) { if (lease.state !== 'terminated' && lease.state !== 'expired') { ptyIds.add(lease.ptyId) - persistedStore!.markSshRemotePtyLease(targetId, lease.ptyId, 'expired') + // Why: only a host-acknowledged force-stop may retire a lease. When it threw we never + // observed those shells, so expiring them would record a verdict we do not hold; mirrors + // ssh:terminateSessions, and the next connect re-attaches (or expires) them on evidence. + if (relayStopAcknowledged) { + persistedStore!.markSshRemotePtyLease(targetId, lease.ptyId, 'expired') + } } } // Why: reset force-kills the remote relay, so every local PTY handle it owned is stale even if the reset command failed after SIGTERM. @@ -98,6 +105,9 @@ export function registerSshConnectionHandlers(): void { ipcMain.handle('ssh:terminateSessions', async (_event, args: { targetId: string }) => { invalidateConnectAttempt(args.targetId) + // Why (#12661): an offline sweep tears down local transport only. The caller must be able to tell + // "the host stopped these" from "nobody asked the host", so carry the verdict out of the lifecycle queue. + let outcome: SshTerminateSessionsResult = { terminated: 0, unverifiable: 0 } await runTargetLifecycle(args.targetId, async () => { const provider = getSshPtyProvider(args.targetId) const leases = persistedStore!.getSshRemotePtyLeases(args.targetId) @@ -142,6 +152,10 @@ export function registerSshConnectionHandlers(): void { ) ) : [] + if (!provider) { + // Nothing observed these remote shells, so their state is unknown — not "nothing to do". + outcome = { terminated: 0, unverifiable: ptyIds.length } + } const shutdownFailures: string[] = [] for (const [index, result] of shutdownResults.entries()) { const { appPtyId, relayPtyId } = ptyIds[index] @@ -154,6 +168,7 @@ export function registerSshConnectionHandlers(): void { clearProviderPtyState(appPtyId) deletePtyOwnership(appPtyId) persistedStore!.markSshRemotePtyLease(args.targetId, relayPtyId, 'terminated') + outcome = { ...outcome, terminated: outcome.terminated + 1 } } if (shutdownFailures.length > 0) { // Why: a failed relay shutdown can leave the remote process alive in the grace window; keep the lease/session so the user can retry. @@ -161,6 +176,7 @@ export function registerSshConnectionHandlers(): void { } await teardownSshTargetTransport(args.targetId, (session) => session.disposeAndPersist()) }) + return outcome }) ipcMain.handle('ssh:resetRelay', (_event, args: { targetId: string }) => { diff --git a/src/main/ipc/ssh-relay-reset-resume.test.ts b/src/main/ipc/ssh-relay-reset-resume.test.ts index 9051cac5a5c..36ad2090435 100644 --- a/src/main/ipc/ssh-relay-reset-resume.test.ts +++ b/src/main/ipc/ssh-relay-reset-resume.test.ts @@ -77,6 +77,38 @@ describe('SSH IPC handlers', () => { expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1') }) + // A force-stop that threw observed nothing about the remote shells, so expiring their leases + // would record a verdict Orca never obtained (docs/reference/ssh-execution-boundary.md). + it('ssh:resetRelay keeps leases alive when the force-stop never reported a result', async () => { + const target: SshTarget = { + id: 'ssh-1', + label: 'Server', + host: 'example.com', + port: 22, + username: 'deploy' + } + const conn = {} + mockSshStore.getTarget.mockReturnValue(target) + mockConnectionManager.connect.mockResolvedValue(conn) + mockConnectionManager.getConnection.mockReturnValue(undefined) + mockStore.getSshRemotePtyLeases.mockReturnValue([ + { targetId: 'ssh-1', ptyId: 'pty-1', state: 'detached' }, + { targetId: 'ssh-1', ptyId: 'pty-2', state: 'attached' } + ]) + vi.mocked(getPtyIdsForConnection).mockReturnValue([]) + mockForceStopRelayForTarget.mockRejectedValueOnce(new Error('channel closed')) + + await expect(handlers.get('ssh:resetRelay')!(null, { targetId: 'ssh-1' })).rejects.toThrow( + 'channel closed' + ) + + expect(mockStore.markSshRemotePtyLease).not.toHaveBeenCalled() + // The local handles are still stale — only the host-side verdict is withheld. + expect(clearProviderPtyState).toHaveBeenCalledWith('ssh:ssh-1@@pty-1') + expect(deletePtyOwnership).toHaveBeenCalledWith('ssh:ssh-1@@pty-2') + expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1') + }) + it('ssh:resetRelay clears scoped live PTYs while expiring raw leases', async () => { const target: SshTarget = { id: 'ssh-1', diff --git a/src/main/ipc/ssh-terminate-sessions.test.ts b/src/main/ipc/ssh-terminate-sessions.test.ts index 77a8638b098..ccdc19bbe34 100644 --- a/src/main/ipc/ssh-terminate-sessions.test.ts +++ b/src/main/ipc/ssh-terminate-sessions.test.ts @@ -95,7 +95,9 @@ describe('SSH IPC handlers', () => { mockPtyProvider.shutdown.mockResolvedValue(undefined) await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - await handlers.get('ssh:terminateSessions')!(null, { targetId: 'ssh-1' }) + await expect( + handlers.get('ssh:terminateSessions')!(null, { targetId: 'ssh-1' }) + ).resolves.toEqual({ terminated: 2, unverifiable: 0 }) expect(mockPtyProvider.shutdown).toHaveBeenCalledWith('ssh:ssh-1@@pty-live', { immediate: true, @@ -168,7 +170,9 @@ describe('SSH IPC handlers', () => { await expect(reconnect).resolves.toMatchObject({ targetId: 'ssh-1', status: 'connected' }) }) - it('ssh:terminateSessions cannot reach expired leases without a relay', async () => { + // Issue #12661: an offline sweep tears down local transport only. Reporting plain success would + // read as "the remote shells are gone" when nobody asked the host. + it('ssh:terminateSessions reports expired leases as unverifiable without a relay', async () => { mockStore.getSshRemotePtyLeases.mockReturnValue([ { targetId: 'ssh-1', ptyId: 'pty-expired', state: 'expired' } ]) @@ -177,10 +181,26 @@ describe('SSH IPC handlers', () => { await expect( handlers.get('ssh:terminateSessions')!(null, { targetId: 'ssh-1' }) - ).resolves.toBeUndefined() + ).resolves.toEqual({ terminated: 0, unverifiable: 1 }) expect(mockPtyProvider.shutdown).not.toHaveBeenCalled() + // Still no forced reconnect: an expired lease can name a host that is gone for good (#2626). expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1') + expect(mockStore.markSshRemotePtyLease).not.toHaveBeenCalledWith( + 'ssh-1', + 'pty-expired', + 'terminated' + ) + }) + + it('ssh:terminateSessions reports nothing unverifiable when there is nothing to reach', async () => { + mockStore.getSshRemotePtyLeases.mockReturnValue([]) + vi.mocked(getSshPtyProvider).mockReturnValue(undefined) + vi.mocked(getPtyIdsForConnection).mockReturnValue([]) + + await expect( + handlers.get('ssh:terminateSessions')!(null, { targetId: 'ssh-1' }) + ).resolves.toEqual({ terminated: 0, unverifiable: 0 }) }) it('ssh:terminateSessions kills expired leases whose remote PTY may still be alive', async () => { diff --git a/src/main/ipc/telemetry.ts b/src/main/ipc/telemetry.ts index c1fe7d0f68d..5e649f51b84 100644 --- a/src/main/ipc/telemetry.ts +++ b/src/main/ipc/telemetry.ts @@ -24,7 +24,9 @@ let storeRef: Store | null = null const MAIN_OWNED_TELEMETRY_EVENTS = new Set([ 'app_starred_orca', + 'daemon_adopted', 'daemon_audit_eligibility', + 'daemon_pty_cwd_denied', 'star_nag_outcome', 'feature_interaction_usage_bucket_reached' ]) diff --git a/src/main/ipc/worktree-base-directory-marker-poller.ts b/src/main/ipc/worktree-base-directory-marker-poller.ts new file mode 100644 index 00000000000..dba1c4df03c --- /dev/null +++ b/src/main/ipc/worktree-base-directory-marker-poller.ts @@ -0,0 +1,289 @@ +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 type { + WorktreeBaseRepoWatchConfig, + WorktreeBaseWatchTarget +} from './worktree-base-directory-event-filter' +import type { + WorktreeBasePollerOptions, + WorktreeBasePollEvent, + WorktreeBaseSubscription, + WorktreePollerWindowVisibility +} from './worktree-base-directory-poller' + +// Why: the mtime gate is an optimization, not a correctness boundary — some +// filesystems have coarse dir timestamps, and pending `.git` markers expire. +// A periodic ungated scan guarantees eventual convergence. +export const WORKTREE_BASE_BACKSTOP_TICKS = 15 + +// Why: a `.git` completion marker lands within moments of its worktree dir +// (git writes it before populating the checkout). Dirs that never get one are +// not worktrees; stop re-statting them after this many ticks and let the +// 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}` +} + +async function dirSignature(path: string): Promise { + try { + return statSignature(await stat(path)) + } catch { + return 'missing' + } +} + +async function hasGitMarker(dir: string): Promise { + try { + await stat(join(dir, '.git')) + return true + } catch { + return false + } +} + +type BaseSnapshot = { + // worktree-candidate dir → whether its `.git` completion marker exists + markers: Map + // dirs whose listing determines the candidate set: the root plus any + // nested repo containers. Their stat signatures gate the next full scan. + gateDirs: string[] + // index-aligned with gateDirs, each sampled *before* that dir's listing + 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. +async function snapshotBase( + rootPath: string, + repos: ReadonlyMap +): Promise { + const markers = new Map() + const gateDirs = [rootPath] + // Why: sampling the signature before the listing makes a write that races the + // scan look stale next tick (one redundant rescan) instead of invisible until + // the backstop, which is up to 15 ticks of missed creates/deletes. + const gateSignatures = [await dirSignature(rootPath)] + const configs = [...repos.values()] + const includeFlat = configs.some((config) => !config.nestWorkspaces) + const nestedRepoNames = new Set( + configs + .filter((config) => config.nestWorkspaces) + .map((config) => normalizeRuntimePathForComparison(config.repoName)) + ) + + // 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) { + if (!entry.isDirectory() && !entry.isSymbolicLink()) { + continue + } + const entryPath = join(rootPath, entry.name) + if (includeFlat) { + candidates.push(entryPath) + } + if (nestedRepoNames.has(normalizeRuntimePathForComparison(entry.name))) { + gateDirs.push(entryPath) + gateSignatures.push(await dirSignature(entryPath)) + const subEntries = await readdirSafe(entryPath) + for (const sub of subEntries) { + if (sub.isDirectory() || sub.isSymbolicLink()) { + candidates.push(join(entryPath, sub.name)) + } + } + } + } + + await forEachWithConcurrency(candidates, MARKER_PROBE_CONCURRENCY, async (dir) => { + markers.set(dir, await hasGitMarker(dir)) + }) + return { markers, gateDirs, gateSignatures } +} + +function diffBase(prev: BaseSnapshot, next: BaseSnapshot): WorktreeBasePollEvent[] { + const events: WorktreeBasePollEvent[] = [] + for (const [dir, marker] of next.markers) { + if (marker && prev.markers.get(dir) !== true) { + events.push({ type: 'create', path: join(dir, '.git') }) + } + } + for (const dir of prev.markers.keys()) { + if (!next.markers.has(dir)) { + events.push({ type: 'delete', path: dir }) + } + } + return events +} + +export async function startBasePoller( + target: WorktreeBaseWatchTarget, + getRepos: () => ReadonlyMap, + onEvents: (events: WorktreeBasePollEvent[]) => void, + pollIntervalMs: number, + visibility: WorktreePollerWindowVisibility, + options: WorktreeBasePollerOptions +): Promise { + let disposed = false + let ticking = false + let tickCount = 0 + let snapshot = await snapshotBase(target.path, getRepos()) + let timer: ReturnType | null = null + let parkedWhileHidden = false + const pendingMarkerMaxTicks = options.pendingMarkerMaxTicks ?? PENDING_MARKER_MAX_TICKS + // dir → first probe tick; null means backstop scans only + const markerProbeStartedAt = new Map() + for (const [dir, marker] of snapshot.markers) { + if (!marker) { + markerProbeStartedAt.set(dir, 0) + } + } + + const fullScan = async (): Promise => { + options.onFullScan?.() + const next = await snapshotBase(target.path, getRepos()) + await options.onSnapshotTaken?.(tickCount) + if (disposed) { + return + } + const events = diffBase(snapshot, next) + for (const [dir, marker] of next.markers) { + if (marker) { + markerProbeStartedAt.delete(dir) + } else if (!markerProbeStartedAt.has(dir)) { + markerProbeStartedAt.set(dir, tickCount) + } + } + for (const dir of markerProbeStartedAt.keys()) { + if (!next.markers.has(dir)) { + markerProbeStartedAt.delete(dir) + } + } + snapshot = next + if (events.length > 0) { + onEvents(events) + } + } + + const checkPendingMarkers = async (): Promise => { + const events: WorktreeBasePollEvent[] = [] + for (const [dir, firstSeenTick] of markerProbeStartedAt) { + if (firstSeenTick === null) { + continue + } + if (tickCount - firstSeenTick > pendingMarkerMaxTicks) { + markerProbeStartedAt.set(dir, null) + continue + } + options.onPendingMarkerProbe?.(join(dir, '.git')) + if (await hasGitMarker(dir)) { + markerProbeStartedAt.delete(dir) + snapshot.markers.set(dir, true) + events.push({ type: 'create', path: join(dir, '.git') }) + } + } + if (!disposed && events.length > 0) { + onEvents(events) + } + } + + const poll = async (forceFullScan = false): Promise => { + tickCount++ + if (forceFullScan || tickCount % WORKTREE_BASE_BACKSTOP_TICKS === 0) { + await fullScan() + return + } + // Idle fast path: when the dirs whose listings define the candidate set + // are untouched, skip the readdir + per-candidate stat fan-out entirely. + const signatures = await Promise.all(snapshot.gateDirs.map(dirSignature)) + const gateChanged = + signatures.length !== snapshot.gateSignatures.length || + signatures.some((sig, index) => sig !== snapshot.gateSignatures[index]) + if (gateChanged) { + await fullScan() + return + } + if (markerProbeStartedAt.size > 0) { + await checkPendingMarkers() + } + } + + const tick = async (forceFullScan = false): Promise => { + timer = null + if (disposed) { + return + } + if (!visibility.isWindowVisible()) { + parkedWhileHidden = true + return + } + if (ticking) { + return + } + ticking = true + // Why: measure from tick start so the cadence is start-to-start (like the old setInterval), not + // gap-after-completion — otherwise each visible refresh lands a full scan-duration late every tick. + const startedAt = Date.now() + try { + await poll(forceFullScan) + } catch { + // Transient fs error: keep the previous snapshot and retry next tick. + } finally { + ticking = false + } + if (!disposed) { + // Why: clamp to [0, pollIntervalMs]. Date.now() is not monotonic — a backward wall-clock jump (NTP) would + // otherwise make elapsed negative and push the next tick out by the adjustment (suppressing refreshes for + // minutes); the upper clamp caps the wait at one interval, the lower clamp keeps a long scan from going negative. + const nextDelay = Math.max( + 0, + Math.min(pollIntervalMs, pollIntervalMs - (Date.now() - startedAt)) + ) + timer = setTimeout(() => void tick(), nextDelay) + timer.unref?.() + } + } + + const unsubscribeVisibility = visibility.onWindowBecameVisible(() => { + if (disposed || !parkedWhileHidden) { + return + } + parkedWhileHidden = false + // Why: the ordinary dir-signature gate can miss same-granule changes made + // while hidden; resume must diff a fresh full snapshot against the baseline. + void tick(true) + }) + + timer = setTimeout(() => void tick(), pollIntervalMs) + timer.unref?.() + + return { + unsubscribe: async () => { + disposed = true + if (timer) { + clearTimeout(timer) + } + unsubscribeVisibility() + } + } +} 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..39c5b5f48c0 100644 --- a/src/main/ipc/worktree-base-directory-poller.ts +++ b/src/main/ipc/worktree-base-directory-poller.ts @@ -1,13 +1,13 @@ -import { readdir, stat } from 'node:fs/promises' -import { join } from 'node:path' -import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' import { isMainWindowVisible, onMainWindowBecameVisible } from '../window/main-window-visibility' import type { WorktreeBaseRepoWatchConfig, WorktreeBaseWatchTarget } from './worktree-base-directory-event-filter' +import { startBasePoller } from './worktree-base-directory-marker-poller' import { startGitCommonWatch } from './worktree-git-common-watch' +export { WORKTREE_BASE_BACKSTOP_TICKS } from './worktree-base-directory-marker-poller' + export type WorktreeBasePollEvent = { type: 'create' | 'update' | 'delete'; path: string } export type WorktreeBaseSubscription = { unsubscribe: () => Promise } @@ -61,6 +61,8 @@ export type WorktreeBasePollerOptions = { visibility?: WorktreePollerWindowVisibility getGitStatusRefPaths?: () => readonly string[] onWatchError?: (error: Error) => void + /** Called when the watcher child dropped an event batch (git-common narrow watch only). */ + onOverflow?: () => void /** Test hook: called whenever a full snapshot scan runs (vs. a gated skip). */ onFullScan?: () => void /** Test hook: called before a pending `.git` marker stat. */ @@ -81,277 +83,6 @@ export type WorktreeBasePollerOptions = { // Orca's own worktree operations notify the renderer directly. export const WORKTREE_BASE_POLL_INTERVAL_MS = 2_000 -// Why: the mtime gate is an optimization, not a correctness boundary — some -// filesystems have coarse dir timestamps, and pending `.git` markers expire. -// A periodic ungated scan guarantees eventual convergence. -export const WORKTREE_BASE_BACKSTOP_TICKS = 15 - -// Why: a `.git` completion marker lands within moments of its worktree dir -// (git writes it before populating the checkout). Dirs that never get one are -// not worktrees; stop re-statting them after this many ticks and let the -// backstop scan cover the pathological case. -const PENDING_MARKER_MAX_TICKS = 300 - -function statSignature(s: { mtimeMs: number; ctimeMs: number; ino: number }): string { - return `${s.mtimeMs}:${s.ctimeMs}:${s.ino}` -} - -async function dirSignature(path: string): Promise { - try { - return statSignature(await stat(path)) - } catch { - return 'missing' - } -} - -async function hasGitMarker(dir: string): Promise { - try { - await stat(join(dir, '.git')) - return true - } catch { - return false - } -} - -type BaseSnapshot = { - // worktree-candidate dir → whether its `.git` completion marker exists - markers: Map - // dirs whose listing determines the candidate set: the root plus any - // nested repo containers. Their stat signatures gate the next full scan. - gateDirs: string[] - // index-aligned with gateDirs, each sampled *before* that dir's listing - gateSignatures: string[] -} - -// 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. -async function snapshotBase( - rootPath: string, - repos: ReadonlyMap -): Promise { - const markers = new Map() - const gateDirs = [rootPath] - // Why: sampling the signature before the listing makes a write that races the - // scan look stale next tick (one redundant rescan) instead of invisible until - // the backstop, which is up to 15 ticks of missed creates/deletes. - const gateSignatures = [await dirSignature(rootPath)] - const configs = [...repos.values()] - const includeFlat = configs.some((config) => !config.nestWorkspaces) - const nestedRepoNames = new Set( - configs - .filter((config) => config.nestWorkspaces) - .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 } - } - - const candidates: string[] = [] - for (const entry of rootEntries) { - if (!entry.isDirectory() && !entry.isSymbolicLink()) { - continue - } - const entryPath = join(rootPath, entry.name) - if (includeFlat) { - candidates.push(entryPath) - } - 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 = [] - } - for (const sub of subEntries) { - if (sub.isDirectory() || sub.isSymbolicLink()) { - candidates.push(join(entryPath, sub.name)) - } - } - } - } - - for (const dir of candidates) { - markers.set(dir, await hasGitMarker(dir)) - } - return { markers, gateDirs, gateSignatures } -} - -function diffBase(prev: BaseSnapshot, next: BaseSnapshot): WorktreeBasePollEvent[] { - const events: WorktreeBasePollEvent[] = [] - for (const [dir, marker] of next.markers) { - if (marker && prev.markers.get(dir) !== true) { - events.push({ type: 'create', path: join(dir, '.git') }) - } - } - for (const dir of prev.markers.keys()) { - if (!next.markers.has(dir)) { - events.push({ type: 'delete', path: dir }) - } - } - return events -} - -async function startBasePoller( - target: WorktreeBaseWatchTarget, - getRepos: () => ReadonlyMap, - onEvents: (events: WorktreeBasePollEvent[]) => void, - pollIntervalMs: number, - visibility: WorktreePollerWindowVisibility, - options: WorktreeBasePollerOptions -): Promise { - let disposed = false - let ticking = false - let tickCount = 0 - let snapshot = await snapshotBase(target.path, getRepos()) - let timer: ReturnType | null = null - let parkedWhileHidden = false - const pendingMarkerMaxTicks = options.pendingMarkerMaxTicks ?? PENDING_MARKER_MAX_TICKS - // dir → first probe tick; null means backstop scans only - const markerProbeStartedAt = new Map() - for (const [dir, marker] of snapshot.markers) { - if (!marker) { - markerProbeStartedAt.set(dir, 0) - } - } - - const fullScan = async (): Promise => { - options.onFullScan?.() - const next = await snapshotBase(target.path, getRepos()) - await options.onSnapshotTaken?.(tickCount) - if (disposed) { - return - } - const events = diffBase(snapshot, next) - for (const [dir, marker] of next.markers) { - if (marker) { - markerProbeStartedAt.delete(dir) - } else if (!markerProbeStartedAt.has(dir)) { - markerProbeStartedAt.set(dir, tickCount) - } - } - for (const dir of markerProbeStartedAt.keys()) { - if (!next.markers.has(dir)) { - markerProbeStartedAt.delete(dir) - } - } - snapshot = next - if (events.length > 0) { - onEvents(events) - } - } - - const checkPendingMarkers = async (): Promise => { - const events: WorktreeBasePollEvent[] = [] - for (const [dir, firstSeenTick] of markerProbeStartedAt) { - if (firstSeenTick === null) { - continue - } - if (tickCount - firstSeenTick > pendingMarkerMaxTicks) { - markerProbeStartedAt.set(dir, null) - continue - } - options.onPendingMarkerProbe?.(join(dir, '.git')) - if (await hasGitMarker(dir)) { - markerProbeStartedAt.delete(dir) - snapshot.markers.set(dir, true) - events.push({ type: 'create', path: join(dir, '.git') }) - } - } - if (!disposed && events.length > 0) { - onEvents(events) - } - } - - const poll = async (forceFullScan = false): Promise => { - tickCount++ - if (forceFullScan || tickCount % WORKTREE_BASE_BACKSTOP_TICKS === 0) { - await fullScan() - return - } - // Idle fast path: when the dirs whose listings define the candidate set - // are untouched, skip the readdir + per-candidate stat fan-out entirely. - const signatures = await Promise.all(snapshot.gateDirs.map(dirSignature)) - const gateChanged = - signatures.length !== snapshot.gateSignatures.length || - signatures.some((sig, index) => sig !== snapshot.gateSignatures[index]) - if (gateChanged) { - await fullScan() - return - } - if (markerProbeStartedAt.size > 0) { - await checkPendingMarkers() - } - } - - const tick = async (forceFullScan = false): Promise => { - timer = null - if (disposed) { - return - } - if (!visibility.isWindowVisible()) { - parkedWhileHidden = true - return - } - if (ticking) { - return - } - ticking = true - // Why: measure from tick start so the cadence is start-to-start (like the old setInterval), not - // gap-after-completion — otherwise each visible refresh lands a full scan-duration late every tick. - const startedAt = Date.now() - try { - await poll(forceFullScan) - } catch { - // Transient fs error: keep the previous snapshot and retry next tick. - } finally { - ticking = false - } - if (!disposed) { - // Why: clamp to [0, pollIntervalMs]. Date.now() is not monotonic — a backward wall-clock jump (NTP) would - // otherwise make elapsed negative and push the next tick out by the adjustment (suppressing refreshes for - // minutes); the upper clamp caps the wait at one interval, the lower clamp keeps a long scan from going negative. - const nextDelay = Math.max( - 0, - Math.min(pollIntervalMs, pollIntervalMs - (Date.now() - startedAt)) - ) - timer = setTimeout(() => void tick(), nextDelay) - timer.unref?.() - } - } - - const unsubscribeVisibility = visibility.onWindowBecameVisible(() => { - if (disposed || !parkedWhileHidden) { - return - } - parkedWhileHidden = false - // Why: the ordinary dir-signature gate can miss same-granule changes made - // while hidden; resume must diff a fresh full snapshot against the baseline. - void tick(true) - }) - - timer = setTimeout(() => void tick(), pollIntervalMs) - timer.unref?.() - - return { - unsubscribe: async () => { - disposed = true - if (timer) { - clearTimeout(timer) - } - unsubscribeVisibility() - } - } -} - /** Watches the shallow paths a worktree base target cares about and emits * watcher-shaped events. Resolves once the baseline (snapshot or narrow * native subscription) is established. */ @@ -373,7 +104,8 @@ export async function startWorktreeBaseDirectoryPoller( visibility, options.onFullScan, options.getGitStatusRefPaths, - options.onWatchError + options.onWatchError, + options.onOverflow ) } return startBasePoller(target, getRepos, onEvents, pollIntervalMs, visibility, options) diff --git a/src/main/ipc/worktree-base-directory-watch-events.ts b/src/main/ipc/worktree-base-directory-watch-events.ts new file mode 100644 index 00000000000..caed5627b2b --- /dev/null +++ b/src/main/ipc/worktree-base-directory-watch-events.ts @@ -0,0 +1,90 @@ +import { + collectLocalWorktreeBaseChanges, + collectRemoteWorktreeBaseChanges, + hasCollectedWorktreeBaseChanges +} from './worktree-base-directory-change-collector' +import { + scheduleWorktreeBaseNotification, + type WorktreeBaseNotificationWatch +} from './worktree-base-directory-notifications' +import { + invalidateActiveGitStatusRefResolution, + invalidateGitStatusRefResolutionForPaths +} from './worktree-git-status-ref-watch' +import type { WorktreeWatcherFailureRefreshCooldown } from './worktree-watcher-failure-refresh-cooldown' + +export type ActiveWatch = WorktreeBaseNotificationWatch & { + subscription: { unsubscribe: () => Promise } + gitStatusRefPaths: Set + watcherFailureRefresh: WorktreeWatcherFailureRefreshCooldown +} + +export function handleLocalWatchEvents( + watch: ActiveWatch, + error: Error | null, + events: { type: 'create' | 'update' | 'delete'; path: string }[], + getActiveWatches: () => Iterable +): void { + if (watch.disposed || watch.mainWindow.isDestroyed()) { + return + } + if (error) { + console.warn(`[worktree-base-watcher] watcher failed for ${watch.path}:`, error) + invalidateActiveGitStatusRefResolution(watch, getActiveWatches) + if (watch.watcherFailureRefresh.consume()) { + scheduleWorktreeBaseNotification(watch, { structureRepoIds: [...watch.repos.keys()] }) + } + return + } + watch.watcherFailureRefresh.reset() + invalidateGitStatusRefResolutionForPaths( + watch, + events.map((event) => event.path), + getActiveWatches + ) + const changes = collectLocalWorktreeBaseChanges(watch, events) + if (hasCollectedWorktreeBaseChanges(changes)) { + scheduleWorktreeBaseNotification(watch, changes) + } +} + +// Why: after a dropped event batch nothing about the prior state can be +// trusted — widen unconditionally (structural + status + head-identity), +// same shape as the remote overflow branch below, bypassing the watcher-error +// cooldown so a burst of overflows during one bulk op cannot suppress the +// refresh the fleet actually needs. +export function handleWatchOverflow( + watch: ActiveWatch, + getActiveWatches: () => Iterable +): void { + if (watch.disposed || watch.mainWindow.isDestroyed()) { + return + } + invalidateActiveGitStatusRefResolution(watch, getActiveWatches) + scheduleWorktreeBaseNotification(watch, { structureRepoIds: [...watch.repos.keys()] }) +} + +export function handleRemoteWatchEvents( + watch: ActiveWatch, + events: Parameters[1], + getActiveWatches: () => Iterable +): void { + if (watch.disposed || watch.mainWindow.isDestroyed()) { + return + } + invalidateGitStatusRefResolutionForPaths( + watch, + events.flatMap((event) => + event.kind === 'overflow' ? [] : [event.absolutePath, event.oldAbsolutePath] + ), + getActiveWatches + ) + const changes = collectRemoteWorktreeBaseChanges(watch, events) + if (changes.overflow) { + handleWatchOverflow(watch, getActiveWatches) + return + } + if (hasCollectedWorktreeBaseChanges(changes)) { + scheduleWorktreeBaseNotification(watch, changes) + } +} diff --git a/src/main/ipc/worktree-base-directory-watcher.test.ts b/src/main/ipc/worktree-base-directory-watcher.test.ts index 5d701b91211..a23bc901bdc 100644 --- a/src/main/ipc/worktree-base-directory-watcher.test.ts +++ b/src/main/ipc/worktree-base-directory-watcher.test.ts @@ -404,6 +404,46 @@ describe('worktree base directory watcher', () => { expect(notifyWorktreesChanged).toHaveBeenCalledOnce() }) + it('widens an overflowed local git-common watch to a structural refresh', async () => { + await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never) + const onOverflow = pollerOptions.get(PROJECT_GIT_COMMON_DIR)?.onOverflow + + const request = { + worktreeId: `repo-1::${PROJECT_ROOT}`, + worktreePath: PROJECT_ROOT, + executionHostId: 'local', + branch: 'refs/heads/feature', + upstreamName: 'origin/feature' + } + const resolve = vi.fn(async () => 'refs/remotes/origin/feature') + await setWorktreeGitStatusRefWatch(request, resolve) + + onOverflow?.() + await vi.advanceTimersByTimeAsync(300) + + expect(notifyWorktreesChanged).toHaveBeenCalledWith(expect.anything(), 'repo-1') + // Overflow is definite proof of loss, not a possibly-transient error — it + // invalidates the cached ref resolution unconditionally. + await setWorktreeGitStatusRefWatch(request, resolve) + expect(resolve).toHaveBeenCalledTimes(2) + }) + + it('does not throttle repeated overflow refreshes the way watcher-error refreshes are throttled', async () => { + await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never) + const onOverflow = pollerOptions.get(PROJECT_GIT_COMMON_DIR)?.onOverflow + + onOverflow?.() + await vi.advanceTimersByTimeAsync(300) + onOverflow?.() + await vi.advanceTimersByTimeAsync(300) + + // A watcher-error burst within the 60s cooldown window collapses to one + // refresh (see "throttles repeated structural refreshes from watcher + // failures" above); overflow must not inherit that gate, since a bulk op + // can legitimately overflow more than once before it settles. + expect(notifyWorktreesChanged).toHaveBeenCalledTimes(2) + }) + it('keeps linked HEAD and lock metadata structural', async () => { await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never) diff --git a/src/main/ipc/worktree-base-directory-watcher.ts b/src/main/ipc/worktree-base-directory-watcher.ts index 57a231e89b2..abb0d51782c 100644 --- a/src/main/ipc/worktree-base-directory-watcher.ts +++ b/src/main/ipc/worktree-base-directory-watcher.ts @@ -6,16 +6,9 @@ import { disposeWorktreeHeadIdentityRefreshState, refreshWorktreeHeadIdentities } from './worktree-head-identity-refresh' -import { - collectLocalWorktreeBaseChanges, - collectRemoteWorktreeBaseChanges, - hasCollectedWorktreeBaseChanges -} from './worktree-base-directory-change-collector' import { clearPendingWorktreeBaseNotifications, - scheduleWorktreeBaseNotification, - supportsWorktreeHeadIdentityRefresh, - type WorktreeBaseNotificationWatch + supportsWorktreeHeadIdentityRefresh } from './worktree-base-directory-notifications' import type { WorktreeBaseWatchTarget } from './worktree-base-directory-event-filter' import { EMPTY_HEAD_IDENTITY_SCOPE } from './worktree-head-identity-scope' @@ -30,18 +23,16 @@ import { import { applyActiveGitStatusRefBinding, clearActiveGitStatusRefBinding, - invalidateActiveGitStatusRefResolution, - invalidateGitStatusRefResolutionForPaths, updateActiveGitStatusRefBinding, type GitStatusRefBindingRequest } from './worktree-git-status-ref-watch' import { WorktreeWatcherFailureRefreshCooldown } from './worktree-watcher-failure-refresh-cooldown' - -type ActiveWatch = WorktreeBaseNotificationWatch & { - subscription: { unsubscribe: () => Promise } - gitStatusRefPaths: Set - watcherFailureRefresh: WorktreeWatcherFailureRefreshCooldown -} +import { + handleLocalWatchEvents, + handleRemoteWatchEvents, + handleWatchOverflow, + type ActiveWatch +} from './worktree-base-directory-watch-events' const activeWatches = new Map() let syncGeneration = 0 @@ -54,59 +45,6 @@ export function setWorktreeGitStatusRefWatch( return updateActiveGitStatusRefBinding(args, () => activeWatches.values(), resolveUpstreamRef) } -function handleLocalWatchEvents( - watch: ActiveWatch, - error: Error | null, - events: { type: 'create' | 'update' | 'delete'; path: string }[] -): void { - if (watch.disposed || watch.mainWindow.isDestroyed()) { - return - } - if (error) { - console.warn(`[worktree-base-watcher] watcher failed for ${watch.path}:`, error) - invalidateActiveGitStatusRefResolution(watch, () => activeWatches.values()) - if (watch.watcherFailureRefresh.consume()) { - scheduleWorktreeBaseNotification(watch, { structureRepoIds: [...watch.repos.keys()] }) - } - return - } - watch.watcherFailureRefresh.reset() - invalidateGitStatusRefResolutionForPaths( - watch, - events.map((event) => event.path), - () => activeWatches.values() - ) - const changes = collectLocalWorktreeBaseChanges(watch, events) - if (hasCollectedWorktreeBaseChanges(changes)) { - scheduleWorktreeBaseNotification(watch, changes) - } -} - -function handleRemoteWatchEvents( - watch: ActiveWatch, - events: Parameters[1] -): void { - if (watch.disposed || watch.mainWindow.isDestroyed()) { - return - } - invalidateGitStatusRefResolutionForPaths( - watch, - events.flatMap((event) => - event.kind === 'overflow' ? [] : [event.absolutePath, event.oldAbsolutePath] - ), - () => activeWatches.values() - ) - const changes = collectRemoteWorktreeBaseChanges(watch, events) - if (changes.overflow) { - invalidateActiveGitStatusRefResolution(watch, () => activeWatches.values()) - scheduleWorktreeBaseNotification(watch, { structureRepoIds: [...watch.repos.keys()] }) - return - } - if (hasCollectedWorktreeBaseChanges(changes)) { - scheduleWorktreeBaseNotification(watch, changes) - } -} - function createActiveWatch( target: WorktreeBaseWatchTarget, mainWindow: BrowserWindow, @@ -146,7 +84,7 @@ async function subscribeTarget( if (!currentWatch || currentWatch.disposed) { return } - handleRemoteWatchEvents(currentWatch, events) + handleRemoteWatchEvents(currentWatch, events, () => activeWatches.values()) }) activeWatch = createActiveWatch( target, @@ -167,7 +105,7 @@ async function subscribeTarget( (events) => { const currentWatch = activeWatches.get(target.key) ?? activeWatch if (currentWatch && !currentWatch.disposed) { - handleLocalWatchEvents(currentWatch, null, events) + handleLocalWatchEvents(currentWatch, null, events, () => activeWatches.values()) } }, { @@ -178,7 +116,13 @@ async function subscribeTarget( onWatchError: (error) => { const currentWatch = activeWatches.get(target.key) ?? activeWatch if (currentWatch && !currentWatch.disposed) { - handleLocalWatchEvents(currentWatch, error, []) + handleLocalWatchEvents(currentWatch, error, [], () => activeWatches.values()) + } + }, + onOverflow: () => { + const currentWatch = activeWatches.get(target.key) ?? activeWatch + if (currentWatch) { + handleWatchOverflow(currentWatch, () => activeWatches.values()) } } } 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..fa7c402c5ec 100644 --- a/src/main/ipc/worktree-git-common-narrow-watch.ts +++ b/src/main/ipc/worktree-git-common-narrow-watch.ts @@ -24,7 +24,12 @@ export async function startGitCommonNarrowWatch( platform: NodeJS.Platform, visibility: WorktreePollerWindowVisibility, onFullScan?: () => void, - onWatchError?: (error: Error) => void + onWatchError?: (error: Error) => void, + // Why: a dropped event batch (>5,000 events, e.g. a fleet-wide bulk op) is a + // harder loss signal than a transient error — nothing about the prior state + // can be trusted, so this bypasses onWatchError's failure cooldown instead + // of reusing it. + onOverflow?: () => void ): Promise { const worktreesDir = join(target.path, 'worktrees') const watcherOptions = platform === 'win32' ? { backend: 'windows' as const } : {} @@ -73,6 +78,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, @@ -222,6 +232,23 @@ export async function startGitCommonNarrowWatch( onEvents([{ type: 'update', path: worktreesDir }]) } } + }, + // Why: the watcher child drops the whole batch past 5,000 events + // (native FSEvents overflow maps to the same op) instead of reporting + // which paths changed. Unlike a transient error, this is definite + // proof of loss, so it always widens rather than falling back to the + // failure-cooldown-gated onWatchError path. + onOverflow: () => { + if (disposed || !active || generation !== nativeSubscriptionGeneration) { + return + } + if (onOverflow) { + onOverflow() + } else if (onWatchError) { + onWatchError(new Error('Git common watcher overflowed')) + } else { + onEvents([{ type: 'update', path: worktreesDir }]) + } } } ) 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.test.ts b/src/main/ipc/worktree-git-common-watch.test.ts index 619133263de..bad700b5445 100644 --- a/src/main/ipc/worktree-git-common-watch.test.ts +++ b/src/main/ipc/worktree-git-common-watch.test.ts @@ -526,6 +526,45 @@ describe('worktree git-common narrow watch (local native platforms)', () => { expect(narrowSubscription().unsubscribe).not.toHaveBeenCalled() }) + it('routes a dropped event batch through the dedicated overflow callback', async () => { + installSubscribeMock() + const commonDir = await makeCommonDir(true) + const received: WorktreeBasePollEvent[][] = [] + const onOverflow = vi.fn() + const watch = await startGitCommonWatch( + makeTarget(commonDir), + (events) => received.push(events), + POLL_MS, + 'darwin', + alwaysVisible, + undefined, + () => [], + undefined, + onOverflow + ) + cleanups.push(() => watch.unsubscribe()) + + narrowSubscription().hooks.onOverflow?.() + + expect(onOverflow).toHaveBeenCalledOnce() + // The dedicated callback owns the refresh; the generic event/error paths + // must not also fire so the caller cannot double-count the same loss. + expect(received).toEqual([]) + expect(narrowSubscription().unsubscribe).not.toHaveBeenCalled() + }) + + it('falls back to a structural change when no overflow callback is wired', async () => { + installSubscribeMock() + const commonDir = await makeCommonDir(true) + const worktreesDir = join(commonDir, 'worktrees') + const received: WorktreeBasePollEvent[][] = [] + await startWatch(commonDir, received) + + narrowSubscription().hooks.onOverflow?.() + + expect(received.flat()).toContainEqual({ type: 'update', path: worktreesDir }) + }) + it('arms via existence polling when the worktrees dir appears later', async () => { installSubscribeMock() const commonDir = await makeCommonDir(false) diff --git a/src/main/ipc/worktree-git-common-watch.ts b/src/main/ipc/worktree-git-common-watch.ts index 9de2c8c3392..d719ca257bb 100644 --- a/src/main/ipc/worktree-git-common-watch.ts +++ b/src/main/ipc/worktree-git-common-watch.ts @@ -31,7 +31,8 @@ export async function startGitCommonWatch( visibility: WorktreePollerWindowVisibility, onFullScan?: () => void, getStatusRefPaths: () => readonly string[] = () => [], - onWatchError?: (error: Error) => void + onWatchError?: (error: Error) => void, + onOverflow?: () => void ): Promise { if (supportsNarrowWatch(platform)) { const [narrowWatch, primaryWatch] = await Promise.all([ @@ -42,7 +43,8 @@ export async function startGitCommonWatch( platform, visibility, onFullScan, - onWatchError + onWatchError, + onOverflow ), startGitCommonPrimaryWatch( target.path, @@ -60,6 +62,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 0b736acd482..eacd2cd133f 100644 --- a/src/main/ipc/worktree-push-target-cleanup.test.ts +++ b/src/main/ipc/worktree-push-target-cleanup.test.ts @@ -108,8 +108,13 @@ describe('cleanupUnusedWorktreePushTargetRemoteWithExec', () => { exec ) expect(removeCalls(exec)).toEqual([]) - // No probing at all when we won't act. - expect(exec).not.toHaveBeenCalled() + // Why: the store flag alone can't rule out ownership -- on-demand + // materialization (#17828) never sets it, so cleanup also probes the + // repo-local `orca-created` config provenance before bailing. + expect(exec).toHaveBeenCalledWith( + ['config', '--get', `remote.${FORK_REMOTE}.orca-created`], + REPO_PATH + ) }) it('never touches origin or upstream', async () => { @@ -242,6 +247,20 @@ describe('cleanupUnusedWorktreePushTargetRemoteWithExec', () => { expect(removeCalls(exec)).toEqual([]) }) + it('removes a remote owned only via git-config provenance (lazily materialized, #17828)', async () => { + // Why: on-demand materialization never sets the store's `remoteCreated` + // flag, so ownership must also be provable from `remote..orca-created`. + const exec = makeExec({ branchConfig: 'true' }) + await cleanupUnusedWorktreePushTargetRemoteWithExec( + REPO_PATH, + 'repo-1::/wt/a', + forkTarget({ remoteCreated: false }), + storeOf({ 'repo-1::/wt/a': forkTarget({ remoteCreated: false }) }), + exec + ) + expect(removeCalls(exec)).toEqual([['remote', 'remove', FORK_REMOTE]]) + }) + it('does nothing when the remote is already gone (get-url throws)', async () => { const exec = makeExec({ getUrlThrows: true }) await cleanupUnusedWorktreePushTargetRemoteWithExec( diff --git a/src/main/ipc/worktree-push-target-cleanup.ts b/src/main/ipc/worktree-push-target-cleanup.ts index 9bf29f718b2..09918ffe8f7 100644 --- a/src/main/ipc/worktree-push-target-cleanup.ts +++ b/src/main/ipc/worktree-push-target-cleanup.ts @@ -16,7 +16,11 @@ export type GitRemoteExec = ( args: string[], cwd: string ) => Promise<{ stdout: string; stderr?: string }> -export type WorktreePushTargetStore = Pick +// Why: `setWorktreeMeta` is optional so existing narrow test stubs (only +// `getAllWorktreeMeta`) keep compiling; callers that want materialize-time +// provenance persistence (worktree-remote.ts) pass a store that has it. +export type WorktreePushTargetStore = Pick & + Partial> export function sameGitHubRemoteUrl(left: string, right: string): boolean { if (left === right) { @@ -181,6 +185,26 @@ function isBranchConfigSeparator(code: number): boolean { return code === 32 || (code >= 9 && code <= 13) } +// Why: on-demand materialization (push/pull/fetch/fast-forward, #17828) never +// updates the store's `pushTarget.remoteCreated` flag, so ownership must also be +// readable from the repo-local `remote..orca-created` config Orca writes +// when it creates the remote (see `worktree-push-target-setup.ts`). +async function remoteHasOrcaProvenance( + execGit: GitRemoteExec, + repoPath: string, + remoteName: string +): Promise { + try { + const { stdout } = await execGit( + ['config', '--get', `remote.${remoteName}.orca-created`], + repoPath + ) + return stdout.trim() === 'true' + } catch { + return false + } +} + // Exported for unit tests: the `execGit` seam lets tests drive the multi-fork // cleanup matrix without touching a real repo. export async function cleanupUnusedWorktreePushTargetRemoteWithExec( @@ -190,11 +214,12 @@ export async function cleanupUnusedWorktreePushTargetRemoteWithExec( store: WorktreePushTargetStore, execGit: GitRemoteExec ): Promise { + if (!target?.remoteUrl || target.remoteName === 'origin' || target.remoteName === 'upstream') { + return + } if ( - !target?.remoteCreated || - !target.remoteUrl || - target.remoteName === 'origin' || - target.remoteName === 'upstream' + !target.remoteCreated && + !(await remoteHasOrcaProvenance(execGit, repoPath, target.remoteName)) ) { return } diff --git a/src/main/ipc/worktree-push-target-setup.test.ts b/src/main/ipc/worktree-push-target-setup.test.ts index be718cfd10c..2d9670c2f9b 100644 --- a/src/main/ipc/worktree-push-target-setup.test.ts +++ b/src/main/ipc/worktree-push-target-setup.test.ts @@ -5,7 +5,9 @@ import { configureCreatedWorktreePushTargetWithExec, ensureUniqueRemoteName, findRemoteForUrl, - prepareWorktreePushTargetWithExec + prepareWorktreePushTargetWithExec, + remoteAlreadyMatchesUrl, + restoreUpstreamAfterMaterialize } from './worktree-push-target-setup' type ExecMock = Mock @@ -15,9 +17,17 @@ const FORK_SSH = 'git@github.com:contributor/orca.git' const FORK_HTTPS = 'https://github.com/contributor/orca.git' // A stateful fake git: `remotes` maps name -> url. `remote add` mutates it so -// later lookups see the new remote, matching real git behavior. -function makeRepoExec(remotes: Record): ExecMock { +// later lookups see the new remote, matching real git behavior. Defaults +// `symbolic-ref --short HEAD` to a real branch name, since a worktree's HEAD +// always resolves to one (mirrors real git, unlike an empty-stdout stub). +function makeRepoExec( + remotes: Record, + checkedOutBranch = 'local-branch' +): ExecMock { return vi.fn(async (args: string[]) => { + if (args[0] === 'symbolic-ref' && args[1] === '--short' && args[2] === 'HEAD') { + return { stdout: `${checkedOutBranch}\n`, stderr: '' } + } if (args[0] === 'remote' && args.length === 1) { return { stdout: Object.keys(remotes).join('\n'), stderr: '' } } @@ -80,6 +90,29 @@ describe('prepareWorktreePushTargetWithExec', () => { }) }) + it('records repo-local provenance on the remote it adds (#17828)', async () => { + const exec = makeRepoExec({ origin: 'git@github.com:stablyai/orca.git' }) + + await prepareWorktreePushTargetWithExec(exec, REPO, forkTarget(), () => false) + + // Why: cleanup's ownership check must survive a store purge (worktree-push-target-cleanup.ts). + // Narrowing the refspec (#17887) also writes `config` calls, so scope to the marker itself. + expect(callsMatching(exec, ['config', 'remote.pr-contributor-orca.orca-created'])).toEqual([ + ['config', 'remote.pr-contributor-orca.orca-created', 'true'] + ]) + }) + + it('does not record provenance when reusing an existing remote', async () => { + const exec = makeRepoExec({ + origin: 'git@github.com:stablyai/orca.git', + 'pr-contributor-orca': FORK_HTTPS + }) + + await prepareWorktreePushTargetWithExec(exec, REPO, forkTarget(), () => false) + + expect(callsMatching(exec, ['config', 'remote.pr-contributor-orca.orca-created'])).toEqual([]) + }) + it('reuses an existing remote pointing at the same fork (SSH vs HTTPS) without adding', async () => { const exec = makeRepoExec({ origin: 'git@github.com:stablyai/orca.git', @@ -158,6 +191,38 @@ describe('findRemoteForUrl', () => { }) }) +describe('remoteAlreadyMatchesUrl', () => { + it('matches an exact URL', async () => { + const exec = makeRepoExec({ 'pr-contributor-orca': FORK_SSH }) + await expect( + remoteAlreadyMatchesUrl(exec, REPO, 'pr-contributor-orca', FORK_SSH) + ).resolves.toBe(true) + }) + + it('matches by GitHub owner/repo across URL protocols', async () => { + const exec = makeRepoExec({ 'pr-contributor-orca': FORK_HTTPS }) + await expect( + remoteAlreadyMatchesUrl(exec, REPO, 'pr-contributor-orca', FORK_SSH) + ).resolves.toBe(true) + }) + + it('returns false when the named remote points elsewhere', async () => { + const exec = makeRepoExec({ + 'pr-contributor-orca': 'git@github.com:someone-else/orca.git' + }) + await expect( + remoteAlreadyMatchesUrl(exec, REPO, 'pr-contributor-orca', FORK_SSH) + ).resolves.toBe(false) + }) + + it('returns false when the named remote does not exist', async () => { + const exec = makeRepoExec({ origin: 'git@github.com:stablyai/orca.git' }) + await expect( + remoteAlreadyMatchesUrl(exec, REPO, 'pr-contributor-orca', FORK_SSH) + ).resolves.toBe(false) + }) +}) + describe('ensureUniqueRemoteName', () => { it('returns the preferred name when it is free', async () => { const exec = makeRepoExec({ origin: 'x' }) @@ -190,6 +255,46 @@ describe('configureCreatedWorktreePushTargetWithExec', () => { }) }) +describe('restoreUpstreamAfterMaterialize', () => { + it('points the checked-out branch upstream at the fork remote', async () => { + const exec = makeRepoExec({}, 'local-branch') + const target = forkTarget() + + const result = await restoreUpstreamAfterMaterialize(exec, '/wt/path', target) + + expect(exec).toHaveBeenCalledWith( + ['branch', '--set-upstream-to', 'pr-contributor-orca/contributor/fix', 'local-branch'], + '/wt/path' + ) + expect(result).toBe(target) + }) + + it('is a no-op when the target has no remoteUrl', async () => { + const exec = makeRepoExec({}, 'local-branch') + const target: GitPushTarget = { remoteName: 'origin', branchName: 'feature' } + + const result = await restoreUpstreamAfterMaterialize(exec, '/wt/path', target) + + expect(callsMatching(exec, ['branch', '--set-upstream-to'])).toEqual([]) + expect(result).toBe(target) + }) + + it('is a no-op when HEAD is detached (no checked-out branch)', async () => { + const exec = vi.fn(async (args: string[]) => { + if (args[0] === 'symbolic-ref') { + throw new Error('fatal: ref HEAD is not a symbolic ref') + } + return { stdout: '', stderr: '' } + }) + const target = forkTarget() + + const result = await restoreUpstreamAfterMaterialize(exec, '/wt/path', target) + + expect(callsMatching(exec, ['branch', '--set-upstream-to'])).toEqual([]) + expect(result).toBe(target) + }) +}) + describe('prepareWorktreePushTargetWithExec rollback', () => { it('removes the remote it just added when the fetch fails', async () => { const remotes: Record = { origin: 'git@github.com:stablyai/orca.git' } diff --git a/src/main/ipc/worktree-push-target-setup.ts b/src/main/ipc/worktree-push-target-setup.ts index e064b1f35c3..59ef4c8fea5 100644 --- a/src/main/ipc/worktree-push-target-setup.ts +++ b/src/main/ipc/worktree-push-target-setup.ts @@ -49,6 +49,54 @@ export async function findRemoteForUrl( return null } +// O(1) probe used before materializing on demand (push/pull/fetch/fast-forward): +// a single `remote get-url ` avoids the O(remotes) `findRemoteForUrl` scan +// once a fork remote already exists under its expected name (#17828). +export async function remoteAlreadyMatchesUrl( + execGit: GitRemoteExec, + repoPath: string, + remoteName: string, + remoteUrl: string +): Promise { + try { + const { stdout } = await execGit(['remote', 'get-url', remoteName], repoPath) + const candidateUrl = stdout.trim() + if (candidateUrl === remoteUrl) { + return true + } + const target = parseGitHubOwnerRepo(remoteUrl) + const candidate = parseGitHubOwnerRepo(candidateUrl) + return Boolean( + target && + candidate && + target.owner.toLowerCase() === candidate.owner.toLowerCase() && + target.repo.toLowerCase() === candidate.repo.toLowerCase() + ) + } catch { + return false + } +} + +// Why (#17828 CodeRabbit follow-up): a deferred remote materialized after create +// (terminal spawn, push/pull/fetch) must restore the upstream link create used to +// configure, or raw `git pull`/`git log @{u}..` keep failing even once the remote +// exists. The checked-out branch is resolved fresh rather than threaded through +// every materialize call site, since `target.branchName` is the fork's PR head ref +// and can differ from the worktree's local branch name (rename-on-collision). +export async function resolveCheckedOutBranchName( + execGit: GitRemoteExec, + repoPath: string +): Promise { + try { + const { stdout } = await execGit(['symbolic-ref', '--short', 'HEAD'], repoPath) + const branch = stdout.trim() + return branch.length > 0 ? branch : null + } catch { + // Detached HEAD or an unreadable ref -- nothing to point upstream. + return null + } +} + export async function ensureUniqueRemoteName( execGit: GitRemoteExec, repoPath: string, @@ -103,16 +151,26 @@ export async function prepareWorktreePushTargetWithExec( remoteName = await ensureUniqueRemoteName(execGit, repoPath, target.remoteName) // Why: `-t --no-tags` means this remote is never, even transiently, // written with the wide default `refs/heads/*` refspec + tag auto-follow (#17828). - // `-t` itself writes a literal (non-wildcard-suffixed) refspec, so immediately - // rewrite it to the trailing-`*` form via `ensureRemoteTracksBranchNarrowly` - // (see that function's comment for why the suffix matters). await execGit( ['remote', 'add', '-t', target.branchName, '--no-tags', remoteName, target.remoteUrl], repoPath ) - await ensureRemoteTracksBranchNarrowly(execGit, repoPath, remoteName, target.branchName) - remoteCreated = true remoteAddedHere = true + try { + // `-t` itself writes a literal (non-wildcard-suffixed) refspec, so immediately + // rewrite it to the trailing-`*` form via `ensureRemoteTracksBranchNarrowly` + // (see that function's comment for why the suffix matters). + await ensureRemoteTracksBranchNarrowly(execGit, repoPath, remoteName, target.branchName) + // Why: repo-local provenance that survives a store purge and is removed + // atomically with the remote itself, unlike the store's `remoteCreated` flag. + await execGit(['config', `remote.${remoteName}.orca-created`, 'true'], repoPath) + } catch (error) { + // Why: a half-configured remote with no provenance marker is unreclaimable -- + // cleanup only runs off that marker, so a failure here must undo the add. + await execGit(['remote', 'remove', remoteName], repoPath).catch(() => {}) + throw error + } + remoteCreated = true } } @@ -138,6 +196,31 @@ export async function prepareWorktreePushTargetWithExec( } } +// Why (#17828 CodeRabbit follow-up, restructured per review): materializing the remote +// alone isn't enough -- raw `git pull`/`git push`/`git log @{u}..` still fail without the +// upstream link create-time configuration used to set up. This must run at the *materializer* +// level (called by both the short-circuit and full-prepare paths in worktree-remote.ts), not +// buried inside `prepare*`, or every call after the first materialize -- and any sibling +// worktree that reuses the same fork remote -- never reaches it. Unconditional (not just +// "newly added") because a reused remote's upstream for *this* worktree's branch isn't +// guaranteed set. The checked-out branch is resolved fresh rather than threaded through +// every materialize call site, since `target.branchName` is the fork's PR head ref and can +// differ from the worktree's local branch name (rename-on-collision). +export async function restoreUpstreamAfterMaterialize( + execGit: GitRemoteExec, + worktreePath: string, + target: GitPushTarget +): Promise { + if (!target.remoteUrl) { + return target + } + const checkedOutBranch = await resolveCheckedOutBranchName(execGit, worktreePath) + if (!checkedOutBranch) { + return target + } + return configureCreatedWorktreePushTargetWithExec(execGit, worktreePath, checkedOutBranch, target) +} + export async function configureCreatedWorktreePushTargetWithExec( execGit: GitRemoteExec, worktreePath: string, diff --git a/src/main/ipc/worktree-remote-push-target-materialization.test.ts b/src/main/ipc/worktree-remote-push-target-materialization.test.ts new file mode 100644 index 00000000000..68ed98ab35c --- /dev/null +++ b/src/main/ipc/worktree-remote-push-target-materialization.test.ts @@ -0,0 +1,593 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { SshGitProvider } from '../providers/ssh-git-provider' +import type { GitPushTarget } from '../../shared/worktree/types' +import type { WorktreePushTargetStore } from './worktree-push-target-cleanup' + +const { gitExecFileAsyncMock } = vi.hoisted(() => ({ gitExecFileAsyncMock: vi.fn() })) +vi.mock('../git/runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock })) + +import { + materializeWorktreePushTargetRemote, + materializeWorktreePushTargetRemoteSsh +} from './worktree-remote' + +const REPO_PATH = '/repo-root' +const FORK_URL = 'git@github.com:contributor/orca.git' +const FORK_REMOTE = 'pr-contributor-orca' + +function forkTarget(overrides: Partial = {}): GitPushTarget { + return { + remoteName: FORK_REMOTE, + branchName: 'contributor/fix', + remoteUrl: FORK_URL, + ...overrides + } +} + +describe('materializeWorktreePushTargetRemote', () => { + beforeEach(() => { + gitExecFileAsyncMock.mockReset() + }) + + it('is a no-op when the target already reports remoteCreated', async () => { + const target = forkTarget({ remoteCreated: true }) + + const result = await materializeWorktreePushTargetRemote(REPO_PATH, target) + + expect(result).toBe(target) + expect(gitExecFileAsyncMock).not.toHaveBeenCalled() + }) + + it('is a no-op for a same-repo target with no remoteUrl', async () => { + const target = forkTarget({ remoteUrl: undefined }) + + const result = await materializeWorktreePushTargetRemote(REPO_PATH, target) + + expect(result).toBe(target) + expect(gitExecFileAsyncMock).not.toHaveBeenCalled() + }) + + it('short-circuits the remote probe but still restores upstream and widens the refspec', async () => { + // Why (#17828 review follow-up): the short-circuit is the common case for every call + // after the first, and for a sibling worktree reusing the same fork remote under a + // different branch -- it must still restore the upstream link and widen the refspec. + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'remote' && args[1] === 'get-url') { + return { stdout: `${FORK_URL}\n`, stderr: '' } + } + if (args[0] === 'config' && args[1] === '--get-all') { + throw new Error('no such section') + } + if (args[0] === 'symbolic-ref') { + return { stdout: 'contributor/fix\n', stderr: '' } + } + return { stdout: '', stderr: '' } + }) + const target = forkTarget() + + const result = await materializeWorktreePushTargetRemote(REPO_PATH, target) + + expect(result).toBe(target) + const calls = gitExecFileAsyncMock.mock.calls.map((call) => call[0] as string[]) + expect(calls).toContainEqual(['remote', 'get-url', FORK_REMOTE]) + expect(calls).toContainEqual([ + 'config', + '--add', + `remote.${FORK_REMOTE}.fetch`, + `+refs/heads/${target.branchName}*:refs/remotes/${FORK_REMOTE}/${target.branchName}*` + ]) + expect(calls).toContainEqual(['config', `remote.${FORK_REMOTE}.tagOpt`, '--no-tags']) + expect(calls).toContainEqual(['symbolic-ref', '--short', 'HEAD']) + expect(calls).toContainEqual([ + 'branch', + '--set-upstream-to', + `${FORK_REMOTE}/${target.branchName}`, + 'contributor/fix' + ]) + }) + + it('materializes the remote (add + provenance + fetch) when the probe misses', async () => { + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'remote' && args[1] === 'get-url') { + throw new Error('No such remote') + } + return { stdout: '', stderr: '' } + }) + const target = forkTarget() + + const result = await materializeWorktreePushTargetRemote(REPO_PATH, target) + + expect(result).toEqual({ ...target, remoteCreated: true }) + const calls = gitExecFileAsyncMock.mock.calls.map((call) => call[0] as string[]) + // Mint uses the narrow `-t --no-tags` add form (#17887), not a bare `remote add`. + expect(calls).toContainEqual([ + 'remote', + 'add', + '-t', + target.branchName, + '--no-tags', + FORK_REMOTE, + FORK_URL + ]) + expect(calls).toContainEqual(['config', `remote.${FORK_REMOTE}.orca-created`, 'true']) + expect(calls).toContainEqual([ + 'fetch', + FORK_REMOTE, + `+refs/heads/${target.branchName}*:refs/remotes/${FORK_REMOTE}/${target.branchName}*` + ]) + }) + + it('fetches the missing tracking ref before restoring upstream on the short-circuit path (#17828 sibling worktree)', async () => { + // Why: a sibling worktree short-circuiting onto an already-existing remote under a + // *new* branch has a widened refspec but no tracking ref yet -- against real git, + // `branch --set-upstream-to` hard-fails with "the requested upstream branch does not + // exist" unless something fetches that branch first. Verified against a real git + // fixture, not just this mock (see PR discussion). + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'remote' && args[1] === 'get-url') { + return { stdout: `${FORK_URL}\n`, stderr: '' } + } + if (args[0] === 'config' && args[1] === '--get-all') { + throw new Error('no such section') + } + if (args[0] === 'rev-parse') { + throw new Error('unknown revision') + } + if (args[0] === 'symbolic-ref') { + return { stdout: 'contributor/fix\n', stderr: '' } + } + return { stdout: '', stderr: '' } + }) + const target = forkTarget() + + const result = await materializeWorktreePushTargetRemote(REPO_PATH, target) + + expect(result).toBe(target) + const fetchCalls = gitExecFileAsyncMock.mock.calls.filter( + (call) => (call[0] as string[])[0] === 'fetch' + ) + expect(fetchCalls).toEqual([ + [ + [ + 'fetch', + FORK_REMOTE, + `+refs/heads/${target.branchName}*:refs/remotes/${FORK_REMOTE}/${target.branchName}*` + ], + expect.objectContaining({ timeout: expect.any(Number) }) + ] + ]) + const calls = gitExecFileAsyncMock.mock.calls.map((call) => call[0] as string[]) + expect(calls).toContainEqual([ + 'rev-parse', + '--verify', + '--quiet', + `refs/remotes/${FORK_REMOTE}/${target.branchName}` + ]) + expect(calls).toContainEqual([ + 'branch', + '--set-upstream-to', + `${FORK_REMOTE}/${target.branchName}`, + 'contributor/fix' + ]) + }) + + it('skips the fetch when the tracking ref already exists on the short-circuit path', async () => { + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'remote' && args[1] === 'get-url') { + return { stdout: `${FORK_URL}\n`, stderr: '' } + } + if (args[0] === 'config' && args[1] === '--get-all') { + throw new Error('no such section') + } + if (args[0] === 'symbolic-ref') { + return { stdout: 'contributor/fix\n', stderr: '' } + } + // rev-parse succeeds by default (ref already exists) -- no fetch should follow. + return { stdout: '', stderr: '' } + }) + const target = forkTarget() + + await materializeWorktreePushTargetRemote(REPO_PATH, target) + + const fetchCalls = gitExecFileAsyncMock.mock.calls.filter( + (call) => (call[0] as string[])[0] === 'fetch' + ) + expect(fetchCalls).toEqual([]) + }) + + it("gives a joiner its own branch wiring instead of the minting sibling's target", async () => { + // Why (#17828 review): the single flight is keyed on the remote, but the refspec widen, + // tracking-ref fetch and upstream link are all per-branch. A sibling worktree joining an + // in-flight mint for a *different* branch previously received the minter's target and + // skipped all three, leaving its own branch with no upstream. + let remoteExists = false + let releaseAdd!: () => void + const addGate = new Promise((resolve) => { + releaseAdd = resolve + }) + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'remote' && args[1] === 'get-url') { + if (!remoteExists) { + throw new Error('No such remote') + } + return { stdout: `${FORK_URL}\n`, stderr: '' } + } + if (args[0] === 'remote' && args[1] === 'add') { + await addGate + remoteExists = true + return { stdout: '', stderr: '' } + } + if (args[0] === 'config' && args[1] === '--get-all') { + throw new Error('no such section') + } + if (args[0] === 'symbolic-ref') { + return { stdout: 'joiner/branch\n', stderr: '' } + } + return { stdout: '', stderr: '' } + }) + + const minter = materializeWorktreePushTargetRemote(REPO_PATH, forkTarget()) + await Promise.resolve() + const joiner = materializeWorktreePushTargetRemote( + REPO_PATH, + forkTarget({ branchName: 'joiner/branch' }) + ) + releaseAdd() + const [, joined] = await Promise.all([minter, joiner]) + + // The joiner keeps its own branch rather than inheriting the minter's. + expect(joined.branchName).toBe('joiner/branch') + const calls = gitExecFileAsyncMock.mock.calls.map((call) => call[0] as string[]) + expect(calls).toContainEqual([ + 'branch', + '--set-upstream-to', + `${FORK_REMOTE}/joiner/branch`, + 'joiner/branch' + ]) + // Exactly one mint: the joiner must not have raced a second `remote add`. + expect(calls.filter((call) => call[0] === 'remote' && call[1] === 'add')).toHaveLength(1) + }) + + it('propagates a failed mint instead of adopting a remote the rollback removed', async () => { + // Why (#17828 review): both mint rollbacks `remote remove`, so adopting after a failed mint + // writes `remote..fetch` with no URL -- a config-only ghost that breaks + // `git fetch --all`, forces later mints to a `-2` name, and survives `git remote remove`. + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'remote' && args[1] === 'get-url') { + throw new Error('No such remote') + } + if (args[0] === 'remote' && args[1] === 'add') { + throw new Error('mint failed') + } + return { stdout: '', stderr: '' } + }) + + const minter = materializeWorktreePushTargetRemote(REPO_PATH, forkTarget()) + await Promise.resolve() + const joiner = materializeWorktreePushTargetRemote( + REPO_PATH, + forkTarget({ branchName: 'joiner/branch' }) + ) + + await expect(minter).rejects.toThrow() + await expect(joiner).rejects.toThrow() + const calls = gitExecFileAsyncMock.mock.calls.map((call) => call[0] as string[]) + // No ghost: nothing wrote refspec or tagOpt config for a remote that does not exist. + expect( + calls.filter((call) => call[0] === 'config' && String(call[2] ?? '').includes(FORK_REMOTE)) + ).toHaveLength(0) + }) + + it('persists remoteCreated to the store when a worktreeId is provided and the mint succeeds', async () => { + // Why (#17828 review follow-up): on-demand materialization never went through the + // create-time setWorktreeMeta write, so a lazily-minted remote stayed invisible to + // #17842's orphan sweep (which gates solely on the stored remoteCreated flag). + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'remote' && args[1] === 'get-url') { + throw new Error('No such remote') + } + return { stdout: '', stderr: '' } + }) + const target = forkTarget() + const setWorktreeMeta = vi.fn() + const store: WorktreePushTargetStore = { + getAllWorktreeMeta: () => ({}), + setWorktreeMeta + } as unknown as WorktreePushTargetStore + + const result = await materializeWorktreePushTargetRemote( + REPO_PATH, + target, + store, + undefined, + {}, + 'worktree-1' + ) + + expect(result).toEqual({ ...target, remoteCreated: true }) + expect(setWorktreeMeta).toHaveBeenCalledWith('worktree-1', { + pushTarget: { ...target, remoteCreated: true } + }) + }) + + it('does not touch the store when no worktreeId is provided', async () => { + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'remote' && args[1] === 'get-url') { + throw new Error('No such remote') + } + return { stdout: '', stderr: '' } + }) + const target = forkTarget() + const setWorktreeMeta = vi.fn() + const store: WorktreePushTargetStore = { + getAllWorktreeMeta: () => ({}), + setWorktreeMeta + } as unknown as WorktreePushTargetStore + + await materializeWorktreePushTargetRemote(REPO_PATH, target, store) + + expect(setWorktreeMeta).not.toHaveBeenCalled() + }) +}) + +describe('materializeWorktreePushTargetRemoteSsh', () => { + it('is a no-op when the target already reports remoteCreated', async () => { + const exec = vi.fn() + const target = forkTarget({ remoteCreated: true }) + + const result = await materializeWorktreePushTargetRemoteSsh( + { exec } as unknown as SshGitProvider, + REPO_PATH, + target + ) + + expect(result).toBe(target) + expect(exec).not.toHaveBeenCalled() + }) + + it('short-circuits the remote probe but still restores upstream (refspec widening is a local-only gap)', async () => { + // Why: mirrors the local short-circuit's upstream restore. Refspec widening is + // intentionally NOT mirrored here -- SSH's bare `remote add` is a pre-existing, + // documented gap this fix does not touch. + const exec = vi.fn(async (args: string[]) => { + if (args[0] === 'remote' && args[1] === 'get-url') { + return { stdout: `${FORK_URL}\n`, stderr: '' } + } + if (args[0] === 'symbolic-ref') { + return { stdout: 'contributor/fix\n', stderr: '' } + } + return { stdout: '', stderr: '' } + }) + const fetchRemoteTrackingRef = vi.fn() + const target = forkTarget() + + const result = await materializeWorktreePushTargetRemoteSsh( + { exec, fetchRemoteTrackingRef } as unknown as SshGitProvider, + REPO_PATH, + target + ) + + expect(result).toBe(target) + const calls = exec.mock.calls.map((call) => call[0] as string[]) + expect(calls).toContainEqual(['remote', 'get-url', FORK_REMOTE]) + expect(calls).toContainEqual(['symbolic-ref', '--short', 'HEAD']) + expect(calls).toContainEqual([ + 'branch', + '--set-upstream-to', + `${FORK_REMOTE}/${target.branchName}`, + 'contributor/fix' + ]) + expect(calls.some((call) => call[0] === 'config' && String(call[2]).includes('.fetch'))).toBe( + false + ) + expect(fetchRemoteTrackingRef).not.toHaveBeenCalled() + }) + + it('fetches the missing tracking ref (one-off, no config write) before restoring upstream on the short-circuit path', async () => { + // SSH mirror of the local sibling-worktree fix: refspec widening stays out of scope + // here, but the branch must still be fetched once before `--set-upstream-to` can + // succeed for a branch this remote has never pulled in. + const exec = vi.fn(async (args: string[]) => { + if (args[0] === 'remote' && args[1] === 'get-url') { + return { stdout: `${FORK_URL}\n`, stderr: '' } + } + if (args[0] === 'rev-parse') { + throw new Error('unknown revision') + } + if (args[0] === 'symbolic-ref') { + return { stdout: 'contributor/fix\n', stderr: '' } + } + return { stdout: '', stderr: '' } + }) + const fetchRemoteTrackingRef = vi.fn(async () => {}) + const target = forkTarget() + + const result = await materializeWorktreePushTargetRemoteSsh( + { exec, fetchRemoteTrackingRef } as unknown as SshGitProvider, + REPO_PATH, + target + ) + + expect(result).toBe(target) + expect(fetchRemoteTrackingRef).toHaveBeenCalledWith( + REPO_PATH, + FORK_REMOTE, + target.branchName, + `refs/remotes/${FORK_REMOTE}/${target.branchName}` + ) + const calls = exec.mock.calls.map((call) => call[0] as string[]) + expect(calls).toContainEqual([ + 'branch', + '--set-upstream-to', + `${FORK_REMOTE}/${target.branchName}`, + 'contributor/fix' + ]) + // Still no config write -- the fetch is a one-off refspec argument, not a widen. + expect(calls.some((call) => call[0] === 'config' && String(call[2]).includes('.fetch'))).toBe( + false + ) + }) + + it('materializes the remote (add + provenance + fetch) when the probe misses', async () => { + const exec = vi.fn(async (args: string[]) => { + if (args[0] === 'remote' && args[1] === 'get-url') { + throw new Error('No such remote') + } + return { stdout: '', stderr: '' } + }) + const fetchRemoteTrackingRef = vi.fn(async () => {}) + const markRemoteOrcaCreated = vi.fn(async () => {}) + const target = forkTarget() + + const result = await materializeWorktreePushTargetRemoteSsh( + { exec, fetchRemoteTrackingRef, markRemoteOrcaCreated } as unknown as SshGitProvider, + REPO_PATH, + target + ) + + expect(result).toEqual({ ...target, remoteCreated: true }) + const calls = exec.mock.calls.map((call) => call[0] as string[]) + expect(calls).toContainEqual(['check-ref-format', '--branch', target.branchName]) + expect(calls).toContainEqual(['remote', 'add', FORK_REMOTE, FORK_URL]) + // Provenance is a narrow RPC, not exec: the relay's generic git.exec blocks config writes. + expect(markRemoteOrcaCreated).toHaveBeenCalledWith(REPO_PATH, FORK_REMOTE) + expect(fetchRemoteTrackingRef).toHaveBeenCalledWith( + REPO_PATH, + FORK_REMOTE, + target.branchName, + `refs/remotes/${FORK_REMOTE}/${target.branchName}` + ) + }) + + it('persists remoteCreated to the store when a worktreeId is provided and the mint succeeds', async () => { + const exec = vi.fn(async (args: string[]) => { + if (args[0] === 'remote' && args[1] === 'get-url') { + throw new Error('No such remote') + } + return { stdout: '', stderr: '' } + }) + const fetchRemoteTrackingRef = vi.fn(async () => {}) + const markRemoteOrcaCreated = vi.fn(async () => {}) + const target = forkTarget() + const setWorktreeMeta = vi.fn() + const store: WorktreePushTargetStore = { + getAllWorktreeMeta: () => ({}), + setWorktreeMeta + } as unknown as WorktreePushTargetStore + + const result = await materializeWorktreePushTargetRemoteSsh( + { exec, fetchRemoteTrackingRef, markRemoteOrcaCreated } as unknown as SshGitProvider, + REPO_PATH, + target, + store, + undefined, + 'worktree-1' + ) + + expect(result).toEqual({ ...target, remoteCreated: true }) + expect(setWorktreeMeta).toHaveBeenCalledWith('worktree-1', { + pushTarget: { ...target, remoteCreated: true } + }) + }) + + // Moved from worktrees-ssh-fork-push-target-remote.test.ts: this behavior lives in + // prepareWorktreePushTargetSsh (invoked here through the materialize wrapper, once + // the fast probe misses) and is unchanged -- it just no longer runs at create time. + it('names the relay upgrade when an older host still rejects the fork remote', async () => { + const exec = vi.fn(async (args: string[]) => { + if (args[0] === 'remote' && args[1] === 'get-url') { + throw new Error('No such remote') + } + if (args[0] === 'remote' && args[1] === 'add') { + throw new Error('Destructive git remote operations are not allowed via exec') + } + return { stdout: '', stderr: '' } + }) + const fetchRemoteTrackingRef = vi.fn() + const target = forkTarget() + + await expect( + materializeWorktreePushTargetRemoteSsh( + { exec, fetchRemoteTrackingRef } as unknown as SshGitProvider, + REPO_PATH, + target + ) + ).rejects.toThrow('Reconnect to deploy the latest relay') + expect(fetchRemoteTrackingRef).not.toHaveBeenCalled() + }) + + it('drops the fork remote it just added when the SSH head fetch fails', async () => { + const exec = vi.fn(async (args: string[]) => { + if (args[0] === 'remote' && args[1] === 'get-url') { + throw new Error('No such remote') + } + return { stdout: '', stderr: '' } + }) + const fetchRemoteTrackingRef = vi.fn(async () => { + throw new Error('network unreachable') + }) + const markRemoteOrcaCreated = vi.fn(async () => {}) + const target = forkTarget() + + await expect( + materializeWorktreePushTargetRemoteSsh( + { exec, fetchRemoteTrackingRef, markRemoteOrcaCreated } as unknown as SshGitProvider, + REPO_PATH, + target + ) + ).rejects.toThrow('network unreachable') + + expect(exec).toHaveBeenCalledWith(['remote', 'remove', FORK_REMOTE], REPO_PATH) + }) + + // Regression: the rollback must not fire on ownership inherited from a sibling + // worktree, deleting the remote that worktree is still pushing through. The probe + // misses under the *requested* remote name so this reaches prepareWorktreePushTargetSsh's + // own by-URL reuse scan, which finds the sibling's differently-named remote. + it('keeps a reused fork remote a sibling worktree owns when the SSH head fetch fails', async () => { + const SIBLING_REMOTE = 'pr-contributor-orca-existing' + const exec = vi.fn(async (args: string[]) => { + if (args[0] === 'remote' && args[1] === 'get-url') { + if (args[2] === SIBLING_REMOTE) { + return { stdout: `${FORK_URL}\n`, stderr: '' } + } + throw new Error('No such remote') + } + if (args[0] === 'remote' && args.length === 1) { + return { stdout: `origin\n${SIBLING_REMOTE}\n`, stderr: '' } + } + return { stdout: '', stderr: '' } + }) + const fetchRemoteTrackingRef = vi.fn(async () => { + throw new Error('network unreachable') + }) + const target = forkTarget() + const store: WorktreePushTargetStore = { + getAllWorktreeMeta: () => ({ + 'repo::/repo-root-sibling': { + pushTarget: { + remoteName: SIBLING_REMOTE, + branchName: 'contributor/other', + remoteUrl: FORK_URL, + remoteCreated: true + } + } + }) + } as unknown as WorktreePushTargetStore + + await expect( + materializeWorktreePushTargetRemoteSsh( + { exec, fetchRemoteTrackingRef } as unknown as SshGitProvider, + REPO_PATH, + target, + store + ) + ).rejects.toThrow('network unreachable') + + expect(exec).not.toHaveBeenCalledWith(['remote', 'remove', SIBLING_REMOTE], REPO_PATH) + expect(exec).not.toHaveBeenCalledWith( + ['remote', 'add', expect.anything(), expect.anything()], + REPO_PATH + ) + }) +}) diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index e53ae264129..0b985a0311f 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -112,8 +112,15 @@ import { configureCreatedWorktreePushTargetWithExec, ensureUniqueRemoteName, findRemoteForUrl, - prepareWorktreePushTargetWithExec + prepareWorktreePushTargetWithExec, + remoteAlreadyMatchesUrl, + restoreUpstreamAfterMaterialize } from './worktree-push-target-setup' +import { + buildNarrowForkFetchRefspec, + ensureRemoteTracksBranchNarrowly, + forkRemoteTrackingRefExists +} from '../git/fork-remote-refspec' import { migrateForkRemoteRefspecs } from './worktree-push-target-refspec-migration' import { isENOENT } from './filesystem-path-containment' import { @@ -166,9 +173,36 @@ const SSH_WORKTREE_CREATE_FETCH_FRESHNESS_MS = 30_000 const SSH_WORKTREE_CREATE_FETCH_CACHE_MAX = 512 // Why: bound the fallback `git fetch origin` so a Windows credential-manager GUI hang (STA-1292) can't wedge worktree creation forever. const CREATE_BASE_FALLBACK_FETCH_TIMEOUT_MS = 60_000 +// Why (#17828 CodeRabbit follow-up): the deferred materialize fetch runs off the main +// create path (terminal spawn, mid-session sync) with nothing else bounding it -- same +// STA-1292 hang risk as the create-time fallback above, so mirror its timeout. +const DEFERRED_PUSH_TARGET_FETCH_TIMEOUT_MS = 60_000 const sshWorktreeCreateFetchInflight = new Map>() const sshWorktreeCreateFetchCompletedAt = new Map() const sshWorktreeCreateFetchQueueTail = new Map>() +// Why (#17828 CodeRabbit follow-up): a terminal spawn and an explicit sync action can +// both call materialize for the same worktree remote at once; without single-flighting, +// the loser's `remote add` races the winner's fetch and can strand a duplicate remote. +const worktreePushTargetMaterializeInflight = new Map>() +const sshWorktreePushTargetMaterializeInflight = new WeakMap< + SshGitProvider, + Map> +>() + +function worktreePushTargetMaterializeKey(repoPath: string, remoteName: string): string { + return `${repoPath}::${remoteName}` +} + +function getSshWorktreePushTargetMaterializeInflight( + provider: SshGitProvider +): Map> { + let inflight = sshWorktreePushTargetMaterializeInflight.get(provider) + if (!inflight) { + inflight = new Map() + sshWorktreePushTargetMaterializeInflight.set(provider, inflight) + } + return inflight +} const sshWorktreeCreateBasePlanInflight = new Map< string, Promise @@ -972,7 +1006,16 @@ export async function prepareWorktreePushTarget( ): Promise { await validateGitPushTarget(repoPath, target, gitOptions) const prepared = await prepareWorktreePushTargetWithExec( - (args, cwd) => gitExecFileAsync(args, { cwd, ...gitOptions }), + // Why: this is only ever reached via the deferred materialize path (#17828) -- bound + // just the network fetch so it can't hang indefinitely (see the timeout constant's + // comment). The other calls this makes (`remote`, `remote add`, `config`) are local-only + // and must stay untimed, matching every other local git call in this file. + (args, cwd) => + gitExecFileAsync(args, { + cwd, + ...gitOptions, + ...(args[0] === 'fetch' ? { timeout: DEFERRED_PUSH_TARGET_FETCH_TIMEOUT_MS } : {}) + }), repoPath, target, (existingRemote) => @@ -993,6 +1036,170 @@ export async function prepareWorktreePushTarget( return prepared } +// Why: on-demand twin of `prepareWorktreePushTarget` for push/pull/fetch/ +// fast-forward (#17828) -- a deferred fork remote is materialized the first +// time it's needed. The cheap named-remote probe keeps every push after the +// first one down to a handful of extra subprocesses (probe, refspec-widen, +// upstream-restore) instead of repeating the O(remotes) scan +// `prepareWorktreePushTargetWithExec` does when it must add. +export async function materializeWorktreePushTargetRemote( + repoPath: string, + target: GitPushTarget, + store?: WorktreePushTargetStore, + repoId?: string, + gitOptions: { wslDistro?: string } = {}, + worktreeId?: string +): Promise { + if (!target.remoteUrl || target.remoteCreated) { + return target + } + const execGit: GitRemoteExec = (args, cwd) => gitExecFileAsync(args, { cwd, ...gitOptions }) + if (await remoteAlreadyMatchesUrl(execGit, repoPath, target.remoteName, target.remoteUrl)) { + return runForkRemoteAdoption(repoPath, target, () => + adoptExistingForkRemoteForBranch( + execGit, + repoPath, + target, + gitOptions, + store, + repoId, + worktreeId + ) + ) + } + const key = worktreePushTargetMaterializeKey(repoPath, target.remoteName) + const existing = worktreePushTargetMaterializeInflight.get(key) + if (existing) { + // Why: the single flight is keyed on the *remote*, but everything after the remote add is + // per-branch. A joiner waiting on a sibling worktree's mint must not take that sibling's + // target -- it would inherit the sibling's branch and silently skip its own refspec widen, + // tracking-ref fetch, and upstream link. Wait for the remote, then do its own. + // + // Why not swallow the rejection: both mint rollbacks remove the remote, so adopting after a + // failed mint would write `remote..fetch` with no URL -- a config-only ghost that + // breaks `git fetch --all`, forces every later mint to a `-2` name, and survives + // `git remote remove`. Propagate instead; the map is already cleared, so a retry re-mints. + await existing + return runForkRemoteAdoption(repoPath, target, () => + adoptExistingForkRemoteForBranch( + execGit, + repoPath, + target, + gitOptions, + store, + repoId, + worktreeId + ) + ) + } + const promise = prepareWorktreePushTarget(repoPath, target, store, repoId, gitOptions) + .then((prepared) => restoreUpstreamAfterMaterialize(execGit, repoPath, prepared)) + .then((prepared) => { + persistMaterializedPushTargetIfCreated(store, worktreeId, prepared) + return prepared + }) + .finally(() => { + if (worktreePushTargetMaterializeInflight.get(key) === promise) { + worktreePushTargetMaterializeInflight.delete(key) + } + }) + worktreePushTargetMaterializeInflight.set(key, promise) + return promise +} + +// Why: the remote already exists -- minted by an earlier call, by create, or by a sibling +// worktree. Everything left is per-branch, and it must run for *this* target: the refspec +// widen, the tracking-ref fetch, and the upstream link. Previously these only ran inside +// prepareWorktreePushTarget, unreachable once the remote was there. +async function adoptExistingForkRemoteForBranch( + execGit: GitRemoteExec, + repoPath: string, + target: GitPushTarget, + gitOptions: { wslDistro?: string }, + store: WorktreePushTargetStore | undefined, + repoId: string | undefined, + worktreeId: string | undefined +): Promise { + await ensureRemoteTracksBranchNarrowly(execGit, repoPath, target.remoteName, target.branchName) + // Why: widening only rewrites config -- it never imports anything. For a sibling worktree's + // first materialize of a *new* branch on an already-existing remote, the branch's tracking + // ref doesn't exist yet, and `--set-upstream-to` below hard-fails with "the requested + // upstream branch does not exist" (verified against real git). Skip the fetch when the ref + // is already there so a repeat push/pull materialize stays a local-only probe. + if ( + !(await forkRemoteTrackingRefExists(execGit, repoPath, target.remoteName, target.branchName)) + ) { + // Why: a network fetch, unlike the local-only probes above -- bound it the same as the + // full-mint path's fetch so it can't hang indefinitely. + await gitExecFileAsync( + [ + 'fetch', + target.remoteName, + buildNarrowForkFetchRefspec(target.remoteName, target.branchName) + ], + { cwd: repoPath, ...gitOptions, timeout: DEFERRED_PUSH_TARGET_FETCH_TIMEOUT_MS } + ) + } + const restored = await restoreUpstreamAfterMaterialize(execGit, repoPath, target) + // Why: a remote another worktree minted is still Orca-owned. Without stamping ownership on + // the adopting worktree too, removing the minter leaves the survivor's metadata unowned and + // #17842's sweep -- which gates solely on `remoteCreated` -- can never reclaim the remote. + // Why derive: no caller supplies both -- IPC handlers pass a store with no repo id, runtime + // commands pass a repo id with no store -- so requiring both made this branch unreachable. + const ownerRepoId = repoId ?? (worktreeId ? getRepoIdFromWorktreeId(worktreeId) : undefined) + const owned = + store !== undefined && + ownerRepoId !== undefined && + isPushTargetRemoteCreatedByKnownWorktree(store, restored, ownerRepoId) + const adopted = owned ? { ...restored, remoteCreated: true } : restored + persistMaterializedPushTargetIfCreated(store, worktreeId, adopted) + return adopted +} + +// Why: the mint single flight only covers `remote add`. Every adopter afterwards writes +// `remote..fetch` and `.tagOpt`, and concurrent `git config --add` has no lock retry -- +// measured 135/160 failures at 8-way concurrency, plus duplicate refspecs when two adopts add +// the same value. Chain adopts per remote so they serialize instead of fanning out. +const forkRemoteAdoptionQueue = new Map>() + +function runForkRemoteAdoption( + repoPath: string, + target: GitPushTarget, + run: () => Promise +): Promise { + const key = worktreePushTargetMaterializeKey(repoPath, target.remoteName) + const previous = forkRemoteAdoptionQueue.get(key) + const next = previous ? previous.then(run, run) : run() + const settled = next.then( + () => undefined, + () => undefined + ) + forkRemoteAdoptionQueue.set(key, settled) + void settled.finally(() => { + if (forkRemoteAdoptionQueue.get(key) === settled) { + forkRemoteAdoptionQueue.delete(key) + } + }) + return next +} + +// Why (review follow-up): on-demand materialization never went through the create-time +// `setWorktreeMeta` write, so the store's `pushTarget.remoteCreated` flag stayed stale +// forever for a lazily-minted remote -- invisible to #17842's orphan sweep +// (`shouldReclaimPrRemote` gates solely on that flag) and to any SSH host whose relay +// predates `markRemoteOrcaCreated` (no git-config marker either). `setWorktreeMeta` is +// optional on `WorktreePushTargetStore` so narrow test/reconciliation stores keep compiling. +function persistMaterializedPushTargetIfCreated( + store: WorktreePushTargetStore | undefined, + worktreeId: string | undefined, + target: GitPushTarget +): void { + if (!target.remoteCreated || !worktreeId || !store?.setWorktreeMeta) { + return + } + store.setWorktreeMeta(worktreeId, { pushTarget: target }) +} + function isPushTargetRemoteCreatedByKnownWorktree( store: WorktreePushTargetStore, target: GitPushTarget, @@ -1062,7 +1269,7 @@ export async function configureCreatedWorktreePushTarget( ) } -async function prepareWorktreePushTargetSsh( +export async function prepareWorktreePushTargetSsh( provider: SshGitProvider, repoPath: string, target: GitPushTarget, @@ -1106,8 +1313,18 @@ async function prepareWorktreePushTargetSsh( } throw error } - remoteCreated = true remoteAddedHere = true + try { + // Why: repo-local provenance mirroring the local path (worktree-push-target-setup.ts). + // A narrow RPC, not provider.exec: the relay's generic git.exec blocks all config writes. + await provider.markRemoteOrcaCreated(repoPath, remoteName) + } catch (error) { + // Why: a remote with no provenance marker is unreclaimable -- cleanup only + // runs off that marker, so a failure here must undo the add. + await provider.exec(['remote', 'remove', remoteName], repoPath).catch(() => {}) + throw error + } + remoteCreated = true } } try { @@ -1129,6 +1346,96 @@ async function prepareWorktreePushTargetSsh( return { ...sanitizedTarget, remoteName, ...(remoteCreated ? { remoteCreated: true } : {}) } } +// SSH twin of `adoptExistingForkRemoteForBranch`. Refspec widening is intentionally absent -- +// SSH's bare `remote add` (no `-t`/`--no-tags`) is a pre-existing, documented gap -- but the +// tracking ref must still exist before `--set-upstream-to` can succeed, and the upstream link +// must be made against *this* target's branch rather than a minting sibling's. +async function adoptExistingSshForkRemoteForBranch( + provider: SshGitProvider, + execGit: GitRemoteExec, + repoPath: string, + target: GitPushTarget, + store: WorktreePushTargetStore | undefined, + worktreeId: string | undefined +): Promise { + if ( + !(await forkRemoteTrackingRefExists(execGit, repoPath, target.remoteName, target.branchName)) + ) { + await provider.fetchRemoteTrackingRef( + repoPath, + target.remoteName, + target.branchName, + `refs/remotes/${target.remoteName}/${target.branchName}` + ) + } + const restored = await restoreUpstreamAfterMaterialize(execGit, repoPath, target) + const ownerRepoId = worktreeId ? getRepoIdFromWorktreeId(worktreeId) : undefined + const owned = + store !== undefined && + ownerRepoId !== undefined && + isPushTargetRemoteCreatedByKnownWorktree(store, restored, ownerRepoId) + const adopted = owned ? { ...restored, remoteCreated: true } : restored + persistMaterializedPushTargetIfCreated(store, worktreeId, adopted) + return adopted +} + +// SSH twin of `materializeWorktreePushTargetRemote` -- the relay has no store +// access and trusts `pushTarget.remoteName` already exists, so a deferred fork +// remote must be materialized client-side before dispatching push/pull/fetch/ +// fast-forward over the mux (#17828). +export async function materializeWorktreePushTargetRemoteSsh( + provider: SshGitProvider, + repoPath: string, + target: GitPushTarget, + store?: WorktreePushTargetStore, + repoId?: string, + worktreeId?: string +): Promise { + if (!target.remoteUrl || target.remoteCreated) { + return target + } + const execGit: GitRemoteExec = (args, cwd) => provider.exec(args, cwd) + if (await remoteAlreadyMatchesUrl(execGit, repoPath, target.remoteName, target.remoteUrl)) { + // Why (review follow-up): mirrors the local short-circuit's upstream restore. Refspec + // widening is intentionally NOT mirrored here -- SSH's bare `remote add` (no `-t`/ + // `--no-tags`, see prepareWorktreePushTargetSsh) is a pre-existing, documented gap this + // fix does not touch. + // + // The tracking ref itself, though, must still exist before `--set-upstream-to` below + // can succeed -- a reused remote's wide default refspec covers a future bare fetch, + // but imports nothing on its own. Fetch just this branch (a one-off refspec argument, + // not a config write) when it isn't already there; skip it otherwise so a repeat + // push/pull materialize stays a local-only probe with no relay round-trip. + return runForkRemoteAdoption(repoPath, target, () => + adoptExistingSshForkRemoteForBranch(provider, execGit, repoPath, target, store, worktreeId) + ) + } + const inflight = getSshWorktreePushTargetMaterializeInflight(provider) + const key = worktreePushTargetMaterializeKey(repoPath, target.remoteName) + const existing = inflight.get(key) + if (existing) { + // Why: same per-branch reasoning as the local twin -- a joiner must not inherit the + // minter's branch. Rejection propagates rather than adopting a remote the rollback removed. + await existing + return runForkRemoteAdoption(repoPath, target, () => + adoptExistingSshForkRemoteForBranch(provider, execGit, repoPath, target, store, worktreeId) + ) + } + const promise = prepareWorktreePushTargetSsh(provider, repoPath, target, store, repoId) + .then((prepared) => restoreUpstreamAfterMaterialize(execGit, repoPath, prepared)) + .then((prepared) => { + persistMaterializedPushTargetIfCreated(store, worktreeId, prepared) + return prepared + }) + .finally(() => { + if (inflight.get(key) === promise) { + inflight.delete(key) + } + }) + inflight.set(key, promise) + return promise +} + export async function cleanupUnusedWorktreePushTargetRemoteSsh( provider: SshGitProvider, repoPath: string, @@ -1763,17 +2070,9 @@ export async function createRemoteWorktree( } } - let preparedPushTarget: GitPushTarget | undefined - if (args.pushTarget) { - // Why: fork-PR SSH worktrees need contributor-remote setup before create, else Push/Sync target origin. - preparedPushTarget = await prepareWorktreePushTargetSsh( - provider, - repo.path, - args.pushTarget, - store, - repo.id - ) - } + // Why: defer the remote add + fetch to first push/pull/fetch/fast-forward + // (#17828) instead of paying it at create time for a read-only review. + const preparedPushTarget: GitPushTarget | undefined = args.pushTarget try { await timing.time('git_worktree_add', async () => @@ -1854,8 +2153,10 @@ export async function createRemoteWorktree( const now = Date.now() // Why: PR/MR worktrees start from a head ref/SHA but Source Control must compare against the review target branch. const metadataBaseRef = args.compareBaseRef ?? remoteTrackingBase?.ref ?? baseBranch - let configuredPushTarget: GitPushTarget | undefined - if (preparedPushTarget) { + // Why: `--set-upstream-to` needs the remote to exist -- true for a same-repo + // target but not for a fork remote, which materializes lazily (#17828). + let configuredPushTarget: GitPushTarget | undefined = preparedPushTarget + if (preparedPushTarget && !preparedPushTarget.remoteUrl) { configuredPushTarget = await configureCreatedWorktreePushTargetWithExec( (args, cwd) => provider.exec(args, cwd), created.path, @@ -2189,130 +2490,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) { @@ -2360,17 +2670,9 @@ export async function createLocalWorktree( } emitCreateWorktreeProgress(mainWindow, 'creating', args.creationId) - let preparedPushTarget: GitPushTarget | undefined - if (args.pushTarget) { - // 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 - ) - } + // Why: defer the remote add + fetch to first push/pull/fetch/fast-forward + // (#17828) instead of paying it at create time for a read-only review. + const preparedPushTarget: GitPushTarget | undefined = args.pushTarget const suggestLocalBaseRefUpdate = !settings.refreshLocalBaseRefOnWorktreeCreate && @@ -2390,7 +2692,7 @@ export async function createLocalWorktree( addResult = (await timing.time('git_worktree_add', async () => { if (sparseDirectories.length === 0 && !checkoutExistingBranch) { - const preparedResult = await consumePreparedWorktreeCreate({ + const prepared = await consumePreparedWorktreeCreate({ repoPath: repo.path, workspaceRoot, worktreePath, @@ -2399,9 +2701,19 @@ export async function createLocalWorktree( refreshLocalBaseRef: settings.refreshLocalBaseRefOnWorktreeCreate, ...(preparedWorktreeOptions ? { options: preparedWorktreeOptions } : {}) }) - if (preparedResult) { - return preparedResult + timing.recordPreparedCheckout( + prepared.status === 'hit' + ? { status: 'hit', retargeted: prepared.retargeted } + : { status: 'miss', reason: prepared.reason } + ) + if (prepared.status === 'hit') { + return prepared.result } + } else { + timing.recordPreparedCheckout({ + status: 'miss', + reason: sparseDirectories.length > 0 ? 'sparse_checkout' : 'checkout_existing_branch' + }) } if (sparseDirectories.length > 0) { if (checkoutExistingBranch) { @@ -2500,9 +2812,10 @@ export async function createLocalWorktree( await retireGeneratedWorktreeName(store, repo, settings, effectiveSanitizedName) } - let configuredPushTarget: GitPushTarget | undefined - if (preparedPushTarget) { - // Why: fork-PR review worktrees publish back to the PR author's branch; set upstream so Push/Sync use the contributor remote, not origin. + // Why: `--set-upstream-to` needs the remote to exist -- true for a same-repo + // target but not for a fork remote, which materializes lazily (#17828). + let configuredPushTarget: GitPushTarget | undefined = preparedPushTarget + if (preparedPushTarget && !preparedPushTarget.remoteUrl) { configuredPushTarget = await configureCreatedWorktreePushTarget( worktreePath, branchName, diff --git a/src/main/ipc/worktrees-create-metadata-persistence.test.ts b/src/main/ipc/worktrees-create-metadata-persistence.test.ts index 934844de2e3..ac003b3a43e 100644 --- a/src/main/ipc/worktrees-create-metadata-persistence.test.ts +++ b/src/main/ipc/worktrees-create-metadata-persistence.test.ts @@ -427,7 +427,10 @@ describe('registerWorktreeHandlers', () => { }) }) - it('configures a PR push target during local create', async () => { + // Was "configures a PR push target during local create": create used to mint the + // fork remote up front. It now defers to first sync (#17828); the minting itself is + // covered by worktree-remote-push-target-materialization.test.ts. + it('defers the fork-PR remote during local create and persists the target unmaterialized', async () => { listWorktreesMock.mockResolvedValue([ { path: '/workspace/improve-dashboard', @@ -449,7 +452,7 @@ describe('registerWorktreeHandlers', () => { } }) - expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + expect(gitExecFileAsyncMock).not.toHaveBeenCalledWith( [ 'remote', 'add', @@ -461,7 +464,7 @@ describe('registerWorktreeHandlers', () => { ], { cwd: '/workspace/repo' } ) - expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + expect(gitExecFileAsyncMock).not.toHaveBeenCalledWith( [ 'fetch', 'pr-prateek-orca', @@ -469,7 +472,8 @@ describe('registerWorktreeHandlers', () => { ], { cwd: '/workspace/repo' } ) - expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + // Upstream can only be set once the remote exists, so it defers with the remote. + expect(gitExecFileAsyncMock).not.toHaveBeenCalledWith( [ 'branch', '--set-upstream-to', @@ -478,20 +482,25 @@ describe('registerWorktreeHandlers', () => { ], { cwd: '/workspace/improve-dashboard' } ) + // Exact object, not objectContaining: `remoteCreated` must stay absent until + // something actually mints the remote. expect(store.setWorktreeMeta).toHaveBeenCalledWith( 'repo-1::/workspace/improve-dashboard', expect.objectContaining({ - pushTarget: expect.objectContaining({ + pushTarget: { remoteName: 'pr-prateek-orca', branchName: 'prateek/fix-sidebar-agents-toggle', - remoteUrl: 'git@github.com:prateek/orca.git', - remoteCreated: true - }) + remoteUrl: 'git@github.com:prateek/orca.git' + } }) ) }) - it('keeps the Orca-created marker when a new worktree reuses an Orca-created fork remote', async () => { + // Was "keeps the Orca-created marker ...": create used to inherit the marker while + // minting. With minting deferred (#17828) create must not claim ownership it has not + // earned; marker inheritance now happens at materialization and is covered by + // worktree-push-target-setup.test.ts. + it('does not claim the Orca-created marker at create when a sibling worktree minted the fork remote', async () => { listWorktreesMock.mockResolvedValue([ { path: '/workspace/improve-dashboard', @@ -538,12 +547,11 @@ describe('registerWorktreeHandlers', () => { expect(store.setWorktreeMeta).toHaveBeenCalledWith( 'repo-1::/workspace/improve-dashboard', expect.objectContaining({ - pushTarget: expect.objectContaining({ + pushTarget: { remoteName: 'pr-contributor-orca', branchName: 'contributor/new-fix', - remoteUrl: 'https://github.com/contributor/orca.git', - remoteCreated: true - }) + remoteUrl: 'https://github.com/contributor/orca.git' + } }) ) }) diff --git a/src/main/ipc/worktrees-local-create-flow.test.ts b/src/main/ipc/worktrees-local-create-flow.test.ts index d01efc489da..abc711bbd9b 100644 --- a/src/main/ipc/worktrees-local-create-flow.test.ts +++ b/src/main/ipc/worktrees-local-create-flow.test.ts @@ -633,7 +633,10 @@ describe('registerWorktreeHandlers', () => { })) as { setup?: unknown startupTerminal?: { spawned: boolean; surface?: string } - timing?: { phases: { phase: string }[] } + timing?: { + phases: { phase: string }[] + preparedCheckout?: { status: string; reason?: string } + } } expect(createSetupRunnerScriptMock).toHaveBeenCalledWith( expect.objectContaining({ id: 'repo-1' }), @@ -695,6 +698,8 @@ describe('registerWorktreeHandlers', () => { 'spawn_startup_terminal' ]) ) + // Nothing warmed this repo, so the create must report the cold path rather than stay silent. + expect(result.timing?.preparedCheckout).toEqual({ status: 'miss', reason: 'none_armed' }) }) it('returns the wrapped setup command when startup spawned but setup creation failed', async () => { diff --git a/src/main/ipc/worktrees-setup-launch-sparse-checkout.test.ts b/src/main/ipc/worktrees-setup-launch-sparse-checkout.test.ts index 0996df03cb4..725f2df8c1f 100644 --- a/src/main/ipc/worktrees-setup-launch-sparse-checkout.test.ts +++ b/src/main/ipc/worktrees-setup-launch-sparse-checkout.test.ts @@ -205,14 +205,14 @@ describe('registerWorktreeHandlers', () => { } ]) - const result = await handlers['worktrees:create'](null, { + const result = (await handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'improve-dashboard', sparseCheckout: { directories: [' packages/web ', 'apps\\api\\', 'packages/web/'], presetId: 'preset-1' } - }) + })) as { timing?: { preparedCheckout?: { status: string; reason?: string } } } expect(addWorktreeMock).not.toHaveBeenCalled() expect(addSparseWorktreeMock).toHaveBeenCalledWith( @@ -240,6 +240,11 @@ describe('registerWorktreeHandlers', () => { sparsePresetId: 'preset-1' }) }) + // A sparse create can never claim a prepared checkout; say so rather than looking like a miss. + expect(result.timing?.preparedCheckout).toEqual({ + status: 'miss', + reason: 'sparse_checkout' + }) }) it('retires a generated sparse name when creation rollback also fails', async () => { diff --git a/src/main/ipc/worktrees-ssh-fork-push-target-remote.test.ts b/src/main/ipc/worktrees-ssh-fork-push-target-remote.test.ts index c952a6be9df..fe80219fa8f 100644 --- a/src/main/ipc/worktrees-ssh-fork-push-target-remote.test.ts +++ b/src/main/ipc/worktrees-ssh-fork-push-target-remote.test.ts @@ -2,6 +2,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { validateGitExecArgs } from '../../relay/git-exec-validator' import { getSshGitProviderMock, getActiveMultiplexerMock } from './worktrees-test-module-mocks' import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness' +import { materializeWorktreePushTargetRemoteSsh } from './worktree-remote' +import type { SshGitProvider } from '../providers/ssh-git-provider' vi.mock('electron', async () => (await import('./worktrees-test-module-mocks')).electronModuleMock() @@ -90,7 +92,10 @@ describe('registerWorktreeHandlers', () => { setupWorktreeHandlers() }) - it('adds the fork remote for an SSH fork-PR worktree through git.exec', async () => { + // Was "adds the fork remote ... through git.exec": create used to mint the fork + // remote unconditionally. It now defers to first sync (#17828) -- split in two so + // each half stays true to a single claim: create stays a no-op, sync still mints. + it('defers minting the fork remote for an SSH fork-PR worktree until first sync', async () => { const repo = { id: 'repo-ssh', path: '/remote/repo', @@ -147,11 +152,13 @@ describe('registerWorktreeHandlers', () => { } }) - expect(exec).toHaveBeenCalledWith( + expect(exec).not.toHaveBeenCalledWith( ['remote', 'add', 'pr-contributor-orca', 'https://github.com/contributor/orca.git'], '/remote/repo' ) - expect(provider.fetchRemoteTrackingRef).toHaveBeenCalledWith( + // fetchRemoteTrackingRef IS called once here, but for create's unrelated + // base-ref refresh (origin/main) -- not for the fork remote, which defers. + expect(provider.fetchRemoteTrackingRef).not.toHaveBeenCalledWith( '/remote/repo', 'pr-contributor-orca', 'contributor/fix', @@ -163,201 +170,58 @@ describe('registerWorktreeHandlers', () => { pushTarget: { remoteName: 'pr-contributor-orca', branchName: 'contributor/fix', - remoteUrl: 'https://github.com/contributor/orca.git', - remoteCreated: true + remoteUrl: 'https://github.com/contributor/orca.git' } }) ) }) - it('names the relay upgrade when an older host still rejects the fork remote', async () => { - const repo = { - id: 'repo-ssh', - path: '/remote/repo', - displayName: 'ssh', - badgeColor: '#000', - addedAt: 0, - connectionId: 'conn-1', - worktreeBaseRef: 'origin/main' - } - const provider = { - exec: vi.fn().mockImplementation(async (args: string[]) => { - if (args[0] === 'remote' && args[1] === 'add') { - throw new Error('Destructive git remote operations are not allowed via exec') - } - if (args[0] === 'remote' && args[1] === 'get-url') { - return { stdout: 'git@github.com:stablyai/orca.git\n', stderr: '' } - } - if (args[0] === 'remote' && args.length === 1) { - return { stdout: 'origin\n', stderr: '' } - } - if (args[0] === 'show-ref') { - throw Object.assign(new Error('missing exact ref'), { code: 1 }) - } - return { stdout: '', stderr: '' } - }), - fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), - addWorktree: vi.fn().mockResolvedValue(undefined), - listWorktrees: vi.fn().mockResolvedValue([]) - } - const mux = { request: vi.fn().mockResolvedValue(undefined), notify: vi.fn() } - store.getRepos.mockReturnValue([repo]) - store.getRepo.mockReturnValue(repo) - getSshGitProviderMock.mockReturnValue(provider) - getActiveMultiplexerMock.mockReturnValue(mux) - - await expect( - handlers['worktrees:create'](null, { - repoId: 'repo-ssh', - name: 'contributor-fix', - branchNameOverride: 'contributor/fix', - pushTarget: { - remoteName: 'pr-contributor-orca', - branchName: 'contributor/fix', - remoteUrl: 'https://github.com/contributor/orca.git' - } - }) - ).rejects.toThrow('Reconnect to deploy the latest relay') - expect(provider.addWorktree).not.toHaveBeenCalled() - }) - - it('drops the fork remote it just added when the SSH head fetch fails', async () => { - const repo = { - id: 'repo-ssh', - path: '/remote/repo', - displayName: 'ssh', - badgeColor: '#000', - addedAt: 0, - connectionId: 'conn-1', - worktreeBaseRef: 'origin/main' - } + // Companion to the deferral test above: `materializeWorktreePushTargetRemoteSsh` is + // exactly what `git:push`/`git:pull`'s SSH dispatch calls before syncing, so this is + // "first sync" without needing the sync IPC handlers registered in this harness. + it('mints the fork remote for an SSH fork-PR worktree on first sync', async () => { const exec = vi.fn().mockImplementation(async (args: string[]) => { validateGitExecArgs(args) if (args[0] === 'remote' && args[1] === 'get-url') { - return { stdout: 'git@github.com:stablyai/orca.git\n', stderr: '' } + throw new Error('No such remote') } if (args[0] === 'remote' && args.length === 1) { return { stdout: 'origin\n', stderr: '' } } - if (args[0] === 'show-ref') { - throw Object.assign(new Error('missing exact ref'), { code: 1 }) - } return { stdout: '', stderr: '' } }) - const provider = { - exec, - fetchRemoteTrackingRef: vi - .fn() - .mockImplementation(async (_repoPath: string, remote: string) => { - if (remote === 'pr-contributor-orca') { - throw new Error('network unreachable') - } - }), - addWorktree: vi.fn().mockResolvedValue(undefined), - listWorktrees: vi.fn().mockResolvedValue([]) + const fetchRemoteTrackingRef = vi.fn().mockResolvedValue(undefined) + const markRemoteOrcaCreated = vi.fn().mockResolvedValue(undefined) + const target = { + remoteName: 'pr-contributor-orca', + branchName: 'contributor/fix', + remoteUrl: 'https://github.com/contributor/orca.git' } - const mux = { request: vi.fn().mockResolvedValue(undefined), notify: vi.fn() } - store.getRepos.mockReturnValue([repo]) - store.getRepo.mockReturnValue(repo) - getSshGitProviderMock.mockReturnValue(provider) - getActiveMultiplexerMock.mockReturnValue(mux) - await expect( - handlers['worktrees:create'](null, { - repoId: 'repo-ssh', - name: 'contributor-fix', - branchNameOverride: 'contributor/fix', - pushTarget: { - remoteName: 'pr-contributor-orca', - branchName: 'contributor/fix', - remoteUrl: 'https://github.com/contributor/orca.git' - } - }) - ).rejects.toThrow('network unreachable') - - expect(exec).toHaveBeenCalledWith(['remote', 'remove', 'pr-contributor-orca'], '/remote/repo') - expect(provider.addWorktree).not.toHaveBeenCalled() - }) - - // Regression: the rollback used to fire on ownership inherited from a sibling - // worktree, deleting the remote that worktree was still pushing through. - it('keeps a reused fork remote a sibling worktree owns when the SSH head fetch fails', async () => { - const repo = { - id: 'repo-ssh', - path: '/remote/repo', - displayName: 'ssh', - badgeColor: '#000', - addedAt: 0, - connectionId: 'conn-1', - worktreeBaseRef: 'origin/main' - } - const exec = vi.fn().mockImplementation(async (args: string[]) => { - validateGitExecArgs(args) - if (args[0] === 'remote' && args[1] === 'get-url') { - return { - stdout: - args[2] === 'pr-contributor-orca' - ? 'git@github.com:contributor/orca.git\n' - : 'git@github.com:stablyai/orca.git\n', - stderr: '' - } - } - if (args[0] === 'remote' && args.length === 1) { - return { stdout: 'origin\npr-contributor-orca\n', stderr: '' } - } - if (args[0] === 'show-ref') { - throw Object.assign(new Error('missing exact ref'), { code: 1 }) - } - return { stdout: '', stderr: '' } - }) - const provider = { - exec, - fetchRemoteTrackingRef: vi - .fn() - .mockImplementation(async (_repoPath: string, remote: string) => { - if (remote === 'pr-contributor-orca') { - throw new Error('network unreachable') - } - }), - addWorktree: vi.fn().mockResolvedValue(undefined), - listWorktrees: vi.fn().mockResolvedValue([]) - } - const mux = { request: vi.fn().mockResolvedValue(undefined), notify: vi.fn() } - store.getRepos.mockReturnValue([repo]) - store.getRepo.mockReturnValue(repo) - store.getAllWorktreeMeta.mockReturnValue({ - 'repo-ssh::/remote/repo-sibling': { - pushTarget: { - remoteName: 'pr-contributor-orca', - branchName: 'contributor/other', - remoteUrl: 'https://github.com/contributor/orca.git', - remoteCreated: true - } - } - }) - getSshGitProviderMock.mockReturnValue(provider) - getActiveMultiplexerMock.mockReturnValue(mux) - - await expect( - handlers['worktrees:create'](null, { - repoId: 'repo-ssh', - name: 'contributor-fix', - branchNameOverride: 'contributor/fix', - pushTarget: { - remoteName: 'pr-contributor-orca', - branchName: 'contributor/fix', - remoteUrl: 'https://github.com/contributor/orca.git' - } - }) - ).rejects.toThrow('network unreachable') - - expect(exec).not.toHaveBeenCalledWith( - ['remote', 'remove', 'pr-contributor-orca'], - '/remote/repo' + const result = await materializeWorktreePushTargetRemoteSsh( + { exec, fetchRemoteTrackingRef, markRemoteOrcaCreated } as unknown as SshGitProvider, + '/remote/repo', + target ) - expect(exec).not.toHaveBeenCalledWith( + + expect(result).toEqual({ ...target, remoteCreated: true }) + expect(exec).toHaveBeenCalledWith( ['remote', 'add', 'pr-contributor-orca', 'https://github.com/contributor/orca.git'], '/remote/repo' ) + expect(fetchRemoteTrackingRef).toHaveBeenCalledWith( + '/remote/repo', + 'pr-contributor-orca', + 'contributor/fix', + 'refs/remotes/pr-contributor-orca/contributor/fix' + ) + expect(markRemoteOrcaCreated).toHaveBeenCalledWith('/remote/repo', 'pr-contributor-orca') }) + + // The relay-upgrade-messaging, fetch-failure rollback, and sibling-remote-preserved + // cases used to be exercised here because create minted the remote unconditionally. + // That code (prepareWorktreePushTargetSsh) is unchanged -- it just no longer runs at + // create time for a fork remote, only from materializeWorktreePushTargetRemoteSsh on + // first sync. Coverage for all three moved with it to + // worktree-remote-push-target-materialization.test.ts, which calls that function directly. }) 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-wsl-runtime-routing.test.ts b/src/main/ipc/worktrees-wsl-runtime-routing.test.ts index 85a015496ed..8c936764291 100644 --- a/src/main/ipc/worktrees-wsl-runtime-routing.test.ts +++ b/src/main/ipc/worktrees-wsl-runtime-routing.test.ts @@ -17,6 +17,7 @@ import { gitExecFileAsyncMock } from './worktrees-test-module-mocks' import { handlers, harnessRepo, setupWorktreeHandlers, store } from './worktrees-test-harness' +import { materializeWorktreePushTargetRemote } from './worktree-remote' import type { WorktreeRuntimeStub } from './worktrees-test-runtime-stub' import { createdWorktreeList, @@ -243,7 +244,11 @@ describe('registerWorktreeHandlers', () => { expectEveryGitCallRoutedTo('Ubuntu') }) - it('routes fork push target setup through the selected WSL project runtime', async () => { + // Was "routes fork push target setup ... through create": create used to mint the + // fork remote (and route it to the selected WSL distro) unconditionally. It now + // defers to first sync (#17828) -- split in two so each half stays true to a single + // claim: create stays a no-op even for a WSL-routed repo, sync still routes to the distro. + it('does not mint a fork remote at create time for a WSL-routed worktree', async () => { mockSelectedWslProjectRuntime() listWorktreesMock.mockResolvedValue([ { @@ -266,6 +271,43 @@ describe('registerWorktreeHandlers', () => { } }) + const calls = gitExecFileAsyncMock.mock.calls.map((call) => call[0] as string[]) + expect(calls).not.toContainEqual(['check-ref-format', '--branch', 'contributor/wsl-fork']) + expect(calls.some((args) => args[0] === 'remote' && args[1] === 'add')).toBe(false) + expect(calls.some((args) => args[0] === 'fetch')).toBe(false) + expect(store.setWorktreeMeta).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + pushTarget: { + remoteName: 'pr-contributor-orca', + branchName: 'contributor/wsl-fork', + remoteUrl: 'git@github.com:contributor/orca.git' + } + }) + ) + }) + + // Companion to the deferral test above: `materializeWorktreePushTargetRemote` is + // exactly what `git:push`/`git:pull`'s local dispatch calls (with the repo's resolved + // wslDistro) before syncing, so this is "first sync" without needing that IPC handler + // registered in this harness. + it('routes fork push target materialization through the selected WSL project runtime', async () => { + gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }) + const target = { + remoteName: 'pr-contributor-orca', + branchName: 'contributor/wsl-fork', + remoteUrl: 'git@github.com:contributor/orca.git' + } + + const result = await materializeWorktreePushTargetRemote( + '/workspace/repo', + target, + undefined, + undefined, + { wslDistro: 'Ubuntu' } + ) + + expect(result).toEqual({ ...target, remoteCreated: true }) const wslRoutingOptions = { cwd: '/workspace/repo', wslDistro: 'Ubuntu' } expect(gitExecFileAsyncMock).toHaveBeenCalledWith( ['check-ref-format', '--branch', 'contributor/wsl-fork'], @@ -303,18 +345,29 @@ describe('registerWorktreeHandlers', () => { ['config', 'remote.pr-contributor-orca.tagOpt', '--no-tags'], wslRoutingOptions ) + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + ['config', 'remote.pr-contributor-orca.orca-created', 'true'], + wslRoutingOptions + ) + // Why: the mint's fetch is the one call in this sequence that talks to the network -- + // bounded the same as the deferred short-circuit's fetch (see DEFERRED_PUSH_TARGET_FETCH_TIMEOUT_MS) + // so a hung credential prompt can't wedge it forever. Every other call here is local-only + // and stays untimed, per `wslRoutingOptions` above. expect(gitExecFileAsyncMock).toHaveBeenCalledWith( [ 'fetch', 'pr-contributor-orca', '+refs/heads/contributor/wsl-fork*:refs/remotes/pr-contributor-orca/contributor/wsl-fork*' ], - wslRoutingOptions + { ...wslRoutingOptions, timeout: expect.any(Number) } ) - expect(gitExecFileAsyncMock).toHaveBeenCalledWith( - ['branch', '--set-upstream-to', 'pr-contributor-orca/contributor/wsl-fork', 'wsl-fork'], - { cwd: '/workspace/wsl-fork', wslDistro: 'Ubuntu' } + // wslDistro threaded through every subprocess this materialize made, not just the adds. + const distros = new Set( + gitExecFileAsyncMock.mock.calls.map( + ([, options]) => (options as { wslDistro?: string } | undefined)?.wslDistro + ) ) + expect(distros).toEqual(new Set(['Ubuntu'])) }) it('routes selected PR branch conflict lookup through the selected WSL project runtime', async () => { diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index ff58a561ee3..3fd2fb41908 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -17,7 +17,10 @@ import { registerSparseCheckoutCacheInvalidation } from './worktrees/listing/reg import { registerWorktreeMetadataHandlers } from './worktrees/metadata/register-worktree-metadata-handlers' import { registerWorktreeForgetHandlers } from './worktrees/removal/register-worktree-forget-handlers' import { registerWorktreeRemovalHandlers } from './worktrees/removal/register-worktree-removal-handlers' -import type { WorktreeIpcContext } from './worktrees/worktree-ipc-context' +import { + createWorktreeRemovalRegistry, + type WorktreeIpcContext +} from './worktrees/worktree-ipc-context' registerDetectedWorktreeScanInvalidation() @@ -66,7 +69,7 @@ export function registerWorktreeHandlers( runtime, ...(options ? { options } : {}), detectedWorktreeCancellations: createSenderScopedRequestCancellations(), - worktreeRemovalsInFlight: new Map() + worktreeRemovalsInFlight: createWorktreeRemovalRegistry() } // Remove all stale registrations before installing any replacement handler. 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/authoritative-local-worktree-metadata-pruning.ts b/src/main/ipc/worktrees/listing/authoritative-local-worktree-metadata-pruning.ts index c5ceb1acafb..ca5c3019844 100644 --- a/src/main/ipc/worktrees/listing/authoritative-local-worktree-metadata-pruning.ts +++ b/src/main/ipc/worktrees/listing/authoritative-local-worktree-metadata-pruning.ts @@ -85,7 +85,12 @@ export async function pruneMetadataMissingFromAuthoritativeLocalScan({ worktreeRetentionPathComparisonKey(repo.path, platform), ...gitWorktrees.map((worktree) => worktreeRetentionPathComparisonKey(worktree.path, platform)) ]) - const probeCandidates = scan.metadata.flatMap((metadata) => { + // Why: only rows a delete could still accept are worth a filesystem probe. This is advisory — + // `pruneSessionlessMissingLocalWorktreeMetadataForRepo` re-checks authoritatively — so it can only + // ever shrink the `stat` fan-out, never widen what gets removed (#17775). + const removableCandidates = + store.selectProbeableLocalWorktreeMetadataCandidates?.(scan) ?? scan.metadata + const probeCandidates = removableCandidates.flatMap((metadata) => { const { worktreeId } = metadata const parsed = splitWorktreeId(worktreeId) const nativeAbsolute = parsed diff --git a/src/main/ipc/worktrees/listing/detected-provider-listing.ts b/src/main/ipc/worktrees/listing/detected-provider-listing.ts index 45ccd4183bb..51a0618ba66 100644 --- a/src/main/ipc/worktrees/listing/detected-provider-listing.ts +++ b/src/main/ipc/worktrees/listing/detected-provider-listing.ts @@ -51,6 +51,7 @@ export async function listDetectedWorktreesForCapturedRepo( let freshScan = true let sideEffectToken: DetectedWorktreeSideEffectToken | undefined let metadataPrune: DetectedWorktreeMetadataPrune | undefined + let hygieneDue: boolean | undefined if (isFolderRepo(repo)) { if (!isCurrent()) { return null @@ -102,6 +103,7 @@ export async function listDetectedWorktreesForCapturedRepo( freshScan = scan.fresh sideEffectToken = scan.sideEffectToken metadataPrune = scan.metadataPrune + hygieneDue = scan.hygieneDue } const aborted = abortedResult() if (aborted) { @@ -123,7 +125,8 @@ export async function listDetectedWorktreesForCapturedRepo( await applyFreshDetectedWorktreeScanSideEffects(store, repo, gitWorktrees, metadataPrune, { isCurrent: () => isCurrent() && !providerAbort?.signal.aborted, sideEffectToken, - signal: providerAbort?.signal + signal: providerAbort?.signal, + ...(hygieneDue === undefined ? {} : { hygieneDue }) }) const aborted = abortedResult() if (aborted) { diff --git a/src/main/ipc/worktrees/listing/detected-worktree-scan-cache.ts b/src/main/ipc/worktrees/listing/detected-worktree-scan-cache.ts index fea200f1a8b..b694bfbf11d 100644 --- a/src/main/ipc/worktrees/listing/detected-worktree-scan-cache.ts +++ b/src/main/ipc/worktrees/listing/detected-worktree-scan-cache.ts @@ -16,6 +16,13 @@ import { resetLocalWorktreeScanGenerationsForTests } from '../../../local-worktree-scan-generation' import { pruneLineageForMissingRepoWorktrees } from '../../../worktree-lineage-pruning' +import { + __resetLocalWorktreeMetadataPruneGateForTests, + isLocalWorktreeMetadataPruneDue, + markLocalWorktreeMetadataPruneStarted, + recordLocalWorktreeListingForPruneGate, + requireLocalWorktreeMetadataPrune +} from '../../../local-worktree-metadata-prune-gate' import { pruneMetadataMissingFromAuthoritativeLocalScan } from './authoritative-local-worktree-metadata-pruning' // Why: absorb renderer polling bursts while bounding external worktree-change lag to one short refresh window. @@ -30,6 +37,7 @@ export type DetectedWorktreeScan = { invalidated: boolean promise: Promise sideEffectToken: DetectedWorktreeSideEffectToken + hygieneDue: boolean metadataPrune?: DetectedWorktreeMetadataPrune } @@ -46,6 +54,8 @@ export type DetectedWorktreeScanResult = { gitWorktrees: GitWorktreeInfo[] fresh: boolean sideEffectToken?: DetectedWorktreeSideEffectToken + /** Whether this scan owns the repo's next store-hygiene pass; absent means "not from a local scan". */ + hygieneDue?: boolean metadataPrune?: DetectedWorktreeMetadataPrune } @@ -54,6 +64,7 @@ export const detectedWorktreeScanInFlight = new Map worktree.path) + ) const routingUnchanged = getDetectedWorktreeScanCacheKey(repo.id, getLocalProjectWorktreeGitOptions(store, repo)) === cacheKey @@ -153,7 +179,7 @@ export async function listDetectedGitWorktrees( return { gitWorktrees, fresh, - ...(fresh ? { sideEffectToken: scan.sideEffectToken } : {}), + ...(fresh ? { sideEffectToken: scan.sideEffectToken, hygieneDue: scan.hygieneDue } : {}), ...(fresh && scan.metadataPrune ? { metadataPrune: scan.metadataPrune } : {}) } } finally { @@ -172,9 +198,11 @@ export async function applyFreshDetectedWorktreeScanSideEffects( isCurrent?: () => boolean sideEffectToken?: DetectedWorktreeSideEffectToken signal?: AbortSignal + /** Undefined means the caller owns no cadence (non-local providers); it keeps the eager behavior. */ + hygieneDue?: boolean } = {} ): Promise { - const { isCurrent = () => true, sideEffectToken, signal } = options + const { isCurrent = () => true, sideEffectToken, signal, hygieneDue = true } = options const generationCurrent = () => sideEffectToken === undefined || isLocalWorktreeScanGenerationCurrent(repo.id, sideEffectToken.generation) @@ -211,12 +239,17 @@ export async function applyFreshDetectedWorktreeScanSideEffects( return false } rememberLocalWorktreeRoots(store, repo, gitWorktrees) - pruneLineageForMissingRepoWorktrees( - store, - repo, - gitWorktrees, - preservedMetadataCandidateIds ? { preservedMetadataCandidateIds } : undefined - ) + // Why: lineage retention is decided against the metadata rows the prune preserved, so running it + // without that pass would drop lineage for rows the pass would have kept. Both halves share the + // hygiene cadence instead. + if (hygieneDue) { + pruneLineageForMissingRepoWorktrees( + store, + repo, + gitWorktrees, + preservedMetadataCandidateIds ? { preservedMetadataCandidateIds } : undefined + ) + } return true } diff --git a/src/main/ipc/worktrees/listing/detected-worktree-scan-hygiene-gate.test.ts b/src/main/ipc/worktrees/listing/detected-worktree-scan-hygiene-gate.test.ts new file mode 100644 index 00000000000..cb8c48833ef --- /dev/null +++ b/src/main/ipc/worktrees/listing/detected-worktree-scan-hygiene-gate.test.ts @@ -0,0 +1,163 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../../../shared/repo-types' +import type { GitWorktreeInfo } from '../../../../shared/worktree/types' + +const { listRepoWorktreesMock, pruneLineageMock, pruneMetadataMock, registerWorktreeRootsMock } = + vi.hoisted(() => ({ + listRepoWorktreesMock: vi.fn(), + pruneLineageMock: vi.fn(), + pruneMetadataMock: vi.fn(), + registerWorktreeRootsMock: vi.fn() + })) + +vi.mock('../../../repo-worktrees', () => ({ listRepoWorktrees: listRepoWorktreesMock })) +vi.mock('../../../project-runtime-git-options', () => ({ + getLocalProjectWorktreeGitOptions: () => ({}) +})) +vi.mock('../../registered-worktree-roots-cache', () => ({ + getRegisteredWorktreeRootsRevision: () => 1, + registerWorktreeRootsForRepo: registerWorktreeRootsMock +})) +vi.mock('../../../worktree-lineage-pruning', () => ({ + pruneLineageForMissingRepoWorktrees: pruneLineageMock +})) +vi.mock('./authoritative-local-worktree-metadata-pruning', () => ({ + pruneMetadataMissingFromAuthoritativeLocalScan: pruneMetadataMock +})) + +const { + DETECTED_WORKTREE_SCAN_CACHE_TTL_MS, + __resetDetectedWorktreeScanCacheForTests, + applyFreshDetectedWorktreeScanSideEffects, + invalidateDetectedWorktreeScanCache, + listDetectedGitWorktrees +} = await import('./detected-worktree-scan-cache') +const { invalidateLocalWorktreeMetadataPruneInputs } = + await import('../../../local-worktree-metadata-prune-gate') + +const repo = { id: 'repo-1', path: '/repos/one', displayName: 'one' } as Repo + +function worktreeAt(path: string): GitWorktreeInfo { + return { path, head: 'abc', branch: 'main', isBare: false, isMainWorktree: path === repo.path } +} + +const captureExpectation = vi.fn(() => ({ repo: { id: repo.id }, metadata: [] })) +const store = { captureNativeLocalWorktreeMetadataScanExpectation: captureExpectation } as never + +/** Each listing must miss the TTL cache, the way a renderer poll past the window does. */ +function advancePastListingTtl(): void { + vi.setSystemTime(Date.now() + DETECTED_WORKTREE_SCAN_CACHE_TTL_MS + 1) +} + +describe('detected worktree scan hygiene gate', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(1_000_000) + listRepoWorktreesMock.mockReset().mockResolvedValue([worktreeAt(repo.path)]) + captureExpectation.mockClear() + pruneLineageMock.mockReset() + pruneMetadataMock + .mockReset() + .mockResolvedValue({ scanGenerationCurrent: true, preservedMetadataCandidateIds: new Set() }) + registerWorktreeRootsMock.mockReset() + __resetDetectedWorktreeScanCacheForTests() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('runs hygiene once and then never again while nothing changes', async () => { + const first = await listDetectedGitWorktrees(store, repo) + expect(first.hygieneDue).toBe(true) + expect(first.metadataPrune).toBeDefined() + + for (let poll = 0; poll < 20; poll += 1) { + advancePastListingTtl() + const scan = await listDetectedGitWorktrees(store, repo) + expect(scan.fresh).toBe(true) + expect(scan.hygieneDue).toBe(false) + expect(scan.metadataPrune).toBeUndefined() + } + expect(captureExpectation).toHaveBeenCalledTimes(1) + }) + + it('still lists on every cache miss while hygiene is parked', async () => { + await listDetectedGitWorktrees(store, repo) + advancePastListingTtl() + await listDetectedGitWorktrees(store, repo) + + expect(listRepoWorktreesMock).toHaveBeenCalledTimes(2) + }) + + it('re-runs hygiene after a worktree lifecycle event', async () => { + await listDetectedGitWorktrees(store, repo) + invalidateDetectedWorktreeScanCache(repo.id) + + expect((await listDetectedGitWorktrees(store, repo)).hygieneDue).toBe(true) + expect(captureExpectation).toHaveBeenCalledTimes(2) + }) + + it('re-runs hygiene when ownership state may have released a row', async () => { + await listDetectedGitWorktrees(store, repo) + advancePastListingTtl() + expect((await listDetectedGitWorktrees(store, repo)).hygieneDue).toBe(false) + + invalidateLocalWorktreeMetadataPruneInputs() + + advancePastListingTtl() + expect((await listDetectedGitWorktrees(store, repo)).hygieneDue).toBe(true) + expect(captureExpectation).toHaveBeenCalledTimes(2) + }) + + it('re-runs hygiene when the listing changes with no event to report it', async () => { + await listDetectedGitWorktrees(store, repo) + advancePastListingTtl() + expect((await listDetectedGitWorktrees(store, repo)).hygieneDue).toBe(false) + + // An external `git worktree remove` nobody notified us about. + listRepoWorktreesMock.mockResolvedValue([worktreeAt(repo.path), worktreeAt('/repos/one-wt')]) + advancePastListingTtl() + await listDetectedGitWorktrees(store, repo) + + advancePastListingTtl() + expect((await listDetectedGitWorktrees(store, repo)).hygieneDue).toBe(true) + }) + + it('skips both prune halves on a scan that does not own the hygiene pass', async () => { + await applyFreshDetectedWorktreeScanSideEffects( + store, + repo, + [worktreeAt(repo.path)], + undefined, + { + hygieneDue: false + } + ) + + expect(pruneMetadataMock).not.toHaveBeenCalled() + expect(pruneLineageMock).not.toHaveBeenCalled() + // Authorized roots are listing state, not hygiene; they must still be refreshed. + expect(registerWorktreeRootsMock).toHaveBeenCalledTimes(1) + }) + + it('prunes lineage when the caller owns the hygiene pass', async () => { + await applyFreshDetectedWorktreeScanSideEffects( + store, + repo, + [worktreeAt(repo.path)], + undefined, + { + hygieneDue: true + } + ) + + expect(pruneLineageMock).toHaveBeenCalledTimes(1) + }) + + it('keeps the eager behavior for callers that carry no gate', async () => { + await applyFreshDetectedWorktreeScanSideEffects(store, repo, [worktreeAt(repo.path)]) + + expect(pruneLineageMock).toHaveBeenCalledTimes(1) + }) +}) 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/ipc/worktrees/listing/register-worktree-catalog-handlers.ts b/src/main/ipc/worktrees/listing/register-worktree-catalog-handlers.ts index 4a882ff24b7..d684461a381 100644 --- a/src/main/ipc/worktrees/listing/register-worktree-catalog-handlers.ts +++ b/src/main/ipc/worktrees/listing/register-worktree-catalog-handlers.ts @@ -91,6 +91,7 @@ export function registerWorktreeCatalogHandlers(context: WorktreeIpcContext): vo let freshScan = true let sideEffectToken: DetectedWorktreeSideEffectToken | undefined let metadataPrune: DetectedWorktreeMetadataPrune | undefined + let hygieneDue: boolean | undefined if (isFolderRepo(repo)) { return listVisibleFolderWorkspaces(store, repo) } else if (repo.connectionId) { @@ -121,6 +122,7 @@ export function registerWorktreeCatalogHandlers(context: WorktreeIpcContext): vo freshScan = scan.fresh sideEffectToken = scan.sideEffectToken metadataPrune = scan.metadataPrune + hygieneDue = scan.hygieneDue } if (freshScan) { await applyFreshDetectedWorktreeScanSideEffects( @@ -129,7 +131,8 @@ export function registerWorktreeCatalogHandlers(context: WorktreeIpcContext): vo gitWorktrees, metadataPrune, { - sideEffectToken + sideEffectToken, + ...(hygieneDue === undefined ? {} : { hygieneDue }) } ) } @@ -183,6 +186,7 @@ export function registerWorktreeCatalogHandlers(context: WorktreeIpcContext): vo let freshScan = true let sideEffectToken: DetectedWorktreeSideEffectToken | undefined let metadataPrune: DetectedWorktreeMetadataPrune | undefined + let hygieneDue: boolean | undefined if (isFolderRepo(repo)) { return listVisibleFolderWorkspaces(store, repo) } else if (repo.connectionId) { @@ -213,10 +217,12 @@ export function registerWorktreeCatalogHandlers(context: WorktreeIpcContext): vo freshScan = scan.fresh sideEffectToken = scan.sideEffectToken metadataPrune = scan.metadataPrune + hygieneDue = scan.hygieneDue } if (freshScan) { await applyFreshDetectedWorktreeScanSideEffects(store, repo, gitWorktrees, metadataPrune, { - sideEffectToken + sideEffectToken, + ...(hygieneDue === undefined ? {} : { hygieneDue }) }) } loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`) diff --git a/src/main/ipc/worktrees/worktree-ipc-context.ts b/src/main/ipc/worktrees/worktree-ipc-context.ts index 5455a71d06e..153e4b723ec 100644 --- a/src/main/ipc/worktrees/worktree-ipc-context.ts +++ b/src/main/ipc/worktrees/worktree-ipc-context.ts @@ -14,3 +14,18 @@ export type WorktreeIpcContext = { detectedWorktreeCancellations: SenderScopedRequestCancellations worktreeRemovalsInFlight: Map } + +// Why: removal and forget both delete refs, and a ref deletion has to take the +// `packed-refs` lock. Idle ref maintenance needs a process-wide view of that +// registry so it never packs while one is running. +let activeWorktreeRemovals: ReadonlyMap | null = null + +export function createWorktreeRemovalRegistry(): Map { + const registry = new Map() + activeWorktreeRemovals = registry + return registry +} + +export function hasWorktreeRemovalsInFlight(): boolean { + return (activeWorktreeRemovals?.size ?? 0) > 0 +} diff --git a/src/main/local-worktree-metadata-prune-gate.test.ts b/src/main/local-worktree-metadata-prune-gate.test.ts new file mode 100644 index 00000000000..9ae5d3b9f8a --- /dev/null +++ b/src/main/local-worktree-metadata-prune-gate.test.ts @@ -0,0 +1,88 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { + __resetLocalWorktreeMetadataPruneGateForTests, + forgetLocalWorktreeMetadataPruneGate, + invalidateLocalWorktreeMetadataPruneInputs, + isLocalWorktreeMetadataPruneDue, + markLocalWorktreeMetadataPruneStarted, + recordLocalWorktreeListingForPruneGate, + requireLocalWorktreeMetadataPrune +} from './local-worktree-metadata-prune-gate' + +describe('local worktree metadata prune gate', () => { + beforeEach(() => { + __resetLocalWorktreeMetadataPruneGateForTests() + }) + + it('is due for a repo it has never seen', () => { + expect(isLocalWorktreeMetadataPruneDue('repo-1')).toBe(true) + }) + + it('stays undue indefinitely once a pass ran and nothing changed', () => { + markLocalWorktreeMetadataPruneStarted('repo-1') + + expect(isLocalWorktreeMetadataPruneDue('repo-1')).toBe(false) + recordLocalWorktreeListingForPruneGate('repo-1', ['/a', '/b']) + recordLocalWorktreeListingForPruneGate('repo-1', ['/b', '/a']) + expect(isLocalWorktreeMetadataPruneDue('repo-1')).toBe(false) + }) + + it('re-arms one repo on its own worktree lifecycle event', () => { + markLocalWorktreeMetadataPruneStarted('repo-1') + markLocalWorktreeMetadataPruneStarted('repo-2') + + requireLocalWorktreeMetadataPrune('repo-1') + + expect(isLocalWorktreeMetadataPruneDue('repo-1')).toBe(true) + expect(isLocalWorktreeMetadataPruneDue('repo-2')).toBe(false) + }) + + it('re-arms every repo when ownership state may have released a row', () => { + markLocalWorktreeMetadataPruneStarted('repo-1') + markLocalWorktreeMetadataPruneStarted('repo-2') + + invalidateLocalWorktreeMetadataPruneInputs() + + expect(isLocalWorktreeMetadataPruneDue('repo-1')).toBe(true) + expect(isLocalWorktreeMetadataPruneDue('repo-2')).toBe(true) + }) + + it('runs each repo once per invalidation, not once per scan', () => { + invalidateLocalWorktreeMetadataPruneInputs() + expect(isLocalWorktreeMetadataPruneDue('repo-1')).toBe(true) + markLocalWorktreeMetadataPruneStarted('repo-1') + + expect(isLocalWorktreeMetadataPruneDue('repo-1')).toBe(false) + expect(isLocalWorktreeMetadataPruneDue('repo-1')).toBe(false) + }) + + it('does not re-arm on the first listing it observes', () => { + markLocalWorktreeMetadataPruneStarted('repo-1') + + recordLocalWorktreeListingForPruneGate('repo-1', ['/a']) + + expect(isLocalWorktreeMetadataPruneDue('repo-1')).toBe(false) + }) + + it('re-arms when a later listing differs from the one the last pass ran against', () => { + recordLocalWorktreeListingForPruneGate('repo-1', ['/a', '/b']) + markLocalWorktreeMetadataPruneStarted('repo-1') + + recordLocalWorktreeListingForPruneGate('repo-1', ['/a']) + + expect(isLocalWorktreeMetadataPruneDue('repo-1')).toBe(true) + }) + + it('drops gate state for a deregistered repo', () => { + recordLocalWorktreeListingForPruneGate('repo-1', ['/a']) + markLocalWorktreeMetadataPruneStarted('repo-1') + + forgetLocalWorktreeMetadataPruneGate('repo-1') + + expect(isLocalWorktreeMetadataPruneDue('repo-1')).toBe(true) + // The forgotten fingerprint must not later read as a change against a stale entry. + recordLocalWorktreeListingForPruneGate('repo-1', ['/a']) + markLocalWorktreeMetadataPruneStarted('repo-1') + expect(isLocalWorktreeMetadataPruneDue('repo-1')).toBe(false) + }) +}) diff --git a/src/main/local-worktree-metadata-prune-gate.ts b/src/main/local-worktree-metadata-prune-gate.ts new file mode 100644 index 00000000000..0daa8a41b9f --- /dev/null +++ b/src/main/local-worktree-metadata-prune-gate.ts @@ -0,0 +1,103 @@ +/** + * Decides whether a detected-worktree scan owes the store a metadata-hygiene pass. + * + * The pass captures a prune expectation over the repo's whole metadata table, `stat`s every + * path-missing candidate, then prunes metadata and lineage. That is O(all rows) and destructive, so + * it must not ride a polled read path: on a store whose dangling rows outnumber live worktrees + * ~100:1 it re-derives an answer it already has, forever, pinning the main process in filesystem + * completion callbacks (#17775). Rows pinned by a persisted session are never removable, so the + * repetition cannot even make progress. + * + * Nothing the pass reads changes on its own, so it is gated on evidence rather than a clock: + * - a worktree lifecycle event for the repo (the existing worktree-change invalidator hub), + * - a mutation that can make some row *more* removable (see `invalidate…PruneInputs`), + * - the repo's git listing differing from the one the last pass ran against. + * With none of those, the pass is a provable repeat and is skipped outright. + * + * Why only "more removable" mutations count: the listing path itself writes worktree metadata + * (discovery backfill, host-ownership stamping), so bumping on every metadata write would re-arm + * the gate from the very scan it gates and restore the storm. Additions and updates can only + * preserve more rows, so staleness there is harmless. + * + * The failure direction is deliberate. A signal we miss leaves a dangling row in place until the + * next one arrives — hygiene lags, and nothing is deleted that would not have been deleted anyway. + */ + +/** Bumped by evidence that some row may have become removable; repos re-run the pass once each. */ +let pruneInputsGeneration = 0 +const observedGenerationByRepo = new Map() +const listingFingerprintByRepo = new Map() + +/** True until the repo has run a pass against the current generation — so also on the first scan. */ +export function isLocalWorktreeMetadataPruneDue(repoId: string): boolean { + return observedGenerationByRepo.get(repoId) !== pruneInputsGeneration +} + +/** + * Claim the pending pass. Recorded when the expectation is captured rather than when the prune + * finishes: a scan that is invalidated or fails mid-flight must not re-arm the full capture on the + * very next listing poll, and any real change re-bumps the generation anyway. + */ +export function markLocalWorktreeMetadataPruneStarted(repoId: string): void { + observedGenerationByRepo.set(repoId, pruneInputsGeneration) +} + +/** One repo's worktrees changed (create/remove/rename). */ +export function requireLocalWorktreeMetadataPrune(repoId: string): void { + observedGenerationByRepo.delete(repoId) +} + +/** + * Some metadata row may have become removable — a worktree-meta/identity/lineage row was deleted, + * or a session, lease, automation or selection released its claim on a workspace. Repo-agnostic + * because ownership is global state: a session release can unpin a row in any repo. + */ +export function invalidateLocalWorktreeMetadataPruneInputs(): void { + pruneInputsGeneration += 1 +} + +/** + * Backstop for changes no event reported: if Git now lists a different set of worktrees than the + * last pass ran against, that pass's conclusions no longer describe this repo. Costs one join over + * a list we already hold. + */ +export function recordLocalWorktreeListingForPruneGate( + repoId: string, + worktreePaths: readonly string[] +): void { + const fingerprint = [...worktreePaths].sort().join('\0') + const previous = listingFingerprintByRepo.get(repoId) + if (previous === fingerprint) { + return + } + listingFingerprintByRepo.set(repoId, fingerprint) + // Why not on the first listing: a repo we have never scanned is already due by default, and the + // scan that produced this listing is the pass that covers it. Re-arming here would double it. + if (previous !== undefined) { + requireLocalWorktreeMetadataPrune(repoId) + } +} + +/** A deregistered repo leaves no reason to retain its gate state. */ +export function forgetLocalWorktreeMetadataPruneGate(repoId: string): void { + observedGenerationByRepo.delete(repoId) + listingFingerprintByRepo.delete(repoId) +} + +export function __resetLocalWorktreeMetadataPruneGateForTests(): void { + pruneInputsGeneration = 0 + observedGenerationByRepo.clear() + listingFingerprintByRepo.clear() +} + +/** Repo teardown: a full removal retires this repo's gate, and either shape can unpin rows in + * other repos, so the shared inputs are always re-armed. */ +export function retireLocalWorktreeMetadataPruneStateForRepo( + repoId: string, + hostId: string | null +): void { + if (hostId === null) { + forgetLocalWorktreeMetadataPruneGate(repoId) + } + invalidateLocalWorktreeMetadataPruneInputs() +} diff --git a/src/main/observability/instrumentation.test.ts b/src/main/observability/instrumentation.test.ts index 2f7d1da05dd..452b5cd155c 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,84 @@ 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) + }) + + it('records a prepared-checkout hit and whether it had to be retargeted', () => { + const { attributes, span } = capture() + addWorktreeCreatePhaseAttributes(span, { + totalDurationMs: 900, + phases: [{ phase: 'git_worktree_add', startedAtMs: 0, durationMs: 400 }], + preparedCheckout: { status: 'hit', retargeted: true } + }) + + expect(attributes['worktree.create.prepared_checkout']).toBe('hit') + expect(attributes['worktree.create.prepared_checkout_retargeted']).toBe(true) + expect(attributes['worktree.create.prepared_checkout_miss']).toBeUndefined() + expect(attributes['worktree.create.unattributed_ms']).toBe(500) + }) + + it('records why a create missed the prepared checkout', () => { + const { attributes, span } = capture() + addWorktreeCreatePhaseAttributes(span, { + totalDurationMs: 8_000, + phases: [], + preparedCheckout: { status: 'miss', reason: 'base_mismatch' } + }) + + expect(attributes['worktree.create.prepared_checkout']).toBe('miss') + expect(attributes['worktree.create.prepared_checkout_miss']).toBe('base_mismatch') + expect(attributes['worktree.create.prepared_checkout_retargeted']).toBeUndefined() + }) + + it('stays silent on paths that never consult the prepared checkout', () => { + const { attributes, span } = capture() + addWorktreeCreatePhaseAttributes(span, { totalDurationMs: 10, phases: [] }) + + expect(attributes['worktree.create.prepared_checkout']).toBeUndefined() + }) +}) diff --git a/src/main/observability/instrumentation.ts b/src/main/observability/instrumentation.ts index bab57b74f64..8569ab6ef04 100644 --- a/src/main/observability/instrumentation.ts +++ b/src/main/observability/instrumentation.ts @@ -21,6 +21,7 @@ // itself becomes a `noopSpan` that swallows all calls — call sites do not // need to branch on whether tracing is on. +import type { PreparedCheckoutOutcome } from '../../shared/worktree/create-types' import { startSpan, withSpan, type ActiveSpan } from './tracer' const GIT_FAST_SUCCESS_THRESHOLD_MS = 250 @@ -202,10 +203,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 +216,80 @@ 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[] + preparedCheckout?: PreparedCheckoutOutcome + } +): void { + span.setAttribute('worktree.create.total_ms', Math.round(timing.totalDurationMs)) + if (timing.preparedCheckout) { + span.setAttribute('worktree.create.prepared_checkout', timing.preparedCheckout.status) + if (timing.preparedCheckout.status === 'hit') { + // A retargeted hit still pays a reset, so it must not be read as a free hit. + span.setAttribute( + 'worktree.create.prepared_checkout_retargeted', + timing.preparedCheckout.retargeted + ) + } else { + span.setAttribute('worktree.create.prepared_checkout_miss', timing.preparedCheckout.reason) + } + } + 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/orcad/native-host-abi.test.ts b/src/main/orcad/native-host-abi.test.ts index 44f469f0faa..dfa2f7d6a7e 100644 --- a/src/main/orcad/native-host-abi.test.ts +++ b/src/main/orcad/native-host-abi.test.ts @@ -6,6 +6,8 @@ import { GLIBC_FLOOR, isBelowGlibcFloor, nativeSlotName, + parseIncompatibleArchitecture, + parseMissingSharedLibrary, parseNodeAbiMismatch, parseUnmetGlibcVersion } from './native-host-abi' @@ -97,6 +99,37 @@ describe('loader error parsing', () => { ) ).toEqual({ built: '115', host: '127' }) }) + + it('names both architectures on mach-o, and admits ELF names none', () => { + expect( + parseIncompatibleArchitecture( + "dlopen(/opt/pty.node, 0x0001): tried: '/opt/pty.node' (mach-o file, but is an incompatible architecture (have 'arm64', need 'x86_64'))" + ) + ).toEqual({ built: 'arm64', host: 'x86_64' }) + // The ELF loader refuses without saying what it found, so the verdict stands but the + // numbers do not exist to report. + expect(parseIncompatibleArchitecture('invalid ELF header')).toEqual({ + built: null, + host: null + }) + expect(parseIncompatibleArchitecture('wrong ELF class: ELFCLASS32')).not.toBeNull() + // A wrong-libc binary is not a wrong-arch binary; conflating them sends the operator + // to rebuild for an architecture that was never wrong. + expect(parseIncompatibleArchitecture("version `GLIBC_2.34' not found")).toBeNull() + }) + + it('names the shared object the loader could not open, on either loader', () => { + expect( + parseMissingSharedLibrary( + 'libstdc++.so.6: cannot open shared object file: No such file or directory' + ) + ).toBe('libstdc++.so.6') + expect(parseMissingSharedLibrary('Library not loaded: /usr/local/lib/libfoo.dylib')).toBe( + '/usr/local/lib/libfoo.dylib' + ) + // A symbol version that is absent is a rebuild, not an install: must not match here. + expect(parseMissingSharedLibrary("/lib/libc.so.6: version `GLIBC_2.34' not found")).toBeNull() + }) }) describe('detectNativeHostAbi', () => { diff --git a/src/main/orcad/native-host-abi.ts b/src/main/orcad/native-host-abi.ts index 2890bb07a15..2335ad3274b 100644 --- a/src/main/orcad/native-host-abi.ts +++ b/src/main/orcad/native-host-abi.ts @@ -121,3 +121,40 @@ export function parseNodeAbiMismatch(loaderError: string): { built: string; host const match = loaderError.match(/NODE_MODULE_VERSION\s+(\d+)\D+NODE_MODULE_VERSION\s+(\d+)/) return match ? { built: match[1], host: match[2] } : null } + +/** + * The architecture the loader refused, e.g. `incompatible architecture (have 'arm64', + * need 'x86_64')` -> { built: 'arm64', host: 'x86_64' }. ELF hosts name no architecture + * (`invalid ELF header`, `wrong ELF class: ELFCLASS32`), so both sides read null there — + * the verdict still holds, only the numbers are missing. + */ +export function parseIncompatibleArchitecture( + loaderError: string +): { built: string | null; host: string | null } | null { + const machO = loaderError.match( + /incompatible architecture \(have '?([\w.]+)'?,?\s*need '?([\w.]+)'?/ + ) + if (machO) { + return { built: machO[1], host: machO[2] } + } + if (/invalid ELF header|wrong ELF class|Exec format error|ELFCLASS(?:32|64)/.test(loaderError)) { + return { built: null, host: null } + } + return null +} + +/** + * The shared object the loader could not find, e.g. + * `libstdc++.so.6: cannot open shared object file` -> 'libstdc++.so.6'. + * + * Kept apart from the glibc floor deliberately: a missing library can be installed, + * whereas a symbol version that does not exist can only be fixed by rebuilding. + */ +export function parseMissingSharedLibrary(loaderError: string): string | null { + const elf = loaderError.match(/([\w.+-]+\.so[\w.]*): cannot open shared object file/) + if (elf) { + return elf[1] + } + const machO = loaderError.match(/Library not loaded:\s*(\S+)/) + return machO ? machO[1] : null +} diff --git a/src/main/orcad/node-pty-loader-diagnosis.ts b/src/main/orcad/node-pty-loader-diagnosis.ts new file mode 100644 index 00000000000..8024a8e0ecb --- /dev/null +++ b/src/main/orcad/node-pty-loader-diagnosis.ts @@ -0,0 +1,85 @@ +/** + * Read a dynamic-loader message and name the one thing that has to change. + * + * Pure on purpose: every shape that matters here belongs to a host we are not — Alpine, + * Ubuntu 20.04, an arm64 box handed an x64 binary — so the classification has to be + * testable from a machine that cannot reproduce any of them. + * + * Shared by the two places a node-pty load can fail: `orcad`'s boot precondition + * (out of process, before anything requires node-pty) and the SSH relay's spawn path. + */ +import type { RuntimeTerminalUnavailableReason } from '../../shared/runtime-types' +import { + parseIncompatibleArchitecture, + parseMissingSharedLibrary, + parseNodeAbiMismatch, + parseUnmetGlibcVersion +} from './native-host-abi' + +export type NodePtyLoadCause = { + reason: RuntimeTerminalUnavailableReason + /** Short human phrase naming the actual values found, not a remedy. */ + detail: string +} + +/** + * node-pty's own loader walks several directories and rethrows only the LAST failure, + * wrapped in this sentence. The tail is therefore the `prebuilds/-` + * miss — `Cannot find module` — even when the real failure was the dynamic loader + * refusing `build/Release/pty.node`. Anything acting on that tail sends the operator to + * install a module that is already installed. + */ +export function isFlattenedNodePtyLoaderMessage(message: string): boolean { + return /Failed to load native module: (?:conpty|pty)\.node(?:,|:|$)/.test(message) +} + +/** The real cause a flattened message still carries, when the last attempt was the telling one. */ +export function classifyNodePtyLoaderMessage(message: string): NodePtyLoadCause { + const abiMismatch = parseNodeAbiMismatch(message) + if (abiMismatch) { + return { + reason: 'abi_mismatch', + detail: `built for Node ABI ${abiMismatch.built}, this host runs ABI ${abiMismatch.host}` + } + } + const unmetGlibc = parseUnmetGlibcVersion(message) + if (unmetGlibc) { + return { reason: 'libc_floor', detail: `the binary requires GLIBC_${unmetGlibc}` } + } + const unmetCxx = message.match(/((?:GLIBCXX_|CXXABI_)[0-9.]+)'? not found/) + if (unmetCxx) { + return { reason: 'libc_floor', detail: `the binary requires ${unmetCxx[1]}` } + } + const arch = parseIncompatibleArchitecture(message) + if (arch) { + return { + reason: 'arch_mismatch', + detail: + arch.built && arch.host + ? `built for ${arch.built}, this host needs ${arch.host}` + : `the loader rejected the binary's format (${firstErrorLine(message)})` + } + } + const missingLibrary = parseMissingSharedLibrary(message) + if (missingLibrary) { + return { + reason: 'shared_library_missing', + detail: `${missingLibrary} is not installed on this host` + } + } + if (/MODULE_NOT_FOUND|Cannot find module/.test(message)) { + return { reason: 'dependency_missing', detail: firstErrorLine(message) } + } + return { reason: 'load_failed', detail: firstErrorLine(message) } +} + +/** + * Why not simply the first non-empty line: when a child dies without catching, node + * prints the offending source line and a caret before the error, so line one is the + * script rather than the diagnosis. Prefer the first line that reads as an error. + */ +export function firstErrorLine(text: string): string { + const lines = text.split('\n').filter((candidate) => candidate.trim().length > 0) + const errorLine = lines.find((candidate) => /^[A-Za-z]*(Error|Exception):/.test(candidate.trim())) + return (errorLine ?? lines[0] ?? text).trim().slice(0, 400) +} diff --git a/src/main/orcad/node-pty-precondition.ts b/src/main/orcad/node-pty-precondition.ts index 2857449c736..7d633bd0f9a 100644 --- a/src/main/orcad/node-pty-precondition.ts +++ b/src/main/orcad/node-pty-precondition.ts @@ -25,13 +25,8 @@ import { parseBuildToolchainProbe, toolchainInstallHintLines } from '../ssh/build-toolchain-diagnosis' -import { - detectNativeHostAbi, - nativeSlotName, - parseNodeAbiMismatch, - parseUnmetGlibcVersion, - type NativeHostAbi -} from './native-host-abi' +import { detectNativeHostAbi, nativeSlotName, type NativeHostAbi } from './native-host-abi' +import { classifyNodePtyLoaderMessage, firstErrorLine } from './node-pty-loader-diagnosis' import { installPrebuiltSlot, type PrebuiltSlotOutcome } from './node-pty-prebuilt-slot' // Why every verdict travels on STDOUT: node echoes the whole `-e` source into stderr @@ -72,21 +67,35 @@ export type NodePtyProbeFailure = { } /** - * Read the child's exit into a cause. Pure, so every failure shape is testable from a - * host that cannot reproduce it — the whole point, since the shapes that matter belong - * to Alpine and Ubuntu 20.04. + * What the child actually reported, before any judgement is made about it. + * + * Split from the classification so callers that need the loader's own words — the relay, + * which quotes them back when nothing recognizes the shape — do not have to re-derive + * them from a formatted verdict. */ -export function classifyNodePtyProbeResult( +export type NodePtyProbeOutcome = + | { kind: 'loaded'; loadedDir: string | null } + | { kind: 'noBinary' } + | { kind: 'loaderError'; message: string } + | { kind: 'signalled'; signal: NodeJS.Signals } + /** The probe never answered. Not evidence about node-pty either way. */ + | { kind: 'unanswered'; detail: string } + /** It answered, but with nothing that names a cause. */ + | { kind: 'unexplained'; detail: string } + +export function readNodePtyProbeOutcome( result: Pick -): NodePtyProbeFailure | null { +): NodePtyProbeOutcome { const stdout = result.stdout if (result.code === 0 && stdout.includes(PROBE_OK_TOKEN)) { - return null + return { + kind: 'loaded', + loadedDir: stdout.split(PROBE_OK_TOKEN)[1]?.trim().split('\n')[0]?.trim() || null + } } if (result.timedOut) { return { - status: 'unverifiable', - reason: 'unknown', + kind: 'unanswered', detail: 'the node-pty load probe did not finish in time, so nothing was established' } } @@ -94,27 +103,51 @@ export function classifyNodePtyProbeResult( // the loader never reaches the catch, and often prints nothing at all. That silence is // exactly the uncatchable case this probe is a separate process for. if (result.signal) { - return { - status: 'blocked', - reason: 'load_crashed', - detail: `the load probe was killed by ${result.signal}` - } + return { kind: 'signalled', signal: result.signal } } if (stdout.includes(NO_BINARY_TOKEN)) { - return { - status: 'blocked', - reason: 'dependency_missing', - detail: 'node-pty is installed but has no compiled binary for this platform' - } + return { kind: 'noBinary' } } const reported = readReportedLoadError(stdout) if (reported !== null) { - return classifyLoaderMessage(reported) + return { kind: 'loaderError', message: reported } } return { - status: 'blocked', - reason: 'load_failed', - detail: firstLine(result.stderr) || `the load probe exited with code ${result.code}` + kind: 'unexplained', + detail: firstErrorLine(result.stderr) || `the load probe exited with code ${result.code}` + } +} + +/** + * Read the child's exit into a cause. Pure, so every failure shape is testable from a + * host that cannot reproduce it — the whole point, since the shapes that matter belong + * to Alpine and Ubuntu 20.04. + */ +export function classifyNodePtyProbeResult( + result: Pick +): NodePtyProbeFailure | null { + const outcome = readNodePtyProbeOutcome(result) + switch (outcome.kind) { + case 'loaded': + return null + case 'unanswered': + return { status: 'unverifiable', reason: 'unknown', detail: outcome.detail } + case 'signalled': + return { + status: 'blocked', + reason: 'load_crashed', + detail: `the load probe was killed by ${outcome.signal}` + } + case 'noBinary': + return { + status: 'blocked', + reason: 'dependency_missing', + detail: 'node-pty is installed but has no compiled binary for this platform' + } + case 'loaderError': + return classifyLoaderMessage(outcome.message) + case 'unexplained': + return { status: 'blocked', reason: 'load_failed', detail: outcome.detail } } } @@ -133,40 +166,7 @@ function readReportedLoadError(stdout: string): string | null { /** Read a dynamic-loader message. Pure, so shapes this host cannot reproduce are testable. */ export function classifyLoaderMessage(message: string): NodePtyProbeFailure { - const abiMismatch = parseNodeAbiMismatch(message) - if (abiMismatch) { - return { - status: 'blocked', - reason: 'abi_mismatch', - detail: `built for Node ABI ${abiMismatch.built}, this host runs ABI ${abiMismatch.host}` - } - } - const unmetGlibc = parseUnmetGlibcVersion(message) - if (unmetGlibc) { - return { - status: 'blocked', - reason: 'libc_floor', - detail: `the binary requires GLIBC_${unmetGlibc}` - } - } - if (/(GLIBCXX_|CXXABI_)[0-9.]+'? not found/.test(message)) { - return { status: 'blocked', reason: 'libc_floor', detail: firstLine(message) } - } - if (/MODULE_NOT_FOUND|Cannot find module/.test(message)) { - return { status: 'blocked', reason: 'dependency_missing', detail: firstLine(message) } - } - return { status: 'blocked', reason: 'load_failed', detail: firstLine(message) } -} - -/** - * Why not simply the first non-empty line: when the child dies without catching, node - * prints the offending source line and a caret before the error, so line one is the - * script rather than the diagnosis. Prefer the first line that reads as an error. - */ -function firstLine(text: string): string { - const lines = text.split('\n').filter((candidate) => candidate.trim().length > 0) - const errorLine = lines.find((candidate) => /^[A-Za-z]*(Error|Exception):/.test(candidate.trim())) - return (errorLine ?? lines[0] ?? text).trim().slice(0, 400) + return { status: 'blocked', ...classifyNodePtyLoaderMessage(message) } } /** @@ -305,7 +305,8 @@ export function checkNodePtyPrecondition( // Loaded. The remaining way terminals fail is spawn-time: node-pty posix_spawns // build/Release/spawn-helper, and a missing one turns every terminal.create into ENOENT // on a host that otherwise looks healthy. That is a degradation, not a boot blocker. - const loadedDir = result.stdout.split(PROBE_OK_TOKEN)[1]?.trim().split('\n')[0]?.trim() + const outcome = readNodePtyProbeOutcome(result) + const loadedDir = outcome.kind === 'loaded' ? outcome.loadedDir : null if (abi.platform !== 'win32') { const helper = join(loadedDir || join(nodePtyDir, 'build', 'Release'), 'spawn-helper') if (!isExecutableFile(helper)) { 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/leasing-ssh-ptys/ssh-pty-lease-operations.ts b/src/main/persistence/leasing-ssh-ptys/ssh-pty-lease-operations.ts index 2759797c442..4dbad3f5007 100644 --- a/src/main/persistence/leasing-ssh-ptys/ssh-pty-lease-operations.ts +++ b/src/main/persistence/leasing-ssh-ptys/ssh-pty-lease-operations.ts @@ -3,6 +3,7 @@ import type { PersistedState } from '../../../shared/persisted-state-types' import type { SshRemotePtyLease } from '../../../shared/ssh-types' import { isTerminalLeafId } from '../../../shared/stable-pane-id' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { invalidateLocalWorktreeMetadataPruneInputs } from '../../local-worktree-metadata-prune-gate' export type SshPtyLeaseOperations = { state: PersistedState @@ -272,6 +273,8 @@ export function removeSshRemotePtyLease( (lease) => lease.targetId !== targetId || lease.ptyId !== relayPtyId ) if (operations.state.sshRemotePtyLeases.length !== before) { + // Why: the lease may have been the last claim on a dangling metadata row (#17775). + invalidateLocalWorktreeMetadataPruneInputs() operations.flush() } } @@ -287,6 +290,8 @@ export function removeSshRemotePtyLeases( (lease) => lease.targetId !== targetId ) if (operations.state.sshRemotePtyLeases.length !== before) { + // Why: the leases may have been the last claim on dangling metadata rows (#17775). + invalidateLocalWorktreeMetadataPruneInputs() operations.flush() } } diff --git a/src/main/persistence/loading-store/metadata-lineage-operations.ts b/src/main/persistence/loading-store/metadata-lineage-operations.ts index 7d4867ce476..4b0a301fe26 100644 --- a/src/main/persistence/loading-store/metadata-lineage-operations.ts +++ b/src/main/persistence/loading-store/metadata-lineage-operations.ts @@ -13,6 +13,7 @@ import { import type { StoreRuntimeState } from './store-runtime-state' import type { WriteSchedulingOperations } from './write-scheduling' import type { SessionHostPartitionOperations } from './session-host-partitions' +import { invalidateLocalWorktreeMetadataPruneInputs } from '../../local-worktree-metadata-prune-gate' import { scheduleSave } from './write-scheduling' import { hasPersistedWorkspaceSession, @@ -32,6 +33,7 @@ import { mergeWorktreeMetaForWrite } from './worktree-meta-write-normalization' import { captureNativeLocalWorktreeMetadataScanExpectation as captureNativeLocalWorktreeMetadataScanExpectationOperation, pruneSessionlessMissingLocalWorktreeMetadataForRepo as pruneSessionlessMissingLocalWorktreeMetadataForRepoOperation, + selectProbeableLocalWorktreeMetadataCandidates as selectProbeableLocalWorktreeMetadataCandidatesOperation, type LocalWorktreeMetadataPruneExpectation, type NativeLocalWorktreeMetadataScanExpectation } from '../tracking-repos/missing-local-worktree-metadata-pruning' @@ -197,9 +199,21 @@ export class MetadataLineageOperations { } ) } + // Why: dropping a row can free the identity key that was vetoing an unrelated row's removal, so + // the metadata prune needs to look again — it is otherwise waiting on evidence (#17775). + invalidateLocalWorktreeMetadataPruneInputs() scheduleSave(this[metadataLineageOperationsContext].scheduling) } + selectProbeableLocalWorktreeMetadataCandidates( + scan: NativeLocalWorktreeMetadataScanExpectation + ): readonly LocalWorktreeMetadataPruneExpectation[] { + return selectProbeableLocalWorktreeMetadataCandidatesOperation( + this[metadataLineageOperationsContext].runtime.state, + scan + ) + } + pruneSessionlessMissingLocalWorktreeMetadataForRepo( scan: NativeLocalWorktreeMetadataScanExpectation, missingMetadata: readonly LocalWorktreeMetadataPruneExpectation[] diff --git a/src/main/persistence/loading-store/repo-lifecycle-operations.ts b/src/main/persistence/loading-store/repo-lifecycle-operations.ts index 095090ee1ca..535108845e1 100644 --- a/src/main/persistence/loading-store/repo-lifecycle-operations.ts +++ b/src/main/persistence/loading-store/repo-lifecycle-operations.ts @@ -12,10 +12,14 @@ 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 { retireLocalWorktreeMetadataPruneStateForRepo } from '../../local-worktree-metadata-prune-gate' 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 +133,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< @@ -191,6 +222,9 @@ export function pruneWorktreeStateForRepo( hostId, (matchesWorktreeId) => pruneMobileClientTabSelections(owner, matchesWorktreeId) ) + // Why: this drops metadata, lineage, leases and session owners in bulk, which can unpin rows in + // other repos, and a full removal retires this repo's own gate state (#17775). + retireLocalWorktreeMetadataPruneStateForRepo(id, hostId) } export function pruneMobileClientTabSelections( @@ -212,6 +246,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/session-host-partitions.ts b/src/main/persistence/loading-store/session-host-partitions.ts index 43b12531a02..6a5743f60ea 100644 --- a/src/main/persistence/loading-store/session-host-partitions.ts +++ b/src/main/persistence/loading-store/session-host-partitions.ts @@ -13,6 +13,7 @@ import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' import { readTerminalScrollbackSnapshotSync } from '../../terminal-scrollback-snapshots' import { preserveRuntimeAuthoredWorkspaceSessionFields } from '../runtime-authored-workspace-session-fields' import { findWorktreeIdForTab } from '../restoring-sessions/pane-identity-migration' +import { invalidateLocalWorktreeMetadataPruneInputs } from '../../local-worktree-metadata-prune-gate' import { removeWorkspaceSessionOwner, workspaceSessionPartitionIdsForHost @@ -132,6 +133,9 @@ export function removeWorkspaceSessionOwnerInPartition( if (!session) { return } + // Why: a session was the last thing pinning some dangling metadata row; releasing it is the + // evidence the metadata prune waits for, and there is no other signal that it happened (#17775). + invalidateLocalWorktreeMetadataPruneInputs() if (resolved === LOCAL_EXECUTION_HOST_ID) { owner[sessionHostPartitionOperationsContext].runtime.state.workspaceSession = session } else { diff --git a/src/main/persistence/loading-store/store-prune-gate-signals.test.ts b/src/main/persistence/loading-store/store-prune-gate-signals.test.ts new file mode 100644 index 00000000000..9c9aa2eb176 --- /dev/null +++ b/src/main/persistence/loading-store/store-prune-gate-signals.test.ts @@ -0,0 +1,105 @@ +/** + * Drives the real `Store`, because the value of the prune gate is entirely in whether the shipping + * write paths signal it. A mutation that unwires the call site survives any test that pokes the gate + * module directly. + */ +import { mkdtempSync, realpathSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' + +vi.mock('electron', () => ({ + app: { + getPath: () => tmpdir(), + getName: () => 'orca-test', + getVersion: () => '0.0.0-test', + isPackaged: false, + on: () => {}, + whenReady: () => Promise.resolve() + }, + safeStorage: { + isEncryptionAvailable: () => false, + encryptString: (value: string) => Buffer.from(value), + decryptString: (value: Buffer) => value.toString() + }, + ipcMain: { on: () => {}, handle: () => {} }, + BrowserWindow: { getAllWindows: () => [] } +})) + +const { Store } = await import('./store') +const { + __resetLocalWorktreeMetadataPruneGateForTests, + isLocalWorktreeMetadataPruneDue, + markLocalWorktreeMetadataPruneStarted +} = await import('../../local-worktree-metadata-prune-gate') + +const REPO_ID = 'repo-1' +const WORKTREE_ID = `${REPO_ID}::/tmp/worktree-a` + +const stores: InstanceType[] = [] + +beforeEach(() => { + __resetLocalWorktreeMetadataPruneGateForTests() +}) + +afterEach(() => { + // Leaving a debounced save armed would write into a temp dir after the test file finishes. + for (const store of stores.splice(0)) { + store.flush() + } + vi.restoreAllMocks() +}) + +function createStore(): InstanceType { + const dir = realpathSync(mkdtempSync(join(tmpdir(), 'orca-store-prune-gate-'))) + const store = new Store({ dataFile: join(dir, 'orca-data.json') }) + stores.push(store) + return store +} + +/** Park the gate the way a completed hygiene pass does. */ +function parkGate(): void { + markLocalWorktreeMetadataPruneStarted(REPO_ID) + expect(isLocalWorktreeMetadataPruneDue(REPO_ID)).toBe(false) +} + +describe('store signals to the worktree metadata prune gate', () => { + it('re-arms the gate when a session releases its claim on a worktree', () => { + const store = createStore() + store.setWorkspaceSession({ + activeRepoId: REPO_ID, + activeWorktreeId: WORKTREE_ID, + activeTabId: 'tab-1', + tabsByWorktree: { [WORKTREE_ID]: [{ id: 'tab-1', worktreeId: WORKTREE_ID }] }, + terminalLayoutsByTabId: {} + } as unknown as WorkspaceSessionState) + parkGate() + + store.removeWorkspaceSessionStateForWorktree(WORKTREE_ID) + + expect(isLocalWorktreeMetadataPruneDue(REPO_ID)).toBe(true) + }) + + it('re-arms the gate when a metadata row is removed', () => { + const store = createStore() + store.setWorktreeMeta(WORKTREE_ID, { displayName: 'a', hostId: 'local' }) + parkGate() + + store.removeWorktreeMeta(WORKTREE_ID) + + expect(isLocalWorktreeMetadataPruneDue(REPO_ID)).toBe(true) + }) + + it('leaves the gate parked for writes that only add or update a claim', () => { + const store = createStore() + parkGate() + + store.setWorktreeMeta(WORKTREE_ID, { displayName: 'a', hostId: 'local' }) + store.setWorktreeMeta(WORKTREE_ID, { displayName: 'b', hostId: 'local' }) + + // Why this matters: the worktree listing itself stamps metadata on every scan, so a gate that + // re-armed on ordinary writes would restore the storm it exists to stop (#17775). + expect(isLocalWorktreeMetadataPruneDue(REPO_ID)).toBe(false) + }) +}) 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/scheduling-automations/automation-definition-operations.ts b/src/main/persistence/scheduling-automations/automation-definition-operations.ts index 93bb7de2cea..c25315e4986 100644 --- a/src/main/persistence/scheduling-automations/automation-definition-operations.ts +++ b/src/main/persistence/scheduling-automations/automation-definition-operations.ts @@ -1,4 +1,5 @@ import { randomUUID } from 'node:crypto' +import { invalidateLocalWorktreeMetadataPruneInputs } from '../../local-worktree-metadata-prune-gate' import type { Automation, AutomationCreateInput, @@ -262,5 +263,7 @@ export function deleteAutomation( operations.state.automationRuns = (operations.state.automationRuns ?? []).filter( (entry) => entry.automationId !== id ) + // Why: the automation and its unfinished runs were pinning their workspace; both are gone (#17775). + invalidateLocalWorktreeMetadataPruneInputs() operations.flush() } diff --git a/src/main/persistence/scheduling-automations/automation-run-operations.ts b/src/main/persistence/scheduling-automations/automation-run-operations.ts index 639fda7b836..73e3be63e8b 100644 --- a/src/main/persistence/scheduling-automations/automation-run-operations.ts +++ b/src/main/persistence/scheduling-automations/automation-run-operations.ts @@ -1,4 +1,6 @@ import { randomUUID } from 'node:crypto' +import { isFinalAutomationRunStatus } from '../../../shared/automations-types' +import { invalidateLocalWorktreeMetadataPruneInputs } from '../../local-worktree-metadata-prune-gate' import type { Automation, AutomationDispatchResult, @@ -182,6 +184,10 @@ export function updateAutomationRun( operations.state.automationRuns = operations.state.automationRuns.map((run) => run.id === result.runId ? updated : run ) + if (!isFinalAutomationRunStatus(current.status) && isFinalAutomationRunStatus(updated.status)) { + // Why: only a non-final run pins its workspace, so finishing releases the claim (#17775). + invalidateLocalWorktreeMetadataPruneInputs() + } touchAutomation(operations.state, updated.automationId, now) operations.flush() return updated 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/local-worktree-metadata-scan-expectation.ts b/src/main/persistence/tracking-repos/local-worktree-metadata-scan-expectation.ts index fca925d0986..a2da7e070a8 100644 --- a/src/main/persistence/tracking-repos/local-worktree-metadata-scan-expectation.ts +++ b/src/main/persistence/tracking-repos/local-worktree-metadata-scan-expectation.ts @@ -203,6 +203,39 @@ function aliasesStillMatch( }) } +/** + * Whether the local host is structurally allowed to drop this row, ignoring concurrent-change checks. + * + * Pure over persisted state — no filesystem, no expectation — so a caller can decide *before* paying + * for a `stat` whether a delete could ever accept the row. Several of these predicates reject the + * same row on every pass forever (a locator also known on a remote host keeps a second alias; a + * legacy row pinned to another host; identity rows that drifted or dangle), which is what turned the + * prune into an unbounded no-progress loop in #17775. + */ +export function isLocallyRemovableWorktreeMetadataRow( + state: PersistedState, + worktreeId: string, + currentAliases: readonly MetadataAliasEntry[] +): boolean { + const localAlias = composeWorktreeHostIdentity(LOCAL_EXECUTION_HOST_ID, worktreeId) + if (currentAliases.some(([alias]) => alias !== localAlias)) { + return false + } + const legacy = state.worktreeMeta[worktreeId] + const identityKeys = state.worktreeIdentityAliases?.[localAlias] + if (identityKeys && identityKeys.length !== 1) { + return false + } + const canonical = identityKeys?.[0] ? state.worktreeMetaByIdentity?.[identityKeys[0]] : undefined + return !( + (legacy?.hostId && legacy.hostId !== LOCAL_EXECUTION_HOST_ID) || + (canonical?.hostId && canonical.hostId !== LOCAL_EXECUTION_HOST_ID) || + (identityKeys && !canonical) || + (legacy && canonical && !isDeepStrictEqual(legacy, canonical)) || + (!legacy && !canonical) + ) +} + export function removeRevalidatedLocalWorktreeMetadata( state: PersistedState, expected: LocalWorktreeMetadataPruneExpectation, @@ -211,29 +244,13 @@ export function removeRevalidatedLocalWorktreeMetadata( ): boolean { if ( !rowStillMatches(state.worktreeMeta, expected.worktreeId, expected.expectedLegacy) || - !aliasesStillMatch(state, expected, currentAliases) + !aliasesStillMatch(state, expected, currentAliases) || + !isLocallyRemovableWorktreeMetadataRow(state, expected.worktreeId, currentAliases) ) { return false } const localAlias = composeWorktreeHostIdentity(LOCAL_EXECUTION_HOST_ID, expected.worktreeId) - if (currentAliases.some(([alias]) => alias !== localAlias)) { - return false - } - const legacy = state.worktreeMeta[expected.worktreeId] const identityKeys = state.worktreeIdentityAliases?.[localAlias] - if (identityKeys && identityKeys.length !== 1) { - return false - } - const canonical = identityKeys?.[0] ? state.worktreeMetaByIdentity?.[identityKeys[0]] : undefined - if ( - (legacy?.hostId && legacy.hostId !== LOCAL_EXECUTION_HOST_ID) || - (canonical?.hostId && canonical.hostId !== LOCAL_EXECUTION_HOST_ID) || - (identityKeys && !canonical) || - (legacy && canonical && !isDeepStrictEqual(legacy, canonical)) || - (!legacy && !canonical) - ) { - return false - } if (identityKeys?.[0]) { removedIdentityKeys?.add(identityKeys[0]) } diff --git a/src/main/persistence/tracking-repos/missing-local-worktree-metadata-pruning.ts b/src/main/persistence/tracking-repos/missing-local-worktree-metadata-pruning.ts index 3ab380bdd8e..0e03a560a17 100644 --- a/src/main/persistence/tracking-repos/missing-local-worktree-metadata-pruning.ts +++ b/src/main/persistence/tracking-repos/missing-local-worktree-metadata-pruning.ts @@ -13,6 +13,7 @@ import { } from '../restoring-sessions/session-worktree-ownership' import { indexMetadataAliasesForWorktreeIds, + isLocallyRemovableWorktreeMetadataRow, removeRevalidatedLocalWorktreeMetadata, type LocalWorktreeMetadataPruneExpectation, type NativeLocalWorktreeMetadataScanExpectation @@ -108,6 +109,42 @@ function isValidCandidateId( ) } +/** + * The captured candidates a delete could still accept, decided without touching the disk. + * + * Why this exists: the caller `stat`s every candidate whose path Git no longer lists, then discovers + * here that most of them are refused anyway — pinned by a persisted session, or structurally + * unremovable on this host. Those verdicts are pure functions of persisted state, so paying for the + * filesystem first inverts the cheap and expensive halves of the decision. On a store with ~1.4k + * dangling rows that was the bulk of a permanent `stat` storm (#17775). + * + * Deliberately advisory: `pruneSessionlessMissingLocalWorktreeMetadataForRepo` re-checks everything + * authoritatively against the capture, so a disagreement here costs at most a wasted `stat` or a row + * lingering one more pass — it can never widen what gets deleted. + */ +export function selectProbeableLocalWorktreeMetadataCandidates( + state: PersistedState, + scan: NativeLocalWorktreeMetadataScanExpectation, + platform = process.platform +): readonly LocalWorktreeMetadataPruneExpectation[] { + const candidateIds = new Set(scan.metadata.map(({ worktreeId }) => worktreeId)) + if (candidateIds.size === 0) { + return scan.metadata + } + const sessionOwners = collectPersistedWorkspaceOwners(state, candidateIds, platform) + const aliasesByWorktreeId = indexMetadataAliasesForWorktreeIds(state, candidateIds) + return scan.metadata.filter( + ({ worktreeId }) => + isValidCandidateId(scan.repo.id, worktreeId, platform) && + !sessionOwners.has(worktreeId) && + isLocallyRemovableWorktreeMetadataRow( + state, + worktreeId, + aliasesByWorktreeId.get(worktreeId) ?? [] + ) + ) +} + export function pruneSessionlessMissingLocalWorktreeMetadataForRepo( state: PersistedState, scan: NativeLocalWorktreeMetadataScanExpectation, diff --git a/src/main/persistence/tracking-repos/probeable-local-worktree-metadata-candidates.test.ts b/src/main/persistence/tracking-repos/probeable-local-worktree-metadata-candidates.test.ts new file mode 100644 index 00000000000..380d0cba1fd --- /dev/null +++ b/src/main/persistence/tracking-repos/probeable-local-worktree-metadata-candidates.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from 'vitest' +import { getDefaultPersistedState } from '../../../shared/constants' +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { Repo } from '../../../shared/repo-types' +import type { WorktreeMeta } from '../../../shared/worktree/meta-types' +import { + captureNativeLocalWorktreeMetadataScanExpectation, + pruneSessionlessMissingLocalWorktreeMetadataForRepo, + selectProbeableLocalWorktreeMetadataCandidates +} from './missing-local-worktree-metadata-pruning' + +const REPO_ID = 'repo-1' + +function makeRepo(): Repo { + return { + id: REPO_ID, + path: '/workspace/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0 + } +} + +function makeMeta(worktreeId: string, overrides: Partial = {}): WorktreeMeta { + return { + instanceId: `instance-${worktreeId}`, + hostId: 'local', + displayName: worktreeId, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + ...overrides + } +} + +function makeState(): PersistedState { + const state = getDefaultPersistedState('/home/test') + state.repos = [makeRepo()] + return state +} + +function probeableIds(state: PersistedState): string[] { + const scan = captureNativeLocalWorktreeMetadataScanExpectation(state, state.repos[0]!) + return selectProbeableLocalWorktreeMetadataCandidates(state, scan, 'linux').map( + ({ worktreeId }) => worktreeId + ) +} + +describe('selectProbeableLocalWorktreeMetadataCandidates', () => { + it('keeps an ordinary sessionless local row', () => { + const state = makeState() + const id = `${REPO_ID}::/workspace/gone` + state.worktreeMeta[id] = makeMeta(id) + + expect(probeableIds(state)).toEqual([id]) + }) + + it('drops a row a persisted session still pins', () => { + const state = makeState() + const pinned = `${REPO_ID}::/workspace/pinned` + const free = `${REPO_ID}::/workspace/free` + state.worktreeMeta[pinned] = makeMeta(pinned) + state.worktreeMeta[free] = makeMeta(free) + state.ui.lastActiveWorktreeId = pinned + + expect(probeableIds(state)).toEqual([free]) + }) + + it('drops a row whose locator is also known on a remote host', () => { + const state = makeState() + const shared = `${REPO_ID}::/workspace/shared` + const free = `${REPO_ID}::/workspace/free` + state.worktreeMeta[shared] = makeMeta(shared) + state.worktreeMeta[free] = makeMeta(free) + state.worktreeIdentityAliases = { [`ssh:box|${shared}`]: ['identity-1'] } + + expect(probeableIds(state)).toEqual([free]) + }) + + it('drops a row pinned to another execution host', () => { + const state = makeState() + const remote = `${REPO_ID}::/workspace/remote` + state.worktreeMeta[remote] = makeMeta(remote, { hostId: 'ssh:box' }) + + expect(probeableIds(state)).toEqual([]) + }) + + it('never widens what the authoritative prune would remove', () => { + const state = makeState() + const ids = [ + `${REPO_ID}::/workspace/free`, + `${REPO_ID}::/workspace/pinned`, + `${REPO_ID}::/workspace/remote` + ] + state.worktreeMeta[ids[0]] = makeMeta(ids[0]) + state.worktreeMeta[ids[1]] = makeMeta(ids[1]) + state.worktreeMeta[ids[2]] = makeMeta(ids[2], { hostId: 'ssh:box' }) + state.ui.lastActiveWorktreeId = ids[1] + + const scan = captureNativeLocalWorktreeMetadataScanExpectation(state, state.repos[0]!) + const selected = selectProbeableLocalWorktreeMetadataCandidates(state, scan, 'linux') + // Feeding the unfiltered capture to the authoritative prune must reach the same verdict. + const removed = pruneSessionlessMissingLocalWorktreeMetadataForRepo( + state, + scan, + scan.metadata, + 'linux' + ) + + expect(selected.map(({ worktreeId }) => worktreeId)).toEqual(removed) + }) +}) 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/providers/ssh-git-provider-api.test.ts b/src/main/providers/ssh-git-provider-api.test.ts index dd0915dafe5..3e6ebf76d74 100644 --- a/src/main/providers/ssh-git-provider-api.test.ts +++ b/src/main/providers/ssh-git-provider-api.test.ts @@ -54,6 +54,7 @@ describe('SshGitProvider public API parity', () => { 'worktreeIsClean', 'refreshLocalBaseRefForWorktreeCreate', 'renameCurrentBranch', + 'markRemoteOrcaCreated', 'forceDeletePreservedBranch', 'exec', 'clone', @@ -63,7 +64,7 @@ describe('SshGitProvider public API parity', () => { 'getRemoteCommitUrl' ] as const - expect(methods).toHaveLength(51) + expect(methods).toHaveLength(52) for (const method of methods) { expect(provider[method], method).toBeTypeOf('function') } diff --git a/src/main/providers/ssh-git-provider-worktree.test.ts b/src/main/providers/ssh-git-provider-worktree.test.ts index 6ddff0a1f68..03722c93a52 100644 --- a/src/main/providers/ssh-git-provider-worktree.test.ts +++ b/src/main/providers/ssh-git-provider-worktree.test.ts @@ -395,4 +395,38 @@ describe('SshGitProvider', () => { provider.forceDeletePreservedBranch('/home/user/repo', 'you/fix-auth', 'abc123') ).rejects.toBe(error) }) + + it('markRemoteOrcaCreated sends the narrow provenance-marker request', async () => { + await provider.markRemoteOrcaCreated('/home/user/repo', 'pr-contributor-orca') + expect(mux.request).toHaveBeenCalledWith('git.markRemoteOrcaCreated', { + repoPath: '/home/user/repo', + remoteName: 'pr-contributor-orca' + }) + }) + + it('markRemoteOrcaCreated degrades to a one-time warning for an older relay', async () => { + mux.request.mockRejectedValue(methodNotFound('git.markRemoteOrcaCreated')) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + try { + await expect( + provider.markRemoteOrcaCreated('/home/user/repo', 'pr-contributor-orca') + ).resolves.toBeUndefined() + await expect( + provider.markRemoteOrcaCreated('/home/user/repo', 'pr-contributor-orca') + ).resolves.toBeUndefined() + expect(warnSpy).toHaveBeenCalledTimes(1) + } finally { + warnSpy.mockRestore() + } + }) + + it('markRemoteOrcaCreated rethrows non-method-not-found errors', async () => { + const error = new Error('remote config write failed') + mux.request.mockRejectedValueOnce(error) + + await expect( + provider.markRemoteOrcaCreated('/home/user/repo', 'pr-contributor-orca') + ).rejects.toBe(error) + }) }) diff --git a/src/main/providers/ssh-git-worktree-provider.ts b/src/main/providers/ssh-git-worktree-provider.ts index d5cf9b5a5e1..8dae1413321 100644 --- a/src/main/providers/ssh-git-worktree-provider.ts +++ b/src/main/providers/ssh-git-worktree-provider.ts @@ -2,6 +2,7 @@ import type { GitStatusResult } from '../../shared/git-status-types' import type { RemoveWorktreeResult } from '../../shared/worktree/create-types' import type { GitWorktreeInfo } from '../../shared/worktree/types' import { CapabilityProbeCache } from '../../shared/capability-probe-cache' +import { assertAuthoritativeWorktreeCatalog } from '../../shared/worktree/worktree-catalog-availability' import { isJsonRpcMethodNotFoundError } from './ssh-git-relay-errors' import { SshGitReviewHeadProvider } from './ssh-git-review-head-provider' @@ -23,6 +24,7 @@ function filterUntrackedPorcelainStatus(stdout: string | undefined): string | un export class SshGitWorktreeProvider extends SshGitReviewHeadProvider { private loggedWorktreeIsCleanFallback = false + private loggedMarkRemoteOrcaCreatedFallback = false // Why: reconnect replaces this provider, so an upgraded relay is naturally re-probed. private readonly worktreeIsCleanCapabilityCache = new CapabilityProbeCache< typeof WORKTREE_IS_CLEAN_CAPABILITY @@ -32,11 +34,14 @@ export class SshGitWorktreeProvider extends SshGitReviewHeadProvider { repoPath: string, options?: { signal?: AbortSignal } ): Promise { - return (await this.mux.request( + const response = await this.mux.request( 'git.listWorktrees', { repoPath }, { signal: options?.signal } - )) as GitWorktreeInfo[] + ) + // Why (#14004): relays before this fix answered a failed worktree scan with `[]`. Mixed versions are + // normal, so refuse the shape here too — a Git repo always lists its own checkout. + return assertAuthoritativeWorktreeCatalog(response, repoPath) } async addWorktree( @@ -127,6 +132,25 @@ export class SshGitWorktreeProvider extends SshGitReviewHeadProvider { }) } + // Why: git.exec blocks config writes outright, so the deferred fork-remote provenance + // marker (#17828) needs its own RPC. Non-essential to push/pull, so an older relay + // that hasn't shipped it yet degrades to no marker rather than failing materialization. + async markRemoteOrcaCreated(repoPath: string, remoteName: string): Promise { + try { + await this.mux.request('git.markRemoteOrcaCreated', { repoPath, remoteName }) + } catch (error) { + if (!isJsonRpcMethodNotFoundError(error)) { + throw error + } + if (!this.loggedMarkRemoteOrcaCreatedFallback) { + this.loggedMarkRemoteOrcaCreatedFallback = true + console.warn( + "[ssh-git] Relay does not implement git.markRemoteOrcaCreated; this remote will lack a git-config provenance marker permanently (reconnecting does not retroactively add it -- only a newer relay deployment does, for remotes added after that). The store's remoteCreated flag remains the fallback ownership signal for cleanup." + ) + } + } + } + async forceDeletePreservedBranch( repoPath: string, branchName: string, diff --git a/src/main/providers/ssh-pty-provider-terminal-repair.test.ts b/src/main/providers/ssh-pty-provider-terminal-repair.test.ts new file mode 100644 index 00000000000..530ce2804d8 --- /dev/null +++ b/src/main/providers/ssh-pty-provider-terminal-repair.test.ts @@ -0,0 +1,155 @@ +// The client half of #17830: a spawn refused for an unloadable node-pty must route into a repair +// instead of printing a paragraph. Covers the seam only — the ledger and the locked rebuild are +// tested in src/main/ssh/ssh-relay-node-pty-repair.test.ts and +// src/main/ssh/ssh-relay-node-pty-spawn-repair.test.ts. + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { SshPtyProvider } from './ssh-pty-provider' +import { createMockMux, type MockMultiplexer } from './ssh-pty-provider-mock-multiplexer' +import { + TERMINAL_UNAVAILABLE_RPC_ERROR_CODE, + type TerminalUnavailableCause +} from '../../shared/terminal-unavailable-cause' + +const UNAVAILABLE_MESSAGE = + "Remote terminals are unavailable: this host's node-pty binary was built for Node ABI 108." + +function cause(overrides: Partial = {}): TerminalUnavailableCause { + return { + status: 'blocked', + reason: 'abi_mismatch', + detail: 'built for NODE_MODULE_VERSION 108, this Node accepts 115', + repairable: true, + host: { + platform: 'linux', + arch: 'x64', + libc: 'glibc', + glibcVersion: '2.31', + nodeAbi: '115', + nodeVersion: 'v20.11.0' + }, + ...overrides + } +} + +/** Shaped exactly as the multiplexer rebuilds a JSON-RPC error response client-side. */ +function relayRejection(data: unknown): Error { + const error = new Error(UNAVAILABLE_MESSAGE) + Object.defineProperty(error, 'code', { value: TERMINAL_UNAVAILABLE_RPC_ERROR_CODE }) + Object.defineProperty(error, 'data', { value: data }) + return error +} + +function spawnCallCount(target: MockMultiplexer): number { + return target.request.mock.calls.filter((call) => call[0] === 'pty.spawn').length +} + +function rejectSpawnOnce(mux: MockMultiplexer, error: Error): void { + mux.request.mockImplementation(async (method: string) => { + if (method === 'pty.spawn') { + throw error + } + return undefined + }) +} + +const SPAWN_OPTS = { cwd: '/repo', cols: 80, rows: 24 } + +let mux: MockMultiplexer +let provider: SshPtyProvider + +beforeEach(() => { + mux = createMockMux() + provider = new SshPtyProvider('conn-1', mux as never) +}) + +describe('terminal-unavailable spawn recovery', () => { + it('routes a repairable cause into recovery and retries once on the repaired provider', async () => { + rejectSpawnOnce(mux, relayRejection(cause())) + const repairedMux = createMockMux() + repairedMux.request.mockResolvedValue({ id: 'pty-1', incarnationId: 'incarnation-1' }) + const repairedProvider = new SshPtyProvider('conn-1', repairedMux as never) + const recover = vi.fn(async () => repairedProvider) + provider.setTerminalUnavailableRecovery(recover) + + const result = await provider.spawn(SPAWN_OPTS) + + expect(result.id).toBe('ssh:conn-1@@pty-1') + expect(recover).toHaveBeenCalledTimes(1) + expect(recover).toHaveBeenCalledWith( + expect.objectContaining({ reason: 'abi_mismatch', status: 'blocked' }) + ) + // Exactly one retry, on the post-repair channel — never a second attempt on the broken one. + expect(spawnCallCount(mux)).toBe(1) + expect(spawnCallCount(repairedMux)).toBe(1) + }) + + it('surfaces the relay message when the retry still fails, and does not recurse into a second repair', async () => { + // The lock-busy shape: the reconnect happened, the rebuild did not, so the relay says the same thing. + rejectSpawnOnce(mux, relayRejection(cause())) + const degradedMux = createMockMux() + const degradedProvider = new SshPtyProvider('conn-1', degradedMux as never) + rejectSpawnOnce(degradedMux, relayRejection(cause())) + const degradedRecover = vi.fn(async () => degradedProvider) + degradedProvider.setTerminalUnavailableRecovery(degradedRecover) + const recover = vi.fn(async () => degradedProvider) + provider.setTerminalUnavailableRecovery(recover) + + await expect(provider.spawn(SPAWN_OPTS)).rejects.toThrow(UNAVAILABLE_MESSAGE) + + expect(recover).toHaveBeenCalledTimes(1) + expect(degradedRecover).not.toHaveBeenCalled() + }) + + it('rethrows without recovery when the recovery declines', async () => { + rejectSpawnOnce(mux, relayRejection(cause())) + provider.setTerminalUnavailableRecovery(async () => null) + + await expect(provider.spawn(SPAWN_OPTS)).rejects.toThrow(UNAVAILABLE_MESSAGE) + }) + + it('never recovers from an unverifiable cause', async () => { + rejectSpawnOnce(mux, relayRejection(cause({ status: 'unverifiable', repairable: true }))) + const recover = vi.fn(async () => provider) + provider.setTerminalUnavailableRecovery(recover) + + await expect(provider.spawn(SPAWN_OPTS)).rejects.toThrow(UNAVAILABLE_MESSAGE) + expect(recover).not.toHaveBeenCalled() + }) + + it('never recovers from a toolchain_missing cause', async () => { + rejectSpawnOnce(mux, relayRejection(cause({ reason: 'toolchain_missing', repairable: false }))) + const recover = vi.fn(async () => provider) + provider.setTerminalUnavailableRecovery(recover) + + await expect(provider.spawn(SPAWN_OPTS)).rejects.toThrow(UNAVAILABLE_MESSAGE) + expect(recover).not.toHaveBeenCalled() + }) + + it('ignores a malformed cause rather than half-reading it', async () => { + rejectSpawnOnce(mux, relayRejection({ status: 'blocked', repairable: true })) + const recover = vi.fn(async () => provider) + provider.setTerminalUnavailableRecovery(recover) + + await expect(provider.spawn(SPAWN_OPTS)).rejects.toThrow(UNAVAILABLE_MESSAGE) + expect(recover).not.toHaveBeenCalled() + }) + + it('leaves an old relay that publishes no cause on today behaviour', async () => { + rejectSpawnOnce(mux, new Error(UNAVAILABLE_MESSAGE)) + const recover = vi.fn(async () => provider) + provider.setTerminalUnavailableRecovery(recover) + + await expect(provider.spawn(SPAWN_OPTS)).rejects.toThrow(UNAVAILABLE_MESSAGE) + expect(recover).not.toHaveBeenCalled() + }) + + it('does not swallow an ordinary spawn failure', async () => { + rejectSpawnOnce(mux, new Error('shell not found')) + const recover = vi.fn(async () => provider) + provider.setTerminalUnavailableRecovery(recover) + + await expect(provider.spawn(SPAWN_OPTS)).rejects.toThrow('shell not found') + expect(recover).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/providers/ssh-pty-provider.ts b/src/main/providers/ssh-pty-provider.ts index a8e4218ce63..f218cc43eca 100644 --- a/src/main/providers/ssh-pty-provider.ts +++ b/src/main/providers/ssh-pty-provider.ts @@ -23,6 +23,7 @@ import { SshPtySpawnExitRaceTracker } from './ssh-pty-spawn-exit-race' import { SshAgentSessionCapabilities } from './ssh-agent-session-capabilities' import type { PtyProcessInspection } from './pty-process-inspection' import { writeToSshPty, writeToSshPtyWithSettlement } from './ssh-pty-write' +import { spawnWithTerminalRuntimeRepair, type TerminalRepairHook } from './ssh-pty-spawn-repair' // Why: sequential relay teardown calls share one absolute budget; convert to the mux-relative timeout only at dispatch. function relayTimeoutOptions(deadlineMs: number | undefined): { timeoutMs: number } | undefined { @@ -38,6 +39,7 @@ export class SshPtyProvider implements IPtyProvider { private readonly agentSessionCapabilities: SshAgentSessionCapabilities private spawnExitRaces = new SshPtySpawnExitRaceTracker() private readonly outputState: SshPtyProviderOutputState + private recoverFromTerminalUnavailable: TerminalRepairHook | null = null requestHostRpc: NonNullable = (method, params, options) => this.mux.request(method, params as Record, options) @@ -76,7 +78,24 @@ export class SshPtyProvider implements IPtyProvider { private toAppPtyId = (id: string): string => toAppSshPtyId(this.connectionId, id) + /** Installed by SshRelaySession, which owns the connection, the repair lock and the reconnect. */ + setTerminalUnavailableRecovery(recover: TerminalRepairHook): void { + this.recoverFromTerminalUnavailable = recover + } + + hasLivePtys(): boolean { + return this.livePtyIds.size > 0 + } + async spawn(opts: PtySpawnOptions): Promise { + return await spawnWithTerminalRuntimeRepair({ + attempt: () => this.spawnWithoutTerminalRuntimeRepair(opts), + recover: this.recoverFromTerminalUnavailable, + retry: (provider) => provider.spawnWithoutTerminalRuntimeRepair(opts) + }) + } + + private async spawnWithoutTerminalRuntimeRepair(opts: PtySpawnOptions): Promise { if (opts.agentSessionEnsure && opts.sessionId) { throw new Error('agent_session_claim_unavailable') } diff --git a/src/main/providers/ssh-pty-spawn-repair.ts b/src/main/providers/ssh-pty-spawn-repair.ts new file mode 100644 index 00000000000..5086283f3fb --- /dev/null +++ b/src/main/providers/ssh-pty-spawn-repair.ts @@ -0,0 +1,45 @@ +/** + * The client seam for #17830: a spawn the relay refused because it cannot load node-pty. + * + * Split out of ssh-pty-provider.ts so the provider keeps only the wiring. The repair itself is + * driven by SshRelaySession, which owns the connection, the repair lock and the reconnect. + */ +import { + mayRepairFromCause, + terminalUnavailableCauseFromError, + type TerminalUnavailableCause +} from '../../shared/terminal-unavailable-cause' + +/** Resolves to the provider registered after a successful repair, or null to keep the rejection. */ +export type TerminalRepairHook = ( + cause: TerminalUnavailableCause +) => Promise + +/** + * Run a spawn, and on a proved-repairable terminal-unavailable rejection repair the host and + * retry exactly once on the provider the repair produced. + * + * Re-issuing is safe because a validated cause is the host's own statement that admission was + * refused before any PTY existed, so there is nothing to duplicate. The retry deliberately goes + * through a caller-supplied thunk that does not re-enter this wrapper, so it cannot recurse. + * Gated on `mayRepairFromCause`, never on the peer's `repairable` flag alone. + */ +export async function spawnWithTerminalRuntimeRepair(args: { + attempt: () => Promise + recover: TerminalRepairHook | null + retry: (provider: TProvider) => Promise +}): Promise { + try { + return await args.attempt() + } catch (error) { + const cause = terminalUnavailableCauseFromError(error) + if (!cause || !mayRepairFromCause(cause) || !args.recover) { + throw error + } + const repaired = await args.recover(cause) + if (!repaired) { + throw error + } + return await args.retry(repaired) + } +} diff --git a/src/main/providers/ssh-worktree-catalog-authority.test.ts b/src/main/providers/ssh-worktree-catalog-authority.test.ts new file mode 100644 index 00000000000..3f11c87dc0f --- /dev/null +++ b/src/main/providers/ssh-worktree-catalog-authority.test.ts @@ -0,0 +1,141 @@ +/** + * Issue #14004: an SSH worktree catalog Orca could not read must never surface as an authoritative + * empty catalog. Covers the whole client-side chain — provider response guard, the repo-level + * listing, and the detected-worktree result whose `authoritative` flag gates renderer terminal + * teardown (`teardownMissingWorktreeTerminalsBestEffort`). + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { SshGitProvider } from './ssh-git-provider' +import { createMockMux, type MockMultiplexer } from './ssh-git-provider-test-harness' +import { isWorktreeCatalogUnavailableError } from '../../shared/worktree/worktree-catalog-availability' +import { listRepoWorktrees } from '../repo-worktrees' +import { listDetectedWorktreesForCapturedRepo } from '../ipc/worktrees/listing/detected-provider-listing' +import type { Repo } from '../../shared/repo-types' +import type { Store } from '../persistence/loading-store/store' + +const { getSshGitProviderMock } = vi.hoisted(() => ({ getSshGitProviderMock: vi.fn() })) + +vi.mock('./ssh-git-dispatch', () => ({ + getSshGitProvider: getSshGitProviderMock, + requireSshGitProvider: getSshGitProviderMock, + getSshGitProviderGeneration: () => 1 +})) + +const CONNECTION_ID = 'conn-1' +const REPO_PATH = '/home/user/repo' +const WORKTREE_PATH = '/home/user/feature' + +const repo: Repo = { + id: 'repo-1', + path: REPO_PATH, + displayName: 'repo', + connectionId: CONNECTION_ID +} as Repo + +const worktreeId = `${repo.id}::${WORKTREE_PATH}` + +function createStore(): Store { + const meta: Record = { + [worktreeId]: { instanceId: 'instance-1' } + } + return { + getRepos: () => [repo], + getRepo: () => repo, + getAllWorktreeMeta: () => meta, + getWorktreeMeta: (id: string) => meta[id], + setWorktreeMeta: vi.fn(), + getAllWorktreeLineage: () => ({}), + getProjectHostSetups: () => [], + getSettings: () => ({}) + } as unknown as Store +} + +describe('SSH worktree catalog authority (#14004)', () => { + let mux: MockMultiplexer + let provider: SshGitProvider + + beforeEach(() => { + mux = createMockMux() + provider = new SshGitProvider(CONNECTION_ID, mux as never) + getSshGitProviderMock.mockReset() + getSshGitProviderMock.mockReturnValue(provider) + }) + + it('refuses an empty relay response instead of publishing an empty catalog', async () => { + // An older relay converted a failed `git worktree list` into `[]`; mixed versions are the normal state. + mux.request.mockResolvedValue([]) + + await expect(provider.listWorktrees(REPO_PATH)).rejects.toSatisfy( + isWorktreeCatalogUnavailableError + ) + }) + + it('refuses a malformed relay response', async () => { + mux.request.mockResolvedValue(undefined) + + await expect(provider.listWorktrees(REPO_PATH)).rejects.toSatisfy( + isWorktreeCatalogUnavailableError + ) + }) + + it('reports an unreachable SSH host as unavailable, not as an empty repo listing', async () => { + getSshGitProviderMock.mockReturnValue(undefined) + + await expect(listRepoWorktrees(repo)).rejects.toSatisfy(isWorktreeCatalogUnavailableError) + }) + + it('does not authorize missing-worktree teardown when the relay listing fails', async () => { + mux.request.mockRejectedValue(new Error('relay request failed')) + + const result = await listDetectedWorktreesForCapturedRepo( + createStore(), + repo, + () => true, + provider + ) + + expect(result).toMatchObject({ authoritative: false, source: 'metadata-fallback' }) + // The persisted workspace survives the failed scan, so the renderer has nothing to reconcile away. + expect( + (result as { worktrees: { id: string }[] }).worktrees.map((worktree) => worktree.id) + ).toContain(worktreeId) + }) + + it('does not authorize missing-worktree teardown when the relay answers with an empty list', async () => { + mux.request.mockResolvedValue([]) + + const result = await listDetectedWorktreesForCapturedRepo( + createStore(), + repo, + () => true, + provider + ) + + expect(result).toMatchObject({ authoritative: false, source: 'metadata-fallback' }) + expect( + (result as { worktrees: { id: string }[] }).worktrees.map((worktree) => worktree.id) + ).toContain(worktreeId) + }) + + it('republishes an authoritative catalog once the relay answers again', async () => { + mux.request.mockResolvedValue([ + { path: REPO_PATH, head: 'abc123', branch: 'main', isBare: false, isMainWorktree: true }, + { + path: WORKTREE_PATH, + head: 'def456', + branch: 'feature', + isBare: false, + isMainWorktree: false + } + ]) + + const result = await listDetectedWorktreesForCapturedRepo( + createStore(), + repo, + () => true, + provider + ) + + expect(result).toMatchObject({ authoritative: true, source: 'git' }) + }) +}) diff --git a/src/main/repo-maintenance-idle-gate.test.ts b/src/main/repo-maintenance-idle-gate.test.ts new file mode 100644 index 00000000000..78728f3a43a --- /dev/null +++ b/src/main/repo-maintenance-idle-gate.test.ts @@ -0,0 +1,134 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const isOnBatteryPowerMock = vi.hoisted(() => vi.fn(() => false)) +const hasPendingPreparationsMock = vi.hoisted(() => vi.fn(() => false)) +const hasRemovalsInFlightMock = vi.hoisted(() => vi.fn(() => false)) +const setProbeMock = vi.hoisted(() => vi.fn()) +const disposeMock = vi.hoisted(() => vi.fn(async () => {})) +const postponeMock = vi.hoisted(() => vi.fn()) +const powerListeners = vi.hoisted(() => new Map void>()) +const appListeners = vi.hoisted(() => new Map void>()) + +vi.mock('electron', () => ({ + app: { + on: (event: string, listener: () => void) => appListeners.set(event, listener), + off: (event: string) => appListeners.delete(event) + }, + powerMonitor: { + isOnBatteryPower: isOnBatteryPowerMock, + on: (event: string, listener: () => void) => powerListeners.set(event, listener), + off: (event: string) => powerListeners.delete(event) + } +})) + +vi.mock('./worktree-create-preparation', () => ({ + hasPendingWorktreeCreatePreparations: hasPendingPreparationsMock +})) + +vi.mock('./ipc/worktrees/worktree-ipc-context', () => ({ + hasWorktreeRemovalsInFlight: hasRemovalsInFlightMock +})) + +vi.mock('./git/local-repo-ref-maintenance', () => ({ + setRepoMaintenanceActivityProbe: setProbeMock, + disposeLocalRepoRefMaintenance: disposeMock, + postponeRepoRefMaintenance: postponeMock +})) + +import { installRepoMaintenanceIdleGate } from './repo-maintenance-idle-gate' + +function installProbe( + overrides: Partial<{ isQuitting: () => boolean; getWorkingAgentCount: () => number }> = {} +): { probe: () => boolean; uninstall: () => Promise } { + const uninstall = installRepoMaintenanceIdleGate({ + isQuitting: () => false, + getWorkingAgentCount: () => 0, + ...overrides + }) + return { probe: setProbeMock.mock.calls.at(-1)?.[0] as () => boolean, uninstall } +} + +beforeEach(() => { + isOnBatteryPowerMock.mockReturnValue(false) + hasPendingPreparationsMock.mockReturnValue(false) + hasRemovalsInFlightMock.mockReturnValue(false) + postponeMock.mockClear() + powerListeners.clear() + appListeners.clear() + setProbeMock.mockClear() + disposeMock.mockClear() +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('repo maintenance idle gate', () => { + it('reports idle when nothing is happening', () => { + expect(installProbe().probe()).toBe(false) + }) + + it('vetoes while an agent is working', () => { + expect(installProbe({ getWorkingAgentCount: () => 1 }).probe()).toBe(true) + }) + + it('vetoes while a worktree create is prepared or in flight', () => { + hasPendingPreparationsMock.mockReturnValue(true) + + expect(installProbe().probe()).toBe(true) + }) + + it('vetoes while a worktree removal is deleting refs', () => { + // Removal deletes branches, and a ref deletion needs the same packed-refs lock. + hasRemovalsInFlightMock.mockReturnValue(true) + + expect(installProbe().probe()).toBe(true) + }) + + it('vetoes on battery power', () => { + isOnBatteryPowerMock.mockReturnValue(true) + + expect(installProbe().probe()).toBe(true) + }) + + it('vetoes during shutdown', () => { + expect(installProbe({ isQuitting: () => true }).probe()).toBe(true) + }) + + it('treats an unavailable power API as not-on-battery', () => { + isOnBatteryPowerMock.mockImplementation(() => { + throw new Error('unsupported') + }) + + expect(installProbe().probe()).toBe(false) + }) + + it('pushes the next attempt out when the machine drops onto battery', () => { + // Do-not-start, never stop-what-is-running: killing a pack to honour a + // battery change would strand a ref lock to save a little unlinking. + installProbe() + + powerListeners.get('on-battery')?.() + + expect(postponeMock).toHaveBeenCalledTimes(1) + }) + + it('pushes the next attempt out when the user comes back to the window', () => { + // A focus transition, not focus itself: a window left focused while the user + // walks away fires no event and blocks nothing. + installProbe() + + appListeners.get('browser-window-focus')?.() + + expect(postponeMock).toHaveBeenCalledTimes(1) + }) + + it('cancels armed timers, unsubscribes both sources, and clears the probe when uninstalled', async () => { + await installProbe().uninstall() + + expect(disposeMock).toHaveBeenCalledTimes(1) + expect(powerListeners.has('on-battery')).toBe(false) + expect(appListeners.has('browser-window-focus')).toBe(false) + expect(setProbeMock).toHaveBeenLastCalledWith(null) + }) +}) diff --git a/src/main/repo-maintenance-idle-gate.ts b/src/main/repo-maintenance-idle-gate.ts new file mode 100644 index 00000000000..78bb25fe68c --- /dev/null +++ b/src/main/repo-maintenance-idle-gate.ts @@ -0,0 +1,67 @@ +import { app, powerMonitor } from 'electron' +import { + disposeLocalRepoRefMaintenance, + postponeRepoRefMaintenance, + setRepoMaintenanceActivityProbe +} from './git/local-repo-ref-maintenance' +import { hasWorktreeRemovalsInFlight } from './ipc/worktrees/worktree-ipc-context' +import { hasPendingWorktreeCreatePreparations } from './worktree-create-preparation' + +/** + * The app-wide "not now" answer for idle repo maintenance. + * + * `pack-refs` holds a general git admission slot for its whole run, which on a + * large backlog is minutes, and takes the `packed-refs` lock while it writes. + * Any ref deletion needs that same lock and gives up after + * `core.packedRefsTimeout` (1s), so worktree removal in particular has to veto + * this -- as does a create in flight, an agent mid-run, and shutdown. Battery is + * a veto too: this is work the user did not ask for, and a plugged-in quiet + * window always comes along later. + */ +export type RepoMaintenanceIdleInputs = { + isQuitting: () => boolean + getWorkingAgentCount: () => number +} + +export function installRepoMaintenanceIdleGate( + inputs: RepoMaintenanceIdleInputs +): () => Promise { + setRepoMaintenanceActivityProbe( + () => + inputs.isQuitting() || + inputs.getWorkingAgentCount() > 0 || + hasPendingWorktreeCreatePreparations() || + hasWorktreeRemovalsInFlight() || + isOnBatteryPower() + ) + // Do-not-start, never stop-what-is-running. Killing a pack to honour a battery + // or focus change would strand a ref lock roughly one time in five to save at + // most a couple of minutes of background unlinking; pushing the next attempt + // out costs nothing and risks nothing. + const onBattery = (): void => { + postponeRepoRefMaintenance() + } + const onFocus = (): void => { + postponeRepoRefMaintenance() + } + powerMonitor.on('on-battery', onBattery) + app.on('browser-window-focus', onFocus) + return () => { + app.off('browser-window-focus', onFocus) + powerMonitor.off('on-battery', onBattery) + // Order matters: clearing the probe alone would leave armed timers running + // against a gate that can no longer see agents, creates, or shutdown. + const stopped = disposeLocalRepoRefMaintenance() + setRepoMaintenanceActivityProbe(null) + return stopped + } +} + +function isOnBatteryPower(): boolean { + try { + return powerMonitor.isOnBatteryPower() + } catch { + // Absence of the API is not evidence of battery; desktops answer false anyway. + return false + } +} diff --git a/src/main/repo-worktrees.test.ts b/src/main/repo-worktrees.test.ts index d0c0ea5c617..d1c3935c1e1 100644 --- a/src/main/repo-worktrees.test.ts +++ b/src/main/repo-worktrees.test.ts @@ -1,11 +1,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { listWorktreesMock, listWorktreesStrictMock } = vi.hoisted(() => ({ +const { listWorktreeGraphMock, listWorktreesMock, listWorktreesStrictMock } = vi.hoisted(() => ({ + listWorktreeGraphMock: vi.fn(), listWorktreesMock: vi.fn(), listWorktreesStrictMock: vi.fn() })) vi.mock('./git/worktree', () => ({ + listWorktreeGraph: listWorktreeGraphMock, listWorktrees: listWorktreesMock, listWorktreesStrict: listWorktreesStrictMock })) @@ -14,11 +16,13 @@ import { createFolderWorktree, isRepoRoot, listLocalRepoWorktreesStrict, + listRepoWorktreeGraph, listRepoWorktrees } from './repo-worktrees' describe('repo-worktrees', () => { beforeEach(() => { + listWorktreeGraphMock.mockReset() listWorktreesMock.mockReset() listWorktreesStrictMock.mockReset() }) @@ -109,6 +113,49 @@ describe('repo-worktrees', () => { expect(result).toHaveLength(1) }) + // Path-only callers must reach the probe-free listing, never the annotated one. + it('delegates to the graph listing without sparse annotation', async () => { + listWorktreeGraphMock.mockResolvedValue([ + { path: '/workspace/repo', head: 'abc', branch: '', isBare: false, isMainWorktree: true } + ]) + + const result = await listRepoWorktreeGraph({ + id: 'repo-1', + path: '/workspace/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + kind: 'git' + }) + + expect(listWorktreeGraphMock).toHaveBeenCalledWith('/workspace/repo') + expect(listWorktreesMock).not.toHaveBeenCalled() + expect(result).toHaveLength(1) + }) + + it('returns the synthetic folder worktree from the graph listing', async () => { + const result = await listRepoWorktreeGraph({ + id: 'repo-1', + path: '/workspace/folder', + displayName: 'folder', + badgeColor: '#000', + addedAt: 0, + kind: 'folder' + }) + + expect(listWorktreeGraphMock).not.toHaveBeenCalled() + expect(result).toEqual([ + createFolderWorktree({ + id: 'repo-1', + path: '/workspace/folder', + displayName: 'folder', + badgeColor: '#000', + addedAt: 0, + kind: 'folder' + }) + ]) + }) + it('delegates strict local listing with the signal and WSL options', async () => { listWorktreesStrictMock.mockResolvedValue([ { path: '/workspace/repo', head: 'abc', branch: '', isBare: false, isMainWorktree: true } diff --git a/src/main/repo-worktrees.ts b/src/main/repo-worktrees.ts index 14751c17861..b7c6e16f91a 100644 --- a/src/main/repo-worktrees.ts +++ b/src/main/repo-worktrees.ts @@ -1,9 +1,10 @@ import type { Repo } from '../shared/repo-types' import type { GitWorktreeInfo } from '../shared/worktree/types' -import { listWorktrees, listWorktreesStrict } from './git/worktree' +import { listWorktreeGraph, listWorktrees, listWorktreesStrict } from './git/worktree' import { isFolderRepo } from '../shared/repo-kind' import { getSshGitProvider } from './providers/ssh-git-dispatch' import { areWorktreePathsEqual } from './ipc/worktree-logic' +import { WorktreeCatalogUnavailableError } from '../shared/worktree/worktree-catalog-availability' type LocalRepoWorktreeListOptions = { wslDistro?: string @@ -42,16 +43,44 @@ export async function listRepoWorktrees( } if (repo.connectionId) { const provider = getSshGitProvider(repo.connectionId) - // Why: runtime worktree resolution can run before SSH providers have - // reattached during startup. Return empty instead of falling back to - // local git against a server path. - return provider ? await provider.listWorktrees(repo.path) : [] + // Why: runtime worktree resolution can run before SSH providers have reattached during startup. + // Never fall back to local git against a server path, and never report the unreachable host as an + // empty catalog (#14004) — callers treat a resolved listing as authoritative. + if (!provider) { + throw new WorktreeCatalogUnavailableError( + `Worktree catalog unavailable for ${repo.path}: SSH connection "${repo.connectionId}" is not connected.` + ) + } + return await provider.listWorktrees(repo.path) } return hasLocalRepoWorktreeListOptions(options) ? await listWorktrees(repo.path, options) : await listWorktrees(repo.path) } +/** + * Worktree rows for callers that read only `worktree.path`. + * + * Skips the sparse-checkout probe behind the badge, which those callers discard. On a WSL repo the + * probe is a 9p stat plus a config read per worktree, re-paid cold after every worktree + * create/remove because that invalidates both the authorized-roots cache and the sparse cache. + */ +export async function listRepoWorktreeGraph( + repo: Repo, + options?: LocalRepoWorktreeListOptions +): Promise { + if (isFolderRepo(repo)) { + return [createFolderWorktree(repo)] + } + if (repo.connectionId) { + const provider = getSshGitProvider(repo.connectionId) + return provider ? await provider.listWorktrees(repo.path) : [] + } + return hasLocalRepoWorktreeListOptions(options) + ? await listWorktreeGraph(repo.path, options) + : await listWorktreeGraph(repo.path) +} + export async function listLocalRepoWorktreesStrict( repo: Repo, options?: LocalRepoWorktreeListOptions diff --git a/src/main/runtime/fetch-remote-cache.test.ts b/src/main/runtime/fetch-remote-cache.test.ts index fc2d761c059..c97966c344e 100644 --- a/src/main/runtime/fetch-remote-cache.test.ts +++ b/src/main/runtime/fetch-remote-cache.test.ts @@ -133,9 +133,11 @@ describe('OrcaRuntimeService.fetchRemoteWithCache', () => { const first = runtime.fetchRemoteWithCache('/repo/c', 'origin') const second = runtime.fetchRemoteWithCache('/repo/c', 'origin') - // Allow both callers to register before we resolve. - await Promise.resolve() - await Promise.resolve() + // Allow both callers to register before we resolve. Each canonicalizes the + // repo key first, so the dispatch lands several microtasks in. + for (let tick = 0; tick < 8; tick += 1) { + await Promise.resolve() + } expect(fetchCallCount()).toBe(1) resolveFetch() diff --git a/src/main/runtime/folder-workspace-pty-teardown.ts b/src/main/runtime/folder-workspace-pty-teardown.ts new file mode 100644 index 00000000000..f5a5b7df4b3 --- /dev/null +++ b/src/main/runtime/folder-workspace-pty-teardown.ts @@ -0,0 +1,38 @@ +import { killAllProcessesForWorktree } from './worktree-teardown' +import type { IPtyProvider } from '../providers/types' +import type { OrcaRuntimeService } from './orca-runtime' + +export type FolderWorkspacePtyTeardownDeps = { + runtime: OrcaRuntimeService + getSshProvider: ((connectionId: string) => IPtyProvider | undefined) | null + getLocalProvider: () => IPtyProvider | null + onPtyStopped: ((ptyId: string) => void) | null +} + +/** + * Best-effort PTY sweep for a folder workspace being removed. Never throws: + * a stuck or unreachable host must not block forgetting the workspace. + */ +export async function teardownFolderWorkspacePtys( + deps: FolderWorkspacePtyTeardownDeps, + worktreeId: string, + connectionId: string | null +): Promise { + const sshPtyProvider = connectionId ? deps.getSshProvider?.(connectionId) : undefined + const ptyProvider = sshPtyProvider ?? deps.getLocalProvider() + if (!ptyProvider) { + return + } + await killAllProcessesForWorktree(worktreeId, { + runtime: deps.runtime, + resolvedWorktreeId: worktreeId, + ...(connectionId ? { resolvedConnectionId: connectionId } : {}), + localProvider: ptyProvider, + onPtyStopped: deps.onPtyStopped ?? undefined, + ...(connectionId + ? { includeProviderInventory: Boolean(sshPtyProvider), includeLocalRegistry: false } + : {}) + }).catch((error) => { + console.warn(`[worktree-teardown] failed for ${worktreeId}:`, error) + }) +} diff --git a/src/main/runtime/graph-sync-deletion-fence.test.ts b/src/main/runtime/graph-sync-deletion-fence.test.ts new file mode 100644 index 00000000000..499607ea5bb --- /dev/null +++ b/src/main/runtime/graph-sync-deletion-fence.test.ts @@ -0,0 +1,200 @@ +/** + * Deletion fence: a renderer snapshot that raced a worktree delete must not + * resurrect the removed occupant's browser/terminal rows in a same-id + * recreation, while the genuine successor is accepted promptly. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + RuntimeMobileSessionTabsResult, + RuntimeMobileSessionTabsSnapshot +} from '../../shared/runtime-types' +import { OrcaRuntimeService } from './orca-runtime' + +const WT = 'repo-1::/tmp/worktree-a' + +const storeBase = { + getRepo: () => ({ + id: 'repo-1', + path: '/tmp/repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1 + }), + getRepos: () => [storeBase.getRepo()], + addRepo: () => {}, + updateRepo: () => undefined as never, + getAllWorktreeMeta: () => ({}), + getGitHubCache: () => ({ pr: {}, issue: {} }), + setWorktreeMeta: () => undefined as never, + getRetiredWorktreeNameRegistry: () => ({ exhaustedTiers: 0, names: [] }), + addRetiredWorktreeName: () => {}, + mergeRetiredWorktreeNames: () => false, + getSettings: () => ({ + workspaceDir: '/tmp/workspaces', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: false, + branchPrefix: 'none', + branchPrefixCustom: '' + }) +} + +function makeRendererSnapshot(args: { + version: number + epoch?: string +}): RuntimeMobileSessionTabsSnapshot { + return { + worktree: WT, + publicationEpoch: args.epoch ?? 'renderer:test-epoch', + snapshotVersion: args.version, + activeGroupId: 'group-1', + activeTabId: 'tab-1::leaf-1', + activeTabType: 'terminal', + tabs: [ + { + type: 'terminal', + id: 'tab-1::leaf-1', + parentTabId: 'tab-1', + leafId: 'leaf-1', + title: 'Terminal 1', + isActive: true + } + ] + } +} + +type RuntimeInternals = { + mobileSessionTabsByWorktree: Map +} + +describe('graph-sync deletion fence', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + type FenceInternals = RuntimeInternals & { + removedMobileSessionWorktreeIds: Map + removeWorktreeMetadataAndHistory: (store: unknown, worktreeId: string) => void + rendererGeneration: string | null + } + + function createFencedRuntime() { + let meta: { instanceId: string; hostId?: string } | undefined = { instanceId: 'old-instance' } + const store = { + ...storeBase, + getWorktreeMeta: () => meta, + removeWorktreeMeta: () => { + meta = undefined + } + } + const runtime = new OrcaRuntimeService(store as never) + const internals = runtime as unknown as FenceInternals + const events: RuntimeMobileSessionTabsResult[] = [] + runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot)) + const sync = ( + mobileSessionTabs: RuntimeMobileSessionTabsSnapshot[], + extra: { rendererGeneration?: string; unchanged?: string[] } = {} + ) => + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [], + ...(extra.rendererGeneration ? { rendererGeneration: extra.rendererGeneration } : {}), + mobileSessionTabs, + ...(extra.unchanged ? { unchangedMobileSessionWorktrees: extra.unchanged } : {}) + } as never) + const recreate = (instanceId: string): void => { + meta = { instanceId } + } + const remove = (): void => internals.removeWorktreeMetadataAndHistory(store, WT) + return { runtime, internals, events, sync, recreate, remove } + } + + it("rejects the deleted occupant's late snapshot after same-id recreation", () => { + const { internals, events, sync, recreate, remove } = createFencedRuntime() + sync([{ ...makeRendererSnapshot({ version: 1 }), worktreeInstanceId: 'old-instance' }]) + vi.advanceTimersByTime(60) + expect(internals.mobileSessionTabsByWorktree.has(WT)).toBe(true) + events.length = 0 + + remove() + expect(events).toEqual([expect.objectContaining({ worktree: WT, removed: true })]) + events.length = 0 + recreate('new-instance') + + sync([{ ...makeRendererSnapshot({ version: 2 }), worktreeInstanceId: 'old-instance' }]) + vi.advanceTimersByTime(60) + + expect(internals.mobileSessionTabsByWorktree.has(WT)).toBe(false) + expect(events).toHaveLength(0) + }) + + it("accepts the recreated occupant's snapshot and clears the fence", () => { + const { internals, events, sync, recreate, remove } = createFencedRuntime() + remove() + events.length = 0 + recreate('new-instance') + + sync([{ ...makeRendererSnapshot({ version: 3 }), worktreeInstanceId: 'new-instance' }]) + vi.advanceTimersByTime(60) + + expect(internals.mobileSessionTabsByWorktree.has(WT)).toBe(true) + expect(events).toEqual([expect.objectContaining({ worktree: WT, snapshotVersion: 3 })]) + expect(internals.removedMobileSessionWorktreeIds.has(WT)).toBe(false) + }) + + it('rejects a snapshot while the removed id has no successor metadata', () => { + const { internals, events, sync, remove } = createFencedRuntime() + remove() + events.length = 0 + + sync([{ ...makeRendererSnapshot({ version: 2 }), worktreeInstanceId: 'new-instance' }]) + vi.advanceTimersByTime(60) + + expect(internals.mobileSessionTabsByWorktree.has(WT)).toBe(false) + expect(events).toHaveLength(0) + }) + + it('fences identity-less frames from the generation that published the deleted occupant', () => { + const { internals, events, sync, recreate, remove } = createFencedRuntime() + sync([makeRendererSnapshot({ version: 1, epoch: 'renderer:gen-1' })], { + rendererGeneration: 'renderer:gen-1' + }) + vi.advanceTimersByTime(60) + remove() + recreate('new-instance') + events.length = 0 + + sync([makeRendererSnapshot({ version: 2, epoch: 'renderer:gen-1' })], { + rendererGeneration: 'renderer:gen-1' + }) + vi.advanceTimersByTime(60) + expect(internals.mobileSessionTabsByWorktree.has(WT)).toBe(false) + expect(events).toHaveLength(0) + + // A reloaded renderer publishes a fresh generation; the resync-path throw + // on a superseded generation needs the graph to leave 'ready' first. + internals.rendererGeneration = null + sync([makeRendererSnapshot({ version: 1, epoch: 'renderer:gen-2' })], { + rendererGeneration: 'renderer:gen-2' + }) + vi.advanceTimersByTime(60) + expect(internals.mobileSessionTabsByWorktree.has(WT)).toBe(true) + }) + + it('does not request a resync for a fenced frame the renderer still lists as unchanged', () => { + const { sync, recreate, remove } = createFencedRuntime() + remove() + recreate('new-instance') + + const first = sync([ + { ...makeRendererSnapshot({ version: 2 }), worktreeInstanceId: 'old-instance' } + ]) + const second = sync([], { unchanged: [WT] }) + + expect(first.mobileSessionResyncWorktrees ?? []).toEqual([]) + expect(second.mobileSessionResyncWorktrees ?? []).toEqual([]) + }) +}) diff --git a/src/main/runtime/orca-runtime-agent-session-operation.test.ts b/src/main/runtime/orca-runtime-agent-session-operation.test.ts index 9199e46783a..ac64dfaa6b7 100644 --- a/src/main/runtime/orca-runtime-agent-session-operation.test.ts +++ b/src/main/runtime/orca-runtime-agent-session-operation.test.ts @@ -66,6 +66,50 @@ function createRuntime(provider?: { return runtime } +// Why: an SSH-backed workspace whose spawn response was lost — the leak in #17929. +function installRemoteReclaimHarness( + runtime: OrcaRuntimeService, + listProcesses: ReturnType +): void { + const handleByPtyId = new Map() + Object.assign(runtime, { + ptyController: { listProcesses }, + resolveTerminalWorkspaceLaunchScope: vi.fn(async () => ({ + id: 'worktree-1', + path: '/remote/worktree-1', + connectionId: 'ssh-1' + })), + executionOwnerSupportsAgentSessionOperation: vi.fn(async () => true), + markWorkspaceTrustedForAgent: vi.fn(async () => {}), + adoptControllerTerminalHandle: vi.fn((ptyId: string, handle: string) => { + handleByPtyId.set(ptyId, handle) + }), + recordPtyWorktree: vi.fn((ptyId: string, worktreeId: string, state: { title?: string }) => ({ + ptyId, + worktreeId, + title: state.title ?? null + })), + issuePtyHandle: vi.fn((pty: { ptyId: string }) => handleByPtyId.get(pty.ptyId)) + }) +} + +async function fenceRemoteAgentSessionSpawn(runtime: OrcaRuntimeService) { + const failure = Object.assign(new Error('execution_owner_unavailable'), { + agentSessionOperationOutcome: 'unknown' as const + }) + const createTerminal = vi + .spyOn(runtime, 'createTerminal') + .mockImplementation(async (_worktree, opts) => { + opts?.onPtySpawnCommitted?.() + throw failure + }) + const id = operationId() + await expect(runtime.createAgentSession(request(id), { clientId: 'device-a' })).rejects.toThrow( + failure.message + ) + return { createTerminal, failure, id } +} + describe('agent-session create operation ledger', () => { it('selects legacy before trust, spawn, or ledger state for an old daemon', async () => { const provider = { @@ -291,6 +335,81 @@ describe('agent-session create operation ledger', () => { expect(createTerminal).toHaveBeenCalledOnce() }) + it('reclaims a fenced remote spawn the host is still holding', async () => { + const runtime = createRuntime() + const listProcesses = vi.fn(async () => [] as never[]) + installRemoteReclaimHarness(runtime, listProcesses) + const { createTerminal, id, failure } = await fenceRemoteAgentSessionSpawn(runtime) + const orphanHandle = createTerminal.mock.calls[0]?.[1]?.preAllocatedHandle as string + listProcesses.mockResolvedValue([ + { + id: 'ssh-1:pty2:e:1', + cwd: '/remote/worktree-1', + title: 'codex', + worktreeId: 'worktree-1', + terminalHandle: orphanHandle + } + ] as never) + + await expect( + runtime.createAgentSession(request(id), { clientId: 'device-a' }) + ).resolves.toMatchObject({ + disposition: 'replayed', + terminal: { handle: orphanHandle, ptyId: 'ssh-1:pty2:e:1', worktreeId: 'worktree-1' } + }) + expect(listProcesses).toHaveBeenCalledWith('ssh-1') + expect(createTerminal).toHaveBeenCalledOnce() + expect(failure.message).toBe('execution_owner_unavailable') + }) + + it('replays the fenced failure when host inventory proves the spawn is gone', async () => { + const runtime = createRuntime() + const listProcesses = vi.fn(async () => [] as never[]) + installRemoteReclaimHarness(runtime, listProcesses) + const { createTerminal, id, failure } = await fenceRemoteAgentSessionSpawn(runtime) + + await expect(runtime.createAgentSession(request(id), { clientId: 'device-a' })).rejects.toThrow( + failure.message + ) + expect(listProcesses).toHaveBeenCalledWith('ssh-1') + expect(createTerminal).toHaveBeenCalledOnce() + }) + + it('replays the fenced failure when the remote host cannot answer', async () => { + const runtime = createRuntime() + const listProcesses = vi.fn(async () => { + throw new Error('relay offline') + }) + installRemoteReclaimHarness(runtime, listProcesses) + const { createTerminal, id, failure } = await fenceRemoteAgentSessionSpawn(runtime) + + await expect(runtime.createAgentSession(request(id), { clientId: 'device-a' })).rejects.toThrow( + failure.message + ) + expect(createTerminal).toHaveBeenCalledOnce() + }) + + it('refuses to adopt a same-handle PTY that belongs to another workspace', async () => { + const runtime = createRuntime() + const listProcesses = vi.fn(async () => [] as never[]) + installRemoteReclaimHarness(runtime, listProcesses) + const { createTerminal, id, failure } = await fenceRemoteAgentSessionSpawn(runtime) + listProcesses.mockResolvedValue([ + { + id: 'ssh-1:pty2:e:9', + cwd: '/remote/worktree-2', + title: 'codex', + worktreeId: 'worktree-2', + terminalHandle: createTerminal.mock.calls[0]?.[1]?.preAllocatedHandle + } + ] as never) + + await expect(runtime.createAgentSession(request(id), { clientId: 'device-a' })).rejects.toThrow( + failure.message + ) + expect(createTerminal).toHaveBeenCalledOnce() + }) + it('retains a replay fence when the provider reports an unknown spawn outcome', async () => { const runtime = createRuntime() const failure = Object.assign(new Error('cleanup could not prove exit'), { diff --git a/src/main/runtime/orca-runtime-create-agent-session.ts b/src/main/runtime/orca-runtime-create-agent-session.ts index 03d187b717e..db2b71a0adc 100644 --- a/src/main/runtime/orca-runtime-create-agent-session.ts +++ b/src/main/runtime/orca-runtime-create-agent-session.ts @@ -24,6 +24,10 @@ import { } from '../../shared/tui-agent-launch-defaults' import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from '../../shared/tui-agent-startup' import type { RuntimeTerminalCreate } from '../../shared/runtime-types' +import type { + AgentSessionCreateOperation, + AgentSessionCreateReclaimIdentity +} from './runtime-terminal-contracts' import { deterministicAgentSessionUuid, isAgentSessionOperationOutcomeUnknown @@ -72,7 +76,16 @@ export class OrcaRuntimeWithCreateAgentSession extends OrcaRuntimeWithGetAgentSe if (existing.fingerprint !== requestFingerprint) { throw new Error('agent_session_operation_conflict') } - const replayed = await existing.promise + let replayed: RuntimeCreateAgentSessionResult + try { + replayed = await existing.promise + } catch (error) { + const reclaimed = await this.reclaimFencedAgentSessionSpawn(existing.reclaim.identity) + if (!reclaimed) { + throw error + } + return { terminal: reclaimed, disposition: 'replayed' } + } return { ...replayed, disposition: 'replayed' } } if (now - operationTimestamp > AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS) { @@ -96,6 +109,7 @@ export class OrcaRuntimeWithCreateAgentSession extends OrcaRuntimeWithGetAgentSe throw new Error('agent_session_operation_capacity') } let retainReplayFence = false + const reclaim: AgentSessionCreateOperation['reclaim'] = {} const operation = (async (): Promise => { // Why: reserve the client operation before any async preflight so concurrent retries cannot // both observe an empty ledger and reach the execution owner independently. @@ -187,6 +201,13 @@ export class OrcaRuntimeWithCreateAgentSession extends OrcaRuntimeWithGetAgentSe const operationLeafId = request.placement?.leafId ?? deterministicAgentSessionUuid(`${executionOperationId}:leaf`) const operationHandle = `term_${deterministicAgentSessionUuid(`${executionOperationId}:handle`)}` + // Why: recorded before dispatch — this handle is exported into the PTY as + // ORCA_TERMINAL_HANDLE, so it is the only name a lost spawn can be re-found by. + reclaim.identity = { + worktreeId: workspace.id, + connectionId: workspace.connectionId ?? null, + terminalHandle: operationHandle + } try { terminal = await this.createTerminal(`id:${workspace.id}`, { command: startup.launchCommand, @@ -216,7 +237,8 @@ export class OrcaRuntimeWithCreateAgentSession extends OrcaRuntimeWithGetAgentSe })() this.agentSessionCreateOperations.set(operationKey, { fingerprint: requestFingerprint, - promise: operation + promise: operation, + reclaim }) const expireOperation = (): void => { const expiresAt = Math.max(now, operationTimestamp) + AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS @@ -245,4 +267,25 @@ export class OrcaRuntimeWithCreateAgentSession extends OrcaRuntimeWithGetAgentSe throw error } } + + // Why: the host may still hold the PTY this operation launched. Adoption-only — + // this never spawns and never kills, so an unreachable or silent host just replays + // the original failure instead of authorising anything. + private async reclaimFencedAgentSessionSpawn( + identity: AgentSessionCreateReclaimIdentity | undefined + ): Promise { + if (!identity) { + return null + } + try { + return await this.reconcileRemoteTerminalCreate( + identity.worktreeId, + identity.terminalHandle, + identity.connectionId + ) + } catch { + // Unverifiable or ambiguous inventory is never evidence the PTY exited. + return null + } + } } diff --git a/src/main/runtime/orca-runtime-file-commands.ts b/src/main/runtime/orca-runtime-file-commands.ts index c44daefacfd..c195ee5dbd8 100644 --- a/src/main/runtime/orca-runtime-file-commands.ts +++ b/src/main/runtime/orca-runtime-file-commands.ts @@ -97,6 +97,16 @@ export class OrcaRuntimeWithFileCommands extends OrcaRuntimeWithPreservedBranchC linkedWorkItem: meta.linkedWorkItem } : null + }, + // Why (#17828 review follow-up): RuntimeGitSyncCommands materializes with no store to + // avoid unrelated side effects; this is its only way back into the persisted + // `pushTarget.remoteCreated` flag that #17842's orphan sweep relies on. + persistMaterializedPushTarget: (worktreeId, pushTarget) => { + const store = this.store + if (!store?.setWorktreeMeta) { + return + } + store.setWorktreeMeta(worktreeId, { pushTarget }) } }) diff --git a/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts b/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts index a78acbad8f5..43853f0f9c0 100644 --- a/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts +++ b/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts @@ -38,6 +38,7 @@ import { RuntimeRepositoryForkBackfill } from './runtime-repository-fork-backfil import { RuntimeWorkspaceSessionController } from './runtime-workspace-session-controller' import { RuntimeAiVaultCommands } from './runtime-ai-vault-commands' import { ClaudeAgentTeamsService } from './claude-agent-teams-service' +import { teardownFolderWorkspacePtys } from './folder-workspace-pty-teardown' export class OrcaRuntimeWithPreservedBranchCleanup extends OrcaRuntimeWithTerminalDrivers { protected readonly preservedBranchCleanup = new RuntimePreservedBranchCleanup(() => @@ -203,7 +204,24 @@ export class OrcaRuntimeWithPreservedBranchCleanup extends OrcaRuntimeWithTermin protected readonly projectGroups = new RuntimeProjectGroupController({ getStore: () => this.store, resolveRepo: (selector) => this.resolveRepoSelector(selector), - notifyReposChanged: () => this.notifyReposChanged() + notifyReposChanged: () => this.notifyReposChanged(), + resolveFolderConnectionId: (workspace) => this.resolveFolderWorkspaceConnectionId(workspace), + teardownFolderWorkspacePtys: (worktreeId, connectionId) => + teardownFolderWorkspacePtys( + { + runtime: this, + getSshProvider: this.getSshProviderFn, + getLocalProvider: () => this.getLocalProvider(), + onPtyStopped: this.onPtyStopped + }, + worktreeId, + connectionId + ), + cleanupRemovedFolderWorkspaceState: (worktreeId) => { + if (this.store) { + this.removeWorktreeMetadataAndHistory(this.store, worktreeId) + } + } }) protected readonly nestedRepoImport = new RuntimeNestedRepoImport({ diff --git a/src/main/runtime/orca-runtime-remove-orphan-or-folder-worktree.ts b/src/main/runtime/orca-runtime-remove-orphan-or-folder-worktree.ts index 697950f229a..d49dc410cd5 100644 --- a/src/main/runtime/orca-runtime-remove-orphan-or-folder-worktree.ts +++ b/src/main/runtime/orca-runtime-remove-orphan-or-folder-worktree.ts @@ -6,6 +6,7 @@ import { invalidateAuthorizedRootsCache } from '../ipc/filesystem-auth' import { isFolderRepo } from '../../shared/repo-kind' import { getRuntimeFolderWorkspaceRootId } from './runtime-folder-workspace' import { killAllProcessesForWorktree } from './worktree-teardown' +import { teardownFolderWorkspacePtys } from './folder-workspace-pty-teardown' export async function removeOrphanOrFolderWorktree({ runtime, @@ -94,19 +95,16 @@ export async function removeOrphanOrFolderWorktree({ const folderSshPtyProvider = folderConnectionId ? runtime.getSshProviderFn?.(folderConnectionId) : undefined - const folderPtyProvider = folderSshPtyProvider ?? runtime.getLocalProvider() - if (folderPtyProvider) { - await killAllProcessesForWorktree(removalTarget.id, { + await teardownFolderWorkspacePtys( + { runtime, - resolvedWorktreeId: removalTarget.id, - ...(folderConnectionId ? { resolvedConnectionId: folderConnectionId } : {}), - localProvider: folderPtyProvider, - onPtyStopped: runtime.onPtyStopped ?? undefined, - ...(folderConnectionId - ? { includeProviderInventory: Boolean(folderSshPtyProvider), includeLocalRegistry: false } - : {}) - }).catch((err) => console.warn(`[worktree-teardown] failed for ${removalTarget.id}:`, err)) - } + getSshProvider: runtime.getSshProviderFn, + getLocalProvider: () => runtime.getLocalProvider(), + onPtyStopped: runtime.onPtyStopped + }, + removalTarget.id, + folderConnectionId + ) await deleteRemoteWorktreeHistory(folderSshPtyProvider, removalTarget.id) runtime.removeWorktreeMetadataAndHistory(store, removalTarget.id, removalHostId) runtime.preservedBranchCleanup.delete(removalTarget.id, cleanupHostId) diff --git a/src/main/runtime/orca-runtime-resolve-browser-network-execution-host-for-worktree.ts b/src/main/runtime/orca-runtime-resolve-browser-network-execution-host-for-worktree.ts index 3ae942280eb..87411ad55d7 100644 --- a/src/main/runtime/orca-runtime-resolve-browser-network-execution-host-for-worktree.ts +++ b/src/main/runtime/orca-runtime-resolve-browser-network-execution-host-for-worktree.ts @@ -23,6 +23,7 @@ import { homedir } from 'node:os' import { getExplicitWorktreeIdSelector } from './runtime-worktree-selection' import { WORKTREE_ID_SEPARATOR } from '../../shared/worktree/id' import { WorktreeIdRequiresFullPathError } from './runtime-worktree-lineage-resolution' +import { triggerTerminalSpawnPushTargetMaterialization } from './runtime-terminal-spawn-push-target-materialization' export class OrcaRuntimeWithResolveBrowserNetworkExecutionHostForWorktree extends OrcaRuntimeWithTransitionGraphReloadToTerminalState { protected resolveBrowserNetworkExecutionHostForWorktree(worktree?: { @@ -124,6 +125,14 @@ export class OrcaRuntimeWithResolveBrowserNetworkExecutionHostForWorktree extend const worktreeSelector = parsed?.type === 'worktree' ? `id:${parsed.worktreeId}` : selector const worktree = await this.resolveWorktreeSelector(worktreeSelector) const repo = this.store?.getRepo(worktree.repoId) ?? null + triggerTerminalSpawnPushTargetMaterialization( + worktree.path, + worktree.pushTarget, + repo, + this.store, + worktree.repoId, + worktree.id + ) return { scope: { id: worktree.id, diff --git a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts index f4790b1a697..58fd6231fc7 100644 --- a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts +++ b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts @@ -5,6 +5,7 @@ import type { RuntimeWorktreeRemovalTarget } from './runtime-worktree-selection' import { resolveRuntimeWorktreeRemovalTarget } from './runtime-worktree-removal-target' import type { RuntimeStore } from './runtime-store-contract' import { splitWorktreeId } from '../../shared/worktree/id' +import { runtimeWorktreeIdsEqual } from './runtime-worktree-path-identity' import { hasWorktreeRemovalRepoOwnerOnOtherHost } from '../worktree-removal-repo-owner' import { advertisedUrlWatcher } from '../ports/advertised-url-watcher' import { deleteWorktreeHistoryDir } from '../terminal-history-deletion' @@ -52,15 +53,36 @@ export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWith ((persistedHostId && persistedHostId !== hostId) || (repoId && hasWorktreeRemovalRepoOwnerOnOtherHost(store, repoId, hostId))) ) + const acceptedRendererSnapshot = this.acceptedRendererMobileSnapshotByWorktree.get(worktreeId) + const storedSnapshot = this.mobileSessionTabsByWorktree.get(worktreeId) if (hostId) { store.removeWorktreeMeta(worktreeId, hostId) } else { store.removeWorktreeMeta(worktreeId) } if (!preservesSameIdOwner) { + // A paired PTY can outlive the delete acknowledgement; it must not be + // rescued into a newly-created occupant of the same path-derived ID. + for (const ptyId of this.pairedRendererSessionOwnedPtyIds) { + const ptyWorktreeId = this.ptysById.get(ptyId)?.worktreeId + if (ptyWorktreeId && runtimeWorktreeIdsEqual(ptyWorktreeId, worktreeId)) { + this.pairedRendererSessionOwnedPtyIds.delete(ptyId) + } + } + const removedPublicationEpoch = + acceptedRendererSnapshot?.publicationEpoch ?? + storedSnapshot?.publicationEpoch ?? + this.rendererGeneration ?? + undefined + this.removedMobileSessionWorktreeIds.set( + worktreeId, + removedPublicationEpoch ? { removedPublicationEpoch } : {} + ) this.mobileSessionTabsByWorktree.delete(worktreeId) this.mobileSessionTabsAgentStatusHeartbeat.removeWorktree(worktreeId) this.acceptedRendererMobileSnapshotByWorktree.delete(worktreeId) + this.cancelScheduledMobileSessionTabsChanged(worktreeId) + this.notifyMobileSessionTabsRemoved(worktreeId) advertisedUrlWatcher.forgetWorktree(worktreeId) deleteWorktreeHistoryDir(worktreeId) this.closeHeadlessBrowserPagesForWorktree(worktreeId) diff --git a/src/main/runtime/orca-runtime-runtime-id.ts b/src/main/runtime/orca-runtime-runtime-id.ts index 2d56a587c08..da55f2229b6 100644 --- a/src/main/runtime/orca-runtime-runtime-id.ts +++ b/src/main/runtime/orca-runtime-runtime-id.ts @@ -126,6 +126,20 @@ export class OrcaRuntimeWithRuntimeId { } >() + // Why: worktree ids are path-derived and get recreated, so a renderer frame + // that raced the delete must be rejected by the removed occupant's identity. + // Entries are cleared once a snapshot carrying the successor's instanceId + // is accepted; identity-less frames are fenced by renderer generation. + protected readonly removedMobileSessionWorktreeIds = new Map< + string, + { + removedPublicationEpoch?: string + // Why: a rejected frame is still "published" on the renderer side, so a + // later unchanged-list mention must not spiral into resync requests. + rejectedPublication?: boolean + } + >() + protected clientSessionTabSelections = new ClientSessionTabSelectionStore() // Why: idempotency map for mobile terminal creation — a retried create with the diff --git a/src/main/runtime/orca-runtime-sync-mobile-session-tabs.ts b/src/main/runtime/orca-runtime-sync-mobile-session-tabs.ts index d8c00eda95d..4d54a322f3e 100644 --- a/src/main/runtime/orca-runtime-sync-mobile-session-tabs.ts +++ b/src/main/runtime/orca-runtime-sync-mobile-session-tabs.ts @@ -11,7 +11,8 @@ export class OrcaRuntimeWithSyncMobileSessionTabs extends OrcaRuntimeWithWriteOr protected syncMobileSessionTabs( snapshots: RuntimeMobileSessionTabsSnapshot[] | undefined, unchangedWorktreeIds?: string[], - resyncWorktreeIds = new Set() + resyncWorktreeIds = new Set(), + rendererGeneration?: string | null ): Set { const changedWorktreeIds = new Set() if (snapshots === undefined) { @@ -21,6 +22,44 @@ export class OrcaRuntimeWithSyncMobileSessionTabs extends OrcaRuntimeWithWriteOr // new object, and the accept gate below drops semantically-unchanged // renderer resends before they replace an entry — so reference identity // before/after detects exactly the entries that actually changed. + const blockedRecreatedWorktreeIds = new Set() + const acceptedSnapshots = snapshots.filter((snapshot) => { + const fence = this.removedMobileSessionWorktreeIds.get(snapshot.worktree) + if (!fence) { + return true + } + const reject = (): false => { + blockedRecreatedWorktreeIds.add(snapshot.worktree) + fence.rejectedPublication = true + return false + } + const currentMeta = this.store?.getWorktreeMeta(snapshot.worktree) + if (!currentMeta) { + return reject() + } + if (snapshot.worktreeInstanceId !== undefined) { + // Why: every catalog row carries an instanceId, so a mismatch against the + // live meta is exactly "not the current occupant" — no removed-id memory needed. + if (snapshot.worktreeInstanceId !== currentMeta.instanceId) { + return reject() + } + // Why: the successor's identity proves the race window closed; the + // instanceId mismatch alone fences any later frame from the old occupant. + this.removedMobileSessionWorktreeIds.delete(snapshot.worktree) + return true + } + // Identity-less frame: only the live renderer generation can speak for the + // successor, and the generation that published the removed occupant never + // can — a same-generation recreate stays fenced until the renderer reloads. + if ( + (typeof rendererGeneration === 'string' && + snapshot.publicationEpoch !== rendererGeneration) || + snapshot.publicationEpoch === fence.removedPublicationEpoch + ) { + return reject() + } + return true + }) const before = new Map(this.mobileSessionTabsByWorktree) this.restoreLivePairedRendererSessionOwnedMobileTerminals(null, { missingSnapshotOnly: true, @@ -31,7 +70,7 @@ export class OrcaRuntimeWithSyncMobileSessionTabs extends OrcaRuntimeWithWriteOr this.getWorkspaceSessionHydrationTargets(Boolean(this.offscreenBrowserBackend)) ) if (this.offscreenBrowserBackend) { - for (const snapshot of snapshots) { + for (const snapshot of acceptedSnapshots) { if (!worktreeSessionsToHydrate.has(snapshot.worktree)) { worktreeSessionsToHydrate.set(snapshot.worktree, null) } @@ -46,7 +85,10 @@ export class OrcaRuntimeWithSyncMobileSessionTabs extends OrcaRuntimeWithWriteOr }) } const nextWorktrees = new Set() - const incomingWorktreeIds = new Set(snapshots.map((snapshot) => snapshot.worktree)) + const incomingWorktreeIds = new Set(acceptedSnapshots.map((snapshot) => snapshot.worktree)) + for (const worktreeId of blockedRecreatedWorktreeIds) { + nextWorktrees.add(worktreeId) + } // Why: the renderer withholds unchanged snapshots to keep the graph payload // small, so these worktrees are still live and must not fall into the prune // below. Ask for a republish when main no longer holds that accepted renderer @@ -57,6 +99,12 @@ export class OrcaRuntimeWithSyncMobileSessionTabs extends OrcaRuntimeWithWriteOr if (existing) { nextWorktrees.add(worktreeId) } + // Why: a fenced frame stays "published" renderer-side; asking for a + // republish would only be fenced again on every sync. + if (!existing && this.removedMobileSessionWorktreeIds.get(worktreeId)?.rejectedPublication) { + nextWorktrees.add(worktreeId) + continue + } if ( existing && accepted && @@ -78,7 +126,7 @@ export class OrcaRuntimeWithSyncMobileSessionTabs extends OrcaRuntimeWithWriteOr // which outlives the dropped snapshot and would reject the republish. this.acceptedRendererMobileSnapshotByWorktree.delete(worktreeId) } - for (const snapshot of snapshots) { + for (const snapshot of acceptedSnapshots) { nextWorktrees.add(snapshot.worktree) const existing = this.mobileSessionTabsByWorktree.get(snapshot.worktree) // Why: judge renderer publication ordering against the renderer's own diff --git a/src/main/runtime/orca-runtime-sync-window-graph.ts b/src/main/runtime/orca-runtime-sync-window-graph.ts index 7f5a2bf7973..f9c6ab358e3 100644 --- a/src/main/runtime/orca-runtime-sync-window-graph.ts +++ b/src/main/runtime/orca-runtime-sync-window-graph.ts @@ -79,7 +79,8 @@ export class OrcaRuntimeWithSyncWindowGraph extends OrcaRuntimeWithAttachWindow const changedMobileWorktrees = this.syncMobileSessionTabs( graph.mobileSessionTabs, graph.unchangedMobileSessionWorktrees, - mobileSessionResyncWorktrees + mobileSessionResyncWorktrees, + rendererGeneration ) const nextLeaves = new Map() const graphSyncedAt = this.nextTitleObservationSequence() diff --git a/src/main/runtime/orca-runtime-terminal-create-deduplication.ts b/src/main/runtime/orca-runtime-terminal-create-deduplication.ts index 6d689ba7e3f..a1743a855ce 100644 --- a/src/main/runtime/orca-runtime-terminal-create-deduplication.ts +++ b/src/main/runtime/orca-runtime-terminal-create-deduplication.ts @@ -40,7 +40,14 @@ export class OrcaRuntimeWithTerminalCreateDeduplication extends OrcaRuntimeWithC clientMutationId, async () => { if (reconcileExisting) { - const adopted = await this.reconcileRemoteTerminalCreate(workspace.id, preAllocatedHandle) + const adopted = await this.reconcileRemoteTerminalCreate( + workspace.id, + preAllocatedHandle, + // Why: an unreachable SSH host vanishes from the aggregate listing, which would read + // as absence and respawn over live remote work. Local/folder workspaces have no + // connection and keep the aggregate listing. + workspace.connectionId ?? null + ) if (adopted) { return adopted } @@ -52,13 +59,16 @@ export class OrcaRuntimeWithTerminalCreateDeduplication extends OrcaRuntimeWithC protected async reconcileRemoteTerminalCreate( worktreeId: string, - terminalHandle: string + terminalHandle: string, + // Why: an aggregate listing drops a non-answering SSH host silently, which would read as + // absence. Scoping to the owning host makes an unreachable relay throw instead. + connectionId?: string | null ): Promise { if (!this.ptyController?.listProcesses) { throw new Error('runtime_unavailable') } const listed = await withTimeoutResult( - this.ptyController.listProcesses(), + this.ptyController.listProcesses(connectionId), PTY_CONTROLLER_LIST_TIMEOUT_MS ) if (!listed.ok) { diff --git a/src/main/runtime/orca-runtime-terminal-create-idempotency.test.ts b/src/main/runtime/orca-runtime-terminal-create-idempotency.test.ts index 9afafb4272f..f8f8600dc1f 100644 --- a/src/main/runtime/orca-runtime-terminal-create-idempotency.test.ts +++ b/src/main/runtime/orca-runtime-terminal-create-idempotency.test.ts @@ -10,14 +10,18 @@ type CreateRun = ( preAllocatedHandle: string | undefined ) => Promise -function createRuntimeForDedupe(listProcesses = vi.fn(async (): Promise => [])) { +function createRuntimeForDedupe( + listProcesses = vi.fn(async (): Promise => []), + scope: { connectionId?: string | null } = {} +) { const handleByPtyId = new Map() const runtime = Object.create(OrcaRuntimeService.prototype) as OrcaRuntimeService Object.assign(runtime, { terminalCreateIdempotency: new RemoteRuntimeTerminalCreateIdempotency(), ptyController: { listProcesses }, resolveTerminalWorkspaceLaunchScope: vi.fn(async (selector: string) => ({ - id: selector.startsWith('id:') ? selector.slice(3) : selector + id: selector.startsWith('id:') ? selector.slice(3) : selector, + ...scope })), adoptControllerTerminalHandle: vi.fn((ptyId: string, handle: string) => { handleByPtyId.set(ptyId, handle) @@ -253,3 +257,126 @@ describe('terminal create idempotency', () => { ).resolves.toEqual(createdTerminal('terminal-2')) }) }) + +// Mirrors listProcessesFromRuntimeController: `undefined` aggregates every provider and +// silently drops a non-answering SSH host, `null` is local-only, a string is host-scoped +// and rethrows the host's failure. +function createHostScopedInventory(hosts: { + local?: PtyProcessInfo[] + ssh?: Record +}) { + const local = hosts.local ?? [] + const ssh = hosts.ssh ?? {} + return vi.fn(async (connectionId?: string | null): Promise => { + if (connectionId === null) { + return local + } + if (typeof connectionId === 'string') { + const host = ssh[connectionId] + if (host === undefined || host === 'unreachable') { + throw new Error('ssh relay did not answer') + } + return host + } + return [ + ...local, + ...Object.values(ssh) + .filter((sessions): sessions is PtyProcessInfo[] => sessions !== 'unreachable') + .flat() + ] + }) +} + +function remoteSession(handle: string | undefined, worktreeId = 'worktree-1'): PtyProcessInfo { + return { + id: `${worktreeId}@@session-a`, + cwd: '/remote/workspace', + title: 'claude', + worktreeId, + ...(handle ? { terminalHandle: handle } : {}) + } +} + +describe('terminal create reconciliation scopes inventory to the owning execution host', () => { + it('reports runtime_unavailable instead of spawning a duplicate when the owning relay cannot answer', async () => { + const listProcesses = createHostScopedInventory({ + // The first create's shell is alive on ssh-1; the relay simply cannot be asked about it. + ssh: { 'ssh-1': 'unreachable', 'ssh-2': [remoteSession(undefined, 'worktree-9')] } + }) + const { runtime } = createRuntimeForDedupe(listProcesses, { connectionId: 'ssh-1' }) + const create = vi.fn() + + await expect( + runtime.dedupeTerminalCreate('device-a', 'id:worktree-1', 'mutation-1', true, create) + ).rejects.toThrow('runtime_unavailable') + expect(create).not.toHaveBeenCalled() + expect(listProcesses).toHaveBeenCalledWith('ssh-1') + }) + + it('adopts the original PTY from the owning host listing', async () => { + const handle = deriveRemoteRuntimeTerminalCreateHandle('device-a', 'worktree-1', 'mutation-1') + const listProcesses = createHostScopedInventory({ ssh: { 'ssh-1': [remoteSession(handle)] } }) + const { runtime } = createRuntimeForDedupe(listProcesses, { connectionId: 'ssh-1' }) + const create = vi.fn() + + await expect( + runtime.dedupeTerminalCreate('device-a', 'id:worktree-1', 'mutation-1', true, create) + ).resolves.toMatchObject({ handle, ptyId: 'worktree-1@@session-a' }) + expect(create).not.toHaveBeenCalled() + }) + + it('still creates a fresh terminal when the owning host authoritatively lacks the handle', async () => { + const listProcesses = createHostScopedInventory({ + ssh: { 'ssh-1': [remoteSession(undefined, 'worktree-other')] } + }) + const { runtime } = createRuntimeForDedupe(listProcesses, { connectionId: 'ssh-1' }) + const create = vi.fn(async (_selector, handle) => + createdTerminal(handle ?? 'missing') + ) + + const result = await runtime.dedupeTerminalCreate( + 'device-a', + 'id:worktree-1', + 'mutation-1', + true, + create + ) + + expect(create).toHaveBeenCalledWith('id:worktree-1', result.handle) + expect(listProcesses).toHaveBeenCalledWith('ssh-1') + }) + + it('scopes the listing to the local host for a workspace with no connection', async () => { + const handle = deriveRemoteRuntimeTerminalCreateHandle('device-a', 'worktree-1', 'mutation-1') + const listProcesses = createHostScopedInventory({ + local: [{ ...remoteSession(handle), cwd: '/local/workspace', title: 'pwsh' }] + }) + const { runtime } = createRuntimeForDedupe(listProcesses, { connectionId: null }) + const create = vi.fn() + + await expect( + runtime.dedupeTerminalCreate('device-a', 'id:worktree-1', 'mutation-1', true, create) + ).resolves.toMatchObject({ handle, ptyId: 'worktree-1@@session-a' }) + expect(listProcesses).toHaveBeenCalledWith(null) + expect(create).not.toHaveBeenCalled() + }) + + it('scopes the listing to the local host for a folder workspace with no connection', async () => { + const listProcesses = createHostScopedInventory({}) + const { runtime } = createRuntimeForDedupe(listProcesses, { connectionId: null }) + const create = vi.fn(async (_selector, handle) => + createdTerminal(handle ?? 'missing', 'folder:folder-1') + ) + + const result = await runtime.dedupeTerminalCreate( + 'device-a', + 'id:folder:folder-1', + 'mutation-1', + true, + create + ) + + expect(create).toHaveBeenCalledWith('id:folder:folder-1', result.handle) + expect(listProcesses).toHaveBeenCalledWith(null) + }) +}) diff --git a/src/main/runtime/orca-runtime-tests/browser-capabilities.spec.ts b/src/main/runtime/orca-runtime-tests/browser-capabilities.spec.ts index 964d69f64d8..6b09d1263b1 100644 --- a/src/main/runtime/orca-runtime-tests/browser-capabilities.spec.ts +++ b/src/main/runtime/orca-runtime-tests/browser-capabilities.spec.ts @@ -501,6 +501,23 @@ describe('OrcaRuntimeService', () => { expect(closeTab).toHaveBeenCalledTimes(2) }) + it('does not rescue a paired renderer PTY into a recreated worktree', () => { + const runtime = createRuntime() + const ptyId = 'paired-pty-deleted-worktree' + runtime.registerPty(ptyId, TEST_WORKTREE_ID, null, { + tabId: 'tab-deleted-worktree', + leafId: 'leaf-deleted-worktree' + }) + const internals = runtime as unknown as { + pairedRendererSessionOwnedPtyIds: Set + } + internals.pairedRendererSessionOwnedPtyIds.add(ptyId) + + runtime['removeWorktreeMetadataAndHistory'](store as never, TEST_WORKTREE_ID) + + expect(internals.pairedRendererSessionOwnedPtyIds.has(ptyId)).toBe(false) + }) + it('closes a worktree’s client-hosted browser pages when its metadata is removed (leak fix)', async () => { const runtime = createRuntime() const host = attachClientBrowserHost(runtime) diff --git a/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation.spec.ts index 64caa486013..65da9caccde 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation.spec.ts @@ -404,27 +404,36 @@ describe('OrcaRuntimeService', () => { wslDistro: 'Ubuntu' } ) - expect(gitSpy).toHaveBeenCalledWith( + // Why: a fork remote is deferred to first push/pull/fetch/fast-forward + // (#17828) instead of being added/fetched at create time, so the create + // path must not run check-ref-format, the fork fetch, or set-upstream-to + // -- the metadata is persisted untouched for on-demand materialization. + expect(gitSpy).not.toHaveBeenCalledWith( ['check-ref-format', '--branch', 'contributor/runtime-wsl'], - { cwd: TEST_REPO_PATH, wslDistro: 'Ubuntu' } + expect.anything() ) - expect(gitSpy).toHaveBeenCalledWith( + expect(gitSpy).not.toHaveBeenCalledWith( [ 'fetch', 'pr-contributor-orca', '+refs/heads/contributor/runtime-wsl*:refs/remotes/pr-contributor-orca/contributor/runtime-wsl*' ], - { cwd: TEST_REPO_PATH, wslDistro: 'Ubuntu' } + expect.anything() ) - expect(gitSpy).toHaveBeenCalledWith( + expect(gitSpy).not.toHaveBeenCalledWith( [ 'branch', '--set-upstream-to', 'pr-contributor-orca/contributor/runtime-wsl', 'runtime-wsl' ], - { cwd: createdWorktree.path, wslDistro: 'Ubuntu' } + expect.anything() ) + expect(result.worktree.pushTarget).toEqual({ + remoteName: 'pr-contributor-orca', + branchName: 'contributor/runtime-wsl', + remoteUrl: 'git@github.com:contributor/orca.git' + }) expect(listWorktrees).toHaveBeenCalledWith(TEST_REPO_PATH, { wslDistro: 'Ubuntu' }) } finally { gitSpy.mockRestore() diff --git a/src/main/runtime/relay/mobile-relay-e2ee.integration.test.ts b/src/main/runtime/relay/mobile-relay-e2ee.integration.test.ts index 1f2b50e0648..bf9ec2ce431 100644 --- a/src/main/runtime/relay/mobile-relay-e2ee.integration.test.ts +++ b/src/main/runtime/relay/mobile-relay-e2ee.integration.test.ts @@ -61,7 +61,8 @@ describe('desktop relay E2EE integration', () => { }) it('splices a simulated phone through CloudRelayTransport with real NaCl E2EE v2', async () => { - const relay = new WebSocketServer({ port: 0, perMessageDeflate: false }) + // host must match the 127.0.0.1 clients dial: a wildcard bind lets a foreign loopback listener claim the port and answer here. + const relay = new WebSocketServer({ host: '127.0.0.1', port: 0, perMessageDeflate: false }) servers.push(relay) await new Promise((resolve) => relay.once('listening', resolve)) const address = relay.address() diff --git a/src/main/runtime/relay/relay-control-client.test.ts b/src/main/runtime/relay/relay-control-client.test.ts index a1ad5c03482..2975b67e631 100644 --- a/src/main/runtime/relay/relay-control-client.test.ts +++ b/src/main/runtime/relay/relay-control-client.test.ts @@ -99,7 +99,8 @@ describe('RelayControlClient', () => { }) it('rejects a control handshake that never receives a proof response', async () => { - const server = new WebSocketServer({ port: 0, perMessageDeflate: false }) + // host must match the 127.0.0.1 clients dial: a wildcard bind lets a foreign loopback listener claim the port and answer here. + const server = new WebSocketServer({ host: '127.0.0.1', port: 0, perMessageDeflate: false }) servers.push(server) await new Promise((resolve) => server.once('listening', resolve)) const address = server.address() @@ -129,7 +130,7 @@ describe('RelayControlClient', () => { }) it('settles an opening control immediately when ownership closes', async () => { - const server = new WebSocketServer({ port: 0, perMessageDeflate: false }) + const server = new WebSocketServer({ host: '127.0.0.1', port: 0, perMessageDeflate: false }) servers.push(server) await new Promise((resolve) => server.once('listening', resolve)) const address = server.address() @@ -165,7 +166,7 @@ describe('RelayControlClient', () => { }) it('proves the host key and drives control/data commands without URL credentials', async () => { - const server = new WebSocketServer({ port: 0, perMessageDeflate: false }) + const server = new WebSocketServer({ host: '127.0.0.1', port: 0, perMessageDeflate: false }) servers.push(server) await new Promise((resolve) => server.once('listening', resolve)) const address = server.address() 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/rpc/relay-transport.test.ts b/src/main/runtime/rpc/relay-transport.test.ts index 8b7303ed805..21b520c8c9e 100644 --- a/src/main/runtime/rpc/relay-transport.test.ts +++ b/src/main/runtime/rpc/relay-transport.test.ts @@ -28,7 +28,8 @@ describe('CloudRelayTransport', () => { }) it('authenticates one query-free host-data socket and forwards messages verbatim', async () => { - const server = new WebSocketServer({ port: 0, perMessageDeflate: false }) + // host must match the 127.0.0.1 clients dial: a wildcard bind lets a foreign loopback listener claim the port and answer here. + const server = new WebSocketServer({ host: '127.0.0.1', port: 0, perMessageDeflate: false }) servers.push(server) await new Promise((resolve) => server.once('listening', resolve)) const address = server.address() diff --git a/src/main/runtime/runtime-git-command-target.ts b/src/main/runtime/runtime-git-command-target.ts index 540fd86f5c5..47131ce7e63 100644 --- a/src/main/runtime/runtime-git-command-target.ts +++ b/src/main/runtime/runtime-git-command-target.ts @@ -1,6 +1,6 @@ import type { GlobalSettings } from '../../shared/global-settings-types' import type { Repo } from '../../shared/repo-types' -import type { GitWorktreeInfo, Worktree } from '../../shared/worktree/types' +import type { GitPushTarget, GitWorktreeInfo, Worktree } from '../../shared/worktree/types' import type { GitRuntimeOptions } from '../git/git-runtime-options' import type { CommitMessageAgentEnvironmentResolvers } from '../text-generation/commit-message-agent-environment' import type { PullRequestLinkedIssueMeta } from '../source-control/pull-request-linked-issue' @@ -22,6 +22,11 @@ export type RuntimeGitCommandHost = { /** `undefined` keeps cached metadata; `null` is the authoritative unlinked answer. */ getWorktreeLinkedIssue?(worktreeId: string): number | null | undefined getWorktreeLinkedIssueMeta?(worktreeId: string): PullRequestLinkedIssueMeta | null | undefined + /** Why (#17828 review follow-up): RuntimeGitSyncCommands deliberately materializes with + * no store (avoids unrelated ownership-inheritance/refspec-migration side effects), so a + * lazily-minted remote still needs a way back into the store's `pushTarget.remoteCreated` + * for #17842's orphan sweep. Called only when materialize reports `remoteCreated: true`. */ + persistMaterializedPushTarget?(worktreeId: string, pushTarget: GitPushTarget): void } export function localGitOptionsForTarget(target: RuntimeGitTarget): GitRuntimeOptions { diff --git a/src/main/runtime/runtime-git-sync-commands.ts b/src/main/runtime/runtime-git-sync-commands.ts index 7cc892bac75..f68b82aac79 100644 --- a/src/main/runtime/runtime-git-sync-commands.ts +++ b/src/main/runtime/runtime-git-sync-commands.ts @@ -9,11 +9,32 @@ import { getSshGitProvider, SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE } from '../providers/ssh-git-dispatch' -import { localGitOptionsForTarget, type RuntimeGitCommandHost } from './runtime-git-command-target' +import { + materializeWorktreePushTargetRemote, + materializeWorktreePushTargetRemoteSsh +} from '../ipc/worktree-remote' +import { + localGitOptionsForTarget, + type RuntimeGitCommandHost, + type RuntimeGitTarget +} from './runtime-git-command-target' export class RuntimeGitSyncCommands { constructor(private readonly host: RuntimeGitCommandHost) {} + // Why (#17828 review follow-up): this class deliberately materializes with no store (see + // the `undefined` args below) to avoid unrelated ownership-inheritance/refspec-migration + // side effects on the RPC path -- so persistence goes through the host callback instead, + // using `target.worktree.id` already resolved here rather than threading a store through. + private persistMaterializedPushTargetIfCreated( + target: RuntimeGitTarget, + materialized: GitPushTarget | undefined + ): void { + if (materialized?.remoteCreated) { + this.host.persistMaterializedPushTarget?.(target.worktree.id, materialized) + } + } + async abortRuntimeGitMerge(worktreeSelector: string): Promise<{ ok: true }> { const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null @@ -73,10 +94,24 @@ export class RuntimeGitSyncCommands { if (!provider) { throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) } - await provider.fetchRemote(target.worktree.path, pushTarget) + const materializedPushTarget = pushTarget + ? await materializeWorktreePushTargetRemoteSsh(provider, target.worktree.path, pushTarget) + : undefined + this.persistMaterializedPushTargetIfCreated(target, materializedPushTarget) + await provider.fetchRemote(target.worktree.path, materializedPushTarget) return { ok: true } } - await gitFetch(target.worktree.path, pushTarget, { + const materializedPushTarget = pushTarget + ? await materializeWorktreePushTargetRemote( + target.worktree.path, + pushTarget, + undefined, + target.repo?.id, + localGitOptionsForTarget(target) + ) + : undefined + this.persistMaterializedPushTargetIfCreated(target, materializedPushTarget) + await gitFetch(target.worktree.path, materializedPushTarget, { ...localGitOptionsForTarget(target), admissionTier: 'interactive' }) @@ -111,10 +146,24 @@ export class RuntimeGitSyncCommands { if (!provider) { throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) } - await provider.pullBranch(target.worktree.path, pushTarget) + const materializedPushTarget = pushTarget + ? await materializeWorktreePushTargetRemoteSsh(provider, target.worktree.path, pushTarget) + : undefined + this.persistMaterializedPushTargetIfCreated(target, materializedPushTarget) + await provider.pullBranch(target.worktree.path, materializedPushTarget) return { ok: true } } - await gitPull(target.worktree.path, pushTarget, { + const materializedPushTarget = pushTarget + ? await materializeWorktreePushTargetRemote( + target.worktree.path, + pushTarget, + undefined, + target.repo?.id, + localGitOptionsForTarget(target) + ) + : undefined + this.persistMaterializedPushTargetIfCreated(target, materializedPushTarget) + await gitPull(target.worktree.path, materializedPushTarget, { ...localGitOptionsForTarget(target), admissionTier: 'interactive' }) @@ -131,10 +180,24 @@ export class RuntimeGitSyncCommands { if (!provider) { throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) } - await provider.fastForwardBranch(target.worktree.path, pushTarget) + const materializedPushTarget = pushTarget + ? await materializeWorktreePushTargetRemoteSsh(provider, target.worktree.path, pushTarget) + : undefined + this.persistMaterializedPushTargetIfCreated(target, materializedPushTarget) + await provider.fastForwardBranch(target.worktree.path, materializedPushTarget) return { ok: true } } - await gitFastForward(target.worktree.path, pushTarget, { + const materializedPushTarget = pushTarget + ? await materializeWorktreePushTargetRemote( + target.worktree.path, + pushTarget, + undefined, + target.repo?.id, + localGitOptionsForTarget(target) + ) + : undefined + this.persistMaterializedPushTargetIfCreated(target, materializedPushTarget) + await gitFastForward(target.worktree.path, materializedPushTarget, { ...localGitOptionsForTarget(target), admissionTier: 'interactive' }) @@ -170,12 +233,26 @@ export class RuntimeGitSyncCommands { if (!provider) { throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) } - await provider.pushBranch(target.worktree.path, publish === true, pushTarget, { + const materializedPushTarget = pushTarget + ? await materializeWorktreePushTargetRemoteSsh(provider, target.worktree.path, pushTarget) + : undefined + this.persistMaterializedPushTargetIfCreated(target, materializedPushTarget) + await provider.pushBranch(target.worktree.path, publish === true, materializedPushTarget, { forceWithLease: forceWithLease === true }) return { ok: true } } - await gitPush(target.worktree.path, publish === true, pushTarget, { + const materializedPushTarget = pushTarget + ? await materializeWorktreePushTargetRemote( + target.worktree.path, + pushTarget, + undefined, + target.repo?.id, + localGitOptionsForTarget(target) + ) + : undefined + this.persistMaterializedPushTargetIfCreated(target, materializedPushTarget) + await gitPush(target.worktree.path, publish === true, materializedPushTarget, { forceWithLease: forceWithLease === true, ...localGitOptionsForTarget(target), admissionTier: 'interactive' diff --git a/src/main/runtime/runtime-local-git-worktree-create.ts b/src/main/runtime/runtime-local-git-worktree-create.ts index 528aa318cd4..4e4b3ebe0b5 100644 --- a/src/main/runtime/runtime-local-git-worktree-create.ts +++ b/src/main/runtime/runtime-local-git-worktree-create.ts @@ -2,10 +2,7 @@ import type { GitPushTarget, GitWorktreeInfo } from '../../shared/worktree/types import type { Repo } from '../../shared/repo-types' import { resolveCreatedWorktree } from '../ipc/created-worktree-reconciliation' import { normalizeSparseDirectories } from '../ipc/sparse-checkout-directories' -import { - configureCreatedWorktreePushTarget, - prepareWorktreePushTarget -} from '../ipc/worktree-remote' +import { configureCreatedWorktreePushTarget } from '../ipc/worktree-remote' import { addSparseWorktree, addWorktree, @@ -129,15 +126,11 @@ export async function createRuntimeLocalGitWorktree(args: { if (args.request.sparseCheckout && sparseDirectories.length === 0) { throw new Error('Sparse checkout requires at least one repo-relative directory.') } + // Why: defer the remote add + fetch (fork case) or the redundant re-fetch + // (same-repo case, already fetched while resolving the PR start point) to + // first use -- push/pull/fetch/fast-forward materialize it on demand + // (#17828). Metadata is persisted untouched; only the git mutation defers. const preparedPushTarget = args.request.pushTarget - ? await prepareWorktreePushTarget( - args.repo.path, - args.request.pushTarget, - args.store, - args.repo.id, - args.localWorktreeGitOptions - ) - : undefined const suggestLocalBaseRefUpdate = !args.settings.refreshLocalBaseRefOnWorktreeCreate && !args.settings.localBaseRefSuggestionDismissed && @@ -185,7 +178,7 @@ export async function createRuntimeLocalGitWorktree(args: { )) ?? {}) let addResult: AddWorktreeResult try { - const preparedResult = + const preparedAttempt = sparseDirectories.length === 0 && !args.checkoutExistingBranch ? await consumePreparedWorktreeCreate({ repoPath: args.repo.path, @@ -197,8 +190,9 @@ export async function createRuntimeLocalGitWorktree(args: { ...(preparedWorktreeOptions ? { options: preparedWorktreeOptions } : {}) }) : null - if (preparedResult) { - addResult = preparedResult + // This path has no create-span recorder, so the miss reason is only observable on the IPC path. + if (preparedAttempt?.status === 'hit') { + addResult = preparedAttempt.result } else if (sparseDirectories.length > 0) { addResult = (await (addOptions @@ -241,14 +235,18 @@ export async function createRuntimeLocalGitWorktree(args: { args.effectiveSanitizedName! ) } - const configuredPushTarget = preparedPushTarget - ? await configureCreatedWorktreePushTarget( - args.worktreePath, - args.branchName, - preparedPushTarget, - args.localWorktreeGitOptions - ) - : undefined + // Why: `--set-upstream-to` requires the remote to already exist -- safe for a + // same-repo target (its remote, e.g. `origin`, always exists) but not for a + // deferred fork remote, which is materialized lazily at first push/pull/fetch. + const configuredPushTarget = + preparedPushTarget && !preparedPushTarget.remoteUrl + ? await configureCreatedWorktreePushTarget( + args.worktreePath, + args.branchName, + preparedPushTarget, + args.localWorktreeGitOptions + ) + : preparedPushTarget const { created } = await resolveCreatedWorktree( args.repo.path, args.worktreePath, 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-project-group-controller-folder-delete.test.ts b/src/main/runtime/runtime-project-group-controller-folder-delete.test.ts new file mode 100644 index 00000000000..1cb7209ba65 --- /dev/null +++ b/src/main/runtime/runtime-project-group-controller-folder-delete.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, vi } from 'vitest' +import { RuntimeProjectGroupController } from './runtime-project-group-controller' +import type { FolderWorkspace } from '../../shared/folder-workspace-types' + +const workspace = { + id: 'ws-1', + projectGroupId: 'group-1', + folderPath: '/tmp/ws' +} as FolderWorkspace + +function createController( + resolveFolderConnectionId: (workspace: FolderWorkspace) => string | null +) { + const removeFolderWorkspace = vi.fn(() => true) + const teardownFolderWorkspacePtys = vi.fn(async () => undefined) + const cleanupRemovedFolderWorkspaceState = vi.fn() + const notifyReposChanged = vi.fn() + const controller = new RuntimeProjectGroupController({ + getStore: () => ({ getFolderWorkspaces: () => [workspace], removeFolderWorkspace }) as never, + resolveRepo: async () => { + throw new Error('unused') + }, + notifyReposChanged, + resolveFolderConnectionId, + teardownFolderWorkspacePtys, + cleanupRemovedFolderWorkspaceState + }) + return { + controller, + removeFolderWorkspace, + teardownFolderWorkspacePtys, + cleanupRemovedFolderWorkspaceState, + notifyReposChanged + } +} + +describe('RuntimeProjectGroupController.deleteFolderWorkspace', () => { + it('tears down PTYs and runtime state before removing the catalog row', async () => { + const deps = createController(() => 'ssh-1') + + await expect(deps.controller.deleteFolderWorkspace('ws-1')).resolves.toEqual({ deleted: true }) + + expect(deps.teardownFolderWorkspacePtys).toHaveBeenCalledWith('folder:ws-1', 'ssh-1') + expect(deps.cleanupRemovedFolderWorkspaceState).toHaveBeenCalledWith('folder:ws-1') + expect(deps.teardownFolderWorkspacePtys.mock.invocationCallOrder[0]).toBeLessThan( + deps.removeFolderWorkspace.mock.invocationCallOrder[0]! + ) + expect(deps.notifyReposChanged).toHaveBeenCalledTimes(1) + }) + + it('still deletes when the folder host is ambiguous, skipping only the PTY sweep', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const deps = createController(() => { + throw new Error('folder_workspace_connection_ambiguous') + }) + + await expect(deps.controller.deleteFolderWorkspace('ws-1')).resolves.toEqual({ deleted: true }) + + expect(deps.teardownFolderWorkspacePtys).not.toHaveBeenCalled() + expect(deps.cleanupRemovedFolderWorkspaceState).toHaveBeenCalledWith('folder:ws-1') + expect(deps.removeFolderWorkspace).toHaveBeenCalledWith('ws-1') + warn.mockRestore() + }) +}) diff --git a/src/main/runtime/runtime-project-group-controller.ts b/src/main/runtime/runtime-project-group-controller.ts index 05d12c43573..2530ef9ad15 100644 --- a/src/main/runtime/runtime-project-group-controller.ts +++ b/src/main/runtime/runtime-project-group-controller.ts @@ -12,11 +12,15 @@ import { } from '../project-groups/folder-workspace-path-status' import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import type { RuntimeStore } from './runtime-store-contract' +import { folderWorkspaceKey } from '../../shared/workspace-scope' type RuntimeProjectGroupDependencies = { getStore: () => RuntimeStore | null resolveRepo: (selector: string) => Promise notifyReposChanged: () => void + resolveFolderConnectionId: (workspace: FolderWorkspace) => string | null + teardownFolderWorkspacePtys: (worktreeId: string, connectionId: string | null) => Promise + cleanupRemovedFolderWorkspaceState: (worktreeId: string) => void } type FolderWorkspaceUpdates = Partial< @@ -211,6 +215,22 @@ export class RuntimeProjectGroupController { if (!store?.removeFolderWorkspace) { throw new Error('runtime_unavailable') } + const workspace = store.getFolderWorkspaces?.().find((entry) => entry.id === folderWorkspaceId) + if (workspace) { + const worktreeId = folderWorkspaceKey(folderWorkspaceId) + // Why: a mixed-host group has no single PTY target; forgetting the + // workspace must still succeed, so skip the sweep instead of failing. + let connectionId: string | null | undefined + try { + connectionId = this.deps.resolveFolderConnectionId(workspace) + } catch (error) { + console.warn(`[folder-workspace] skipping PTY teardown for ${worktreeId}:`, error) + } + if (connectionId !== undefined) { + await this.deps.teardownFolderWorkspacePtys(worktreeId, connectionId) + } + this.deps.cleanupRemovedFolderWorkspaceState(worktreeId) + } const deleted = store.removeFolderWorkspace(folderWorkspaceId) if (deleted) { this.deps.notifyReposChanged() diff --git a/src/main/runtime/runtime-remote-fetch-controller.ts b/src/main/runtime/runtime-remote-fetch-controller.ts index c48a8437a3d..b3b9df5dcba 100644 --- a/src/main/runtime/runtime-remote-fetch-controller.ts +++ b/src/main/runtime/runtime-remote-fetch-controller.ts @@ -1,4 +1,9 @@ import { GIT_FETCH_SKIP_AUTO_MAINTENANCE_CONFIG_ARGS } from '../../shared/git-fetch-auto-maintenance' +import { getCanonicalRepoKey } from '../git/canonical-repo-key' +import { + armLocalRepoRefMaintenance, + setRepoRefMaintenanceBusyProbe +} from '../git/local-repo-ref-maintenance' import { gitExecFileAsync } from '../git/runner' import { setBoundedMapEntry } from './runtime-async-boundaries' @@ -33,6 +38,11 @@ export class RuntimeRemoteFetchController { return this.fetchLastCompletedAt } + /** `${runtimeKey}::${gitCommonDir}` -- one repo on one execution host. */ + async getCanonicalRepoKey(repoPath: string, gitOptions: GitOptions = {}): Promise { + return getCanonicalRepoKey(repoPath, gitOptions) + } + async getCanonicalFetchKey( repoPath: string, remote: string, @@ -45,23 +55,41 @@ export class RuntimeRemoteFetchController { setBoundedMapEntry(this.canonicalFetchKeyCache, cacheKey, cached, REMOTE_FETCH_CACHE_MAX) return cached } - let resolved = cacheKey - try { - const { stdout } = await gitExecFileAsync( - ['rev-parse', '--path-format=absolute', '--git-common-dir'], - { cwd: repoPath, ...gitOptions } - ) - const commonDir = stdout.trim() - if (commonDir) { - resolved = `${runtimeKey}::${commonDir}::${remote}` - } - } catch { - // The caller path remains a safe serialization key when canonicalization fails. - } + const resolved = `${await this.getCanonicalRepoKey(repoPath, gitOptions)}::${remote}` setBoundedMapEntry(this.canonicalFetchKeyCache, cacheKey, resolved, REMOTE_FETCH_CACHE_MAX) return resolved } + /** + * Orca strips git's auto-maintenance off these fetches, so every one of them + * adds to a loose-ref backlog nothing else will ever pack. Arm the idle sweep + * that pays it back; each fetch pushes the attempt a further quiet period out. + */ + private armRefMaintenance(repoPath: string, gitOptions: GitOptions): void { + void this.getCanonicalRepoKey(repoPath, gitOptions) + .then((key) => { + setRepoRefMaintenanceBusyProbe(key, () => this.hasInflightFetchForRepo(key)) + armLocalRepoRefMaintenance({ + key, + repoPath, + ...(gitOptions.wslDistro ? { wslDistro: gitOptions.wslDistro } : {}) + }) + }) + .catch(() => { + // Maintenance is best effort; a repo we cannot name is a repo we skip. + }) + } + + private hasInflightFetchForRepo(repoKey: string): boolean { + const prefix = `${repoKey}::` + for (const key of this.fetchInflight.keys()) { + if (key.startsWith(prefix)) { + return true + } + } + return false + } + private enqueueRemoteFetch( remoteKey: string, runFetch: () => Promise @@ -123,6 +151,7 @@ export class RuntimeRemoteFetchController { }) ).finally(() => { this.fetchInflight.delete(key) + this.armRefMaintenance(repoPath, gitOptions) }) this.fetchInflight.set(key, promise) return promise @@ -178,6 +207,7 @@ export class RuntimeRemoteFetchController { }) }).finally(() => { this.fetchInflight.delete(key) + this.armRefMaintenance(repoPath, gitOptions) }) this.fetchInflight.set(key, promise) return promise diff --git a/src/main/runtime/runtime-remote-fetch-ref-maintenance.test.ts b/src/main/runtime/runtime-remote-fetch-ref-maintenance.test.ts new file mode 100644 index 00000000000..f577da24854 --- /dev/null +++ b/src/main/runtime/runtime-remote-fetch-ref-maintenance.test.ts @@ -0,0 +1,145 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +// Why: Orca's fetches are what create the loose-ref backlog (they suppress +// git's auto-maintenance), so the fetch controller is where the idle sweep has +// to be armed. These tests pin that wiring and the per-repo busy signal it +// hands the sweep. + +const gitExecFileAsyncMock = vi.hoisted(() => vi.fn()) +const armMock = vi.hoisted(() => vi.fn()) +const busyProbeMock = vi.hoisted(() => vi.fn()) + +vi.mock('../git/runner', async (importOriginal) => ({ + ...((await importOriginal()) as Record), + gitExecFileAsync: gitExecFileAsyncMock +})) + +vi.mock('../git/local-repo-ref-maintenance', async (importOriginal) => ({ + ...((await importOriginal()) as Record), + armLocalRepoRefMaintenance: armMock, + setRepoRefMaintenanceBusyProbe: busyProbeMock +})) + +import { _resetCanonicalRepoKeyCacheForTests } from '../git/canonical-repo-key' +import { RuntimeRemoteFetchController } from './runtime-remote-fetch-controller' + +function armedTargets(): { key: string }[] { + return armMock.mock.calls.map(([args]) => args as { key: string }) +} + +/** The per-repo "a fetch is in flight" answer the controller registers for a key. */ +function busyProbeFor(key: string): (() => boolean) | undefined { + return busyProbeMock.mock.calls.findLast(([registered]) => registered === key)?.[1] as + | (() => boolean) + | undefined +} + +beforeEach(() => { + _resetCanonicalRepoKeyCacheForTests() + gitExecFileAsyncMock.mockReset() + armMock.mockReset() + busyProbeMock.mockReset() + gitExecFileAsyncMock.mockImplementation(async (argv: string[]) => + argv[0] === 'rev-parse' ? { stdout: '/repo/.git\n', stderr: '' } : { stdout: '', stderr: '' } + ) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('fetch-armed ref maintenance', () => { + it('arms the sweep for the repo after a remote fetch, keyed by common dir', async () => { + const controller = new RuntimeRemoteFetchController() + + await controller.getOrStartRemoteFetch('/repo/worktrees/a', 'origin') + + expect(armedTargets().map((target) => target.key)).toEqual(['local::/repo/.git']) + }) + + it('gives every worktree of one repo the same maintenance key', async () => { + const controller = new RuntimeRemoteFetchController() + + await controller.getOrStartRemoteFetch('/repo/worktrees/a', 'origin') + await controller.getOrStartRemoteTrackingBaseRefresh('/repo/worktrees/b', { + remote: 'origin', + branch: 'main', + ref: 'refs/remotes/origin/main', + base: 'origin/main' + }) + + const keys = new Set(armedTargets().map((target) => target.key)) + expect(keys).toEqual(new Set(['local::/repo/.git'])) + }) + + it('scopes the key to the WSL distro that executes the repo', async () => { + const controller = new RuntimeRemoteFetchController() + + await controller.getOrStartRemoteFetch('//wsl$/Ubuntu/repo', 'origin', { + wslDistro: 'Ubuntu' + }) + + expect(armedTargets()[0]?.key).toBe('wsl:Ubuntu::/repo/.git') + }) + + it('does not collapse every repo onto one key on Git older than 2.31', async () => { + // Old Git echoes the unrecognized `--path-format` flag, exits 0, and prints a + // relative `.git`; taking that raw would name every repository identically. + gitExecFileAsyncMock.mockImplementation(async (argv: string[]) => + argv[0] === 'rev-parse' + ? { stdout: '--path-format=absolute\n.git\n', stderr: '' } + : { stdout: '', stderr: '' } + ) + const controller = new RuntimeRemoteFetchController() + + await controller.getOrStartRemoteFetch('/repo/one', 'origin') + await controller.getOrStartRemoteFetch('/repo/two', 'origin') + + expect(armedTargets().map((entry) => entry.key)).toEqual([ + 'local::/repo/one/.git', + 'local::/repo/two/.git' + ]) + }) + + it('reports the repo as busy while another fetch on it is in flight', async () => { + const controller = new RuntimeRemoteFetchController() + await controller.getOrStartRemoteFetch('/repo', 'first') + const isBusy = busyProbeFor('local::/repo/.git') + expect(isBusy?.()).toBe(false) + + let releaseFetch: (() => void) | undefined + gitExecFileAsyncMock.mockImplementation(async (argv: string[]) => { + if (argv[0] === 'rev-parse') { + return { stdout: '/repo/.git\n', stderr: '' } + } + await new Promise((resolve) => { + releaseFetch = resolve + }) + return { stdout: '', stderr: '' } + }) + const second = controller.getOrStartRemoteFetch('/repo', 'second') + await vi.waitFor(() => expect(releaseFetch).toBeDefined()) + expect(isBusy?.()).toBe(true) + + releaseFetch?.() + await second + expect(isBusy?.()).toBe(false) + }) + + it('arms even when the fetch fails, because a partial fetch still writes refs', async () => { + const controller = new RuntimeRemoteFetchController() + gitExecFileAsyncMock.mockImplementation(async (argv: string[]) => { + if (argv[0] === 'rev-parse') { + return { stdout: '/repo/.git\n', stderr: '' } + } + throw new Error('network is unreachable') + }) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + + await expect(controller.getOrStartRemoteFetch('/repo', 'origin')).resolves.toEqual({ + ok: false, + errorKind: 'git_error' + }) + expect(armedTargets()).toHaveLength(1) + }) +}) 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/runtime/runtime-terminal-contracts.ts b/src/main/runtime/runtime-terminal-contracts.ts index be6767d65c6..3ea47fd6598 100644 --- a/src/main/runtime/runtime-terminal-contracts.ts +++ b/src/main/runtime/runtime-terminal-contracts.ts @@ -54,9 +54,19 @@ export type TerminalCreateOptions = { deferMobileSessionPublish?: boolean } +/** Identity a fenced spawn can be re-found by in the execution host's own inventory. */ +export type AgentSessionCreateReclaimIdentity = { + worktreeId: string + connectionId: string | null + terminalHandle: string +} + export type AgentSessionCreateOperation = { fingerprint: string promise: Promise + // Why: a lost pty.spawn response leaves the host holding a live PTY the client + // never named; this is the name it was launched under, so a replay can adopt it. + reclaim: { identity?: AgentSessionCreateReclaimIdentity } } export type PtyForegroundAgentRefresh = { diff --git a/src/main/runtime/runtime-terminal-spawn-push-target-materialization.test.ts b/src/main/runtime/runtime-terminal-spawn-push-target-materialization.test.ts new file mode 100644 index 00000000000..748225de225 --- /dev/null +++ b/src/main/runtime/runtime-terminal-spawn-push-target-materialization.test.ts @@ -0,0 +1,176 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { GitPushTarget } from '../../shared/worktree/types' +import type { Repo } from '../../shared/repo-types' +import type { Store } from '../persistence' + +const { + materializeLocalMock, + materializeSshMock, + getSshGitProviderMock, + getLocalProjectWorktreeGitOptionsMock +} = vi.hoisted(() => ({ + materializeLocalMock: vi.fn(), + materializeSshMock: vi.fn(), + getSshGitProviderMock: vi.fn(), + getLocalProjectWorktreeGitOptionsMock: vi.fn() +})) +vi.mock('../ipc/worktree-remote', () => ({ + materializeWorktreePushTargetRemote: materializeLocalMock, + materializeWorktreePushTargetRemoteSsh: materializeSshMock +})) +vi.mock('../providers/ssh-git-dispatch', () => ({ + getSshGitProvider: getSshGitProviderMock +})) +vi.mock('../project-runtime-git-options', () => ({ + getLocalProjectWorktreeGitOptions: getLocalProjectWorktreeGitOptionsMock +})) + +import { triggerTerminalSpawnPushTargetMaterialization } from './runtime-terminal-spawn-push-target-materialization' + +const WORKTREE_PATH = '/repo/worktree' +const FORK_URL = 'git@github.com:contributor/orca.git' +const REPO_ID = 'repo-1' +const STORE = {} as Store +const LOCAL_REPO = { id: REPO_ID, path: '/repo', connectionId: null } as unknown as Repo +const SSH_REPO = { id: REPO_ID, path: '/repo', connectionId: 'conn-1' } as unknown as Repo + +function forkTarget(overrides: Partial = {}): GitPushTarget { + return { + remoteName: 'pr-contributor-orca', + branchName: 'contributor/fix', + remoteUrl: FORK_URL, + ...overrides + } +} + +// Flush the fire-and-forget microtask queue so assertions see the dispatched call. +const flush = (): Promise => new Promise((resolve) => setImmediate(resolve)) + +describe('triggerTerminalSpawnPushTargetMaterialization', () => { + let warnSpy: ReturnType + + beforeEach(() => { + materializeLocalMock.mockReset().mockResolvedValue(undefined) + materializeSshMock.mockReset().mockResolvedValue(undefined) + getSshGitProviderMock.mockReset() + getLocalProjectWorktreeGitOptionsMock.mockReset().mockReturnValue({}) + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + afterEach(() => { + warnSpy.mockRestore() + }) + + it('is a no-op when there is no push target', () => { + triggerTerminalSpawnPushTargetMaterialization(WORKTREE_PATH, undefined, LOCAL_REPO, STORE) + expect(materializeLocalMock).not.toHaveBeenCalled() + expect(materializeSshMock).not.toHaveBeenCalled() + }) + + it('is a no-op for a same-repo push target with no remoteUrl', () => { + triggerTerminalSpawnPushTargetMaterialization( + WORKTREE_PATH, + forkTarget({ remoteUrl: undefined }), + LOCAL_REPO, + STORE + ) + expect(materializeLocalMock).not.toHaveBeenCalled() + }) + + it('is a no-op when the target already reports remoteCreated', () => { + triggerTerminalSpawnPushTargetMaterialization( + WORKTREE_PATH, + forkTarget({ remoteCreated: true }), + LOCAL_REPO, + STORE + ) + expect(materializeLocalMock).not.toHaveBeenCalled() + }) + + it('materializes over the local transport with resolved WSL git options, repoId and worktreeId, fire-and-forget', () => { + getLocalProjectWorktreeGitOptionsMock.mockReturnValue({ wslDistro: 'Ubuntu' }) + const target = forkTarget() + const result = triggerTerminalSpawnPushTargetMaterialization( + WORKTREE_PATH, + target, + LOCAL_REPO, + STORE, + REPO_ID, + 'worktree-1' + ) + expect(result).toBeUndefined() + expect(getLocalProjectWorktreeGitOptionsMock).toHaveBeenCalledWith(STORE, LOCAL_REPO) + expect(materializeLocalMock).toHaveBeenCalledWith( + WORKTREE_PATH, + target, + STORE, + REPO_ID, + { wslDistro: 'Ubuntu' }, + 'worktree-1' + ) + expect(materializeSshMock).not.toHaveBeenCalled() + }) + + it('materializes over SSH when the repo has a connectionId and a provider is registered', () => { + const provider = { exec: vi.fn() } + getSshGitProviderMock.mockReturnValue(provider) + const target = forkTarget() + triggerTerminalSpawnPushTargetMaterialization( + WORKTREE_PATH, + target, + SSH_REPO, + STORE, + REPO_ID, + 'worktree-1' + ) + expect(getSshGitProviderMock).toHaveBeenCalledWith('conn-1') + expect(materializeSshMock).toHaveBeenCalledWith( + provider, + WORKTREE_PATH, + target, + STORE, + undefined, + 'worktree-1' + ) + expect(materializeLocalMock).not.toHaveBeenCalled() + expect(getLocalProjectWorktreeGitOptionsMock).not.toHaveBeenCalled() + }) + + it('is a no-op when the SSH connection has dropped (no registered provider)', () => { + getSshGitProviderMock.mockReturnValue(undefined) + triggerTerminalSpawnPushTargetMaterialization(WORKTREE_PATH, forkTarget(), SSH_REPO, STORE) + expect(materializeSshMock).not.toHaveBeenCalled() + expect(materializeLocalMock).not.toHaveBeenCalled() + }) + + it('falls back to default git options when WSL project runtime resolution throws', () => { + getLocalProjectWorktreeGitOptionsMock.mockImplementation(() => { + throw new Error('repair-required') + }) + triggerTerminalSpawnPushTargetMaterialization(WORKTREE_PATH, forkTarget(), LOCAL_REPO, STORE) + expect(materializeLocalMock).toHaveBeenCalledWith( + WORKTREE_PATH, + forkTarget(), + STORE, + undefined, + {}, + undefined + ) + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('failed to resolve local git options'), + expect.any(Error) + ) + }) + + it('swallows a materialize rejection instead of crashing the caller', async () => { + materializeLocalMock.mockRejectedValue(new Error('remote add failed')) + expect(() => + triggerTerminalSpawnPushTargetMaterialization(WORKTREE_PATH, forkTarget(), LOCAL_REPO, STORE) + ).not.toThrow() + await flush() + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('failed to materialize push target remote'), + expect.any(Error) + ) + }) +}) diff --git a/src/main/runtime/runtime-terminal-spawn-push-target-materialization.ts b/src/main/runtime/runtime-terminal-spawn-push-target-materialization.ts new file mode 100644 index 00000000000..958ccb99d14 --- /dev/null +++ b/src/main/runtime/runtime-terminal-spawn-push-target-materialization.ts @@ -0,0 +1,84 @@ +import { getSshGitProvider } from '../providers/ssh-git-dispatch' +import { + materializeWorktreePushTargetRemote, + materializeWorktreePushTargetRemoteSsh +} from '../ipc/worktree-remote' +import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' +import type { GitPushTarget } from '../../shared/worktree/types' +import type { Repo } from '../../shared/repo-types' +import type { Store } from '../persistence' + +// Why (#17828): a fork-PR remote deferred at worktree-create time must exist before an +// autonomous agent's raw git commands run in a freshly opened terminal -- "sync through +// Orca first" isn't an option mid-task. Fires on every terminal spawn into the worktree; +// materialize() is already a no-op once the remote exists, so repeat spawns cost one probe. +// Never awaited by callers: terminal spawn must not block on remote-add/fetch network I/O. +export function triggerTerminalSpawnPushTargetMaterialization( + worktreePath: string, + pushTarget: GitPushTarget | undefined, + repo: Repo | null | undefined, + store: Store | undefined, + repoId?: string, + worktreeId?: string +): void { + if (!pushTarget?.remoteUrl || pushTarget.remoteCreated) { + return + } + const connectionId = repo?.connectionId ?? undefined + const materialized = connectionId + ? materializeOverSsh(connectionId, worktreePath, pushTarget, store, worktreeId) + : materializeWorktreePushTargetRemote( + worktreePath, + pushTarget, + store, + repoId, + localGitOptionsForTerminalSpawn(store, repo), + worktreeId + ) + materialized.catch((error: unknown) => { + console.warn( + `[terminal-spawn] failed to materialize push target remote for ${worktreePath}:`, + error + ) + }) +} + +function materializeOverSsh( + connectionId: string, + worktreePath: string, + pushTarget: GitPushTarget, + store: Store | undefined, + worktreeId: string | undefined +): Promise { + const provider = getSshGitProvider(connectionId) + if (!provider) { + // Why: connection dropped -- the next Orca-driven sync action will retry via its own dispatch. + return Promise.resolve(pushTarget) + } + return materializeWorktreePushTargetRemoteSsh( + provider, + worktreePath, + pushTarget, + store, + undefined, + worktreeId + ) +} + +function localGitOptionsForTerminalSpawn( + store: Store | undefined, + repo: Repo | null | undefined +): { wslDistro?: string } { + if (!store || !repo) { + return {} + } + try { + // Why: a WSL-hosted repo's remote add/fetch must run under the same distro as + // the terminal, or it can target the wrong git binary entirely (repair-required + // project runtimes throw here -- fall back to host git rather than crash spawn). + return getLocalProjectWorktreeGitOptions(store, repo) + } catch (error) { + console.warn(`[terminal-spawn] failed to resolve local git options for ${repo.path}:`, error) + return {} + } +} diff --git a/src/main/skills/skill-freshness-inventory.test.ts b/src/main/skills/skill-freshness-inventory.test.ts index 4ef015cee27..7d160ba704b 100644 --- a/src/main/skills/skill-freshness-inventory.test.ts +++ b/src/main/skills/skill-freshness-inventory.test.ts @@ -875,41 +875,45 @@ describe('read-only skill freshness inventory', () => { ]) }) - it('invents no installations when the plugin cache trips the entry budget (#10918)', async () => { - const test = await fixture() - await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.currentMarkdown) - const pluginCache = join(test.homeDir, '.codex', 'plugins', 'cache') - await mkdir(pluginCache, { recursive: true }) - // Why: the production bound, not an injected one — #10918 is the real constant - // collapsing the scan to the cache root, and only a real cache proves that path. - const entries = Array.from({ length: MAXIMUM_PLUGIN_SCAN_ENTRIES + 1 }, (_, index) => - join(pluginCache, `entry-${index}`) - ) - for (let index = 0; index < entries.length; index += 512) { - await Promise.all(entries.slice(index, index + 512).map((path) => writeFile(path, ''))) - } + it.skipIf(process.platform === 'win32')( + 'invents no installations when the plugin cache trips the entry budget (#10918)', + async () => { + const test = await fixture() + await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.currentMarkdown) + const pluginCache = join(test.homeDir, '.codex', 'plugins', 'cache') + await mkdir(pluginCache, { recursive: true }) + // Why: the production bound, not an injected one — #10918 is the real constant + // collapsing the scan to the cache root, and only a real cache proves that path. + const entries = Array.from({ length: MAXIMUM_PLUGIN_SCAN_ENTRIES + 1 }, (_, index) => + join(pluginCache, `entry-${index}`) + ) + for (let index = 0; index < entries.length; index += 512) { + await Promise.all(entries.slice(index, index + 512).map((path) => writeFile(path, ''))) + } - const inventory = await inventorySkillFreshness({ - currentAppVersion: '2.0.0', - homeDir: test.homeDir, - repos: [], - resourceRoot: test.resourceRoot - }) - - // Why: assert the bound actually tripped first — if the fixture stopped reaching it, - // the placement assertion below would still pass and cover nothing. - expect(inventory.scanIssues).toEqual([ - expect.objectContaining({ - rootId: 'codex-plugin-cache', - path: pluginCache, - reason: 'entry-limit', - errorCode: null + const inventory = await inventorySkillFreshness({ + currentAppVersion: '2.0.0', + homeDir: test.homeDir, + repos: [], + resourceRoot: test.resourceRoot }) - ]) - // Why: the truncated root is not evidence of a copy. Fabricating one per manifest name - // is what pinned an unclearable "Needs attention" on every card in #10918. - expect(inventory.installations).toEqual([ - expect.objectContaining({ name: 'orca-cli', status: 'current', topology: 'canonical-copy' }) - ]) - }, 90_000) + + // Why: assert the bound actually tripped first — if the fixture stopped reaching it, + // the placement assertion below would still pass and cover nothing. + expect(inventory.scanIssues).toEqual([ + expect.objectContaining({ + rootId: 'codex-plugin-cache', + path: pluginCache, + reason: 'entry-limit', + errorCode: null + }) + ]) + // Why: the truncated root is not evidence of a copy. Fabricating one per manifest name + // is what pinned an unclearable "Needs attention" on every card in #10918. + expect(inventory.installations).toEqual([ + expect.objectContaining({ name: 'orca-cli', status: 'current', topology: 'canonical-copy' }) + ]) + }, + 90_000 + ) }) 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/ssh/remote-install-gc.ts b/src/main/ssh/remote-install-gc.ts index b14b7e11fba..a76d119cf8a 100644 --- a/src/main/ssh/remote-install-gc.ts +++ b/src/main/ssh/remote-install-gc.ts @@ -22,6 +22,7 @@ import { tryAcquireRelayGcClaim } from './ssh-relay-gc-claim' import { cleanupRelayGcTombstones } from './ssh-relay-gc-tombstone' +import { gcRelayNativeDepsCache } from './ssh-relay-native-deps-cache-gc' import { listRemoteInstallBaseDirsCommand, MAX_RELAY_GC_LISTING_ENTRIES, @@ -245,12 +246,25 @@ export async function gcOldRelayVersions( options?: { windowsNodePath?: string windowsSockNames?: string[] + /** + * Cache entries this connection depends on, whether or not it links to them. Also the gate: + * a caller that could not compute a key is not using the shared-cache model on this host, and + * a pass only ever collects what its own model created (see `remote-install-model.ts`). + */ + nativeDepsCacheKeys?: readonly string[] } ): Promise { await gcOldRemoteInstallVersions(conn, RELAY_INSTALL_MODEL, remoteHome, currentDirAbsPath, host, { ...options, isDirLive: (dir) => hasLiveRelaySocket(conn, dir, host, options) }) + // Why after and not before: version-dir removal is what turns a cache entry unreferenced, so + // running it second lets one pass reclaim both instead of leaving the tree for the next connect. + if (options?.nativeDepsCacheKeys?.length) { + await gcRelayNativeDepsCache(conn, host, remoteHome, { + pinnedKeys: options.nativeDepsCacheKeys + }).catch(() => {}) + } } async function hasLiveRelaySocket( diff --git a/src/main/ssh/ssh-relay-deploy-helpers.test.ts b/src/main/ssh/ssh-relay-deploy-helpers.test.ts index acda4594ca2..f17fc858164 100644 --- a/src/main/ssh/ssh-relay-deploy-helpers.test.ts +++ b/src/main/ssh/ssh-relay-deploy-helpers.test.ts @@ -550,6 +550,27 @@ describe('execCommand', () => { expect(channel.stderr.listenerCount('data')).toBe(0) }) + it('hands a zero-exit command stderr to onStderr instead of dropping it', async () => { + // Why: probes fenced with `|| echo MISSING` always exit 0, so the resolve path used to be the + // one place the failure reason was discarded. + const channel = createMockChannel() + const conn = { exec: vi.fn().mockResolvedValue(channel) } + const captured: string[] = [] + const commandPromise = execCommand(conn as never, "(node -e 'x' || echo MISSING)", { + onStderr: (stderr) => captured.push(stderr) + }) + + await Promise.resolve() + channel.stderr.emit('data', Buffer.from('node: --bogus is not allowed in NODE_OPTIONS\n')) + channel.emit('data', Buffer.from('MISSING\n')) + channel.emit('close', 0) + + await expect(commandPromise).resolves.toBe('MISSING\n') + expect(captured).toEqual(['node: --bogus is not allowed in NODE_OPTIONS\n']) + // onStderr must not leak into the SSH exec options. + expect(conn.exec).toHaveBeenCalledWith("(node -e 'x' || echo MISSING)", {}) + }) + it('uses custom command timeouts without forwarding them to SSH exec', async () => { vi.useFakeTimers() try { diff --git a/src/main/ssh/ssh-relay-deploy.test.ts b/src/main/ssh/ssh-relay-deploy.test.ts index 3134461fda1..fdd719cb91f 100644 --- a/src/main/ssh/ssh-relay-deploy.test.ts +++ b/src/main/ssh/ssh-relay-deploy.test.ts @@ -186,9 +186,9 @@ describe('deployAndLaunchRelay', () => { expect(progress).toContain('Starting relay...') }) - it('does not launch fresh after unconfirmed stale-socket cleanup', async () => { + it('does not launch fresh after an unconfirmed endpoint-incumbent probe', async () => { const conn = makeMockConnection() - const unconfirmedCleanup = Object.assign(new Error('socket cleanup still running'), { + const unconfirmedCleanup = Object.assign(new Error('endpoint probe still running'), { sshChannelCloseConfirmed: false }) vi.mocked(waitForSentinel).mockRejectedValueOnce(new Error('stale relay reconnect failed')) diff --git a/src/main/ssh/ssh-relay-deploy.ts b/src/main/ssh/ssh-relay-deploy.ts index 911d185b0fd..54c64a80b20 100644 --- a/src/main/ssh/ssh-relay-deploy.ts +++ b/src/main/ssh/ssh-relay-deploy.ts @@ -33,6 +33,12 @@ import { abandonInstall, gcOldRelayVersions } from './ssh-relay-versioned-install' +import { + attachRelayNativeDepsCache, + promoteRelayNativeDepsCache, + resolveRelayNativeDepsCacheKey, + type RelayNativeDepsCacheContext +} from './ssh-relay-native-deps-cache-install' import { acquireInstallLock } from './ssh-relay-install-lock' import { tryAcquireRelayRepairLock } from './ssh-relay-repair-lock' import { @@ -77,6 +83,8 @@ import { import { detectRemoteHostPlatform } from './ssh-remote-platform-detection' import { powerShellCommand, powerShellLiteral, powerShellNativeArg } from './ssh-remote-powershell' import { relaySocketNameForInstanceId } from './ssh-relay-instance-id' +import { resolveRelayEndpointBeforeRelaunch } from './ssh-relay-endpoint-takeover' +import { sweepSupersededRelayEndpoints } from './ssh-relay-superseded-endpoints' import { isSshSessionLimitError } from './ssh-session-limit-error' import { isWindowsRelayPipePath, @@ -116,12 +124,13 @@ function execHostCommand( conn: SshConnection, hostPlatform: RemoteHostPlatform, command: string, - options?: { timeoutMs?: number; signal?: AbortSignal } + options?: { timeoutMs?: number; signal?: AbortSignal; onStderr?: (stderr: string) => void } ): Promise { return execCommand(conn, command, { wrapCommand: !isWindowsRemoteHost(hostPlatform), timeoutMs: options?.timeoutMs, - signal: options?.signal + signal: options?.signal, + onStderr: options?.onStderr }) } @@ -519,7 +528,8 @@ async function deployAndLaunchRelayAttempt( nodePath, deploySignal, [], - launchNamespace + launchNamespace, + remoteHome ) console.log('[ssh-relay] Native deps installed') @@ -580,11 +590,31 @@ async function deployAndLaunchRelayAttempt( hostPlatform, recoverOneStaleRelayUploadStageCommand(hostPlatform, uploadStagePoolDir) ) + .catch(() => {}) + // Why before GC: a superseded relay pins its version dir via the live-socket probe, so the + // sweep has to settle first or GC keeps every orphan's tree forever. + .then(() => + sweepSupersededRelayEndpoints(conn, hostPlatform, { + remoteHome, + currentRelayDir: remoteRelayDir, + sockName: relaySocketNameForInstanceId(relayInstanceId), + nodePath: launched.nodePath + }) + ) .catch(() => {}) .then(() => gcOldRelayVersions(conn, remoteHome, remoteRelayDir, hostPlatform, { windowsNodePath: launched.nodePath, - windowsSockNames: [relaySocketNameForInstanceId(relayInstanceId)] + windowsSockNames: [relaySocketNameForInstanceId(relayInstanceId)], + // Why pin rather than rely on the symlink alone: a deploy that fell back to a + // per-directory install has no reference to show, and its key must still survive. + nativeDepsCacheKeys: [ + resolveRelayNativeDepsCacheKey({ + platform, + localRelayDir, + deps: RELAY_NATIVE_DEPS + }) + ].filter((key): key is string => key !== null) }) ) .catch(() => {}) @@ -722,24 +752,34 @@ function nativeDepsProbeJs(successToken: string): string { return `(()=>{const missing=[];try{${loadNodePty}}catch{missing.push("node-pty")}try{require("@parcel/watcher")}catch{missing.push("@parcel/watcher")}if(missing.length){console.log("${NATIVE_DEPS_MISSING_PREFIX}"+missing.join(","));process.exitCode=1}else{console.log(${JSON.stringify(successToken)})}})()` } -function missingNativeDepsFromProbe(output: string): RelayNativeDepName[] { +/** + * Which deps the probe *named* as unloadable, or `undefined` when the answer names none. + * + * Only the probe's own marker line is evidence about the deps. An answer without one (node never + * ran, was killed, exited before the script) says nothing, so it must not be read as "all of them" — + * that inference deleted both native modules on every reconnect of an affected host. + */ +function missingNativeDepsFromProbe(output: string): RelayNativeDepName[] | undefined { const marker = output .split(/\r?\n/) .find((line) => line.trim().startsWith(NATIVE_DEPS_MISSING_PREFIX)) if (!marker) { - return [...RELAY_NATIVE_DEP_NAMES] + return undefined } const reported = marker.trim().slice(NATIVE_DEPS_MISSING_PREFIX.length).split(',') - return RELAY_NATIVE_DEP_NAMES.filter((name) => reported.includes(name)) + const named = RELAY_NATIVE_DEP_NAMES.filter((name) => reported.includes(name)) + return named.length > 0 ? named : undefined } /** - * `ok` — the probe answered and both deps loaded. `blocked` — the probe answered and named deps - * that failed to load. `unverifiable` — the probe never answered, which is evidence about the - * transport, not about the deps. + * `ok` — the probe answered and both deps loaded. `blocked` — the probe answered with a marker + * naming deps that failed to load. `unverifiable` — the probe never answered, or answered nothing + * that names a dep; both are evidence about the probe, not about the deps. * * Why `unverifiable` is not `blocked`: repairing on it does `rm -rf node_modules/node-pty` and a - * node-gyp source build (no Linux prebuild) against a relay that was never shown to be broken. + * node-gyp source build (no Linux prebuild) against a relay that was never shown to be broken. An + * unparseable answer is the worse half of that — it is deterministic and per-host, so a node that + * cannot start (bad NODE_OPTIONS, OOM, exit 127) deleted both modules on every reconnect forever. * Same verdict discipline as `src/main/orcad/node-pty-precondition.ts` and * docs/reference/ssh-execution-boundary.md — loss of contact is not evidence. */ @@ -754,6 +794,7 @@ async function probeRequiredNativeDeps( ): Promise<{ status: RelayNativeDepsProbeStatus; missing: RelayNativeDepName[] }> { const escapedNode = shellEscape(nodePath) const probeJs = nativeDepsProbeJs('ORCA-NATIVE-DEPS-OK') + let probeStderr = '' try { const command = isWindowsRemoteHost(hostPlatform) ? commandWithNodePath( @@ -762,16 +803,32 @@ async function probeRequiredNativeDeps( remoteDir, `try { & ${powerShellLiteral(nodePath)} -e ${powerShellNativeArg(probeJs)} } catch { 'MISSING' }` ) - : commandWithNodePath( + : // Why: no `2>/dev/null` — it discarded the only line that says why node never reached the + // script. stderr stays its own stream so it can't be mistaken for the verdict, mirroring + // src/main/orcad/node-pty-precondition.ts. + commandWithNodePath( hostPlatform, nodePath, remoteDir, - `(${escapedNode} -e ${shellEscape(probeJs)} 2>/dev/null || echo MISSING)` + `(${escapedNode} -e ${shellEscape(probeJs)} || echo MISSING)` ) - const probe = await execHostCommand(conn, hostPlatform, command, { signal }) - return probe.includes('ORCA-NATIVE-DEPS-OK') - ? { status: 'ok', missing: [] } - : { status: 'blocked', missing: missingNativeDepsFromProbe(probe) } + const probe = await execHostCommand(conn, hostPlatform, command, { + signal, + onStderr: (text) => { + probeStderr = text + } + }) + if (probe.includes('ORCA-NATIVE-DEPS-OK')) { + return { status: 'ok', missing: [] } + } + const missing = missingNativeDepsFromProbe(probe) + if (!missing) { + console.warn( + `[ssh-relay][NATIVE-DEPS-PROBE-UNPARSEABLE] Probe at ${remoteDir} answered without naming a dep; launching as-is. stdout=${probe.trim().slice(-200)} stderr=${probeStderr.trim().slice(-500)}` + ) + return { status: 'unverifiable', missing: [] } + } + return { status: 'blocked', missing } } catch { signal?.throwIfAborted() // Why: an unanswered probe says nothing about the deps; reporting MISSING here reset and @@ -974,8 +1031,27 @@ async function installNativeDeps( nodePath: string, signal?: AbortSignal, resetDeps: RelayNativeDepName[] = [], - namespace?: RelayInstallNamespace + namespace?: RelayInstallNamespace, + remoteHome?: string ): Promise { + // Why a repair opts out: reset does `rm -rf node_modules/node-pty`, and through a shared + // symlink that is every relay on the host losing its addon. Repairs detach and install + // privately instead (the install command's own prefix drops the link). + const localRelayDir = resetDeps.length === 0 && remoteHome ? getLocalRelayPath(platform) : null + const cacheContext: RelayNativeDepsCacheContext | null = + remoteHome && localRelayDir + ? { + hostPlatform, + remoteHome, + relayDir: remoteDir, + platform, + localRelayDir, + deps: RELAY_NATIVE_DEPS, + signal + } + : null + const cache = cacheContext ? await attachRelayNativeDepsCache(conn, cacheContext) : null + const writeRelayPackageJson = async (deps: Record): Promise => { await writeRelayFile( conn, @@ -1003,13 +1079,32 @@ async function installNativeDeps( // Why: type:commonjs pins module resolution against Node default flips or a remote ~/.npmrc type=module. await writeRelayPackageJson(RELAY_NATIVE_DEPS) + if (cache?.mode === 'linked') { + await makeNodePtySpawnHelperExecutable(conn, remoteDir, hostPlatform, signal) + const linkedProbe = await probeInstalledNativeDeps( + conn, + remoteDir, + hostPlatform, + nodePath, + signal + ) + if (linkedProbe.available) { + return + } + // Why fall through rather than repair the entry: it is shared, and something else on this + // host may be running out of it right now. This directory installs its own copy instead. + console.warn( + `[ssh-relay][NATIVE-CACHE-UNUSABLE] shared entry ${cache.key} did not load at ${remoteDir} (${platform}); installing per-directory. stderr=${linkedProbe.stderr.trim().slice(-500)}` + ) + } + try { const installArgs = Object.entries(RELAY_NATIVE_DEPS) .map(([dep, version]) => shellEscape(`${dep}@${version}`)) .join(' ') // Why: npm reports a present package as up to date even if a native file was deleted; reset only deps the probe found broken. const resetCommand = resetNativeDepsCommand(hostPlatform, resetDeps) - const resetPrefix = resetCommand ? `${resetCommand}; ` : '' + const resetPrefix = `${detachSharedNativeDepsCommand(hostPlatform)}${resetCommand ? `${resetCommand}; ` : ''}` const command = isWindowsRemoteHost(hostPlatform) ? commandWithNodePath( hostPlatform, @@ -1118,6 +1213,12 @@ async function installNativeDeps( } } + // Why promotion is gated on the probe and not on npm's exit code: an entry is shared, so the + // only evidence worth publishing is this host having loaded both addons out of that tree. + if (probe.available && cacheContext && cache) { + await promoteRelayNativeDepsCache(conn, cacheContext, cache.key) + } + // MISSING is non-fatal by design: the relay still serves fs/git/preflight; only native-backed ops fail on hosts that can't build the addons. if (!probe.available) { console.warn( @@ -1126,6 +1227,20 @@ async function installNativeDeps( } } +/** + * Drop a shared-cache symlink before anything writes into `node_modules`. + * + * Why it prefixes every install rather than living in its own exec: `rm -rf node_modules/node-pty` + * and `npm install` both follow the link, so a repair on one relay directory would otherwise + * rewrite the tree every other relay on the host is running out of. + */ +function detachSharedNativeDepsCommand(hostPlatform: RemoteHostPlatform): string { + if (isWindowsRemoteHost(hostPlatform)) { + return '' + } + return 'if [ -L node_modules ]; then rm -f node_modules; fi; ' +} + function resetNativeDepsCommand( hostPlatform: RemoteHostPlatform, resetDeps: RelayNativeDepName[] @@ -1200,7 +1315,7 @@ async function installNativeDepsWithoutNodePty( hostPlatform, nodePath, remoteDir, - `${resetCommand}; npm install --ignore-scripts=false --omit=dev --no-audit --no-fund ${installArgs} 2>&1` + `${detachSharedNativeDepsCommand(hostPlatform)}${resetCommand}; npm install --ignore-scripts=false --omit=dev --no-audit --no-fund ${installArgs} 2>&1` ), { timeoutMs: NATIVE_DEPS_COMMAND_TIMEOUT_MS, signal } ) @@ -1337,7 +1452,8 @@ async function probeInstalledNativeDeps( } return { available: probeOutput.includes(PROBE_OK), - missing: probeOutput.includes(PROBE_OK) ? [] : missingNativeDepsFromProbe(probeOutput), + // A markerless answer names no dep, so it reports none; `available` already carries the failure. + missing: probeOutput.includes(PROBE_OK) ? [] : (missingNativeDepsFromProbe(probeOutput) ?? []), output: probeOutput, stderr: remoteStderr } @@ -1457,17 +1573,15 @@ async function launchRelay( } catch (err) { signal?.throwIfAborted() console.warn( - '[ssh-relay] Socket reconnect failed, launching fresh relay:', + '[ssh-relay] Socket reconnect failed, establishing what owns the endpoint:', err instanceof Error ? err.message : String(err) ) - // Why: stale socket from a crashed relay — remove it so the fresh launch can bind at the same path. - await execCommand(conn, `rm -f ${shellEscape(sockFile)}`, { signal }).catch( - (cleanupErr) => { - if (isUnconfirmedSshCommandTermination(cleanupErr)) { - throw cleanupErr - } - } - ) + // Why not `rm -f`: unlinking does not close the listener the incumbent already holds, + // so a refused --connect (version mismatch, rotated credential) used to leave a live + // relay running forever with its PTYs while a replacement bound the same path (#8585). + await resolveRelayEndpointBeforeRelaunch(conn, hostPlatform, nodePath, sockFile, err, { + signal + }) signal?.throwIfAborted() } } diff --git a/src/main/ssh/ssh-relay-endpoint-incumbent-shell.integration.test.ts b/src/main/ssh/ssh-relay-endpoint-incumbent-shell.integration.test.ts new file mode 100644 index 00000000000..7ece0b6532e --- /dev/null +++ b/src/main/ssh/ssh-relay-endpoint-incumbent-shell.integration.test.ts @@ -0,0 +1,170 @@ +/** + * The probe and reap scripts run on someone else's machine and decide whether a process is + * signalled, so the shell itself is the part worth testing for real. These cases run the + * generated scripts through /bin/sh against real unix sockets and real processes. + */ +import { execFile, spawn, type ChildProcess } from 'node:child_process' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest' +import { + isReapableRelayHusk, + parseRelayEndpointIncumbentProbe, + relayEndpointIncumbentProbeCommand, + type RelayEndpointIncumbent +} from './ssh-relay-endpoint-incumbent' +import { reapEmptyRelayHuskCommand } from './ssh-relay-endpoint-takeover' + +const posixOnly = process.platform === 'win32' ? describe.skip : describe + +const FAKE_RELAY_SOURCE = ` +const net = require('net') +const sock = process.argv[process.argv.indexOf('--sock-path') + 1] +if (process.argv.includes('--with-child')) { + require('child_process').spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { + stdio: 'ignore' + }) +} +net.createServer(() => {}).listen(sock, () => process.stdout.write('READY\\n')) +process.on('SIGTERM', () => process.exit(0)) +` + +function sh(script: string): Promise { + return new Promise((resolve, reject) => { + execFile('/bin/sh', ['-c', script], { timeout: 20_000 }, (error, stdout) => { + if (error) { + reject(error) + return + } + resolve(stdout) + }) + }) +} + +let workDir: string +let hasLsof = false +const running: ChildProcess[] = [] + +function startFakeRelay(sockPath: string, withChild = false): Promise { + const args = [join(workDir, 'relay.js'), '--sock-path', sockPath] + if (withChild) { + args.push('--with-child') + } + const child = spawn(process.execPath, args, { stdio: ['ignore', 'pipe', 'ignore'] }) + running.push(child) + return new Promise((resolve, reject) => { + child.stdout.on('data', (chunk: Buffer) => { + if (chunk.toString().includes('READY')) { + resolve(child) + } + }) + child.on('exit', () => reject(new Error('fake relay exited before listening'))) + }) +} + +async function probe(sockPath: string): Promise { + const output = await sh(relayEndpointIncumbentProbeCommand(process.execPath, sockPath)) + return parseRelayEndpointIncumbentProbe(sockPath, output) +} + +beforeAll(async () => { + workDir = mkdtempSync(join(tmpdir(), 'orca-relay-incumbent-')) + writeFileSync(join(workDir, 'relay.js'), FAKE_RELAY_SOURCE) + hasLsof = await sh('command -v lsof >/dev/null 2>&1 && echo yes || echo no').then( + (out) => out.trim() === 'yes' + ) +}) + +afterEach(() => { + while (running.length > 0) { + running.pop()?.kill('SIGKILL') + } +}) + +afterAll(() => { + rmSync(workDir, { recursive: true, force: true }) +}) + +it('runs the holder-enumeration assertions on this machine', () => { + // Why asserted rather than assumed: the cases below degrade to verdict-only checks without + // lsof, and a silently degraded suite would stop covering the reap gate entirely. + expect(hasLsof).toBe(true) +}) + +posixOnly('relay endpoint probe against a real socket', () => { + it('reports live, and identifies the holding process, for a listening relay', async () => { + const sockPath = join(workDir, 'live.sock') + const relay = await startFakeRelay(sockPath) + const incumbent = await probe(sockPath) + + expect(incumbent.verdict).toBe('live') + expect(incumbent.evidence).toBe('accepted-connection') + expect(incumbent.socketPresent).toBe(true) + if (!hasLsof) { + return + } + expect(incumbent.holders.map((holder) => holder.pid)).toEqual([relay.pid]) + expect(incumbent.holders[0]).toMatchObject({ matchesRelayArgv: true, childCount: 0 }) + expect(isReapableRelayHusk(incumbent)).toBe(true) + }) + + it('refuses to call a relay with a live child an empty husk', async () => { + const sockPath = join(workDir, 'busy.sock') + await startFakeRelay(sockPath, true) + const incumbent = await probe(sockPath) + + expect(incumbent.verdict).toBe('live') + if (!hasLsof) { + return + } + expect(incumbent.holders[0].childCount).toBeGreaterThan(0) + expect(isReapableRelayHusk(incumbent)).toBe(false) + }) + + it('reports exited for a socket inode a SIGKILLed relay left behind', async () => { + const sockPath = join(workDir, 'stale.sock') + const relay = await startFakeRelay(sockPath) + relay.kill('SIGKILL') + await new Promise((resolve) => relay.on('exit', resolve)) + + const incumbent = await probe(sockPath) + expect(incumbent.socketPresent).toBe(true) + expect(incumbent.verdict).toBe(hasLsof ? 'exited' : 'unverifiable') + }) + + it('reports no listener for a path that was never bound', async () => { + const incumbent = await probe(join(workDir, 'never-existed.sock')) + expect(incumbent.socketPresent).toBe(false) + expect(incumbent.verdict).toBe(hasLsof ? 'exited' : 'unverifiable') + }) +}) + +posixOnly('empty relay husk reap against a real process', () => { + it('terminates a proven-empty relay and confirms the pid is gone', async () => { + const sockPath = join(workDir, 'husk.sock') + const relay = await startFakeRelay(sockPath) + const output = await sh(reapEmptyRelayHuskCommand(relay.pid!, sockPath)) + expect(output.trim()).toBe('GONE') + }) + + it('refuses to signal a relay that acquired a child after it was probed', async () => { + const sockPath = join(workDir, 'raced.sock') + const relay = await startFakeRelay(sockPath, true) + const output = await sh(reapEmptyRelayHuskCommand(relay.pid!, sockPath)) + expect(output.trim()).toBe('BUSY') + expect(relay.killed).toBe(false) + }) + + it('refuses to signal a pid whose argv is not this relay at this socket', async () => { + const sockPath = join(workDir, 'mismatch.sock') + await startFakeRelay(sockPath) + const bystander = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { + stdio: 'ignore' + }) + running.push(bystander) + const output = await sh(reapEmptyRelayHuskCommand(bystander.pid!, sockPath)) + expect(output.trim()).toBe('MISMATCH') + expect(bystander.killed).toBe(false) + }) +}) diff --git a/src/main/ssh/ssh-relay-endpoint-incumbent.test.ts b/src/main/ssh/ssh-relay-endpoint-incumbent.test.ts new file mode 100644 index 00000000000..a65cb33fc57 --- /dev/null +++ b/src/main/ssh/ssh-relay-endpoint-incumbent.test.ts @@ -0,0 +1,240 @@ +import { describe, expect, it, vi } from 'vitest' + +const execCommand = vi.fn() +vi.mock('./ssh-relay-deploy-helpers', () => ({ + execCommand: (...args: unknown[]) => execCommand(...args), + isUnconfirmedSshCommandTermination: (error: unknown) => + (error as { sshChannelCloseConfirmed?: boolean } | null)?.sshChannelCloseConfirmed === false +})) + +import { + describeRelayEndpointIncumbent, + isReapableRelayHusk, + mayLaunchOverRelayEndpoint, + parseRelayEndpointIncumbentProbe, + probeRelayEndpointIncumbent, + relayEndpointIncumbentProbeCommand, + withHandshakeRefusalEvidence, + type RelayEndpointIncumbent +} from './ssh-relay-endpoint-incumbent' +import type { SshConnection } from './ssh-connection' +import { getRemoteHostPlatform } from './ssh-remote-platform' + +const SOCK = '/home/u/.orca-remote/relay-0.1.0+aaaa/relay-deadbeef.sock' +const POSIX_HOST = getRemoteHostPlatform('linux-x64') +const WINDOWS_HOST = getRemoteHostPlatform('win32-x64') + +function probeOutput(lines: string[]): string { + return ['ORCA-INCUMBENT-BEGIN', ...lines, 'ORCA-INCUMBENT-END'].join('\n') +} + +describe('parseRelayEndpointIncumbentProbe', () => { + it('reports live when the socket accepted a connection', () => { + const incumbent = parseRelayEndpointIncumbentProbe( + SOCK, + probeOutput(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=4242 yes 13']) + ) + expect(incumbent.verdict).toBe('live') + expect(incumbent.evidence).toBe('accepted-connection') + expect(incumbent.holders).toEqual([{ pid: 4242, matchesRelayArgv: true, childCount: 13 }]) + }) + + it('reports live when a process still holds an inode that refuses connections', () => { + const incumbent = parseRelayEndpointIncumbentProbe( + SOCK, + probeOutput(['PRESENT=yes', 'LISTEN=refused', 'HOLDERS_SOURCE=lsof', 'HOLDER=91 yes 2']) + ) + expect(incumbent.verdict).toBe('live') + expect(incumbent.evidence).toBe('holder-process') + }) + + it('reports exited only when the connect was refused AND nothing holds the socket', () => { + const incumbent = parseRelayEndpointIncumbentProbe( + SOCK, + probeOutput(['PRESENT=yes', 'LISTEN=refused', 'HOLDERS_SOURCE=lsof']) + ) + expect(incumbent.verdict).toBe('exited') + expect(incumbent.evidence).toBe('no-holder') + expect(incumbent.socketPresent).toBe(true) + }) + + it('reports unverifiable when the host cannot enumerate socket holders', () => { + const incumbent = parseRelayEndpointIncumbentProbe( + SOCK, + probeOutput(['PRESENT=yes', 'LISTEN=refused', 'HOLDERS_SOURCE=unavailable']) + ) + expect(incumbent.verdict).toBe('unverifiable') + expect(incumbent.holdersEnumerable).toBe(false) + }) + + it('reports unverifiable when the connect probe timed out', () => { + const incumbent = parseRelayEndpointIncumbentProbe( + SOCK, + probeOutput(['PRESENT=yes', 'LISTEN=unknown', 'HOLDERS_SOURCE=lsof']) + ) + expect(incumbent.verdict).toBe('unverifiable') + }) + + it('reports unverifiable for truncated or garbled probe output', () => { + expect(parseRelayEndpointIncumbentProbe(SOCK, 'PRESENT=yes\nLISTEN=refused').verdict).toBe( + 'unverifiable' + ) + expect(parseRelayEndpointIncumbentProbe(SOCK, '').verdict).toBe('unverifiable') + }) + + it('drops holder lines that do not carry a usable pid', () => { + const incumbent = parseRelayEndpointIncumbentProbe( + SOCK, + probeOutput(['PRESENT=yes', 'LISTEN=refused', 'HOLDERS_SOURCE=lsof', 'HOLDER=- no unknown']) + ) + expect(incumbent.holders).toEqual([]) + expect(incumbent.verdict).toBe('exited') + }) + + it('keeps an unreadable child count as null rather than zero', () => { + const [holder] = parseRelayEndpointIncumbentProbe( + SOCK, + probeOutput(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=7 yes unknown']) + ).holders + expect(holder.childCount).toBeNull() + }) +}) + +describe('probeRelayEndpointIncumbent', () => { + it('never asserts death when the probe itself could not run', async () => { + execCommand.mockRejectedValueOnce(new Error('channel closed')) + const incumbent = await probeRelayEndpointIncumbent( + {} as SshConnection, + POSIX_HOST, + '/usr/bin/node', + SOCK + ) + expect(incumbent.verdict).toBe('unverifiable') + expect(incumbent.holders).toEqual([]) + }) + + it('does not shell out on Windows hosts, where the endpoint is a named pipe', async () => { + execCommand.mockClear() + const incumbent = await probeRelayEndpointIncumbent( + {} as SshConnection, + WINDOWS_HOST, + 'node.exe', + SOCK + ) + expect(execCommand).not.toHaveBeenCalled() + expect(incumbent.verdict).toBe('unverifiable') + }) +}) + +describe('relayEndpointIncumbentProbeCommand', () => { + it('ANDs the lsof selectors so it cannot match unrelated unix-socket holders', () => { + expect(relayEndpointIncumbentProbeCommand('/usr/bin/node', SOCK)).toContain( + 'lsof -t -a -U "$sock"' + ) + }) + + it('never mutates the host: no unlink, no signal', () => { + const command = relayEndpointIncumbentProbeCommand('/usr/bin/node', SOCK) + expect(command).not.toMatch(/\brm\b/) + expect(command).not.toMatch(/\bkill\b/) + }) +}) + +describe('withHandshakeRefusalEvidence', () => { + it('upgrades an unenumerable endpoint to live when the daemon answered the handshake', () => { + const probed = parseRelayEndpointIncumbentProbe( + SOCK, + probeOutput(['PRESENT=yes', 'LISTEN=unknown', 'HOLDERS_SOURCE=unavailable']) + ) + const incumbent = withHandshakeRefusalEvidence(probed) + expect(incumbent.verdict).toBe('live') + expect(incumbent.evidence).toBe('handshake-refusal') + expect(mayLaunchOverRelayEndpoint(incumbent)).toBe(false) + }) + + it('leaves stronger evidence in place', () => { + const probed = parseRelayEndpointIncumbentProbe( + SOCK, + probeOutput(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof']) + ) + expect(withHandshakeRefusalEvidence(probed).evidence).toBe('accepted-connection') + }) +}) + +describe('mayLaunchOverRelayEndpoint', () => { + const verdicts: RelayEndpointIncumbent['verdict'][] = ['live', 'unverifiable', 'exited'] + it.each(verdicts)('permits a relaunch for %s only when it is not live', (verdict) => { + const incumbent = { ...parseRelayEndpointIncumbentProbe(SOCK, ''), verdict } + expect(mayLaunchOverRelayEndpoint(incumbent)).toBe(verdict !== 'live') + }) +}) + +describe('isReapableRelayHusk', () => { + const husk = parseRelayEndpointIncumbentProbe( + SOCK, + probeOutput(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=500 yes 0']) + ) + + it('accepts a single proven relay holder with zero children', () => { + expect(isReapableRelayHusk(husk)).toBe(true) + }) + + it('refuses a relay that still holds children', () => { + expect( + isReapableRelayHusk({ + ...husk, + holders: [{ pid: 500, matchesRelayArgv: true, childCount: 1 }] + }) + ).toBe(false) + }) + + it('refuses a holder whose child count could not be read', () => { + expect( + isReapableRelayHusk({ + ...husk, + holders: [{ pid: 500, matchesRelayArgv: true, childCount: null }] + }) + ).toBe(false) + }) + + it('refuses a holder whose argv is not this relay at this socket', () => { + expect( + isReapableRelayHusk({ + ...husk, + holders: [{ pid: 500, matchesRelayArgv: false, childCount: 0 }] + }) + ).toBe(false) + }) + + it('refuses when more than one process holds the socket', () => { + expect( + isReapableRelayHusk({ + ...husk, + holders: [ + { pid: 500, matchesRelayArgv: true, childCount: 0 }, + { pid: 501, matchesRelayArgv: true, childCount: 0 } + ] + }) + ).toBe(false) + }) + + it('refuses an unverifiable endpoint however empty it looks', () => { + expect(isReapableRelayHusk({ ...husk, verdict: 'unverifiable' })).toBe(false) + expect(isReapableRelayHusk({ ...husk, holdersEnumerable: false })).toBe(false) + }) +}) + +describe('describeRelayEndpointIncumbent', () => { + it('distinguishes "no holders" from "could not enumerate holders"', () => { + const none = parseRelayEndpointIncumbentProbe( + SOCK, + probeOutput(['PRESENT=yes', 'LISTEN=refused', 'HOLDERS_SOURCE=lsof']) + ) + const unknown = parseRelayEndpointIncumbentProbe( + SOCK, + probeOutput(['PRESENT=yes', 'LISTEN=refused', 'HOLDERS_SOURCE=unavailable']) + ) + expect(describeRelayEndpointIncumbent(none)).toContain('holders=none') + expect(describeRelayEndpointIncumbent(unknown)).toContain('holders=unenumerable') + }) +}) diff --git a/src/main/ssh/ssh-relay-endpoint-incumbent.ts b/src/main/ssh/ssh-relay-endpoint-incumbent.ts new file mode 100644 index 00000000000..2688267f4c7 --- /dev/null +++ b/src/main/ssh/ssh-relay-endpoint-incumbent.ts @@ -0,0 +1,285 @@ +/** + * Who currently owns a relay socket path, answered with host evidence. + * + * The client used to answer this by assumption: a failed `--connect` was read as "the relay + * crashed", the socket was `rm -f`'d, and a fresh relay bound the same path. Unlinking a unix + * socket does not close the listener the incumbent already holds, so an alive-but-refusing + * relay (the `RelayVersionMismatchError` case, and the credential-rotation case) was left + * running forever with its PTYs (#8585). + * + * The verdict vocabulary is fixed by docs/reference/ssh-execution-boundary.md — `live` / + * `unverifiable` / `exited`, with no synonyms and no collapsing. Two consequences are load + * bearing here: + * + * - `exited` is a claim about **this endpoint**, not about every relay on the host. It means + * nothing holds this socket path, established positively (a connect that was refused *and* + * an enumeration that found no holder). A relay whose socket was already unlinked is + * invisible to this probe by construction — that is what the superseded sweep is for. + * - a probe that could not run, a host without `lsof`, or a connect that failed for any other + * reason is `unverifiable`. It never authorizes unlinking, rebinding over, or signalling. + */ +import type { SshConnection } from './ssh-connection' +import { shellEscape } from './ssh-connection-utils' +import { execCommand, isUnconfirmedSshCommandTermination } from './ssh-relay-deploy-helpers' +import { isWindowsRemoteHost, type RemoteHostPlatform } from './ssh-remote-platform' + +export type RelayEndpointVerdict = 'live' | 'unverifiable' | 'exited' + +export type RelayEndpointEvidence = + | 'accepted-connection' + | 'handshake-refusal' + | 'holder-process' + | 'no-holder' + | 'inconclusive' + +export type RelayEndpointHolder = { + pid: number + /** The holder's argv names relay.js AND this exact socket path. */ + matchesRelayArgv: boolean + /** Direct children, or null when `pgrep` could not answer. Never guessed. */ + childCount: number | null +} + +export type RelayEndpointIncumbent = { + sockPath: string + verdict: RelayEndpointVerdict + evidence: RelayEndpointEvidence + socketPresent: boolean + /** Pids proven to hold this exact socket. Empty when the host could not enumerate them. */ + holders: RelayEndpointHolder[] + /** False when no enumeration tool was available — an empty `holders` then proves nothing. */ + holdersEnumerable: boolean +} + +const PROBE_BEGIN = 'ORCA-INCUMBENT-BEGIN' +const PROBE_END = 'ORCA-INCUMBENT-END' +const CONNECT_PROBE_TIMEOUT_MS = 1000 + +// Why ES5 syntax: nodePath may be a host-resolved system node, not the bundled one. +const CONNECT_PROBE_JS = [ + 'var s=require("net").connect(process.argv[1]);', + 'var done=false;', + 'function say(v){if(done)return;done=true;try{s.destroy()}catch(e){};', + 'process.stdout.write(v);process.exit(0)}', + 's.on("connect",function(){say("accepted")});', + 's.on("error",function(e){', + 'say(e.code==="ECONNREFUSED"?"refused":e.code==="ENOENT"?"absent":"unknown")});', + `setTimeout(function(){say("unknown")},${CONNECT_PROBE_TIMEOUT_MS})` +].join('') + +/** + * A POSIX probe that reports only what the host actually observed. Every field has an + * explicit "could not tell" value; nothing is inferred from a missing tool. + */ +export function relayEndpointIncumbentProbeCommand(nodePath: string, sockPath: string): string { + const sock = shellEscape(sockPath) + const node = shellEscape(nodePath) + return [ + `sock=${sock}`, + `node=${node}`, + `printf '%s\\n' ${shellEscape(PROBE_BEGIN)}`, + 'if [ -S "$sock" ]; then', + " printf 'PRESENT=yes\\n'", + ` listen=$("$node" -e ${shellEscape(CONNECT_PROBE_JS)} "$sock" 2>/dev/null) || listen=unknown`, + ' [ -n "$listen" ] || listen=unknown', + 'else', + " printf 'PRESENT=no\\n'", + ' listen=absent', + 'fi', + 'printf \'LISTEN=%s\\n\' "$listen"', + 'if command -v lsof >/dev/null 2>&1; then', + " printf 'HOLDERS_SOURCE=lsof\\n'", + // Why -a: lsof ORs its selectors, so without it every unix-socket holder on the box + // would be reported as holding this path (#8762). + ' for pid in $(lsof -t -a -U "$sock" 2>/dev/null); do', + ' args=$(ps -o args= -p "$pid" 2>/dev/null | tr "\\n" " ")', + ' match=no', + ' case "$args" in *relay.js*"$sock"*) match=yes ;; esac', + ' kids=unknown', + ' if command -v pgrep >/dev/null 2>&1; then', + ' kids=$(pgrep -P "$pid" 2>/dev/null | grep -c .)', + ' fi', + ' printf \'HOLDER=%s %s %s\\n\' "$pid" "$match" "$kids"', + ' done', + 'else', + " printf 'HOLDERS_SOURCE=unavailable\\n'", + 'fi', + `printf '%s\\n' ${shellEscape(PROBE_END)}` + ].join('\n') +} + +export function parseRelayEndpointIncumbentProbe( + sockPath: string, + output: string +): RelayEndpointIncumbent { + const lines = output.split('\n').map((line) => line.trim()) + if (!lines.includes(PROBE_BEGIN) || !lines.includes(PROBE_END)) { + return unverifiableEndpoint(sockPath) + } + const socketPresent = lines.includes('PRESENT=yes') + const listen = lines.find((line) => line.startsWith('LISTEN='))?.slice('LISTEN='.length) ?? '' + const holdersEnumerable = lines.includes('HOLDERS_SOURCE=lsof') + const holders = lines + .filter((line) => line.startsWith('HOLDER=')) + .map((line) => parseHolder(line.slice('HOLDER='.length))) + .filter((holder): holder is RelayEndpointHolder => holder !== null) + + if (listen === 'accepted') { + return { + sockPath, + verdict: 'live', + evidence: 'accepted-connection', + socketPresent, + holders, + holdersEnumerable + } + } + if (holders.length > 0) { + // The inode is held by a running process that is not accepting — wedged, not gone. + return { + sockPath, + verdict: 'live', + evidence: 'holder-process', + socketPresent, + holders, + holdersEnumerable + } + } + if (holdersEnumerable && (listen === 'refused' || listen === 'absent')) { + return { + sockPath, + verdict: 'exited', + evidence: 'no-holder', + socketPresent, + holders, + holdersEnumerable + } + } + return { ...unverifiableEndpoint(sockPath), socketPresent, holders, holdersEnumerable } +} + +function parseHolder(value: string): RelayEndpointHolder | null { + const [rawPid, rawMatch, rawKids] = value.split(/\s+/) + const pid = Number.parseInt(rawPid ?? '', 10) + if (!Number.isInteger(pid) || pid <= 0) { + return null + } + const childCount = Number.parseInt(rawKids ?? '', 10) + return { + pid, + matchesRelayArgv: rawMatch === 'yes', + childCount: Number.isInteger(childCount) && childCount >= 0 ? childCount : null + } +} + +function unverifiableEndpoint(sockPath: string): RelayEndpointIncumbent { + return { + sockPath, + verdict: 'unverifiable', + evidence: 'inconclusive', + socketPresent: false, + holders: [], + holdersEnumerable: false + } +} + +export async function probeRelayEndpointIncumbent( + conn: SshConnection, + hostPlatform: RemoteHostPlatform, + nodePath: string, + sockPath: string, + options?: { signal?: AbortSignal } +): Promise { + // Windows relays are named pipes: there is no inode to unlink and no `lsof`, so the + // orphan-by-unlink mechanism this probe defends against cannot occur there. + if (isWindowsRemoteHost(hostPlatform)) { + return unverifiableEndpoint(sockPath) + } + try { + const output = await execCommand(conn, relayEndpointIncumbentProbeCommand(nodePath, sockPath), { + wrapCommand: true, + signal: options?.signal + }) + return parseRelayEndpointIncumbentProbe(sockPath, output) + } catch (err) { + // An exec whose channel never confirmed close may still be running remotely; the caller + // must not race a detached launch against it. + if (isUnconfirmedSshCommandTermination(err)) { + throw err + } + // Any other unanswered probe observes nothing. It is never evidence of death. + return unverifiableEndpoint(sockPath) + } +} + +/** + * A relay that told us its version over the wire is `live` by positive host evidence, even on + * a host where nothing can enumerate socket holders. + */ +export function withHandshakeRefusalEvidence( + incumbent: RelayEndpointIncumbent +): RelayEndpointIncumbent { + if (incumbent.verdict === 'live') { + return incumbent + } + return { ...incumbent, verdict: 'live', evidence: 'handshake-refusal' } +} + +/** + * May a fresh relay be launched onto this path? + * + * Only `live` forbids it. `unverifiable` is permitted because the *daemon* — not the client — + * performs the takeover: `RelaySocketOwnership.listen` re-probes on EADDRINUSE, refuses to + * steal a path that accepts connections, and only unlinks an inode whose identity is + * unchanged. That check is atomic with the bind, which a client-side `rm -f` can never be. + */ +export function mayLaunchOverRelayEndpoint(incumbent: RelayEndpointIncumbent): boolean { + return incumbent.verdict !== 'live' +} + +/** + * A live relay that provably holds nothing: identity confirmed against its argv, exactly one + * holder, and zero children. Reaping it destroys no user work. Anything less is retained — + * killing the wrong pid on someone's remote host is the worst outcome available here. + */ +export function isReapableRelayHusk(incumbent: RelayEndpointIncumbent): boolean { + if (incumbent.verdict !== 'live' || !incumbent.holdersEnumerable) { + return false + } + if (incumbent.holders.length !== 1) { + return false + } + const [holder] = incumbent.holders + return holder.matchesRelayArgv && holder.childCount === 0 +} + +export function describeRelayEndpointIncumbent(incumbent: RelayEndpointIncumbent): string { + const holders = incumbent.holders + .map((holder) => `${holder.pid}(children=${holder.childCount ?? 'unknown'})`) + .join(',') + return ( + `${incumbent.sockPath} verdict=${incumbent.verdict} evidence=${incumbent.evidence} ` + + `holders=${incumbent.holdersEnumerable ? holders || 'none' : 'unenumerable'}` + ) +} + +/** + * Thrown instead of orphaning: a live relay owns the endpoint and refused us, so the path is + * not ours to rebind. Terminal for this attempt — the user resolves it with Reset Relay, + * which signals the incumbent deliberately and with consent. + */ +export class RelayEndpointHeldError extends Error { + readonly name = 'RelayEndpointHeldError' + constructor(readonly incumbent: RelayEndpointIncumbent) { + super( + `A live relay still owns ${incumbent.sockPath} and refused this connection ` + + `(${describeRelayEndpointIncumbent(incumbent)}). Orca will not replace it, because ` + + 'unlinking its socket would strand its terminals. Use Reset Relay for this host to ' + + 'stop it, then reconnect.' + ) + } +} + +export function isRelayEndpointHeldError(err: unknown): err is RelayEndpointHeldError { + return err instanceof RelayEndpointHeldError +} diff --git a/src/main/ssh/ssh-relay-endpoint-takeover.test.ts b/src/main/ssh/ssh-relay-endpoint-takeover.test.ts new file mode 100644 index 00000000000..687d633b92b --- /dev/null +++ b/src/main/ssh/ssh-relay-endpoint-takeover.test.ts @@ -0,0 +1,159 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const execCommand = vi.fn() +vi.mock('./ssh-relay-deploy-helpers', () => ({ + execCommand: (...args: unknown[]) => execCommand(...args), + isUnconfirmedSshCommandTermination: (error: unknown) => + (error as { sshChannelCloseConfirmed?: boolean } | null)?.sshChannelCloseConfirmed === false +})) + +import { isRelayEndpointHeldError } from './ssh-relay-endpoint-incumbent' +import { + interpretRelayHuskReapOutput, + reapEmptyRelayHuskCommand, + resolveRelayEndpointBeforeRelaunch +} from './ssh-relay-endpoint-takeover' +import { RelayVersionMismatchError } from './ssh-relay-version-mismatch-error' +import type { SshConnection } from './ssh-connection' +import { getRemoteHostPlatform } from './ssh-remote-platform' + +const SOCK = '/home/u/.orca-remote/relay-0.1.0+aaaa/relay-deadbeef.sock' +const HOST = getRemoteHostPlatform('linux-x64') +const CONN = {} as SshConnection + +function probe(lines: string[]): string { + return ['ORCA-INCUMBENT-BEGIN', ...lines, 'ORCA-INCUMBENT-END'].join('\n') +} + +function issuedCommands(): string[] { + return execCommand.mock.calls.map((call) => String(call[1])) +} + +function resolve(reconnectError: unknown = new Error('connect failed')): Promise { + return resolveRelayEndpointBeforeRelaunch(CONN, HOST, '/usr/bin/node', SOCK, reconnectError) +} + +beforeEach(() => { + execCommand.mockReset() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.spyOn(console, 'log').mockImplementation(() => {}) +}) + +describe('incumbent alive and refusing', () => { + it('refuses to rebind a live relay holding PTYs, and signals nothing', async () => { + execCommand.mockResolvedValueOnce( + probe(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=3669803 yes 13']) + ) + await expect(resolve()).rejects.toSatisfy(isRelayEndpointHeldError) + // The whole point of #8585: the incumbent's socket must survive so it is not orphaned. + expect(issuedCommands().some((command) => /\brm -f\b/.test(command))).toBe(false) + expect(issuedCommands().some((command) => /\bkill\b/.test(command))).toBe(false) + }) + + it('names the incumbent pid and the Reset Relay escape hatch in the error', async () => { + execCommand.mockResolvedValue( + probe(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=3669803 yes 13']) + ) + await expect(resolve()).rejects.toThrow(/3669803\(children=13\)/) + await expect(resolve()).rejects.toThrow(/Reset Relay/) + }) + + it('treats a version mismatch as live even where holders cannot be enumerated', async () => { + execCommand.mockResolvedValue( + probe(['PRESENT=yes', 'LISTEN=unknown', 'HOLDERS_SOURCE=unavailable']) + ) + const mismatch = new RelayVersionMismatchError('0.1.0+new', '0.1.0+old', '') + await expect(resolve(mismatch)).rejects.toSatisfy(isRelayEndpointHeldError) + expect(issuedCommands().some((command) => /\brm -f\b/.test(command))).toBe(false) + }) + + it('reaps a live relay only when it provably holds nothing, and confirms it is gone', async () => { + execCommand + .mockResolvedValueOnce( + probe(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=80583 yes 0']) + ) + .mockResolvedValueOnce('GONE\n') + await expect(resolve()).resolves.toMatchObject({ verdict: 'live' }) + expect(issuedCommands()[1]).toContain('kill -TERM "$pid"') + }) + + it('does not launch over an empty relay whose death could not be confirmed', async () => { + execCommand + .mockResolvedValueOnce( + probe(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=80583 yes 0']) + ) + .mockResolvedValueOnce('LIVE\n') + await expect(resolve()).rejects.toSatisfy(isRelayEndpointHeldError) + }) + + it('does not launch over a relay the host refused to signal on its own re-check', async () => { + execCommand + .mockResolvedValueOnce( + probe(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=80583 yes 0']) + ) + .mockResolvedValueOnce('BUSY\n') + await expect(resolve()).rejects.toSatisfy(isRelayEndpointHeldError) + }) +}) + +describe('incumbent genuinely gone', () => { + it('permits the relaunch without unlinking anything itself', async () => { + execCommand.mockResolvedValueOnce( + probe(['PRESENT=yes', 'LISTEN=refused', 'HOLDERS_SOURCE=lsof']) + ) + await expect(resolve()).resolves.toMatchObject({ verdict: 'exited', evidence: 'no-holder' }) + // The daemon unlinks under an identity check that is atomic with its bind; the client + // cannot be, which is what created the orphan in the first place. + expect(issuedCommands().some((command) => /\brm -f\b/.test(command))).toBe(false) + }) +}) + +describe('incumbent unverifiable', () => { + it('permits the relaunch but never claims the incumbent exited', async () => { + execCommand.mockResolvedValueOnce( + probe(['PRESENT=yes', 'LISTEN=unknown', 'HOLDERS_SOURCE=unavailable']) + ) + await expect(resolve()).resolves.toMatchObject({ verdict: 'unverifiable' }) + expect(issuedCommands()).toHaveLength(1) + }) + + it('stays unverifiable when the probe command itself fails', async () => { + execCommand.mockRejectedValueOnce(new Error('exec timeout')) + await expect(resolve()).resolves.toMatchObject({ verdict: 'unverifiable' }) + }) +}) + +describe('reapEmptyRelayHuskCommand', () => { + it('re-verifies argv and emptiness on the host immediately before signalling', () => { + const command = reapEmptyRelayHuskCommand(4242, SOCK) + expect(command.indexOf('MISMATCH')).toBeLessThan(command.indexOf('kill -TERM')) + expect(command.indexOf('BUSY')).toBeLessThan(command.indexOf('kill -TERM')) + }) + + it('sends SIGTERM only, so the relay runs its own socket cleanup', () => { + const command = reapEmptyRelayHuskCommand(4242, SOCK) + expect(command).toContain('kill -TERM') + expect(command).not.toContain('kill -KILL') + expect(command).not.toContain('-9') + }) + + it('aborts without signalling when the host cannot count children', () => { + expect(reapEmptyRelayHuskCommand(4242, SOCK)).toContain( + "command -v pgrep >/dev/null 2>&1 || { printf 'BUSY\\n'; exit 0; }" + ) + }) +}) + +describe('interpretRelayHuskReapOutput', () => { + it('claims reaped only for a post-signal liveness check that failed', () => { + expect(interpretRelayHuskReapOutput('GONE\n')).toBe('reaped') + expect(interpretRelayHuskReapOutput('LIVE\n')).toBe('reap-unconfirmed') + expect(interpretRelayHuskReapOutput('')).toBe('reap-unconfirmed') + expect(interpretRelayHuskReapOutput('unexpected noise')).toBe('reap-unconfirmed') + }) + + it('reports a host-side refusal as retained rather than as a failed kill', () => { + expect(interpretRelayHuskReapOutput('MISMATCH\n')).toBe('retained-live-work') + expect(interpretRelayHuskReapOutput('BUSY\n')).toBe('retained-live-work') + }) +}) diff --git a/src/main/ssh/ssh-relay-endpoint-takeover.ts b/src/main/ssh/ssh-relay-endpoint-takeover.ts new file mode 100644 index 00000000000..f104aab5256 --- /dev/null +++ b/src/main/ssh/ssh-relay-endpoint-takeover.ts @@ -0,0 +1,133 @@ +/** + * Deciding whether a relay socket path is ours to take, and acting on the answer. + * + * The only destructive action available here is a SIGTERM to a relay that has been proven — + * by argv, by socket-holder enumeration, and by a zero child count re-checked on the host + * immediately before the signal — to hold nothing at all. Everything else is left running. + * Per docs/reference/ssh-execution-boundary.md, a relay we merely failed to reach is + * `unverifiable`, and `unverifiable` never authorizes a kill or a rebind. + */ +import type { SshConnection } from './ssh-connection' +import { shellEscape } from './ssh-connection-utils' +import { execCommand, isUnconfirmedSshCommandTermination } from './ssh-relay-deploy-helpers' +import { + describeRelayEndpointIncumbent, + isReapableRelayHusk, + mayLaunchOverRelayEndpoint, + probeRelayEndpointIncumbent, + RelayEndpointHeldError, + withHandshakeRefusalEvidence, + type RelayEndpointIncumbent +} from './ssh-relay-endpoint-incumbent' +import { isRelayVersionMismatchError } from './ssh-relay-version-mismatch-error' +import type { RemoteHostPlatform } from './ssh-remote-platform' + +/** `reaped` is only reachable from a post-signal `kill -0` that failed. Nothing else claims it. */ +export type RelayHuskReapResult = 'reaped' | 'reap-unconfirmed' | 'retained-live-work' + +const REAP_CONFIRM_ATTEMPTS = 15 + +/** + * Signal one relay, re-verifying identity and emptiness inside the same command. + * + * The re-verification is not belt-and-braces: a client can attach and spawn a PTY between the + * probe and the signal, and pids are reused. `MISMATCH`/`BUSY` abort without signalling. + */ +export function reapEmptyRelayHuskCommand(pid: number, sockPath: string): string { + return [ + `pid=${shellEscape(String(pid))}`, + `sock=${shellEscape(sockPath)}`, + 'args=$(ps -o args= -p "$pid" 2>/dev/null | tr "\\n" " ")', + 'case "$args" in *relay.js*"$sock"*) ;; *) printf \'MISMATCH\\n\'; exit 0 ;; esac', + "command -v pgrep >/dev/null 2>&1 || { printf 'BUSY\\n'; exit 0; }", + 'kids=$(pgrep -P "$pid" 2>/dev/null | grep -c .)', + '[ "$kids" = "0" ] || { printf \'BUSY\\n\'; exit 0; }', + // SIGTERM only: the relay's own handler disposes and unlinks. SIGKILL would leave the + // socket inode behind and skip that shutdown path for no gain on an empty daemon. + 'kill -TERM "$pid" 2>/dev/null || true', + 'i=0', + `while [ $i -lt ${REAP_CONFIRM_ATTEMPTS} ]; do`, + ' kill -0 "$pid" 2>/dev/null || { printf \'GONE\\n\'; exit 0; }', + ' sleep 0.2', + ' i=$((i+1))', + 'done', + "printf 'LIVE\\n'" + ].join('\n') +} + +export function interpretRelayHuskReapOutput(output: string): RelayHuskReapResult { + const state = output.trim().split('\n').pop()?.trim() + if (state === 'GONE') { + return 'reaped' + } + // The host refused on its own re-check: what is there is not the empty relay we probed, so + // nothing was signalled and nothing is claimed about it. + if (state === 'MISMATCH' || state === 'BUSY') { + return 'retained-live-work' + } + return 'reap-unconfirmed' +} + +export async function reapEmptyRelayHusk( + conn: SshConnection, + incumbent: RelayEndpointIncumbent, + options?: { signal?: AbortSignal } +): Promise { + const holder = incumbent.holders[0] + if (!holder) { + return 'retained-live-work' + } + try { + const output = await execCommand( + conn, + reapEmptyRelayHuskCommand(holder.pid, incumbent.sockPath), + { wrapCommand: true, signal: options?.signal } + ) + return interpretRelayHuskReapOutput(output) + } catch (err) { + if (isUnconfirmedSshCommandTermination(err)) { + throw err + } + return 'reap-unconfirmed' + } +} + +/** + * Called when `--connect` to an existing socket failed and the caller is about to launch a + * replacement at the same path. Resolves to nothing when the launch may proceed; throws + * `RelayEndpointHeldError` when a live relay owns the path and holds work. + * + * `unverifiable` deliberately permits the launch: the daemon, not the client, performs the + * takeover. `RelaySocketOwnership.listen` re-probes on EADDRINUSE, refuses a path that accepts + * connections, and only unlinks an inode whose identity is unchanged — a check that is atomic + * with the bind, which a client-side `rm -f` can never be. + */ +export async function resolveRelayEndpointBeforeRelaunch( + conn: SshConnection, + hostPlatform: RemoteHostPlatform, + nodePath: string, + sockPath: string, + reconnectError: unknown, + options?: { signal?: AbortSignal } +): Promise { + const probed = await probeRelayEndpointIncumbent(conn, hostPlatform, nodePath, sockPath, options) + // A daemon that answered the handshake with its own version is live by positive host + // evidence, even where nothing can enumerate socket holders. + const incumbent = isRelayVersionMismatchError(reconnectError) + ? withHandshakeRefusalEvidence(probed) + : probed + console.warn(`[ssh-relay] Relay endpoint incumbent: ${describeRelayEndpointIncumbent(incumbent)}`) + + if (mayLaunchOverRelayEndpoint(incumbent)) { + return incumbent + } + if (!isReapableRelayHusk(incumbent)) { + throw new RelayEndpointHeldError(incumbent) + } + const result = await reapEmptyRelayHusk(conn, incumbent, options) + if (result !== 'reaped') { + throw new RelayEndpointHeldError(incumbent) + } + console.log(`[ssh-relay] Reaped empty relay husk holding ${sockPath}`) + return incumbent +} diff --git a/src/main/ssh/ssh-relay-exec-command.ts b/src/main/ssh/ssh-relay-exec-command.ts index c631cd2d41a..fb0a4fee012 100644 --- a/src/main/ssh/ssh-relay-exec-command.ts +++ b/src/main/ssh/ssh-relay-exec-command.ts @@ -13,6 +13,11 @@ const MAX_EXEC_OUTPUT_CHARS = 1024 * 1024 type ExecCommandOptions = SshExecOptions & { timeoutMs?: number + // Why: a zero-exit command resolves with stdout alone, so the reason a wrapped-in-`|| echo` + // probe failed is discarded. Callers that need that diagnostic opt in here rather than + // folding stderr into stdout, where it would match the probe's own token strings. + // On the system-ssh transport this stream also carries local OpenSSH noise; log-only. + onStderr?: (stderr: string) => void } type SshCommandTerminationError = Error & { @@ -33,7 +38,7 @@ export async function execCommand( command: string, options?: ExecCommandOptions ): Promise { - const { timeoutMs = EXEC_TIMEOUT_MS, ...execOptions } = options ?? {} + const { timeoutMs = EXEC_TIMEOUT_MS, onStderr, ...execOptions } = options ?? {} const signal = options?.signal if (signal?.aborted) { throw createSshOperationAbortError() @@ -151,6 +156,9 @@ export async function execCommand( ) ) } else { + if (stderr && onStderr) { + onStderr(redactRelayInstallMarkerTokens(stderr)) + } settle(resolve, stdout) } } diff --git a/src/main/ssh/ssh-relay-native-deps-cache-commands.ts b/src/main/ssh/ssh-relay-native-deps-cache-commands.ts new file mode 100644 index 00000000000..52d09de1b19 --- /dev/null +++ b/src/main/ssh/ssh-relay-native-deps-cache-commands.ts @@ -0,0 +1,210 @@ +/** + * The remote shell for the shared native-deps cache (`ssh-relay-native-deps-cache.ts`). + * + * Every script here is POSIX `sh` and answers with one token, because the only alternative to a + * token is inferring success from an exit status the transport can also produce. A command that + * cannot answer is a cache miss, never a licence to delete: `MISS` and `NOT_PROMOTED` both leave + * the relay directory owning its own `node_modules`, which is exactly today's behaviour. + */ +import { shellEscape } from './ssh-connection-utils' +import { + RELAY_NATIVE_DEPS_CACHE_COMPLETE_NAME, + RELAY_NATIVE_DEPS_CACHE_TOMBSTONE_PREFIX, + relayNativeDepsCacheBaseDir, + relayNativeDepsCacheEntryDir, + relayNativeDepsCacheNodeModulesPath, + remoteInstallRootDir +} from './ssh-relay-native-deps-cache' +import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' + +export const RELAY_NATIVE_CACHE_LINKED = '__ORCA_NATIVE_CACHE__LINKED' +export const RELAY_NATIVE_CACHE_SEEDED = '__ORCA_NATIVE_CACHE__SEEDED' +export const RELAY_NATIVE_CACHE_MISS = '__ORCA_NATIVE_CACHE__MISS' +export const RELAY_NATIVE_CACHE_PROMOTED = '__ORCA_NATIVE_CACHE__PROMOTED' +export const RELAY_NATIVE_CACHE_NOT_PROMOTED = '__ORCA_NATIVE_CACHE__NOT_PROMOTED' +export const RELAY_NATIVE_CACHE_LIST_OK = '__ORCA_NATIVE_CACHE__LIST_OK' +export const RELAY_NATIVE_CACHE_REFS_OK = '__ORCA_NATIVE_CACHE__REFS_OK' +export const RELAY_NATIVE_CACHE_REFS_ERR = '__ORCA_NATIVE_CACHE__REFS_ERR' + +/** + * How old an entry without `.deps-complete` must be before another deploy may reclaim it. Well + * past the 15-minute deploy ceiling, so a live installer is never mistaken for a crashed one. + * Reclaiming is safe at any age in principle — nothing links an entry until it is complete — but + * the margin is what keeps that argument from resting on a single `[ -f ]`. + */ +const CACHE_TAKEOVER_MINUTES = 120 + +/** A crashed GC pass leaves a tombstone; it drains once no in-flight pass could still own it. */ +const CACHE_TOMBSTONE_SWEEP_MINUTES = 30 + +/** Bounds every listing, matching `MAX_RELAY_GC_LISTING_ENTRIES`' role for version dirs. */ +export const MAX_RELAY_NATIVE_CACHE_LISTING_ENTRIES = 64 + +export type RelayNativeDepsCachePaths = { + host: RemoteHostPlatform + remoteHome: string + relayDir: string + key: string +} + +function cachePaths(paths: RelayNativeDepsCachePaths): { + base: string + entry: string + target: string + nodeModules: string + root: string +} { + const { host, remoteHome, relayDir, key } = paths + return { + base: relayNativeDepsCacheBaseDir(host, remoteHome), + entry: relayNativeDepsCacheEntryDir(host, remoteHome, key), + target: relayNativeDepsCacheNodeModulesPath(host, remoteHome, key), + nodeModules: joinRemotePath(host, relayDir, 'node_modules'), + root: remoteInstallRootDir(host, remoteHome) + } +} + +/** + * Link a complete entry into the relay directory, or seed a private tree from a sibling relay + * directory that already has a matching one. + * + * The seed exists so the first deploy after this ships does not recompile once more on a host + * that already paid for the compile. It is not trusted: the copy is a plain private install until + * the normal probe loads both addons, and only then is it promoted. + */ +export function ensureRelayNativeDepsCacheCommand( + paths: RelayNativeDepsCachePaths, + deps: Readonly> +): string { + const { entry, target, nodeModules, root } = cachePaths(paths) + // Why grep the sibling's manifest: an older Orca pinned different versions, and a + // toolchain-skip host wrote one with node-pty removed. Both must fail to qualify. + const depGuards = Object.entries(deps).map( + ([name, version]) => `grep -F -q ${shellEscape(`"${name}":"${version}"`)} "$pj" || continue` + ) + return [ + `cache=${shellEscape(entry)}`, + `target=${shellEscape(target)}`, + `nm=${shellEscape(nodeModules)}`, + `root=${shellEscape(root)}`, + `if [ -f "$cache/${RELAY_NATIVE_DEPS_CACHE_COMPLETE_NAME}" ] && [ -d "$target" ]; then`, + ' if [ -L "$nm" ]; then', + ` if [ "$(readlink "$nm" 2>/dev/null)" = "$target" ]; then printf '%s\\n' ${RELAY_NATIVE_CACHE_LINKED}; exit 0; fi`, + ' rm -f "$nm" 2>/dev/null || true', + ' fi', + ` if [ ! -e "$nm" ] && ln -s "$target" "$nm" 2>/dev/null; then printf '%s\\n' ${RELAY_NATIVE_CACHE_LINKED}; exit 0; fi`, + ` printf '%s\\n' ${RELAY_NATIVE_CACHE_MISS}; exit 0`, + 'fi', + 'if [ ! -e "$nm" ] && [ ! -L "$nm" ]; then', + ' for cand in "$root"/relay-*/node_modules; do', + ' [ -d "$cand" ] || continue', + ' [ -L "$cand" ] && continue', + ' [ -d "$cand/node-pty" ] || continue', + ' [ -d "$cand/@parcel/watcher" ] || continue', + ' pj="${cand%/node_modules}/package.json"', + ' [ -f "$pj" ] || continue', + ...depGuards.map((guard) => ` ${guard}`), + ' seed="$nm.seed.$$"', + ' rm -rf "$seed" 2>/dev/null || true', + ' if cp -Rp "$cand" "$seed" 2>/dev/null && mv "$seed" "$nm" 2>/dev/null; then', + ` printf '%s\\n' ${RELAY_NATIVE_CACHE_SEEDED}; exit 0`, + ' fi', + ' rm -rf "$seed" 2>/dev/null || true', + ' break', + ' done', + 'fi', + `printf '%s\\n' ${RELAY_NATIVE_CACHE_MISS}` + ].join('\n') +} + +/** + * Publish a probe-verified private tree as the shared entry, then link the relay directory to it. + * + * `mkdir "$cache"` is the election: exactly one deploy creates the directory, and a loser keeps + * its own tree rather than writing into someone else's. `.deps-complete` is written last, after + * the symlink exists, so an entry is never linkable before it is referenced. + */ +export function promoteRelayNativeDepsCacheCommand(paths: RelayNativeDepsCachePaths): string { + const { base, entry, target, nodeModules } = cachePaths(paths) + const notPromoted = `printf '%s\\n' ${RELAY_NATIVE_CACHE_NOT_PROMOTED}` + return [ + `base=${shellEscape(base)}`, + `cache=${shellEscape(entry)}`, + `target=${shellEscape(target)}`, + `nm=${shellEscape(nodeModules)}`, + `[ -d "$nm" ] || { ${notPromoted}; exit 0; }`, + `if [ -L "$nm" ]; then ${notPromoted}; exit 0; fi`, + `mkdir -p "$base" 2>/dev/null || { ${notPromoted}; exit 0; }`, + `if [ -d "$cache" ] && [ ! -f "$cache/${RELAY_NATIVE_DEPS_CACHE_COMPLETE_NAME}" ]; then`, + ` if [ -n "$(find "$cache" -maxdepth 0 -mmin +${CACHE_TAKEOVER_MINUTES} 2>/dev/null)" ]; then`, + ' rm -rf "$cache" 2>/dev/null || true', + ' fi', + 'fi', + `mkdir "$cache" 2>/dev/null || { ${notPromoted}; exit 0; }`, + 'if mv "$nm" "$target" 2>/dev/null; then', + ' if ln -s "$target" "$nm" 2>/dev/null; then', + ` : > "$cache/${RELAY_NATIVE_DEPS_CACHE_COMPLETE_NAME}" 2>/dev/null || true`, + ` if [ -f "$cache/${RELAY_NATIVE_DEPS_CACHE_COMPLETE_NAME}" ]; then printf '%s\\n' ${RELAY_NATIVE_CACHE_PROMOTED}; exit 0; fi`, + ' fi', + ' rm -f "$nm" 2>/dev/null || true', + // Why the rm is conditional on the move back: a failed restore leaves the only copy of the + // tree inside an incomplete entry. Deleting it there would cost the relay its native deps. + ' if mv "$target" "$nm" 2>/dev/null; then rm -rf "$cache" 2>/dev/null || true; fi', + ` ${notPromoted}; exit 0`, + 'fi', + 'rm -rf "$cache" 2>/dev/null || true', + notPromoted + ].join('\n') +} + +/** Complete entries only; an incomplete one belongs to an installer, not to GC. */ +export function listRelayNativeDepsCacheEntriesCommand( + host: RemoteHostPlatform, + remoteHome: string +): string { + const base = relayNativeDepsCacheBaseDir(host, remoteHome) + return [ + `base=${shellEscape(base)}`, + `[ -d "$base" ] || { printf '%s\\n' ${RELAY_NATIVE_CACHE_LIST_OK}; exit 0; }`, + `find "$base" -maxdepth 1 -name ${shellEscape(`${RELAY_NATIVE_DEPS_CACHE_TOMBSTONE_PREFIX}*`)} -mmin +${CACHE_TOMBSTONE_SWEEP_MINUTES} -exec rm -rf {} + 2>/dev/null || true`, + 'n=0', + 'for d in "$base"/*/; do', + ' [ -d "$d" ] || continue', + ` [ -f "$d${RELAY_NATIVE_DEPS_CACHE_COMPLETE_NAME}" ] || continue`, + ' name=${d%/}', + ' name=${name##*/}', + ` printf 'ENTRY %s\\n' "$name"`, + ' n=$((n+1))', + ` if [ "$n" -ge ${MAX_RELAY_NATIVE_CACHE_LISTING_ENTRIES} ]; then break; fi`, + 'done', + `printf '%s\\n' ${RELAY_NATIVE_CACHE_LIST_OK}` + ].join('\n') +} + +/** + * Every symlinked `node_modules` under `~/.orca-remote/`, as its raw target. + * + * The scan is deliberately wider than `relay-*`: a directory this client does not recognise still + * counts as a referrer. An unreadable link or an overrun listing answers `REFS_ERR`, which stops + * the whole pass — an incomplete reference list is not evidence that anything is unreferenced. + */ +export function listRelayNativeDepsCacheReferencesCommand( + host: RemoteHostPlatform, + remoteHome: string +): string { + const root = remoteInstallRootDir(host, remoteHome) + return [ + `root=${shellEscape(root)}`, + `[ -d "$root" ] || { printf '%s\\n' ${RELAY_NATIVE_CACHE_REFS_OK}; exit 0; }`, + 'n=0', + 'for d in "$root"/*/node_modules; do', + ' [ -L "$d" ] || continue', + ' t=$(readlink "$d" 2>/dev/null) || t=""', + ` if [ -z "$t" ]; then printf '%s\\n' ${RELAY_NATIVE_CACHE_REFS_ERR}; exit 0; fi`, + ` printf 'REF %s\\n' "$t"`, + ' n=$((n+1))', + ` if [ "$n" -ge ${MAX_RELAY_NATIVE_CACHE_LISTING_ENTRIES} ]; then printf '%s\\n' ${RELAY_NATIVE_CACHE_REFS_ERR}; exit 0; fi`, + 'done', + `printf '%s\\n' ${RELAY_NATIVE_CACHE_REFS_OK}` + ].join('\n') +} diff --git a/src/main/ssh/ssh-relay-native-deps-cache-deploy.test.ts b/src/main/ssh/ssh-relay-native-deps-cache-deploy.test.ts new file mode 100644 index 00000000000..61e6133b9a5 --- /dev/null +++ b/src/main/ssh/ssh-relay-native-deps-cache-deploy.test.ts @@ -0,0 +1,301 @@ +// The claim this file has to hold up: a second deploy of a *different bundle* to the same host +// runs no `npm install` at all. Everything else here is the fallback ladder underneath it — a +// host that cannot link, cannot publish, or answers nothing still deploys exactly as before. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as RelayInstallMarkerModule from './ssh-relay-install-marker' + +vi.mock('electron', () => ({ + app: { getAppPath: () => '/mock/app' } +})) + +vi.mock('fs', () => ({ + existsSync: vi.fn().mockReturnValue(true), + readFileSync: vi.fn().mockReturnValue('0.1.0+testhash') +})) + +vi.mock('./relay-protocol', () => ({ + RELAY_VERSION: '0.1.0', + RELAY_REMOTE_DIR: '.orca-remote', + parseUnameToRelayPlatform: vi.fn().mockReturnValue('linux-x64'), + RELAY_SENTINEL: 'ORCA-RELAY v0.1.0 READY\n', + RELAY_SENTINEL_TIMEOUT_MS: 10_000 +})) + +vi.mock('./ssh-relay-deploy-helpers', () => ({ + uploadDirectory: vi.fn().mockResolvedValue(undefined), + waitForSentinel: vi.fn().mockResolvedValue({ + write: vi.fn(), + onData: vi.fn(), + onClose: vi.fn() + }), + isUnconfirmedSshCommandTermination: (error: unknown) => + error instanceof Error && + (error as Error & { sshChannelCloseConfirmed?: boolean }).sshChannelCloseConfirmed === false, + execCommand: vi.fn() +})) + +vi.mock('./ssh-remote-node-resolution', () => ({ + resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node') +})) + +vi.mock('./ssh-relay-install-marker', async (importOriginal) => ({ + ...(await importOriginal()), + createRelayInstallMarkerFileName: () => '.sftp-namespace-00000000000000000000000000000000' +})) + +vi.mock('./ssh-relay-versioned-install', () => ({ + readLocalFullVersion: vi.fn().mockReturnValue('0.1.0+testhash'), + computeRemoteRelayDir: (home: string, v: string) => `${home}/.orca-remote/relay-${v}`, + isRelayAlreadyInstalled: vi.fn().mockResolvedValue(false), + finalizeInstall: vi.fn().mockResolvedValue(undefined), + abandonInstall: vi.fn().mockResolvedValue(undefined), + gcOldRelayVersions: vi.fn().mockResolvedValue(undefined) +})) + +vi.mock('./ssh-relay-install-lock', () => ({ + acquireInstallLock: vi.fn().mockResolvedValue(undefined), + RELAY_INSTALL_LOCK_NAME: '.install-lock' +})) + +vi.mock('./ssh-relay-repair-lock', () => ({ + tryAcquireRelayRepairLock: vi.fn().mockResolvedValue('acquired') +})) + +vi.mock('./ssh-relay-gc-claim', () => ({ + releaseRelayGcClaimWithRetry: vi.fn().mockResolvedValue('released'), + tryAcquireRelayGcClaim: vi.fn().mockResolvedValue('launch-token'), + waitForRelayGcClaimRelease: vi.fn().mockResolvedValue(undefined) +})) + +import { deployAndLaunchRelay } from './ssh-relay-deploy' +import { execCommand, uploadDirectory } from './ssh-relay-deploy-helpers' +import { parseUnameToRelayPlatform } from './relay-protocol' +import { isRelayAlreadyInstalled, gcOldRelayVersions } from './ssh-relay-versioned-install' +import { + makeMockConnection, + makeStagedFirstInstallExecPrefix, + type ExecResponse, + type SftpWriteCapture +} from './ssh-relay-native-deps-install-fixture' +import { + RELAY_NATIVE_CACHE_LINKED, + RELAY_NATIVE_CACHE_MISS, + RELAY_NATIVE_CACHE_PROMOTED +} from './ssh-relay-native-deps-cache-commands' + +// Everything after the probe on a healthy install: stderr cleanup, stage cleanup, launch. +const LAUNCH_TAIL: ExecResponse[] = ['', 'DEAD', '', 'READY'] + +describe('relay native-deps cache on the deploy path', () => { + let warnSpy: ReturnType + const sftpCapture: SftpWriteCapture = { paths: [], contents: {}, execCallCountAtWrite: {} } + + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(execCommand).mockReset().mockResolvedValue('') + vi.mocked(uploadDirectory).mockResolvedValue(undefined) + sftpCapture.paths.length = 0 + for (const k of Object.keys(sftpCapture.contents)) { + delete sftpCapture.contents[k] + } + for (const k of Object.keys(sftpCapture.execCallCountAtWrite)) { + delete sftpCapture.execCallCountAtWrite[k] + } + vi.mocked(parseUnameToRelayPlatform).mockReturnValue('linux-x64') + vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(false) + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + afterEach(() => { + warnSpy.mockRestore() + }) + + function feed(responses: ExecResponse[]): void { + const mockExec = vi.mocked(execCommand) + for (const r of responses) { + if (typeof r === 'string') { + mockExec.mockResolvedValueOnce(r) + } else { + mockExec.mockRejectedValueOnce(new Error(r.reject)) + } + } + } + + function execCommands(): string[] { + return vi.mocked(execCommand).mock.calls.map(([, command]) => command) + } + + /** + * A first install whose cache probe answers `cacheAnswer`. The prefix's last slot is the cache + * probe, so overriding it is the only difference between a hit and a miss. + */ + function firstInstall(cacheAnswer: string, tail: ExecResponse[]): ExecResponse[] { + const prefix = makeStagedFirstInstallExecPrefix() + prefix[prefix.length - 1] = cacheAnswer + return [...prefix, ...tail] + } + + it('runs no npm install when a different bundle finds a complete entry on the host', async () => { + const conn = makeMockConnection(sftpCapture) + feed( + firstInstall(RELAY_NATIVE_CACHE_LINKED, [ + '', // chmod prebuilds, through the symlink + 'ORCA-NPTY-PROBE-OK\n', + '', // rm probe stderr + ...LAUNCH_TAIL + ]) + ) + + await expect(deployAndLaunchRelay(conn)).resolves.toBeDefined() + + const commands = execCommands() + expect(commands.some((c) => c.includes('npm install'))).toBe(false) + expect(commands.some((c) => c.includes('npm rebuild'))).toBe(false) + // The bundle still gets its own directory; only the native tree is shared. + expect(commands.some((c) => c.includes('.orca-remote/relay-0.1.0+testhash'))).toBe(true) + expect(commands.some((c) => /\.orca-remote\/native\/linux-x64-[0-9a-f]{16}/.test(c))).toBe(true) + }) + + it('still installs on the first deploy, then publishes the tree the probe loaded', async () => { + const conn = makeMockConnection(sftpCapture) + feed( + firstInstall(RELAY_NATIVE_CACHE_MISS, [ + '', // npm install + '', // chmod prebuilds + 'ORCA-NPTY-PROBE-OK\n', + '', // rm probe stderr + RELAY_NATIVE_CACHE_PROMOTED, + ...LAUNCH_TAIL + ]) + ) + + await expect(deployAndLaunchRelay(conn)).resolves.toBeDefined() + + const commands = execCommands() + const install = commands.find((c) => c.includes('npm install')) ?? '' + expect(install).toContain('node-pty@1.1.0') + // The install runs in the relay directory; publication moves the finished tree afterwards. + expect(install).toContain('.orca-remote/relay-0.1.0+testhash') + const promote = commands.findLast((c) => c.includes('mkdir "$cache"')) ?? '' + expect(promote).toContain(': > "$cache/.deps-complete"') + }) + + it('does not publish a tree the probe could not load', async () => { + const conn = makeMockConnection(sftpCapture) + feed( + firstInstall(RELAY_NATIVE_CACHE_MISS, [ + '', // npm install + '', // chmod prebuilds + 'MISSING\n', + '', // cat probe stderr + '', // rm probe stderr + '', // npm rebuild + '', // chmod prebuilds after rebuild + 'MISSING\n', + '', // cat stderr after rebuild + '', // rm stderr after rebuild + ...LAUNCH_TAIL + ]) + ) + + await expect(deployAndLaunchRelay(conn)).resolves.toBeDefined() + + expect(execCommands().some((c) => c.includes('mkdir "$cache"'))).toBe(false) + }) + + it('installs per-directory when the host cannot answer the cache probe at all', async () => { + const conn = makeMockConnection(sftpCapture) + const prefix = makeStagedFirstInstallExecPrefix() + prefix[prefix.length - 1] = { reject: 'mkdir: Read-only file system' } + feed([ + ...prefix, + '', // npm install still runs + '', // chmod prebuilds + 'ORCA-NPTY-PROBE-OK\n', + '', // rm probe stderr + '', // publication is attempted and answers nothing + ...LAUNCH_TAIL + ]) + + await expect(deployAndLaunchRelay(conn)).resolves.toBeDefined() + + expect(execCommands().some((c) => c.includes('npm install'))).toBe(true) + }) + + it('falls back to its own install when a linked entry does not load on this host', async () => { + const conn = makeMockConnection(sftpCapture) + feed( + firstInstall(RELAY_NATIVE_CACHE_LINKED, [ + '', // chmod prebuilds + 'MISSING\n', // the shared tree does not load here + '', // cat probe stderr + '', // rm probe stderr + '', // npm install, privately, after the prefix detaches the symlink + '', // chmod prebuilds + 'ORCA-NPTY-PROBE-OK\n', + '', // rm probe stderr + '', // promotion attempt (the entry already exists, so it is declined) + ...LAUNCH_TAIL + ]) + ) + + await expect(deployAndLaunchRelay(conn)).resolves.toBeDefined() + + const install = execCommands().find((c) => c.includes('npm install')) ?? '' + // Why this prefix is the whole point: npm follows the symlink, and every other relay on the + // host is running out of the tree on the other side of it. + expect(install).toContain('if [ -L node_modules ]; then rm -f node_modules; fi;') + const warnings = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) + expect(warnings.some((m) => m.includes('[ssh-relay][NATIVE-CACHE-UNUSABLE]'))).toBe(true) + }) + + it('detaches rather than resetting through the link when repairing an installed relay', async () => { + vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(true) + const conn = makeMockConnection(sftpCapture) + const bothMissing = 'ORCA-NATIVE-DEPS-MISSING:node-pty,@parcel/watcher\nMISSING' + feed([ + '__ORCA_REMOTE_PLATFORM__ Linux x86_64', + '/home/u', + bothMissing, // health probe before the repair lock + bothMissing, // re-probe under the lock + '', // install-owner marker + '', // npm install + '', // chmod prebuilds + 'ORCA-NPTY-PROBE-OK\n', + '', // rm probe stderr + 'DEAD', + '', // publish the per-launch credential + 'READY' + ]) + + await expect(deployAndLaunchRelay(conn)).resolves.toBeDefined() + + const commands = execCommands() + // A repair never consults the shared entry: its reset would rewrite a tree it does not own. + expect(commands.some((c) => c.includes('.orca-remote/native/'))).toBe(false) + const install = commands.find((c) => c.includes('npm install')) ?? '' + expect(install).toContain('if [ -L node_modules ]; then rm -f node_modules; fi;') + expect(install).toContain("rm -rf 'node_modules/node-pty'") + }) + + it('pins its own key so a GC pass cannot collect the entry this connection depends on', async () => { + const conn = makeMockConnection(sftpCapture) + feed( + firstInstall(RELAY_NATIVE_CACHE_LINKED, [ + '', // chmod prebuilds + 'ORCA-NPTY-PROBE-OK\n', + '', // rm probe stderr + ...LAUNCH_TAIL + ]) + ) + + await expect(deployAndLaunchRelay(conn)).resolves.toBeDefined() + await vi.waitFor(() => expect(vi.mocked(gcOldRelayVersions)).toHaveBeenCalled()) + + const options = vi.mocked(gcOldRelayVersions).mock.calls.at(-1)?.[4] + expect(options?.nativeDepsCacheKeys).toEqual([ + expect.stringMatching(/^linux-x64-[0-9a-f]{16}$/) + ]) + }) +}) diff --git a/src/main/ssh/ssh-relay-native-deps-cache-gc.ts b/src/main/ssh/ssh-relay-native-deps-cache-gc.ts new file mode 100644 index 00000000000..e2349ad1bbf --- /dev/null +++ b/src/main/ssh/ssh-relay-native-deps-cache-gc.ts @@ -0,0 +1,243 @@ +/** + * Garbage collection for the shared native-deps cache. + * + * This is the part that can hurt: a cache entry is the only copy of node-pty for every relay + * directory that links to it, so a wrong deletion takes native modules away from a running relay. + * The discipline is `remote-install-gc.ts`': **an unanswered probe blocks deletion.** A listing + * that does not end in its own OK token, a symlink whose target will not read, a reference whose + * shape this client does not recognise — each aborts the entire pass rather than narrowing it. + * Loss of contact is never evidence that a tree is unreferenced + * (`docs/reference/ssh-execution-boundary.md`). + * + * Deletion is then the same three-step move `remote-install-gc.ts` uses for version dirs: rename + * to a tombstone, re-read the references under the rename, and only then remove. A deploy that + * linked the entry between the first listing and the rename shows up in the recheck, and its tree + * is moved back. + */ +import type { SshConnection } from './ssh-connection' +import { execCommand } from './ssh-relay-deploy-helpers' +import { + isRelayNativeDepsCacheEntryName, + relayNativeDepsCacheBaseDir, + relayNativeDepsCacheNodeModulesPath, + supportsRelayNativeDepsCache, + RELAY_NATIVE_DEPS_CACHE_TOMBSTONE_PREFIX +} from './ssh-relay-native-deps-cache' +import { + listRelayNativeDepsCacheEntriesCommand, + listRelayNativeDepsCacheReferencesCommand, + MAX_RELAY_NATIVE_CACHE_LISTING_ENTRIES, + RELAY_NATIVE_CACHE_LIST_OK, + RELAY_NATIVE_CACHE_REFS_OK +} from './ssh-relay-native-deps-cache-commands' +import { moveRemoteTreeCommand, removeRemoteTreeCommand } from './ssh-remote-commands' +import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' + +type ReferenceScan = + | { readable: true; referencedKeys: Set } + /** Anything this client could not fully account for. No entry may be deleted on it. */ + | { readable: false } + +function execHostCommand( + conn: SshConnection, + host: RemoteHostPlatform, + command: string +): Promise { + return execCommand(conn, command, { wrapCommand: host.commandDialect !== 'powershell' }) +} + +/** + * Remove complete cache entries that nothing links to. + * + * `pinnedKeys` is the connection's own key. The referencing symlink is written before the entry + * becomes listable, so a live entry is already protected by the reference scan; the pin is there + * so a deploy that fell back to a per-directory install cannot have its key deleted underneath a + * retry either. + */ +export async function gcRelayNativeDepsCache( + conn: SshConnection, + host: RemoteHostPlatform, + remoteHome: string, + options?: { pinnedKeys?: readonly string[] } +): Promise { + if (!supportsRelayNativeDepsCache(host)) { + return + } + const base = relayNativeDepsCacheBaseDir(host, remoteHome) + let entries: string[] + try { + entries = parseCacheEntryListing( + await execHostCommand(conn, host, listRelayNativeDepsCacheEntriesCommand(host, remoteHome)) + ) + } catch { + return + } + if (entries.length === 0) { + return + } + const scan = await scanCacheReferences(conn, host, remoteHome) + if (!scan.readable) { + return + } + const pinned = new Set(options?.pinnedKeys ?? []) + const candidates = entries.filter((key) => !scan.referencedKeys.has(key) && !pinned.has(key)) + const removed: string[] = [] + for (const key of candidates) { + if (await removeUnreferencedCacheEntry(conn, host, remoteHome, base, key)) { + removed.push(key) + } + } + if (removed.length > 0) { + console.log( + `[relay] native-deps cache GC: removed ${removed.length} entry(ies): ${removed.join(', ')}` + ) + } +} + +async function removeUnreferencedCacheEntry( + conn: SshConnection, + host: RemoteHostPlatform, + remoteHome: string, + base: string, + key: string +): Promise { + const entryDir = joinRemotePath(host, base, key) + const tombstone = joinRemotePath( + host, + base, + `${RELAY_NATIVE_DEPS_CACHE_TOMBSTONE_PREFIX}${key}.${process.pid}.${Date.now()}` + ) + try { + const moved = await execHostCommand( + conn, + host, + moveRemoteTreeCommand(host, entryDir, tombstone) + ) + if (moved.trim() !== 'MOVED') { + return false + } + } catch { + return false + } + // Why recheck under the rename: a deploy that read `.deps-complete` before it moved can still + // be creating its symlink. Its reference now names a path that no longer exists, so restoring + // the tree is the only outcome that leaves that relay with working native deps. + let recheck: ReferenceScan + try { + recheck = await scanCacheReferences(conn, host, remoteHome) + } catch { + recheck = { readable: false } + } + if (!recheck.readable || recheck.referencedKeys.has(key)) { + await execHostCommand(conn, host, moveRemoteTreeCommand(host, tombstone, entryDir)).catch( + () => {} + ) + return false + } + try { + await execHostCommand(conn, host, removeRemoteTreeCommand(host, tombstone)) + return true + } catch { + // The sweep in the entry listing drains a tombstone this pass could not remove. + return false + } +} + +async function scanCacheReferences( + conn: SshConnection, + host: RemoteHostPlatform, + remoteHome: string +): Promise { + let output: string + try { + output = await execHostCommand( + conn, + host, + listRelayNativeDepsCacheReferencesCommand(host, remoteHome) + ) + } catch { + return { readable: false } + } + const lines = output.split(/\r?\n/).map((line) => line.trim()) + if (!lines.includes(RELAY_NATIVE_CACHE_REFS_OK)) { + return { readable: false } + } + const referencedKeys = new Set() + const base = relayNativeDepsCacheBaseDir(host, remoteHome) + for (const line of lines) { + if (!line.startsWith('REF ')) { + continue + } + const attribution = attributeReference(line.slice('REF '.length), base, host, remoteHome) + if (attribution.kind === 'unattributable') { + return { readable: false } + } + if (attribution.kind === 'entry') { + referencedKeys.add(attribution.key) + } + } + return { readable: true, referencedKeys } +} + +type ReferenceAttribution = + | { kind: 'entry'; key: string } + /** A link that points somewhere else entirely; it holds no cache entry alive. */ + | { kind: 'outside' } + | { kind: 'unattributable' } + +/** + * Which cache entry a symlink target names. + * + * A relative target is `unattributable` on purpose. Every link Orca writes is absolute, so a + * relative one is a tree with a history this pass cannot reconstruct, and guessing which entry it + * resolves to is exactly the inference that deletes a live relay's modules. + */ +function attributeReference( + target: string, + base: string, + host: RemoteHostPlatform, + remoteHome: string +): ReferenceAttribution { + if (!target.startsWith('/') || target.includes('/../') || target.endsWith('/..')) { + return { kind: 'unattributable' } + } + if (!target.startsWith(`${base}/`)) { + return { kind: 'outside' } + } + const rest = target.slice(base.length + 1).split('/') + if ( + rest.length !== 2 || + rest[1] !== 'node_modules' || + !isRelayNativeDepsCacheEntryName(rest[0]) + ) { + return { kind: 'unattributable' } + } + // Why rebuild the path rather than trust the split: the target must be exactly what this client + // writes for that key, not merely something that parses into two plausible segments. + return target === relayNativeDepsCacheNodeModulesPath(host, remoteHome, rest[0]) + ? { kind: 'entry', key: rest[0] } + : { kind: 'unattributable' } +} + +function parseCacheEntryListing(output: string): string[] { + const lines = output.split(/\r?\n/).map((line) => line.trim()) + if (!lines.includes(RELAY_NATIVE_CACHE_LIST_OK)) { + return [] + } + const entries: string[] = [] + for (const line of lines) { + if (!line.startsWith('ENTRY ')) { + continue + } + const name = line.slice('ENTRY '.length) + // Why re-validate a name the host produced: it is about to be interpolated into `mv` and + // `rm -rf`. Only names this client could itself have minted are eligible. + if ( + isRelayNativeDepsCacheEntryName(name) && + entries.length < MAX_RELAY_NATIVE_CACHE_LISTING_ENTRIES + ) { + entries.push(name) + } + } + return entries +} diff --git a/src/main/ssh/ssh-relay-native-deps-cache-install.ts b/src/main/ssh/ssh-relay-native-deps-cache-install.ts new file mode 100644 index 00000000000..adc1f01d54f --- /dev/null +++ b/src/main/ssh/ssh-relay-native-deps-cache-install.ts @@ -0,0 +1,209 @@ +/** + * The deploy-side half of the shared native-deps cache: resolve this build's key, try to link an + * existing entry, and publish a probe-verified tree afterwards. + * + * Both entry points answer with a value, never an exception. The cache is an optimization on a + * path that must still connect a host with a read-only home, no `ln`, or an SSH server that drops + * the channel — every one of those is a plain per-directory install, which is what the relay did + * before this existed. + */ +import { existsSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import type { SshConnection } from './ssh-connection' +import { RELAY_ARTIFACTS } from '../../shared/relay-artifacts' +import { execCommand } from './ssh-relay-deploy-helpers' +import { NATIVE_DEPS_COMMAND_TIMEOUT_MS } from './ssh-relay-deploy-timing' +import { + computeRelayNativeDepsCacheKey, + supportsRelayNativeDepsCache, + RELAY_NATIVE_DEPS_PATCH_ARTIFACT_PATTERN, + type RelayNativeDepsCachePatchSource +} from './ssh-relay-native-deps-cache' +import { + ensureRelayNativeDepsCacheCommand, + promoteRelayNativeDepsCacheCommand, + RELAY_NATIVE_CACHE_LINKED, + RELAY_NATIVE_CACHE_PROMOTED, + RELAY_NATIVE_CACHE_SEEDED, + type RelayNativeDepsCachePaths +} from './ssh-relay-native-deps-cache-commands' +import type { RemoteHostPlatform } from './ssh-remote-platform' + +/** + * `linked` — the relay directory now points at a complete shared entry and needs no install. + * `private` — it owns (or is about to own) its own tree, which promotion may later publish. + */ +export type RelayNativeDepsCacheAttachment = { + mode: 'linked' | 'private' + key: string +} + +export type RelayNativeDepsCacheContext = { + hostPlatform: RemoteHostPlatform + remoteHome: string + relayDir: string + platform: string + localRelayDir: string + deps: Readonly> + signal?: AbortSignal +} + +function execHostCommand( + conn: SshConnection, + host: RemoteHostPlatform, + command: string, + signal?: AbortSignal +): Promise { + return execCommand(conn, command, { + wrapCommand: host.commandDialect !== 'powershell', + // Why the native-deps budget and not the default 30s: a seeding copy moves a whole + // node_modules on the host's own disk, which is fast but not instant on a cold cache. + timeoutMs: NATIVE_DEPS_COMMAND_TIMEOUT_MS, + signal + }) +} + +/** + * Every shipped artifact that patches the installed native tree, read for hashing. + * + * Reading is best-effort by design: a patch this client cannot read must not silently drop out of + * the key, so an unreadable one disables the cache rather than producing a key that claims the + * patch was applied. + */ +export function readRelayNativeDepsPatchSources( + localRelayDir: string +): RelayNativeDepsCachePatchSource[] | null { + const sources: RelayNativeDepsCachePatchSource[] = [] + for (const artifact of RELAY_ARTIFACTS) { + if (!RELAY_NATIVE_DEPS_PATCH_ARTIFACT_PATTERN.test(artifact.filename)) { + continue + } + const path = join(localRelayDir, artifact.filename) + try { + if (!existsSync(path)) { + continue + } + sources.push({ filename: artifact.filename, contents: readFileSync(path, 'utf-8') }) + } catch { + return null + } + } + return sources +} + +/** This build's cache key, or null when it cannot be computed and the cache must stay off. */ +export function resolveRelayNativeDepsCacheKey(context: { + platform: string + localRelayDir: string + deps: Readonly> +}): string | null { + const patchSources = readRelayNativeDepsPatchSources(context.localRelayDir) + if (!patchSources) { + return null + } + try { + return computeRelayNativeDepsCacheKey({ + platform: context.platform, + deps: context.deps, + patchSources + }) + } catch (err) { + console.warn( + `[ssh-relay] Native-deps cache key unavailable for ${context.platform}: ${ + err instanceof Error ? err.message : String(err) + }` + ) + return null + } +} + +/** + * Link a complete entry, or leave the relay directory to install privately. + * + * Returns null when the cache is off for this host, which keeps the caller on the exact command + * sequence it ran before the cache existed. + */ +export async function attachRelayNativeDepsCache( + conn: SshConnection, + context: RelayNativeDepsCacheContext +): Promise { + if (!supportsRelayNativeDepsCache(context.hostPlatform)) { + return null + } + const key = resolveRelayNativeDepsCacheKey(context) + if (!key) { + return null + } + const paths = cachePathsFor(context, key) + try { + const output = await execHostCommand( + conn, + context.hostPlatform, + ensureRelayNativeDepsCacheCommand(paths, context.deps), + context.signal + ) + if (output.includes(RELAY_NATIVE_CACHE_LINKED)) { + console.log(`[ssh-relay] Native deps linked from shared cache entry ${key}`) + return { mode: 'linked', key } + } + if (output.includes(RELAY_NATIVE_CACHE_SEEDED)) { + console.log(`[ssh-relay] Seeded native deps for ${key} from an existing install on this host`) + } + return { mode: 'private', key } + } catch (err) { + context.signal?.throwIfAborted() + // Why still 'private' and not null: the relay directory owns nothing yet either way, and the + // install that follows is identical. Promotion afterwards is separately best-effort. + console.warn( + `[ssh-relay] Native-deps cache probe for ${key} failed; installing per-directory: ${ + err instanceof Error ? err.message : String(err) + }` + ) + return { mode: 'private', key } + } +} + +/** + * Publish a private tree the probe just loaded, and link the relay directory to it. + * + * Called only after `probeInstalledNativeDeps` reported both addons loadable on this host, so an + * entry is never published on the strength of a successful `npm install` alone. + */ +export async function promoteRelayNativeDepsCache( + conn: SshConnection, + context: RelayNativeDepsCacheContext, + key: string +): Promise { + try { + const output = await execHostCommand( + conn, + context.hostPlatform, + promoteRelayNativeDepsCacheCommand(cachePathsFor(context, key)), + context.signal + ) + console.log( + output.includes(RELAY_NATIVE_CACHE_PROMOTED) + ? `[ssh-relay] Published native deps as shared cache entry ${key}` + : `[ssh-relay] Native deps stay per-directory; shared cache entry ${key} was not published` + ) + } catch (err) { + context.signal?.throwIfAborted() + console.warn( + `[ssh-relay] Could not publish native-deps cache entry ${key}: ${ + err instanceof Error ? err.message : String(err) + }` + ) + } +} + +function cachePathsFor( + context: RelayNativeDepsCacheContext, + key: string +): RelayNativeDepsCachePaths { + return { + host: context.hostPlatform, + remoteHome: context.remoteHome, + relayDir: context.relayDir, + key + } +} diff --git a/src/main/ssh/ssh-relay-native-deps-cache-shell.test.ts b/src/main/ssh/ssh-relay-native-deps-cache-shell.test.ts new file mode 100644 index 00000000000..530eed7238e --- /dev/null +++ b/src/main/ssh/ssh-relay-native-deps-cache-shell.test.ts @@ -0,0 +1,243 @@ +// The cache's safety argument is made of `sh`, not TypeScript: `mkdir` elects the publisher, +// `.deps-complete` gates linking, and a failed publish must put the tree back. Asserting on the +// command strings cannot show any of that, so these run the real scripts against a real tree. + +import { execFileSync } from 'node:child_process' +import { + mkdirSync, + mkdtempSync, + readlinkSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, + existsSync, + lstatSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { getRemoteHostPlatform } from './ssh-remote-platform' +import { + computeRelayNativeDepsCacheKey, + relayNativeDepsCacheEntryDir, + relayNativeDepsCacheNodeModulesPath +} from './ssh-relay-native-deps-cache' +import { + ensureRelayNativeDepsCacheCommand, + listRelayNativeDepsCacheEntriesCommand, + listRelayNativeDepsCacheReferencesCommand, + promoteRelayNativeDepsCacheCommand, + RELAY_NATIVE_CACHE_LINKED, + RELAY_NATIVE_CACHE_LIST_OK, + RELAY_NATIVE_CACHE_MISS, + RELAY_NATIVE_CACHE_NOT_PROMOTED, + RELAY_NATIVE_CACHE_PROMOTED, + RELAY_NATIVE_CACHE_REFS_OK, + RELAY_NATIVE_CACHE_SEEDED +} from './ssh-relay-native-deps-cache-commands' + +const HOST = getRemoteHostPlatform('linux-x64') +const DEPS = { 'node-pty': '1.1.0', '@parcel/watcher': '2.5.6' } as const +const KEY = computeRelayNativeDepsCacheKey({ platform: 'linux-x64', deps: DEPS }) + +// Debian and Ubuntu point /bin/sh at dash, which is stricter than the bash-in-sh-mode that macOS +// ships; run against both when both exist so a bashism cannot pass here and fail on a host. +const SHELLS = ['/bin/sh', '/bin/dash'].filter((shell) => existsSync(shell)) + +describe.runIf(process.platform !== 'win32').each(SHELLS)( + 'relay native-deps cache shell scripts (%s)', + (shell) => { + let home: string + + const relayDir = (version: string): string => join(home, '.orca-remote', `relay-${version}`) + + function sh(command: string): string { + return execFileSync(shell, ['-c', command], { encoding: 'utf-8' }) + } + + /** A relay directory holding its own installed tree, exactly as `npm install` leaves it. */ + function makePrivateInstall(version: string, deps: Record = DEPS): string { + const dir = relayDir(version) + mkdirSync(join(dir, 'node_modules', 'node-pty', 'build'), { recursive: true }) + mkdirSync(join(dir, 'node_modules', '@parcel', 'watcher'), { recursive: true }) + writeFileSync(join(dir, 'node_modules', 'node-pty', 'build', 'pty.node'), 'binary') + writeFileSync(join(dir, 'package.json'), JSON.stringify({ dependencies: deps })) + return dir + } + + function ensure(version: string): string { + return sh( + ensureRelayNativeDepsCacheCommand( + { host: HOST, remoteHome: home, relayDir: relayDir(version), key: KEY }, + DEPS + ) + ).trim() + } + + function promote(version: string): string { + return sh( + promoteRelayNativeDepsCacheCommand({ + host: HOST, + remoteHome: home, + relayDir: relayDir(version), + key: KEY + }) + ).trim() + } + + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'orca-relay-cache-')) + mkdirSync(join(home, '.orca-remote'), { recursive: true }) + }) + + afterEach(() => { + rmSync(home, { recursive: true, force: true }) + }) + + it('publishes a probe-verified tree and links the relay directory to it', () => { + makePrivateInstall('0.1.0+aaa') + + expect(promote('0.1.0+aaa')).toBe(RELAY_NATIVE_CACHE_PROMOTED) + + const target = relayNativeDepsCacheNodeModulesPath(HOST, home, KEY) + expect(readlinkSync(join(relayDir('0.1.0+aaa'), 'node_modules'))).toBe(target) + expect(statSync(join(target, 'node-pty', 'build', 'pty.node')).isFile()).toBe(true) + expect( + existsSync(join(relayNativeDepsCacheEntryDir(HOST, home, KEY), '.deps-complete')) + ).toBe(true) + }) + + it('links a second bundle to the published tree with no install of its own', () => { + makePrivateInstall('0.1.0+aaa') + promote('0.1.0+aaa') + + // A different bundle: fresh directory, no node_modules, nothing installed. + mkdirSync(relayDir('0.1.0+bbb'), { recursive: true }) + expect(ensure('0.1.0+bbb')).toBe(RELAY_NATIVE_CACHE_LINKED) + + const linked = join(relayDir('0.1.0+bbb'), 'node_modules') + expect(readlinkSync(linked)).toBe(relayNativeDepsCacheNodeModulesPath(HOST, home, KEY)) + expect(statSync(join(linked, 'node-pty', 'build', 'pty.node')).isFile()).toBe(true) + }) + + it('will not link an entry whose completion sentinel is absent', () => { + makePrivateInstall('0.1.0+aaa') + promote('0.1.0+aaa') + rmSync(join(relayNativeDepsCacheEntryDir(HOST, home, KEY), '.deps-complete')) + + mkdirSync(relayDir('0.1.0+bbb'), { recursive: true }) + expect(ensure('0.1.0+bbb')).toBe(RELAY_NATIVE_CACHE_MISS) + expect(existsSync(join(relayDir('0.1.0+bbb'), 'node_modules'))).toBe(false) + }) + + it('seeds from a sibling install rather than recompiling once more', () => { + makePrivateInstall('0.1.0+aaa') + mkdirSync(relayDir('0.1.0+bbb'), { recursive: true }) + + expect(ensure('0.1.0+bbb')).toBe(RELAY_NATIVE_CACHE_SEEDED) + + const seeded = join(relayDir('0.1.0+bbb'), 'node_modules') + expect(lstatSync(seeded).isSymbolicLink()).toBe(false) + expect(statSync(join(seeded, 'node-pty', 'build', 'pty.node')).isFile()).toBe(true) + // The source is untouched, so a relay running out of it is unaffected. + expect(existsSync(join(relayDir('0.1.0+aaa'), 'node_modules', 'node-pty'))).toBe(true) + }) + + it('refuses to seed from a sibling pinned to different versions', () => { + makePrivateInstall('0.1.0+aaa', { 'node-pty': '1.0.0', '@parcel/watcher': '2.5.6' }) + mkdirSync(relayDir('0.1.0+bbb'), { recursive: true }) + + expect(ensure('0.1.0+bbb')).toBe(RELAY_NATIVE_CACHE_MISS) + expect(existsSync(join(relayDir('0.1.0+bbb'), 'node_modules'))).toBe(false) + }) + + it('refuses to seed from a sibling that had node-pty skipped', () => { + makePrivateInstall('0.1.0+aaa') + rmSync(join(relayDir('0.1.0+aaa'), 'node_modules', 'node-pty'), { recursive: true }) + mkdirSync(relayDir('0.1.0+bbb'), { recursive: true }) + + expect(ensure('0.1.0+bbb')).toBe(RELAY_NATIVE_CACHE_MISS) + }) + + it('leaves a directory that installed for itself alone', () => { + makePrivateInstall('0.1.0+aaa') + promote('0.1.0+aaa') + const own = makePrivateInstall('0.1.0+bbb') + + expect(ensure('0.1.0+bbb')).toBe(RELAY_NATIVE_CACHE_MISS) + expect(lstatSync(join(own, 'node_modules')).isSymbolicLink()).toBe(false) + }) + + it('elects exactly one publisher and leaves the loser its own tree', () => { + makePrivateInstall('0.1.0+aaa') + makePrivateInstall('0.1.0+bbb') + + expect(promote('0.1.0+aaa')).toBe(RELAY_NATIVE_CACHE_PROMOTED) + expect(promote('0.1.0+bbb')).toBe(RELAY_NATIVE_CACHE_NOT_PROMOTED) + + // The loser must not have handed its tree to an entry it lost the race for. + const loser = join(relayDir('0.1.0+bbb'), 'node_modules') + expect(lstatSync(loser).isSymbolicLink()).toBe(false) + expect(statSync(join(loser, 'node-pty', 'build', 'pty.node')).isFile()).toBe(true) + }) + + it('never republishes through a symlink it already holds', () => { + makePrivateInstall('0.1.0+aaa') + promote('0.1.0+aaa') + + expect(promote('0.1.0+aaa')).toBe(RELAY_NATIVE_CACHE_NOT_PROMOTED) + expect(statSync(join(relayDir('0.1.0+aaa'), 'node_modules', 'node-pty')).isDirectory()).toBe( + true + ) + }) + + it('survives removing a linked relay directory without touching the shared tree', () => { + makePrivateInstall('0.1.0+aaa') + promote('0.1.0+aaa') + mkdirSync(relayDir('0.1.0+bbb'), { recursive: true }) + ensure('0.1.0+bbb') + + // Exactly what version GC does to an idle directory. + sh(`rm -rf ${JSON.stringify(relayDir('0.1.0+bbb'))}`) + + const target = relayNativeDepsCacheNodeModulesPath(HOST, home, KEY) + expect(statSync(join(target, 'node-pty', 'build', 'pty.node')).isFile()).toBe(true) + }) + + it('reports the published entry and every symlink that references it', () => { + makePrivateInstall('0.1.0+aaa') + promote('0.1.0+aaa') + + const entries = sh(listRelayNativeDepsCacheEntriesCommand(HOST, home)).trim().split('\n') + expect(entries).toEqual([`ENTRY ${KEY}`, RELAY_NATIVE_CACHE_LIST_OK]) + + const refs = sh(listRelayNativeDepsCacheReferencesCommand(HOST, home)).trim().split('\n') + expect(refs).toEqual([ + `REF ${relayNativeDepsCacheNodeModulesPath(HOST, home, KEY)}`, + RELAY_NATIVE_CACHE_REFS_OK + ]) + }) + + it('reports a symlink no Orca version wrote, so GC can refuse the pass', () => { + makePrivateInstall('0.1.0+aaa') + promote('0.1.0+aaa') + mkdirSync(relayDir('0.1.0+bbb'), { recursive: true }) + symlinkSync('../relay-0.1.0+aaa/node_modules', join(relayDir('0.1.0+bbb'), 'node_modules')) + + const refs = sh(listRelayNativeDepsCacheReferencesCommand(HOST, home)).trim().split('\n') + expect(refs).toContain('REF ../relay-0.1.0+aaa/node_modules') + expect(refs.at(-1)).toBe(RELAY_NATIVE_CACHE_REFS_OK) + }) + + it('answers cleanly on a host that has never installed anything', () => { + expect(sh(listRelayNativeDepsCacheEntriesCommand(HOST, home)).trim()).toBe( + RELAY_NATIVE_CACHE_LIST_OK + ) + expect(sh(listRelayNativeDepsCacheReferencesCommand(HOST, home)).trim()).toBe( + RELAY_NATIVE_CACHE_REFS_OK + ) + }) + } +) diff --git a/src/main/ssh/ssh-relay-native-deps-cache.test.ts b/src/main/ssh/ssh-relay-native-deps-cache.test.ts new file mode 100644 index 00000000000..bfe0c481983 --- /dev/null +++ b/src/main/ssh/ssh-relay-native-deps-cache.test.ts @@ -0,0 +1,277 @@ +// The cache is shared across every relay directory on a host, so these cover the two things that +// make sharing safe: the key changes when the tree's inputs change, and GC refuses to delete on +// anything short of a complete, attributable reference listing. + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('./ssh-relay-deploy-helpers', () => ({ + execCommand: vi.fn() +})) + +import { execCommand } from './ssh-relay-deploy-helpers' +import type { SshConnection } from './ssh-connection' +import { getRemoteHostPlatform } from './ssh-remote-platform' +import { + computeRelayNativeDepsCacheKey, + isRelayNativeDepsCacheEntryName, + relayNativeDepsCacheEntryDir, + relayNativeDepsCacheNodeModulesPath, + supportsRelayNativeDepsCache +} from './ssh-relay-native-deps-cache' +import { + ensureRelayNativeDepsCacheCommand, + promoteRelayNativeDepsCacheCommand, + RELAY_NATIVE_CACHE_LIST_OK, + RELAY_NATIVE_CACHE_REFS_ERR, + RELAY_NATIVE_CACHE_REFS_OK +} from './ssh-relay-native-deps-cache-commands' +import { gcRelayNativeDepsCache } from './ssh-relay-native-deps-cache-gc' + +const POSIX = getRemoteHostPlatform('linux-x64') +const WINDOWS = getRemoteHostPlatform('win32-x64') +const HOME = '/home/u' +const DEPS = { 'node-pty': '1.1.0', '@parcel/watcher': '2.5.6' } as const +const KEY = computeRelayNativeDepsCacheKey({ platform: 'linux-x64', deps: DEPS }) +const RELAY_DIR = `${HOME}/.orca-remote/relay-0.1.0+aaa` + +const conn = {} as SshConnection +const mockExec = vi.mocked(execCommand) + +function refsOk(...targets: string[]): string { + return [...targets.map((t) => `REF ${t}`), RELAY_NATIVE_CACHE_REFS_OK].join('\n') +} + +function listing(...keys: string[]): string { + return [...keys.map((k) => `ENTRY ${k}`), RELAY_NATIVE_CACHE_LIST_OK].join('\n') +} + +describe('computeRelayNativeDepsCacheKey', () => { + it('keys on the dependency set, not on the relay bundle', () => { + expect(computeRelayNativeDepsCacheKey({ platform: 'linux-x64', deps: DEPS })).toBe(KEY) + expect( + computeRelayNativeDepsCacheKey({ + platform: 'linux-x64', + deps: { '@parcel/watcher': '2.5.6', 'node-pty': '1.1.0' } + }) + ).toBe(KEY) + }) + + it('mints a new entry when a dependency version moves', () => { + expect( + computeRelayNativeDepsCacheKey({ + platform: 'linux-x64', + deps: { ...DEPS, 'node-pty': '1.2.0' } + }) + ).not.toBe(KEY) + }) + + it('mints a new entry when a patch applied to the tree changes', () => { + const withPatch = computeRelayNativeDepsCacheKey({ + platform: 'linux-x64', + deps: DEPS, + patchSources: [{ filename: 'node-pty-1.1.0-patch.cjs', contents: 'a' }] + }) + const withChangedPatch = computeRelayNativeDepsCacheKey({ + platform: 'linux-x64', + deps: DEPS, + patchSources: [{ filename: 'node-pty-1.1.0-patch.cjs', contents: 'b' }] + }) + expect(withPatch).not.toBe(KEY) + expect(withChangedPatch).not.toBe(withPatch) + }) + + it('separates platforms so one host never links another architecture', () => { + expect(computeRelayNativeDepsCacheKey({ platform: 'linux-arm64', deps: DEPS })).not.toBe(KEY) + expect(KEY.startsWith('linux-x64-')).toBe(true) + }) + + it('refuses a platform it cannot recognise rather than building a path from it', () => { + expect(() => computeRelayNativeDepsCacheKey({ platform: '../../etc', deps: DEPS })).toThrow( + /Unsafe relay native-deps cache key/ + ) + expect(isRelayNativeDepsCacheEntryName('../../etc')).toBe(false) + expect(isRelayNativeDepsCacheEntryName(KEY)).toBe(true) + }) + + it('leaves Windows on the per-directory install', () => { + expect(supportsRelayNativeDepsCache(POSIX)).toBe(true) + expect(supportsRelayNativeDepsCache(WINDOWS)).toBe(false) + }) +}) + +describe('ensureRelayNativeDepsCacheCommand', () => { + const command = ensureRelayNativeDepsCacheCommand( + { host: POSIX, remoteHome: HOME, relayDir: RELAY_DIR, key: KEY }, + DEPS + ) + + it('links only an entry that carries the completion sentinel', () => { + expect(command).toContain(`[ -f "$cache/.deps-complete" ]`) + expect(command).toContain('ln -s "$target" "$nm"') + expect(command).toContain(relayNativeDepsCacheNodeModulesPath(POSIX, HOME, KEY)) + }) + + it('never overwrites a directory the relay installed for itself', () => { + // The link is only created on a path that does not exist; a real node_modules reads as a miss. + expect(command).toContain('if [ ! -e "$nm" ] && ln -s "$target" "$nm"') + }) + + it('seeds only from a sibling whose manifest pins the same versions', () => { + for (const [name, version] of Object.entries(DEPS)) { + expect(command).toContain(`grep -F -q '"${name}":"${version}"' "$pj"`) + } + expect(command).toContain('[ -d "$cand/node-pty" ] || continue') + }) +}) + +describe('promoteRelayNativeDepsCacheCommand', () => { + const command = promoteRelayNativeDepsCacheCommand({ + host: POSIX, + remoteHome: HOME, + relayDir: RELAY_DIR, + key: KEY + }) + + it('elects one publisher with mkdir rather than a lock', () => { + expect(command).toContain('mkdir "$cache" 2>/dev/null') + }) + + it('writes the completion sentinel after the symlink exists', () => { + expect(command.indexOf('ln -s "$target" "$nm"')).toBeLessThan( + command.indexOf(': > "$cache/.deps-complete"') + ) + }) + + it('never deletes the entry unless the tree made it back to the relay directory', () => { + expect(command).toContain('if mv "$target" "$nm" 2>/dev/null; then rm -rf "$cache"') + }) + + it('refuses to publish a directory that is already a shared symlink', () => { + expect(command).toContain('if [ -L "$nm" ]; then') + }) +}) + +describe('gcRelayNativeDepsCache', () => { + beforeEach(() => { + mockExec.mockReset().mockResolvedValue('') + }) + + it('removes an entry nothing links to', async () => { + mockExec + .mockResolvedValueOnce(listing(KEY)) + .mockResolvedValueOnce(refsOk()) + .mockResolvedValueOnce('MOVED') + .mockResolvedValueOnce(refsOk()) + .mockResolvedValueOnce('') + + await gcRelayNativeDepsCache(conn, POSIX, HOME) + + const last = mockExec.mock.calls.at(-1)?.[1] ?? '' + expect(last).toContain('rm -rf') + expect(last).toContain('.gc-tombstone.') + }) + + it('keeps an entry a live relay depends on', async () => { + mockExec + .mockResolvedValueOnce(listing(KEY)) + .mockResolvedValueOnce(refsOk(relayNativeDepsCacheNodeModulesPath(POSIX, HOME, KEY))) + + await gcRelayNativeDepsCache(conn, POSIX, HOME) + + expect(mockExec).toHaveBeenCalledTimes(2) + expect(mockExec.mock.calls.some(([, c]) => c.startsWith('rm -rf'))).toBe(false) + }) + + it('keeps every entry when the reference listing never answers', async () => { + mockExec.mockResolvedValueOnce(listing(KEY)).mockResolvedValueOnce('REF /somewhere\n') + + await gcRelayNativeDepsCache(conn, POSIX, HOME) + + expect(mockExec).toHaveBeenCalledTimes(2) + }) + + it('keeps every entry when the reference scan reports an unreadable link', async () => { + mockExec.mockResolvedValueOnce(listing(KEY)).mockResolvedValueOnce(RELAY_NATIVE_CACHE_REFS_ERR) + + await gcRelayNativeDepsCache(conn, POSIX, HOME) + + expect(mockExec).toHaveBeenCalledTimes(2) + }) + + it('keeps every entry when a reference has a shape this client never writes', async () => { + // A relative target cannot be attributed to an entry without guessing what it resolves to. + mockExec + .mockResolvedValueOnce(listing(KEY)) + .mockResolvedValueOnce(refsOk('../native/x/node_modules')) + + await gcRelayNativeDepsCache(conn, POSIX, HOME) + + expect(mockExec).toHaveBeenCalledTimes(2) + }) + + it('ignores a link that points outside the cache entirely', async () => { + mockExec + .mockResolvedValueOnce(listing(KEY)) + .mockResolvedValueOnce(refsOk('/opt/shared/node_modules')) + .mockResolvedValueOnce('MOVED') + .mockResolvedValueOnce(refsOk('/opt/shared/node_modules')) + .mockResolvedValueOnce('') + + await gcRelayNativeDepsCache(conn, POSIX, HOME) + + expect(mockExec.mock.calls.some(([, c]) => c.startsWith('rm -rf'))).toBe(true) + }) + + it('restores the tree when a deploy links the entry after the tombstone rename', async () => { + mockExec + .mockResolvedValueOnce(listing(KEY)) + .mockResolvedValueOnce(refsOk()) + .mockResolvedValueOnce('MOVED') + .mockResolvedValueOnce(refsOk(relayNativeDepsCacheNodeModulesPath(POSIX, HOME, KEY))) + .mockResolvedValueOnce('MOVED') + + await gcRelayNativeDepsCache(conn, POSIX, HOME) + + const last = mockExec.mock.calls.at(-1)?.[1] ?? '' + expect(last).toContain('mv ') + expect(last).toContain(relayNativeDepsCacheEntryDir(POSIX, HOME, KEY)) + expect(mockExec.mock.calls.some(([, c]) => c.startsWith('rm -rf'))).toBe(false) + }) + + it('restores the tree when the recheck itself cannot answer', async () => { + mockExec + .mockResolvedValueOnce(listing(KEY)) + .mockResolvedValueOnce(refsOk()) + .mockResolvedValueOnce('MOVED') + .mockRejectedValueOnce(new Error('channel closed')) + .mockResolvedValueOnce('MOVED') + + await gcRelayNativeDepsCache(conn, POSIX, HOME) + + expect(mockExec.mock.calls.some(([, c]) => c.startsWith('rm -rf'))).toBe(false) + }) + + it('never removes a pinned key', async () => { + mockExec.mockResolvedValueOnce(listing(KEY)).mockResolvedValueOnce(refsOk()) + + await gcRelayNativeDepsCache(conn, POSIX, HOME, { pinnedKeys: [KEY] }) + + expect(mockExec).toHaveBeenCalledTimes(2) + }) + + it('drops a listed name it could not have minted rather than interpolating it', async () => { + mockExec + .mockResolvedValueOnce(`ENTRY ../../.ssh\n${RELAY_NATIVE_CACHE_LIST_OK}`) + .mockResolvedValueOnce(refsOk()) + + await gcRelayNativeDepsCache(conn, POSIX, HOME) + + expect(mockExec).toHaveBeenCalledTimes(1) + }) + + it('does nothing on a host that never creates entries', async () => { + await gcRelayNativeDepsCache(conn, WINDOWS, 'C:\\Users\\u') + + expect(mockExec).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ssh/ssh-relay-native-deps-cache.ts b/src/main/ssh/ssh-relay-native-deps-cache.ts new file mode 100644 index 00000000000..934aaff63a6 --- /dev/null +++ b/src/main/ssh/ssh-relay-native-deps-cache.ts @@ -0,0 +1,145 @@ +/** + * Where the relay's compiled native dependencies live, and what their identity is keyed on. + * + * A relay install directory is keyed on the JS bundle hash, which moves on every commit to + * `src/relay/` or the `src/shared/` it pulls in. `node_modules` used to live inside it, so a + * dependency set that is a pinned constant (`RELAY_NATIVE_DEPS`) was reinstalled — and on Linux, + * where node-pty ships no prebuild, recompiled from source — on every new bundle (#18009). The + * directory key was coupled to the wrong quantity. + * + * The tree now lives at `~/.orca-remote/native/-/node_modules` and each + * relay directory holds a symlink to it. Three rules make one tree safe to share: + * + * 1. **A published entry is immutable.** `.deps-complete` is written last, only after a probe on + * this host loaded both addons. Nothing installs, rebuilds or resets into a published entry: a + * repair detaches the symlink and installs privately, so a host with a broken toolchain can + * never `rm -rf node_modules/node-pty` out from under a live relay that shares the tree. + * 2. **Publication elects one winner with `mkdir`.** The entry either does not exist (this deploy + * builds it privately and promotes it) or is already complete (this deploy links it). There is + * no window in which two deploys write one tree, so no client-side lock is needed. + * 3. **Every failure degrades to today's per-directory install.** A host that cannot symlink, + * cannot create the directory, or answers nothing still deploys, one bundle at a time. + * + * Windows is deliberately excluded. node-pty's npm tarball ships win32 prebuilts, so there is no + * compile to avoid there, and `node-pty-1.1.0-console-list-agent-patch.cjs` mutates the installed + * tree in place — which rule 1 forbids for a shared one. + */ +import { createHash } from 'node:crypto' +import { RELAY_REMOTE_DIR } from './relay-protocol' +import { RELAY_BUILD_PLATFORMS } from '../../shared/relay-artifacts' +import { isWindowsRemoteHost, joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' + +/** Sibling of `relay-` and `orcad-`; owned by neither model's version GC. */ +export const RELAY_NATIVE_DEPS_CACHE_DIR_NAME = 'native' + +/** Written last. Its presence is the only thing that makes an entry linkable. */ +export const RELAY_NATIVE_DEPS_CACHE_COMPLETE_NAME = '.deps-complete' + +/** Hidden so the entry listing skips it, and swept by age so a crashed pass drains. */ +export const RELAY_NATIVE_DEPS_CACHE_TOMBSTONE_PREFIX = '.gc-tombstone.' + +/** + * Bump when the remote install starts mutating the installed tree in a way the hashed inputs + * below cannot see — a new `npm rebuild` flag, a new post-install step, a patch applied by + * something other than a shipped `node-pty-*` artifact. A published entry is never repaired in + * place; only a new key retires it. + */ +export const RELAY_NATIVE_DEPS_CACHE_EPOCH = 1 + +/** + * Shipped relay artifacts that patch the installed native tree. Their bytes go into the key, so + * changing a patch mints a new entry instead of leaving hosts on a tree built from the old one. + */ +export const RELAY_NATIVE_DEPS_PATCH_ARTIFACT_PATTERN = /^node-pty-.*\.(cjs|js|patch)$/ + +const CACHE_KEY_HASH_LENGTH = 16 + +const CACHE_ENTRY_NAME_REGEX = new RegExp( + `^(${RELAY_BUILD_PLATFORMS.join('|')})-[0-9a-f]{${CACHE_KEY_HASH_LENGTH}}$` +) + +export type RelayNativeDepsCachePatchSource = { + filename: string + contents: string +} + +/** + * `-` over the dependency set, the epoch, and every patch the + * remote install applies. Platform and arch stay in the name rather than the hash so an operator + * reading `~/.orca-remote/native/` can tell what an entry is for. + */ +export function computeRelayNativeDepsCacheKey(input: { + platform: string + deps: Readonly> + patchSources?: readonly RelayNativeDepsCachePatchSource[] +}): string { + const hash = createHash('sha256') + hash.update(`epoch ${RELAY_NATIVE_DEPS_CACHE_EPOCH}\n`) + for (const [name, version] of Object.entries(input.deps).sort(([a], [b]) => (a < b ? -1 : 1))) { + hash.update(`dep ${name} ${version}\n`) + } + const patches = [...(input.patchSources ?? [])].sort((a, b) => (a.filename < b.filename ? -1 : 1)) + for (const patch of patches) { + hash.update( + `patch ${patch.filename} ${createHash('sha256').update(patch.contents).digest('hex')}\n` + ) + } + const key = `${input.platform}-${hash.digest('hex').slice(0, CACHE_KEY_HASH_LENGTH)}` + if (!isRelayNativeDepsCacheEntryName(key)) { + // Why: the key reaches the host inside `mv` and `rm -rf`; an unrecognized platform must + // disable the cache rather than arrive as a path fragment nobody validated. + throw new Error(`Unsafe relay native-deps cache key: ${JSON.stringify(key)}`) + } + return key +} + +/** + * Whether a name the host listed is one this client may move or delete. Every GC candidate goes + * through here before it reaches a shell. + */ +export function isRelayNativeDepsCacheEntryName(name: string): boolean { + return CACHE_ENTRY_NAME_REGEX.test(name) +} + +/** `~/.orca-remote` — the parent both relay dirs and the cache sit under. */ +export function remoteInstallRootDir(host: RemoteHostPlatform, remoteHome: string): string { + return joinRemotePath(host, remoteHome, RELAY_REMOTE_DIR) +} + +/** `~/.orca-remote/native` */ +export function relayNativeDepsCacheBaseDir(host: RemoteHostPlatform, remoteHome: string): string { + return joinRemotePath( + host, + remoteInstallRootDir(host, remoteHome), + RELAY_NATIVE_DEPS_CACHE_DIR_NAME + ) +} + +/** `~/.orca-remote/native/` */ +export function relayNativeDepsCacheEntryDir( + host: RemoteHostPlatform, + remoteHome: string, + key: string +): string { + if (!isRelayNativeDepsCacheEntryName(key)) { + throw new Error(`Unsafe relay native-deps cache key: ${JSON.stringify(key)}`) + } + return joinRemotePath(host, relayNativeDepsCacheBaseDir(host, remoteHome), key) +} + +/** `~/.orca-remote/native//node_modules` — the symlink target, and the reference identity. */ +export function relayNativeDepsCacheNodeModulesPath( + host: RemoteHostPlatform, + remoteHome: string, + key: string +): string { + return joinRemotePath(host, relayNativeDepsCacheEntryDir(host, remoteHome, key), 'node_modules') +} + +/** + * Windows hosts install node-pty from an npm prebuilt and then patch the tree in place, so they + * keep the per-directory install. Nothing else about their deploy changes. + */ +export function supportsRelayNativeDepsCache(host: RemoteHostPlatform): boolean { + return !isWindowsRemoteHost(host) +} diff --git a/src/main/ssh/ssh-relay-native-deps-install-fixture.ts b/src/main/ssh/ssh-relay-native-deps-install-fixture.ts index e8fc2274583..922b8bedf61 100644 --- a/src/main/ssh/ssh-relay-native-deps-install-fixture.ts +++ b/src/main/ssh/ssh-relay-native-deps-install-fixture.ts @@ -54,6 +54,11 @@ export function makeMockConnection(capture: SftpWriteCapture): SshConnection { export type ExecResponse = string | { reject: string } +// The answer a genuinely broken pair produces: a marker line naming both deps. A bare `MISSING` +// names none, so it is unverifiable and must never stand in for this. +export const BOTH_NATIVE_DEPS_MISSING_PROBE = + 'ORCA-NATIVE-DEPS-MISSING:node-pty,@parcel/watcher\nMISSING' + const STAGE_OWNER = '.sftp-namespace-00000000000000000000000000000000' export function makeStagedFirstInstallExecPrefix(): ExecResponse[] { @@ -64,19 +69,20 @@ export function makeStagedFirstInstallExecPrefix(): ExecResponse[] { `__ORCA_UPLOAD_STAGE_SLOT__${STAGE_OWNER}:slot-0`, '', // chmod staged node '', // final install namespace marker - `__ORCA_UPLOAD_STAGE_PROMOTION__${STAGE_OWNER}:PROMOTED` + `__ORCA_UPLOAD_STAGE_PROMOTION__${STAGE_OWNER}:PROMOTED`, + // Shared native-deps cache probe; an empty answer is a miss, so the per-directory install runs. + '' ] } // Repair reconnect (isRelayAlreadyInstalled → true) where BOTH native deps are broken and the host // cannot compile node-pty, so the caller's resets must survive into the node-pty-less reinstall. export function makeRepairToolchainSkipExecResponses(): ExecResponse[] { - const bothMissing = 'ORCA-NATIVE-DEPS-MISSING:node-pty,@parcel/watcher\nMISSING' return [ '__ORCA_REMOTE_PLATFORM__ Linux x86_64', '/home/u', - bothMissing, // health probe before lock - bothMissing, // re-probe under the repair lock + BOTH_NATIVE_DEPS_MISSING_PROBE, // health probe before lock + BOTH_NATIVE_DEPS_MISSING_PROBE, // re-probe under the repair lock '', // SFTP-namespace install-owner marker (repair) { reject: 'gyp ERR! stack Error: not found: make' }, 'PKG apk', // toolchain probe: no HAVE lines @@ -139,7 +145,7 @@ export function makeExecResponses(opts: { '', // rm -rf node-pty + reinstall without it // node-pty is always reported missing here; the probe never resolves OK, so cat + rm both run. opts.nodePtySkipWatcher === 'missing' - ? 'ORCA-NATIVE-DEPS-MISSING:node-pty,@parcel/watcher\nMISSING\n' + ? `${BOTH_NATIVE_DEPS_MISSING_PROBE}\n` : 'ORCA-NATIVE-DEPS-MISSING:node-pty\nMISSING\n', '', // cat probe stderr '', // rm -f probe stderr @@ -168,8 +174,10 @@ export function makeExecResponses(opts: { ] // Cleanup execs only run when the probe resolved (not when it rejected). const probeResolved = typeof probeSlot === 'string' + let loadable = false if (probeResolved) { const probeOk = probeSlot.includes('ORCA-NPTY-PROBE-OK') + loadable = probeOk if (!probeOk) { slots.push('') // cat stderr (graceful failure path captures detail) } @@ -179,12 +187,17 @@ export function makeExecResponses(opts: { slots.push('') // chmod prebuilds after rebuild const repairProbe = opts.repairProbe === 'ok' ? 'ORCA-NPTY-PROBE-OK\n' : 'MISSING\n' slots.push(repairProbe) - if (!repairProbe.includes('ORCA-NPTY-PROBE-OK')) { + loadable = repairProbe.includes('ORCA-NPTY-PROBE-OK') + if (!loadable) { slots.push('') // cat stderr after unsuccessful rebuild } slots.push('') // rm -f stderr after rebuild probe } } + // Publication is gated on the probe: only a tree this host actually loaded is shared. + if (loadable) { + slots.push('') // promote the private tree into the shared native-deps cache + } slots.push('', 'DEAD', '', 'READY') // clean stage root, launch, credential, readiness return slots } diff --git a/src/main/ssh/ssh-relay-native-deps-install.test.ts b/src/main/ssh/ssh-relay-native-deps-install.test.ts index 5297da7c85a..16144fb2cf3 100644 --- a/src/main/ssh/ssh-relay-native-deps-install.test.ts +++ b/src/main/ssh/ssh-relay-native-deps-install.test.ts @@ -83,6 +83,7 @@ import { import { acquireInstallLock } from './ssh-relay-install-lock' import { tryAcquireRelayRepairLock } from './ssh-relay-repair-lock' import { + BOTH_NATIVE_DEPS_MISSING_PROBE, decodePowerShellCommand, makeExecResponses, makeMockConnection, @@ -677,8 +678,8 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { feed([ '__ORCA_REMOTE_PLATFORM__ Linux x86_64', '/home/u', - 'MISSING', // health probe: require() fails - 'MISSING', // re-probe after lock + BOTH_NATIVE_DEPS_MISSING_PROBE, // health probe: require() names both deps + BOTH_NATIVE_DEPS_MISSING_PROBE, // re-probe after lock '', // SFTP-namespace install-owner marker (repair) { reject: 'npm ERR! network ETIMEDOUT' }, // npm install fails (offline) 'DEAD', @@ -701,8 +702,8 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { vi.mocked(execCommand) .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') .mockResolvedValueOnce('/home/u') - .mockResolvedValueOnce('MISSING') - .mockResolvedValueOnce('MISSING') + .mockResolvedValueOnce(BOTH_NATIVE_DEPS_MISSING_PROBE) + .mockResolvedValueOnce(BOTH_NATIVE_DEPS_MISSING_PROBE) .mockResolvedValueOnce('') // SFTP-namespace install-owner marker (repair) .mockRejectedValueOnce( Object.assign(new Error('npm termination was not confirmed'), { @@ -843,7 +844,7 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { feed([ '__ORCA_REMOTE_PLATFORM__ Linux x86_64', '/home/u', - 'MISSING', + BOTH_NATIVE_DEPS_MISSING_PROBE, 'DEAD', '', // remote credential generation without a namespace marker 'READY' diff --git a/src/main/ssh/ssh-relay-native-deps-probe-verdict.test.ts b/src/main/ssh/ssh-relay-native-deps-probe-verdict.test.ts index c67270db4c3..df946dfe346 100644 --- a/src/main/ssh/ssh-relay-native-deps-probe-verdict.test.ts +++ b/src/main/ssh/ssh-relay-native-deps-probe-verdict.test.ts @@ -75,8 +75,11 @@ vi.mock('./ssh-connection-utils', () => ({ import { deployAndLaunchRelay } from './ssh-relay-deploy' import { execCommand, uploadDirectory } from './ssh-relay-deploy-helpers' import { parseUnameToRelayPlatform } from './relay-protocol' +import { resolveRemoteNodePath } from './ssh-remote-node-resolution' import { finalizeInstall, isRelayAlreadyInstalled } from './ssh-relay-versioned-install' import { + BOTH_NATIVE_DEPS_MISSING_PROBE, + decodePowerShellCommand, makeMockConnection, type ExecResponse, type SftpWriteCapture @@ -156,13 +159,67 @@ describe('native-deps repair probe verdicts', () => { expect(outcome, 'lost contact must not abort the connection').not.toBeInstanceOf(Error) }) - it('still resets and repairs when the probe answers without the OK marker', async () => { + it('leaves node_modules intact when the probe answers without naming a dep', async () => { + // The bare `MISSING` a `|| echo MISSING` subshell emits when node never reached the script + // (bad NODE_OPTIONS, OOM kill, exit 127). The shell answered; the answer is not about the deps. const conn = makeMockConnection(sftpCapture) feed([ '__ORCA_REMOTE_PLATFORM__ Linux x86_64', '/home/u', - 'MISSING', // answered, no marker line: both deps are genuinely broken - 'MISSING', // re-probe under the repair lock + 'MISSING', // answered, no marker line: nothing here names a dep + '', // launch namespace marker + 'DEAD', + '', // publish the per-launch credential + 'READY' + ]) + + const outcome = await deployAndLaunchRelay(conn).then( + (result) => result, + (err: Error) => err + ) + + const commands = execCommands() + expect(warnings().some((message) => message.includes('Repairing missing native deps'))).toBe( + false + ) + expect(commands.some((command) => command.includes(NODE_PTY_RESET))).toBe(false) + expect(commands.some((command) => command.includes(WATCHER_RESET))).toBe(false) + expect(commands.some((command) => command.includes('npm install'))).toBe(false) + // One probe only: an unverifiable answer must not fall through to the locked re-probe. + expect(commands.filter((command) => command.includes('ORCA-NATIVE-DEPS-OK'))).toHaveLength(1) + expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() + expect(outcome, 'an unparseable answer must not abort the connection').not.toBeInstanceOf(Error) + expect(warnings().some((message) => message.includes('NATIVE-DEPS-PROBE-UNPARSEABLE'))).toBe( + true + ) + }) + + it('carries the probe stderr into the unparseable-answer warning', async () => { + const conn = makeMockConnection(sftpCapture) + vi.mocked(execCommand) + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') + .mockResolvedValueOnce('/home/u') + .mockImplementationOnce((_conn, _command, options) => { + options?.onStderr?.('node: --inspect-brk is not allowed in NODE_OPTIONS') + return Promise.resolve('MISSING') + }) + feed(['', 'DEAD', '', 'READY']) + + await expect(deployAndLaunchRelay(conn)).resolves.toBeDefined() + + // Why: `2>/dev/null` used to drop the one line that says which host config broke node. + expect( + warnings().find((message) => message.includes('NATIVE-DEPS-PROBE-UNPARSEABLE')) + ).toContain('not allowed in NODE_OPTIONS') + }) + + it('still resets both deps when the probe names both', async () => { + const conn = makeMockConnection(sftpCapture) + feed([ + '__ORCA_REMOTE_PLATFORM__ Linux x86_64', + '/home/u', + BOTH_NATIVE_DEPS_MISSING_PROBE, // answered: both deps are genuinely broken + BOTH_NATIVE_DEPS_MISSING_PROBE, // re-probe under the repair lock '', // SFTP-namespace install-owner marker (repair) '', // npm install native deps '', // chmod prebuilds @@ -181,6 +238,62 @@ describe('native-deps repair probe verdicts', () => { expect(vi.mocked(finalizeInstall)).toHaveBeenCalledTimes(1) }) + it('leaves a Windows relay intact when its probe answers without naming a dep', async () => { + // The PowerShell branch has the same hole: `try { & node -e ... } catch { 'MISSING' }` prints + // nothing when node exits non-zero without reaching the script. + vi.mocked(parseUnameToRelayPlatform).mockReturnValueOnce('win32-x64') + vi.mocked(resolveRemoteNodePath).mockResolvedValueOnce('C:/Program Files/nodejs/node.exe') + const conn = makeMockConnection(sftpCapture) + feed([ + '__ORCA_REMOTE_PLATFORM__ Windows AMD64', + 'C:\\Users\\u', + '', // health probe: PowerShell swallowed the native failure, so nothing names a dep + '', // no persisted active pipe marker + 'WAITING', // initial pipe probe + '', // publish the per-launch credential + '', // WMI relay launch + 'READY', // readiness poll + '' // persist active pipe marker + ]) + + await expect(deployAndLaunchRelay(conn)).resolves.toBeDefined() + + const scripts = execCommands().map((command) => decodePowerShellCommand(command) ?? command) + expect(scripts.some((script) => script.includes('node_modules/node-pty'))).toBe(false) + expect(scripts.some((script) => script.includes('node_modules/@parcel/watcher'))).toBe(false) + expect(scripts.some((script) => script.includes('npm install'))).toBe(false) + expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() + expect(warnings().some((message) => message.includes('NATIVE-DEPS-PROBE-UNPARSEABLE'))).toBe( + true + ) + }) + + it('resets only the dep the probe names', async () => { + const conn = makeMockConnection(sftpCapture) + const watcherMissing = 'ORCA-NATIVE-DEPS-MISSING:@parcel/watcher\nMISSING' + feed([ + '__ORCA_REMOTE_PLATFORM__ Linux x86_64', + '/home/u', + watcherMissing, + watcherMissing, // re-probe under the repair lock + '', // SFTP-namespace install-owner marker (repair) + '', // npm install native deps + '', // chmod prebuilds + 'ORCA-NPTY-PROBE-OK\n', + '', // rm probe stderr + 'DEAD', + '', // publish the per-launch credential + 'READY' + ]) + + await expect(deployAndLaunchRelay(conn)).resolves.toBeDefined() + + const install = execCommands().find((command) => command.includes('npm install')) ?? '' + expect(install).toContain(WATCHER_RESET) + expect(install).not.toContain(NODE_PTY_RESET) + expect(vi.mocked(finalizeInstall)).toHaveBeenCalledTimes(1) + }) + it('skips repair entirely when the probe answers OK', async () => { const conn = makeMockConnection(sftpCapture) feed([ diff --git a/src/main/ssh/ssh-relay-node-pty-repair.test.ts b/src/main/ssh/ssh-relay-node-pty-repair.test.ts new file mode 100644 index 00000000000..7c1b1800259 --- /dev/null +++ b/src/main/ssh/ssh-relay-node-pty-repair.test.ts @@ -0,0 +1,204 @@ +// Why: the once-only ledger is the whole safety story here — an unbounded rebuild loop against a +// remote is worse than the bug it chases, so every gate that stops one gets a test. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + forgetRelayNodePtyRepairs, + recoverRelayNodePtyForSpawn, + relayNodePtyRepairAttempts +} from './ssh-relay-node-pty-repair' +import type { TerminalUnavailableCause } from '../../shared/terminal-unavailable-cause' + +const HOST: TerminalUnavailableCause['host'] = { + platform: 'linux', + arch: 'x64', + libc: 'glibc', + glibcVersion: '2.31', + nodeAbi: '115', + nodeVersion: 'v20.11.0' +} + +function cause(overrides: Partial = {}): TerminalUnavailableCause { + return { + status: 'blocked', + reason: 'abi_mismatch', + detail: 'built for NODE_MODULE_VERSION 108, this Node accepts 115', + repairable: true, + host: HOST, + ...overrides + } +} + +describe('recoverRelayNodePtyForSpawn', () => { + const TARGET = 'host-a' + let warnSpy: ReturnType + + beforeEach(() => { + forgetRelayNodePtyRepairs(TARGET) + forgetRelayNodePtyRepairs('host-b') + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + afterEach(() => { + warnSpy.mockRestore() + forgetRelayNodePtyRepairs(TARGET) + forgetRelayNodePtyRepairs('host-b') + }) + + function harness(overrides: { hasLivePtys?: boolean; reconnect?: () => Promise } = {}) { + const reconnect = vi.fn(overrides.reconnect ?? (async () => {})) + const repaired = { name: 'post-repair-provider' } + const resolveProvider = vi.fn(() => repaired) + return { + reconnect, + resolveProvider, + repaired, + run: (c: TerminalUnavailableCause | null) => + recoverRelayNodePtyForSpawn({ + targetId: TARGET, + cause: c, + hasLivePtys: () => overrides.hasLivePtys === true, + reconnect, + resolveProvider + }) + } + } + + it('repairs a repairable cause with exactly one reconnect and hands back the new provider', async () => { + const h = harness() + + const result = await h.run(cause()) + + expect(result.outcome).toBe('repaired') + expect(result.provider).toBe(h.repaired) + expect(h.reconnect).toHaveBeenCalledTimes(1) + expect([...relayNodePtyRepairAttempts(TARGET)]).toEqual(['abi_mismatch']) + }) + + it('does not repair a second time for the same cause on the same host', async () => { + const first = harness() + await first.run(cause()) + const second = harness() + + const result = await second.run(cause()) + + expect(result.outcome).toBe('already-attempted') + expect(result.provider).toBeNull() + expect(second.reconnect).not.toHaveBeenCalled() + }) + + it('spends the attempt even when the repair reconnect fails, so it cannot loop', async () => { + const failing = harness({ + reconnect: async () => { + throw new Error('relay repair lock is wedged') + } + }) + + const first = await failing.run(cause()) + expect(first.outcome).toBe('reconnect-failed') + expect(first.provider).toBeNull() + + const second = harness() + const retry = await second.run(cause()) + + expect(retry.outcome).toBe('already-attempted') + expect(second.reconnect).not.toHaveBeenCalled() + }) + + it('keeps the ledger per host, so a second host still gets its one attempt', async () => { + const h = harness() + await h.run(cause()) + const other = vi.fn(async () => {}) + + const result = await recoverRelayNodePtyForSpawn({ + targetId: 'host-b', + cause: cause(), + hasLivePtys: () => false, + reconnect: other, + resolveProvider: () => ({}) + }) + + expect(result.outcome).toBe('repaired') + expect(other).toHaveBeenCalledTimes(1) + }) + + it('keeps the ledger per reason, so a different proved fault still gets its one attempt', async () => { + const h = harness() + await h.run(cause()) + const second = harness() + + const result = await second.run(cause({ reason: 'arch_mismatch' })) + + expect(result.outcome).toBe('repaired') + expect([...relayNodePtyRepairAttempts(TARGET)].sort()).toEqual([ + 'abi_mismatch', + 'arch_mismatch' + ]) + }) + + it('never repairs an unverifiable cause, whatever the peer claims about repairability', async () => { + const h = harness() + + // Why repairable:true here: #14830 is exactly a peer flag believed over the status. + const result = await h.run(cause({ status: 'unverifiable', repairable: true })) + + expect(result.outcome).toBe('not-repairable') + expect(h.reconnect).not.toHaveBeenCalled() + expect([...relayNodePtyRepairAttempts(TARGET)]).toEqual([]) + }) + + it('never repairs a toolchain_missing cause — the rebuild needs the missing compiler', async () => { + const h = harness() + + const result = await h.run(cause({ reason: 'toolchain_missing', repairable: false })) + + expect(result.outcome).toBe('not-repairable') + expect(h.reconnect).not.toHaveBeenCalled() + expect([...relayNodePtyRepairAttempts(TARGET)]).toEqual([]) + }) + + it('never repairs when the relay published no cause at all', async () => { + const h = harness() + + const result = await h.run(null) + + expect(result.outcome).toBe('not-repairable') + expect(h.reconnect).not.toHaveBeenCalled() + }) + + it('does not rebuild under live PTYs, and does not spend the attempt doing so', async () => { + const live = harness({ hasLivePtys: true }) + + const blocked = await live.run(cause()) + expect(blocked.outcome).toBe('ptys-live') + expect(live.reconnect).not.toHaveBeenCalled() + expect([...relayNodePtyRepairAttempts(TARGET)]).toEqual([]) + + const idle = harness() + expect((await idle.run(cause())).outcome).toBe('repaired') + }) + + it('withholds the retry when the reconnect produced no provider', async () => { + const reconnect = vi.fn(async () => {}) + + const result = await recoverRelayNodePtyForSpawn({ + targetId: TARGET, + cause: cause(), + hasLivePtys: () => false, + reconnect, + resolveProvider: () => null + }) + + expect(result.outcome).toBe('no-provider') + expect(result.provider).toBeNull() + expect(reconnect).toHaveBeenCalledTimes(1) + }) + + it('gives a host a fresh attempt only after an explicit disconnect', async () => { + await harness().run(cause()) + forgetRelayNodePtyRepairs(TARGET) + const afterDisconnect = harness() + + expect((await afterDisconnect.run(cause())).outcome).toBe('repaired') + }) +}) diff --git a/src/main/ssh/ssh-relay-node-pty-repair.ts b/src/main/ssh/ssh-relay-node-pty-repair.ts new file mode 100644 index 00000000000..149b6c486cb --- /dev/null +++ b/src/main/ssh/ssh-relay-node-pty-repair.ts @@ -0,0 +1,115 @@ +/** + * Turning a spawn-time "remote terminals are unavailable" into an actual repair. + * + * The fault is proved on the relay at spawn time; the only thing that can fix it — + * `repairInstalledNativeDeps` in ssh-relay-deploy.ts — runs on the client during deploy, under + * `tryAcquireRelayRepairLock`. So the recovery here is deliberately indirect: it does not touch + * the remote `node_modules` itself, it drives one relay reconnect and lets the locked deploy path + * do the rebuild. All `node_modules` mutation stays behind that lock. + * + * The invariant that matters more than the recovery: **at most one repair per host per reason.** + * A rebuild loop against a remote is worse than the bug it is chasing, so the attempt is recorded + * before the reconnect starts, not after it succeeds. A repair that ran and failed is still an + * attempt, and this host will render the relay's message from then on. + */ +import { + mayRepairFromCause, + type TerminalUnavailableCause +} from '../../shared/terminal-unavailable-cause' + +/** targetId -> the cause reasons already spent on this host, for the life of the session. */ +const attemptedRepairsByTarget = new Map>() + +export type RelayNodePtyRepairOutcome = + /** The cause is not proved-and-rebuildable; render the relay's message. */ + | 'not-repairable' + /** This host already spent its one attempt on this reason. */ + | 'already-attempted' + /** The relay is still serving PTYs, so a rebuild under it is not accounted for. */ + | 'ptys-live' + /** No reconnect was possible, or it failed; the attempt is still spent. */ + | 'reconnect-failed' + /** Reconnected, but no provider came back to retry on. */ + | 'no-provider' + | 'repaired' + +/** Forget a host's spent attempts. Disconnect is user action, so it may earn a fresh one. */ +export function forgetRelayNodePtyRepairs(targetId: string): void { + attemptedRepairsByTarget.delete(targetId) +} + +/** Test/diagnostic view of the ledger. */ +export function relayNodePtyRepairAttempts(targetId: string): ReadonlySet { + return attemptedRepairsByTarget.get(targetId) ?? new Set() +} + +/** False when this host has already spent its attempt on this reason. Marks on success. */ +function claimRepairAttempt(targetId: string, reason: string): boolean { + const spent = attemptedRepairsByTarget.get(targetId) + if (spent) { + if (spent.has(reason)) { + return false + } + spent.add(reason) + return true + } + attemptedRepairsByTarget.set(targetId, new Set([reason])) + return true +} + +export type RelayNodePtyRepairRequest = { + targetId: string + /** Already parsed and schema-validated; null when the relay published no cause. */ + cause: TerminalUnavailableCause | null + /** Whether this client still holds live PTYs on the relay about to be rebuilt. */ + hasLivePtys: () => boolean + /** One relay reconnect, which runs the locked `repairInstalledNativeDeps`. */ + reconnect: () => Promise + /** The provider registered after the reconnect — never the one that failed. */ + resolveProvider: () => TProvider | null +} + +/** + * Drive one repair for a failed spawn, returning the provider to retry on. + * + * Gates on `mayRepairFromCause`, not on the peer's `repairable` flag: an `unverifiable` cause + * proves nothing and must never trigger a rebuild (#14830, docs/reference/ssh-execution-boundary.md). + */ +export async function recoverRelayNodePtyForSpawn( + request: RelayNodePtyRepairRequest +): Promise<{ outcome: RelayNodePtyRepairOutcome; provider: TProvider | null }> { + const { targetId, cause } = request + if (!mayRepairFromCause(cause) || !cause) { + return { outcome: 'not-repairable', provider: null } + } + // Why check before claiming: a live PTY means the rebuild's blast radius is unaccounted for, and + // that is a reason to wait, not a spent attempt. Costs nothing remote, so it cannot loop. + if (request.hasLivePtys()) { + console.warn( + `[ssh-relay-repair] Not rebuilding node-pty on ${targetId} for ${cause.reason}: the relay is still serving PTYs` + ) + return { outcome: 'ptys-live', provider: null } + } + if (!claimRepairAttempt(targetId, cause.reason)) { + console.warn( + `[ssh-relay-repair] node-pty repair for ${cause.reason} on ${targetId} already ran; not retrying` + ) + return { outcome: 'already-attempted', provider: null } + } + + console.warn( + `[ssh-relay-repair] Reconnecting ${targetId} once to rebuild node-pty (${cause.reason}): ${cause.detail}` + ) + try { + await request.reconnect() + } catch (error) { + console.warn( + `[ssh-relay-repair] Repair reconnect for ${targetId} failed: ${ + error instanceof Error ? error.message : String(error) + }` + ) + return { outcome: 'reconnect-failed', provider: null } + } + const provider = request.resolveProvider() + return provider ? { outcome: 'repaired', provider } : { outcome: 'no-provider', provider: null } +} diff --git a/src/main/ssh/ssh-relay-node-pty-spawn-repair.test.ts b/src/main/ssh/ssh-relay-node-pty-spawn-repair.test.ts new file mode 100644 index 00000000000..12a51ce6bdd --- /dev/null +++ b/src/main/ssh/ssh-relay-node-pty-spawn-repair.test.ts @@ -0,0 +1,288 @@ +// The other half of the #17830 recovery: proof that the reconnect a spawn-time cause triggers is +// the SAME locked deploy repair, not a second rebuild path. `tryAcquireRelayRepairLock` is the only +// thing standing between two clients and a concurrent `node_modules` rewrite, so a lock this path +// cannot take must degrade to the relay's message rather than proceed. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as RelayInstallMarkerModule from './ssh-relay-install-marker' + +vi.mock('electron', () => ({ + app: { getAppPath: () => '/mock/app' } +})) + +vi.mock('fs', () => ({ + existsSync: vi.fn().mockReturnValue(true), + readFileSync: vi.fn().mockReturnValue('0.1.0+testhash') +})) + +vi.mock('./relay-protocol', () => ({ + RELAY_VERSION: '0.1.0', + RELAY_REMOTE_DIR: '.orca-remote', + parseUnameToRelayPlatform: vi.fn().mockReturnValue('linux-x64'), + RELAY_SENTINEL: 'ORCA-RELAY v0.1.0 READY\n', + RELAY_SENTINEL_TIMEOUT_MS: 10_000 +})) + +vi.mock('./ssh-relay-deploy-helpers', () => ({ + uploadDirectory: vi.fn().mockResolvedValue(undefined), + waitForSentinel: vi.fn().mockResolvedValue({ + write: vi.fn(), + onData: vi.fn(), + onClose: vi.fn() + }), + isUnconfirmedSshCommandTermination: () => false, + execCommand: vi.fn() +})) + +vi.mock('./ssh-remote-node-resolution', () => ({ + resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node') +})) + +vi.mock('./ssh-relay-install-marker', async (importOriginal) => ({ + ...(await importOriginal()), + createRelayInstallMarkerFileName: () => '.sftp-namespace-00000000000000000000000000000000' +})) + +vi.mock('./ssh-relay-versioned-install', () => ({ + readLocalFullVersion: vi.fn().mockReturnValue('0.1.0+testhash'), + computeRemoteRelayDir: (home: string, v: string) => `${home}/.orca-remote/relay-${v}`, + isRelayAlreadyInstalled: vi.fn().mockResolvedValue(true), + finalizeInstall: vi.fn().mockResolvedValue(undefined), + abandonInstall: vi.fn().mockResolvedValue(undefined), + gcOldRelayVersions: vi.fn().mockResolvedValue(undefined) +})) + +vi.mock('./ssh-relay-install-lock', () => ({ + acquireInstallLock: vi.fn().mockResolvedValue(undefined), + RELAY_INSTALL_LOCK_NAME: '.install-lock' +})) + +vi.mock('./ssh-relay-repair-lock', () => ({ + tryAcquireRelayRepairLock: vi.fn().mockResolvedValue('acquired') +})) + +vi.mock('./ssh-relay-gc-claim', () => ({ + releaseRelayGcClaimWithRetry: vi.fn().mockResolvedValue('released'), + tryAcquireRelayGcClaim: vi.fn().mockResolvedValue('launch-token'), + waitForRelayGcClaimRelease: vi.fn().mockResolvedValue(undefined) +})) + +vi.mock('./ssh-connection-utils', () => ({ + shellEscape: (s: string) => `'${s}'` +})) + +import { deployAndLaunchRelay } from './ssh-relay-deploy' +import { execCommand } from './ssh-relay-deploy-helpers' +import { parseUnameToRelayPlatform } from './relay-protocol' +import { isRelayAlreadyInstalled } from './ssh-relay-versioned-install' +import { tryAcquireRelayRepairLock } from './ssh-relay-repair-lock' +import { + makeMockConnection, + type ExecResponse, + type SftpWriteCapture +} from './ssh-relay-native-deps-install-fixture' +import { forgetRelayNodePtyRepairs, recoverRelayNodePtyForSpawn } from './ssh-relay-node-pty-repair' +import type { TerminalUnavailableCause } from '../../shared/terminal-unavailable-cause' + +const TARGET = 'repair-host' + +const ABI_MISMATCH: TerminalUnavailableCause = { + status: 'blocked', + reason: 'abi_mismatch', + detail: 'built for NODE_MODULE_VERSION 108, this Node accepts 115', + repairable: true, + host: { + platform: 'linux', + arch: 'x64', + libc: 'glibc', + glibcVersion: '2.31', + nodeAbi: '115', + nodeVersion: 'v20.11.0' + } +} + +// The relay dir is complete but node-pty will not load, which is exactly what the spawn-time cause +// describes. @parcel/watcher is healthy, so only node-pty is reset and rebuilt. +const NODE_PTY_BROKEN = 'ORCA-NATIVE-DEPS-MISSING:node-pty\nMISSING' + +function repairSucceedsResponses(): ExecResponse[] { + return [ + '__ORCA_REMOTE_PLATFORM__ Linux x86_64', + '/home/u', + NODE_PTY_BROKEN, // health probe before the lock + NODE_PTY_BROKEN, // re-probe under the repair lock + '', // SFTP-namespace install-owner marker + '', // reset node-pty + npm install + '', // chmod prebuilds + 'ORCA-NPTY-PROBE-OK\n', // node-pty loads again + '', // rm -f probe stderr + 'DEAD', + '', // publish the per-launch credential + 'READY' + ] +} + +function lockUnavailableResponses(): ExecResponse[] { + return [ + '__ORCA_REMOTE_PLATFORM__ Linux x86_64', + '/home/u', + NODE_PTY_BROKEN, // health probe before the lock + 'DEAD', + '', // publish the per-launch credential + 'READY' + ] +} + +describe('spawn-time node-pty repair through the locked deploy path', () => { + let warnSpy: ReturnType + const sftpCapture: SftpWriteCapture = { + paths: [], + contents: {}, + execCallCountAtWrite: {} + } + + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(execCommand).mockReset().mockResolvedValue('') + sftpCapture.paths.length = 0 + for (const key of Object.keys(sftpCapture.contents)) { + delete sftpCapture.contents[key] + } + for (const key of Object.keys(sftpCapture.execCallCountAtWrite)) { + delete sftpCapture.execCallCountAtWrite[key] + } + vi.mocked(parseUnameToRelayPlatform).mockReturnValue('linux-x64') + vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(true) + vi.mocked(tryAcquireRelayRepairLock).mockResolvedValue('acquired') + forgetRelayNodePtyRepairs(TARGET) + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + afterEach(() => { + warnSpy.mockRestore() + forgetRelayNodePtyRepairs(TARGET) + }) + + function feed(responses: ExecResponse[]): void { + const mockExec = vi.mocked(execCommand) + for (const response of responses) { + if (typeof response === 'string') { + mockExec.mockResolvedValueOnce(response) + } else { + mockExec.mockRejectedValueOnce(new Error(response.reject)) + } + } + } + + function recover(conn: ReturnType, deploys: { count: number }) { + return recoverRelayNodePtyForSpawn({ + targetId: TARGET, + cause: ABI_MISMATCH, + hasLivePtys: () => false, + reconnect: async () => { + deploys.count += 1 + await deployAndLaunchRelay(conn) + }, + resolveProvider: () => ({ generation: deploys.count }) + }) + } + + function execCalls(): string[] { + return vi.mocked(execCommand).mock.calls.map(([, command]) => String(command)) + } + + it('rebuilds node-pty under the repair lock and returns the post-reconnect provider', async () => { + const conn = makeMockConnection(sftpCapture) + feed(repairSucceedsResponses()) + const deploys = { count: 0 } + + const result = await recover(conn, deploys) + + expect(result.outcome).toBe('repaired') + expect(result.provider).toEqual({ generation: 1 }) + expect(deploys.count).toBe(1) + expect(vi.mocked(tryAcquireRelayRepairLock)).toHaveBeenCalledTimes(1) + const install = execCalls().find((command) => command.includes('npm install')) ?? '' + expect(install).toContain('npm install') + // The reset is what makes an ABI-mismatched binding recompile instead of being reported up to date. + expect(install).toContain("rm -rf 'node_modules/node-pty'") + }) + + it('does not repair or reconnect a second time for the same cause on the same host', async () => { + const conn = makeMockConnection(sftpCapture) + feed(repairSucceedsResponses()) + const deploys = { count: 0 } + await recover(conn, deploys) + vi.mocked(execCommand).mockReset().mockResolvedValue('') + + const second = await recover(conn, deploys) + + expect(second.outcome).toBe('already-attempted') + expect(second.provider).toBeNull() + expect(deploys.count).toBe(1) + expect(execCalls()).toEqual([]) + expect(vi.mocked(tryAcquireRelayRepairLock)).toHaveBeenCalledTimes(1) + }) + + it.each(['busy', 'error'] as const)( + 'leaves the host untouched and degrades to the relay message when the repair lock is %s', + async (lockResult) => { + const conn = makeMockConnection(sftpCapture) + vi.mocked(tryAcquireRelayRepairLock).mockResolvedValue(lockResult) + feed(lockUnavailableResponses()) + const deploys = { count: 0 } + + const result = await recover(conn, deploys) + + // The reconnect happened; the rebuild did not, so the retried spawn hits the same relay + // rejection and the user reads today's message. Nothing wrote to node_modules unlocked. + expect(deploys.count).toBe(1) + expect(execCalls().some((command) => command.includes('npm install'))).toBe(false) + expect(execCalls().some((command) => command.includes('node_modules/node-pty'))).toBe(false) + expect(result.outcome).toBe('repaired') + const warnings = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) + expect(warnings.some((line) => line.includes(`repair lock is ${lockResult}`))).toBe(true) + } + ) + + it('never reaches the deploy path for an unverifiable cause', async () => { + const conn = makeMockConnection(sftpCapture) + const deploys = { count: 0 } + + const result = await recoverRelayNodePtyForSpawn({ + targetId: TARGET, + cause: { ...ABI_MISMATCH, status: 'unverifiable' }, + hasLivePtys: () => false, + reconnect: async () => { + deploys.count += 1 + await deployAndLaunchRelay(conn) + }, + resolveProvider: () => ({ generation: deploys.count }) + }) + + expect(result.outcome).toBe('not-repairable') + expect(deploys.count).toBe(0) + expect(vi.mocked(tryAcquireRelayRepairLock)).not.toHaveBeenCalled() + expect(execCalls()).toEqual([]) + }) + + it('never reaches the deploy path for a toolchain_missing cause', async () => { + const conn = makeMockConnection(sftpCapture) + const deploys = { count: 0 } + + const result = await recoverRelayNodePtyForSpawn({ + targetId: TARGET, + cause: { ...ABI_MISMATCH, reason: 'toolchain_missing', repairable: false }, + hasLivePtys: () => false, + reconnect: async () => { + deploys.count += 1 + await deployAndLaunchRelay(conn) + }, + resolveProvider: () => ({ generation: deploys.count }) + }) + + expect(result.outcome).toBe('not-repairable') + expect(deploys.count).toBe(0) + expect(execCalls()).toEqual([]) + }) +}) diff --git a/src/main/ssh/ssh-relay-orphan-abandon-paths.test.ts b/src/main/ssh/ssh-relay-orphan-abandon-paths.test.ts index 9d08ac7da0f..b63f93a6c2b 100644 --- a/src/main/ssh/ssh-relay-orphan-abandon-paths.test.ts +++ b/src/main/ssh/ssh-relay-orphan-abandon-paths.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { SshRelaySession } from './ssh-relay-session' import { createMockDeps, mockDeploySuccess } from './ssh-relay-session-test-fixtures' +import { isProvenProcessExit } from '../../shared/terminal-exit-cause' const { muxRequestMock, openConsumerSessionMock } = vi.hoisted(() => ({ muxRequestMock: vi.fn(), @@ -186,14 +187,18 @@ describe('SshRelaySession abandoned remote PTYs', () => { expect(clearProviderPtyState).not.toHaveBeenCalledWith(APP_PTY_ID) }) - it('retires the lease without a kill when the relay proves the PTY is gone', async () => { - // pty.attach verifies process liveness before answering not-found, so this is the one branch - // with positive proof of death — and a dead process needs no shutdown request. + it('stops claiming the id without asserting an exit when the relay answers not-found', async () => { + // pty.attach answers not-found both when it verified the pid is dead AND when its session map + // simply has no such id — which is every id after a relay restart, since the relay renumbers + // from pty-1. The client cannot tell those apart, so this branch may release the id but must + // not certify a death: the exit it publishes carries the unverified-loss sentinel, never a + // status the renderer would read as a real exit. const { deps, shutdown } = await establishWithFailingReattach( new Error('PTY "pty-live" not found') ) expect(shutdown).not.toHaveBeenCalled() + // 'expired' records that reattach gave up on the id, not that the shell died; ssh:terminateSessions still reaches it. expect(deps.mockStore.markSshRemotePtyLease).toHaveBeenCalledWith( 'target-1', 'pty-live', @@ -204,6 +209,15 @@ describe('SshRelaySession abandoned remote PTYs', () => { id: APP_PTY_ID, code: -1 }) + const exitCall = vi + .mocked(deps.mockWindow.webContents.send) + .mock.calls.find(([channel]) => channel === 'pty:exit') + if (!exitCall) { + throw new Error('expected a pty:exit publication') + } + // The ratchet that makes the above safe: swapping -1 for any provable status would turn an + // unreachable relay into a death certificate, closing tabs and dropping leaf↔PTY bindings. + expect(isProvenProcessExit((exitCall[1] as { code: number }).code)).toBe(false) }) it('keeps a recovered session attached when reattach succeeds after an earlier drop', async () => { diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index bdc7c4370f2..68254fbb108 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -6,6 +6,9 @@ import type { BrowserWindow } from 'electron' import { deployAndLaunchRelay } from './ssh-relay-deploy' import { execCommand } from './ssh-relay-deploy-helpers' import { isRelayVersionMismatchError } from './ssh-relay-version-mismatch-error' +import { isRelayEndpointHeldError } from './ssh-relay-endpoint-incumbent' +import { forgetRelayNodePtyRepairs, recoverRelayNodePtyForSpawn } from './ssh-relay-node-pty-repair' +import type { TerminalUnavailableCause } from '../../shared/terminal-unavailable-cause' import { replayPendingSshPtyKills } from './ssh-pending-pty-kill-replay' import { SshChannelMultiplexer } from './ssh-channel-multiplexer' import { SshPtyProvider } from '../providers/ssh-pty-provider' @@ -318,6 +321,8 @@ export class SshRelaySession { private _onReady: ((targetId: string) => void) | null = null private portScanner: PortScanner | null = null private currentConnection: SshConnection | null = null + // Why: a self-driven repair reconnect must not silently re-negotiate the target's grace window. + private lastGraceTimeSeconds: number | undefined = undefined private hostPlatform: RemoteHostPlatform | null = null private remoteCliBridgeEnv: RemoteCliBridgeEnv | null = null private aiVaultListMethodSupported: boolean | null = null @@ -516,6 +521,7 @@ export class SshRelaySession { this.aiVaultListMethodSupported = null this.aiVaultTitleMethodSupported = null this.currentConnection = conn + this.lastGraceTimeSeconds = graceTimeSeconds try { const { @@ -629,7 +635,13 @@ export class SshRelaySession { } // Why: terminal on first connect — a deployed binary against a still-running legacy daemon, or a // claim another connection holds. Notify the callback but still rethrow. - if (isRelayVersionMismatchError(err) || isSshOwnerAdmissionBlockedError(err)) { + // RelayEndpointHeldError is terminal for the same reason: a live incumbent owns the + // socket path, and backoff cannot make it hand it over. The user resolves it. + if ( + isRelayVersionMismatchError(err) || + isRelayEndpointHeldError(err) || + isSshOwnerAdmissionBlockedError(err) + ) { console.warn( `[ssh-relay-session] Terminal relay error on initial connect for ${this.targetId}: ${err.message}` ) @@ -656,6 +668,7 @@ export class SshRelaySession { this.aiVaultListMethodSupported = null this.aiVaultTitleMethodSupported = null this.currentConnection = conn + this.lastGraceTimeSeconds = graceTimeSeconds // Why: stop scanning before teardownProviders so the poll timer can't fire against a disposed multiplexer. this.stopPortScanning() @@ -783,7 +796,13 @@ export class SshRelaySession { } // Why terminal: neither a version mismatch nor a blocked owner claim is reconcilable by backoff // retry, so fire the typed callback and drop out of 'reconnecting'. - if (isRelayVersionMismatchError(err) || isSshOwnerAdmissionBlockedError(err)) { + // RelayEndpointHeldError is terminal for the same reason: a live incumbent owns the + // socket path, and backoff cannot make it hand it over. The user resolves it. + if ( + isRelayVersionMismatchError(err) || + isRelayEndpointHeldError(err) || + isSshOwnerAdmissionBlockedError(err) + ) { console.warn( `[ssh-relay-session] Terminal relay error for ${this.targetId}: ${err.message}` ) @@ -849,6 +868,9 @@ export class SshRelaySession { this.teardownProviders('shutdown') this.currentConnection = null this._state = 'disposed' + // Why here and not on reconnect: an explicit disconnect is user action, so the host earns a + // fresh node-pty repair attempt. A reconnect must not, or the repair becomes a loop. + forgetRelayNodePtyRepairs(this.targetId) const recoveryRemoval = forgetSshPtyConsumerRecovery( this.targetId, this.ptyConsumerClientInstanceId, @@ -982,6 +1004,44 @@ export class SshRelaySession { }) } + /** + * A spawn was refused because the relay cannot load node-pty. Reconnect once so the deploy + * path's `repairInstalledNativeDeps` rebuilds it under `tryAcquireRelayRepairLock`, then hand + * back the provider registered by that reconnect for a single retry. + * + * Nothing here mutates the remote directly — a lock-less rebuild could collide with a + * concurrent reconnect's repair, so the locked deploy path stays the only writer. If the lock + * is busy it launches degraded, the retry hits the same rejection, and the user sees the + * relay's message. The attempt is spent either way. + */ + private async recoverRemoteTerminalRuntime( + requestingProvider: SshPtyProvider, + cause: TerminalUnavailableCause + ): Promise { + const { provider } = await recoverRelayNodePtyForSpawn({ + targetId: this.targetId, + cause, + hasLivePtys: () => requestingProvider.hasLivePtys(), + reconnect: async () => { + const conn = this.currentConnection + if (!conn || this.isDisposed()) { + throw new Error('no_live_ssh_connection') + } + await this.reconnect(conn, this.lastGraceTimeSeconds) + }, + resolveProvider: () => { + if (this._state !== 'ready' || this.isDisposed()) { + return null + } + const current = getSshPtyProvider(this.targetId) as SshPtyProvider | undefined + // Why identity-checked: a reconnect that fell back to the same provider would retry + // against the same unrepaired relay. + return current && current !== requestingProvider ? current : null + } + }) + return provider + } + // Why: shared by establish() and reconnect() so both use the exact same registration sequence. private async registerProviders( mux: SshChannelMultiplexer, @@ -1021,6 +1081,10 @@ export class SshRelaySession { this.remoteCliBridgeEnv ?? undefined, providerGeneration ) + // Why optional-call: session tests register partial provider stubs, same as the pause adapter below. + ptyProvider.setTerminalUnavailableRecovery?.((cause) => + this.recoverRemoteTerminalRuntime(ptyProvider, cause) + ) const consumerOwnerState = this.activePtyConsumerOwner() if (consumerOwnerState) { ptyProvider.setPtyDeliveryPauseAdapter?.(({ id, providerGeneration: generation, paused }) => { diff --git a/src/main/ssh/ssh-relay-sftp-namespace-install.test.ts b/src/main/ssh/ssh-relay-sftp-namespace-install.test.ts index 3809af2c3d9..4c0874979b3 100644 --- a/src/main/ssh/ssh-relay-sftp-namespace-install.test.ts +++ b/src/main/ssh/ssh-relay-sftp-namespace-install.test.ts @@ -91,6 +91,7 @@ import { isRelayAlreadyInstalled } from './ssh-relay-versioned-install' import { tryAcquireRelayRepairLock } from './ssh-relay-repair-lock' +import { BOTH_NATIVE_DEPS_MISSING_PROBE } from './ssh-relay-native-deps-install-fixture' import type { SshConnection } from './ssh-connection' import type { SftpNamespacePathMapping } from './sftp-namespace-resolution' @@ -250,10 +251,12 @@ const POSIX_FIRST_INSTALL = [ '', // chmod staged node '', // final install namespace marker STAGE_PROMOTED, + '', // shared native-deps cache probe (miss) '', // npm install native deps '', // chmod prebuilds 'ORCA-NPTY-PROBE-OK\n', '', // rm probe stderr + '', // promote into the shared native-deps cache '', // clean stage root 'DEAD', '', // publish the per-launch credential @@ -267,10 +270,12 @@ const POSIX_SYSTEM_SSH_FIRST_INSTALL = [ STAGE_RESERVED, '', // chmod staged node STAGE_PROMOTED, + '', // shared native-deps cache probe (miss) '', // npm install native deps '', // chmod prebuilds 'ORCA-NPTY-PROBE-OK\n', '', // rm probe stderr + '', // promote into the shared native-deps cache '', // clean stage root 'DEAD', '', // publish the per-launch credential @@ -281,8 +286,8 @@ const POSIX_SYSTEM_SSH_FIRST_INSTALL = [ const POSIX_REPAIR = [ '__ORCA_REMOTE_PLATFORM__ Linux x86_64', SHELL_HOME, - 'MISSING', // probe before the repair lock - 'MISSING', // re-probe under the lock + BOTH_NATIVE_DEPS_MISSING_PROBE, // probe before the repair lock: the marker names both deps + BOTH_NATIVE_DEPS_MISSING_PROBE, // re-probe under the lock '', // install-owner marker '', // npm install native deps '', // chmod prebuilds @@ -692,8 +697,8 @@ describe('relay repair writes on a split SFTP namespace', () => { feed([ '__ORCA_REMOTE_PLATFORM__ Linux x86_64', SHELL_HOME, - 'MISSING', - 'MISSING', + BOTH_NATIVE_DEPS_MISSING_PROBE, + BOTH_NATIVE_DEPS_MISSING_PROBE, '', // npm install native deps '', // chmod prebuilds 'ORCA-NPTY-PROBE-OK\n', @@ -716,7 +721,12 @@ describe('relay repair writes on a split SFTP namespace', () => { it('degrades to shell paths when marker creation fails outright', async () => { const conn = makeConnection(capture) - feed(['__ORCA_REMOTE_PLATFORM__ Linux x86_64', SHELL_HOME, 'MISSING', 'MISSING']) + feed([ + '__ORCA_REMOTE_PLATFORM__ Linux x86_64', + SHELL_HOME, + BOTH_NATIVE_DEPS_MISSING_PROBE, + BOTH_NATIVE_DEPS_MISSING_PROBE + ]) vi.mocked(execCommand).mockRejectedValueOnce(new Error('read-only file system')) feed([ '', // npm install native deps @@ -742,7 +752,12 @@ describe('relay repair writes on a split SFTP namespace', () => { it('keeps the repair lock when marker creation has unconfirmed termination', async () => { const conn = makeConnection(capture) - feed(['__ORCA_REMOTE_PLATFORM__ Linux x86_64', SHELL_HOME, 'MISSING', 'MISSING']) + feed([ + '__ORCA_REMOTE_PLATFORM__ Linux x86_64', + SHELL_HOME, + BOTH_NATIVE_DEPS_MISSING_PROBE, + BOTH_NATIVE_DEPS_MISSING_PROBE + ]) vi.mocked(execCommand).mockRejectedValueOnce( Object.assign(new Error('marker teardown unconfirmed'), { sshChannelCloseConfirmed: false }) ) diff --git a/src/main/ssh/ssh-relay-superseded-endpoints.test.ts b/src/main/ssh/ssh-relay-superseded-endpoints.test.ts new file mode 100644 index 00000000000..874d9aae3fe --- /dev/null +++ b/src/main/ssh/ssh-relay-superseded-endpoints.test.ts @@ -0,0 +1,164 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const execCommand = vi.fn() +vi.mock('./ssh-relay-deploy-helpers', () => ({ + execCommand: (...args: unknown[]) => execCommand(...args), + isUnconfirmedSshCommandTermination: (error: unknown) => + (error as { sshChannelCloseConfirmed?: boolean } | null)?.sshChannelCloseConfirmed === false +})) + +import { parseRelayEndpointIncumbentProbe } from './ssh-relay-endpoint-incumbent' +import { + classifySupersededRelay, + supersededRelayEndpointListCommand, + sweepSupersededRelayEndpoints +} from './ssh-relay-superseded-endpoints' +import type { SshConnection } from './ssh-connection' +import { getRemoteHostPlatform } from './ssh-remote-platform' + +const HOME = '/home/u' +const SOCK_NAME = 'relay-deadbeef.sock' +const CURRENT_DIR = `${HOME}/.orca-remote/relay-0.1.0+bd3ec370d21d` +const OLD_SOCK = `${HOME}/.orca-remote/relay-0.1.0+7175e0a40ea7/${SOCK_NAME}` +const HOST = getRemoteHostPlatform('linux-x64') +const WINDOWS_HOST = getRemoteHostPlatform('win32-x64') +const CONN = {} as SshConnection + +const SWEEP = { + remoteHome: HOME, + currentRelayDir: CURRENT_DIR, + sockName: SOCK_NAME, + nodePath: '/usr/bin/node' +} + +function probe(lines: string[]): string { + return ['ORCA-INCUMBENT-BEGIN', ...lines, 'ORCA-INCUMBENT-END'].join('\n') +} + +function incumbent(lines: string[]): ReturnType { + return parseRelayEndpointIncumbentProbe(OLD_SOCK, probe(lines)) +} + +function issuedCommands(): string[] { + return execCommand.mock.calls.map((call) => String(call[1])) +} + +beforeEach(() => { + execCommand.mockReset() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.spyOn(console, 'log').mockImplementation(() => {}) +}) + +describe('supersededRelayEndpointListCommand', () => { + it('globs sibling version dirs for this target socket and skips the current one', () => { + const command = supersededRelayEndpointListCommand(SWEEP) + expect(command).toContain('"$base"/relay-*/"$sock_name"') + expect(command).toContain('[ "$dir" = "$current" ] && continue') + expect(command).toContain(SOCK_NAME) + expect(command).toContain(CURRENT_DIR) + }) +}) + +describe('classifySupersededRelay', () => { + it('retains a live relay that still owns PTYs', () => { + expect( + classifySupersededRelay( + incumbent([ + 'PRESENT=yes', + 'LISTEN=accepted', + 'HOLDERS_SOURCE=lsof', + 'HOLDER=3669803 yes 13' + ]) + ) + ).toBe('retained-live-work') + }) + + it('nominates only a proven empty relay for reaping', () => { + expect( + classifySupersededRelay( + incumbent(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=80583 yes 0']) + ) + ).toBe('reap-candidate') + }) + + it('removes only a socket proven to have no holder', () => { + expect( + classifySupersededRelay(incumbent(['PRESENT=yes', 'LISTEN=refused', 'HOLDERS_SOURCE=lsof'])) + ).toBe('stale-endpoint-removed') + }) + + it('does nothing at all for an unverifiable endpoint', () => { + expect( + classifySupersededRelay( + incumbent(['PRESENT=yes', 'LISTEN=unknown', 'HOLDERS_SOURCE=unavailable']) + ) + ).toBe('unverifiable') + }) +}) + +describe('sweepSupersededRelayEndpoints', () => { + it('leaves an upgrade-orphaned relay that still owns terminals running, untouched', async () => { + execCommand + .mockResolvedValueOnce(`${OLD_SOCK}\n`) + .mockResolvedValueOnce( + probe(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=3669803 yes 13']) + ) + const findings = await sweepSupersededRelayEndpoints(CONN, HOST, SWEEP) + expect(findings).toHaveLength(1) + expect(findings[0]).toMatchObject({ sockPath: OLD_SOCK, outcome: 'retained-live-work' }) + expect(issuedCommands().some((command) => /\bkill\b/.test(command))).toBe(false) + expect(issuedCommands().some((command) => /\brm -f\b/.test(command))).toBe(false) + }) + + it('reaps the empty husk an upgrade leaves behind, once the host confirms it is gone', async () => { + execCommand + .mockResolvedValueOnce(`${OLD_SOCK}\n`) + .mockResolvedValueOnce( + probe(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=80583 yes 0']) + ) + .mockResolvedValueOnce('GONE\n') + const findings = await sweepSupersededRelayEndpoints(CONN, HOST, SWEEP) + expect(findings[0].outcome).toBe('reaped') + expect(issuedCommands()[2]).toContain('kill -TERM "$pid"') + }) + + it('reports reap-unconfirmed rather than reaped when the pid is still there', async () => { + execCommand + .mockResolvedValueOnce(`${OLD_SOCK}\n`) + .mockResolvedValueOnce( + probe(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=80583 yes 0']) + ) + .mockResolvedValueOnce('LIVE\n') + const findings = await sweepSupersededRelayEndpoints(CONN, HOST, SWEEP) + expect(findings[0].outcome).toBe('reap-unconfirmed') + }) + + it('unlinks an orphaned socket only once nothing holds it, unpinning the dir for GC', async () => { + execCommand + .mockResolvedValueOnce(`${OLD_SOCK}\n`) + .mockResolvedValueOnce(probe(['PRESENT=yes', 'LISTEN=refused', 'HOLDERS_SOURCE=lsof'])) + .mockResolvedValueOnce('') + const findings = await sweepSupersededRelayEndpoints(CONN, HOST, SWEEP) + expect(findings[0].outcome).toBe('stale-endpoint-removed') + expect(issuedCommands()[2]).toBe(`rm -f '${OLD_SOCK}'`) + }) + + it('touches nothing on a host it cannot interrogate', async () => { + execCommand + .mockResolvedValueOnce(`${OLD_SOCK}\n`) + .mockResolvedValueOnce(probe(['PRESENT=yes', 'LISTEN=unknown', 'HOLDERS_SOURCE=unavailable'])) + const findings = await sweepSupersededRelayEndpoints(CONN, HOST, SWEEP) + expect(findings[0].outcome).toBe('unverifiable') + expect(issuedCommands()).toHaveLength(2) + }) + + it('is a no-op when the listing fails, and never guesses at what was there', async () => { + execCommand.mockRejectedValueOnce(new Error('exec failed')) + await expect(sweepSupersededRelayEndpoints(CONN, HOST, SWEEP)).resolves.toEqual([]) + }) + + it('does not run against Windows hosts, whose endpoints are named pipes', async () => { + await expect(sweepSupersededRelayEndpoints(CONN, WINDOWS_HOST, SWEEP)).resolves.toEqual([]) + expect(execCommand).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ssh/ssh-relay-superseded-endpoints.ts b/src/main/ssh/ssh-relay-superseded-endpoints.ts new file mode 100644 index 00000000000..1a9554f7b82 --- /dev/null +++ b/src/main/ssh/ssh-relay-superseded-endpoints.ts @@ -0,0 +1,178 @@ +/** + * Relays this target left behind at a *different* version directory. + * + * Every relay build installs to `~/.orca-remote/relay-/` and binds its socket + * inside it, so the socket path moves on every app update even though the filename component + * is stable. After an update the new client binds a path the previous relay's PTYs were never + * associated with, and the previous relay is never contacted again (#13614, #13852). Nothing + * signals it and nothing reclaims it: with `--grace-time 0` it keeps its shells and agents + * alive forever. + * + * This sweep makes that population *visible and deliberate* rather than silent. It does not + * make it recoverable — the daemon handshake compares the build's content hash exactly + * (`relay-handshake.ts`), so a new client cannot speak to an old daemon at all. See the report + * on this change for what a real cross-version handoff would require. + * + * The one thing it will terminate is a relay that provably holds nothing. Everything else is + * retained, including everything it merely failed to reach. + */ +import type { SshConnection } from './ssh-connection' +import { shellEscape } from './ssh-connection-utils' +import { RELAY_REMOTE_DIR } from './relay-protocol' +import { execCommand } from './ssh-relay-deploy-helpers' +import { + describeRelayEndpointIncumbent, + isReapableRelayHusk, + probeRelayEndpointIncumbent, + type RelayEndpointIncumbent +} from './ssh-relay-endpoint-incumbent' +import { reapEmptyRelayHusk } from './ssh-relay-endpoint-takeover' +import { isWindowsRemoteHost, type RemoteHostPlatform } from './ssh-remote-platform' + +/** + * `reaped` is the only outcome that claims a process ended, and it is only reachable from a + * post-signal `kill -0` that failed. A signal we sent but could not confirm is + * `reap-unconfirmed`, which is `unverifiable` — not `exited` by another name. + */ +export type SupersededRelayOutcome = + | 'reaped' + | 'reap-unconfirmed' + | 'retained-live-work' + | 'stale-endpoint-removed' + | 'unverifiable' + +export type SupersededRelayFinding = { + sockPath: string + outcome: SupersededRelayOutcome + incumbent: RelayEndpointIncumbent +} + +export type SupersededRelaySweepOptions = { + remoteHome: string + /** Absolute path of the version directory this client just launched into; never swept. */ + currentRelayDir: string + /** Stable per-target socket filename, from `relaySocketNameForInstanceId`. */ + sockName: string + nodePath: string + signal?: AbortSignal +} + +const MAX_SWEPT_ENDPOINTS = 32 + +export function supersededRelayEndpointListCommand(options: { + remoteHome: string + currentRelayDir: string + sockName: string +}): string { + return [ + `base=${shellEscape(`${options.remoteHome}/${RELAY_REMOTE_DIR}`)}`, + `sock_name=${shellEscape(options.sockName)}`, + `current=${shellEscape(options.currentRelayDir)}`, + 'for sock in "$base"/relay-*/"$sock_name"; do', + ' [ -S "$sock" ] || continue', + ' dir=${sock%/*}', + ' [ "$dir" = "$current" ] && continue', + ' printf \'%s\\n\' "$sock"', + 'done' + ].join('\n') +} + +/** Remove a socket inode proven to have no holder, so version-dir GC can reclaim the tree. */ +export function removeStaleRelayEndpointCommand(sockPath: string): string { + return `rm -f ${shellEscape(sockPath)}` +} + +export function classifySupersededRelay( + incumbent: RelayEndpointIncumbent +): Exclude | 'reap-candidate' { + if (incumbent.verdict === 'exited') { + return incumbent.socketPresent ? 'stale-endpoint-removed' : 'unverifiable' + } + if (incumbent.verdict !== 'live') { + return 'unverifiable' + } + return isReapableRelayHusk(incumbent) ? 'reap-candidate' : 'retained-live-work' +} + +export async function sweepSupersededRelayEndpoints( + conn: SshConnection, + hostPlatform: RemoteHostPlatform, + options: SupersededRelaySweepOptions +): Promise { + if (isWindowsRemoteHost(hostPlatform)) { + return [] + } + let listing: string + try { + listing = await execCommand(conn, supersededRelayEndpointListCommand(options), { + wrapCommand: true, + signal: options.signal + }) + } catch { + return [] + } + const sockPaths = listing + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.startsWith('/')) + .slice(0, MAX_SWEPT_ENDPOINTS) + + const findings: SupersededRelayFinding[] = [] + for (const sockPath of sockPaths) { + options.signal?.throwIfAborted() + const incumbent = await probeRelayEndpointIncumbent( + conn, + hostPlatform, + options.nodePath, + sockPath, + { signal: options.signal } + ) + findings.push({ + sockPath, + outcome: await applySupersededRelayDecision(conn, incumbent, options), + incumbent + }) + } + logSupersededRelayFindings(findings) + return findings +} + +async function applySupersededRelayDecision( + conn: SshConnection, + incumbent: RelayEndpointIncumbent, + options: SupersededRelaySweepOptions +): Promise { + const decision = classifySupersededRelay(incumbent) + if (decision === 'stale-endpoint-removed') { + try { + await execCommand(conn, removeStaleRelayEndpointCommand(incumbent.sockPath), { + wrapCommand: true, + signal: options.signal + }) + return 'stale-endpoint-removed' + } catch { + return 'unverifiable' + } + } + if (decision !== 'reap-candidate') { + return decision + } + return reapEmptyRelayHusk(conn, incumbent, { signal: options.signal }) +} + +function logSupersededRelayFindings(findings: SupersededRelayFinding[]): void { + for (const finding of findings) { + const detail = describeRelayEndpointIncumbent(finding.incumbent) + if (finding.outcome === 'retained-live-work') { + console.warn( + `[ssh-relay] Superseded relay retained (holds live work; not signalled): ${detail}` + ) + continue + } + if (finding.outcome === 'unverifiable' || finding.outcome === 'reap-unconfirmed') { + console.warn(`[ssh-relay] Superseded relay ${finding.outcome}: ${detail}`) + continue + } + console.log(`[ssh-relay] Superseded relay ${finding.outcome}: ${detail}`) + } +} 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-observers.ts b/src/main/startup/main-process-observers.ts index c3d83450b7c..ba37b0a312f 100644 --- a/src/main/startup/main-process-observers.ts +++ b/src/main/startup/main-process-observers.ts @@ -18,6 +18,7 @@ import { AgentSessionTransitionRecorder } from '../stats/agent-session-transitio import { ClaudeUsageStore } from '../claude-usage/store' import { CodexUsageStore } from '../codex-usage/store' import { OpenCodeUsageStore } from '../opencode-usage/store' +import { installRepoMaintenanceIdleGate } from '../repo-maintenance-idle-gate' import { mainProcessState as state } from './main-process-state' export function initializeMainProcessObservers(): void { @@ -35,6 +36,10 @@ export function initializeMainProcessObservers(): void { ) // Why: start from empty — disk-hydrated status rows are UI continuity only; only this runtime's hook events keep the computer awake. state.agentAwakeService.setStatuses([]) + state.uninstallRepoMaintenanceIdleGate = installRepoMaintenanceIdleGate({ + isQuitting: () => state.isQuitting, + getWorkingAgentCount: () => state.agentAwakeService?.getWorkingAgentCount() ?? 0 + }) const collectChangedProviderSessionWorktrees = createHookProviderSessionInvalidator() const publishProviderSessionChanges = (identities: AgentHookProviderSessionIdentity[]): void => { const ownedIdentities = identities.map((identity) => ({ diff --git a/src/main/startup/main-process-quit.ts b/src/main/startup/main-process-quit.ts index ec893456c5a..61203bc3164 100644 --- a/src/main/startup/main-process-quit.ts +++ b/src/main/startup/main-process-quit.ts @@ -14,6 +14,7 @@ import { clearRuntimeMetadataIfOwned } from '../runtime/runtime-metadata' import { shutdownPairedRuntimeBrowserClientHosts } from '../browser/paired-runtime-browser-client-host-runtime' import { browserManager } from '../browser/browser-manager' import { stopCodexStateDbBackfillRecoveries } from '../codex/codex-state-db-backfill-recovery' +import { awaitPackedRefsLockRelease } from '../git/local-repo-ref-maintenance' import { settleTeardownWithinDeadline, settleWithinMs } from '../quit-teardown-deadline' import { quitTeardownStartGate } from '../quit-teardown-start-gate' import { setUnreadDockBadgeCount } from '../dock/unread-badge' @@ -33,6 +34,8 @@ let daemonDisconnectDone = false let watcherShutdownPromise: Promise | null = null // Why 2s: a config delete is best-effort, not durable state. const GROK_HOOK_CLEANUP_DEADLINE_MS = 2_000 +// Why 2s: long enough for a `pack-refs` child to take SIGTERM and unlink its lock. +const REF_MAINTENANCE_QUIT_DEADLINE_MS = 2_000 function shutdownWatchersOnce(): Promise { if (state.watcherShutdownDone) { @@ -73,6 +76,10 @@ function installBeforeQuitHandler(): void { state.unsubscribeAgentAwakeStatusChanges = null state.agentAwakeService?.dispose() state.agentAwakeService = null + // Why wait but not uninstall: a renderer beforeunload can still veto this + // quit, and tearing the sweep down here would kill it for the rest of the + // session. `isQuitting` already vetoes new attempts; will-quit does the teardown. + state.repoMaintenanceShutdown = awaitPackedRefsLockRelease() // Why: defer PTY cleanup to will-quit so the renderer captures scrollback before PTY-exit events unmount TerminalPane (dropping its capture callbacks). state.rateLimits?.stop() }) @@ -123,6 +130,16 @@ function installWillQuitHandler(): void { const structuredAgentSessionShutdown = stopStructuredAgentSessionRuntime() state.pluginService = null setUnreadDockBadgeCount(0) + // Why wait rather than kill: the child finishes fine orphaned, and signalling + // it mid-prune strands a ref lock Git never clears. The wait is only for the + // short rewrite window, and is bounded so a quit can never hang on it. + const refMaintenanceShutdown = settleWithinMs( + Promise.all([state.repoMaintenanceShutdown, state.uninstallRepoMaintenanceIdleGate?.()]).then( + () => {} + ), + REF_MAINTENANCE_QUIT_DEADLINE_MS + ).then(() => {}) + state.uninstallRepoMaintenanceIdleGate = null agentHookServer.stop() // Why Windows only: POSIX hooks short-circuit on ORCA_PANE_KEY, while Windows must register a // bare script path that cannot express the guard and would otherwise keep spawning after quit. @@ -219,6 +236,7 @@ function installWillQuitHandler(): void { { name: 'plugin-hosts', promise: pluginHostShutdown }, { name: 'skill-uploads', promise: skillUploadShutdown }, { name: 'grok-hooks', promise: grokHookCleanup }, + { name: 'ref-maintenance', promise: refMaintenanceShutdown }, { name: 'codex-backfill-recovery', promise: codexBackfillRecoveryShutdown }, { name: 'structured-agent-session', promise: structuredAgentSessionShutdown }, { name: 'usage-cache', promise: usageCacheFlush }, diff --git a/src/main/startup/main-process-state.ts b/src/main/startup/main-process-state.ts index ea1e0a33299..d48219b461e 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, @@ -70,6 +71,8 @@ export const mainProcessState = { headlessBrowserDisplayAvailable: false, starNag: null as StarNagService | null, agentAwakeService: null as AgentAwakeService | null, + uninstallRepoMaintenanceIdleGate: null as (() => Promise) | null, + repoMaintenanceShutdown: Promise.resolve() as Promise, crashReports: null as CrashReportStore | null, unsubscribeAgentAwakeStatusChanges: null as (() => void) | null, publishProviderSessionChanges: null as @@ -90,6 +93,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-agent-status.ts b/src/main/startup/main-window-agent-status.ts index 2e583830a48..3b2ba9cbd71 100644 --- a/src/main/startup/main-window-agent-status.ts +++ b/src/main/startup/main-window-agent-status.ts @@ -35,6 +35,7 @@ export function installMainWindowAgentStatusListeners(options: MainWindowAgentSt connectionId, payload, receivedAt, + evidenceObservedAt, stateStartedAt, launchToken, providerSession, @@ -57,6 +58,7 @@ export function installMainWindowAgentStatusListeners(options: MainWindowAgentSt worktreeId, connectionId, receivedAt, + ...(evidenceObservedAt !== undefined ? { evidenceObservedAt } : {}), stateStartedAt, ...(providerSession ? { providerSession } : {}), ...(observation ? { observation } : {}), @@ -88,6 +90,7 @@ export function installMainWindowAgentStatusListeners(options: MainWindowAgentSt worktreeId, connectionId, receivedAt, + ...(evidenceObservedAt !== undefined ? { evidenceObservedAt } : {}), stateStartedAt, ...(providerSession ? { providerSession } : {}), ...(promptInteractionKey ? { promptInteractionKey } : {}), 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/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index 128ff2e0e4e..ff7d84bd706 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -70,7 +70,7 @@ export function attachMainWindowServices( } ): void { registerAppReloadHandler(mainWindow, options?.onBeforeRendererReload) - registerRepoHandlers(mainWindow, store) + registerRepoHandlers(mainWindow, store, runtime) // Why: repo IPC mutations must also invalidate paired clients' catalogs (#11994). setRepoRemoteClientNotifier(runtime) setWorktreeCatalogRemoteClientNotifier(runtime) 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-claim.test.ts b/src/main/worktree-create-preparation-claim.test.ts new file mode 100644 index 00000000000..8abc42f30fe --- /dev/null +++ b/src/main/worktree-create-preparation-claim.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it } from 'vitest' +import { + preparationPathKey, + selectPreparationForCreate, + type PreparationCandidate, + type PreparationRequest +} from './worktree-create-preparation-claim' + +function candidate(overrides: Partial = {}): PreparationCandidate { + return { + repoPathKey: '/repo', + workspaceRootKey: '/workspace', + wslDistro: '', + baseBranch: 'origin/main', + canonicalBase: 'refs/remotes/origin/main', + createdAt: 1_000, + ...overrides + } +} + +function request(overrides: Partial = {}): PreparationRequest { + return { + repoPathKey: '/repo', + workspaceRootKey: '/workspace', + wslDistro: '', + baseBranch: 'origin/main', + canonicalBase: 'refs/remotes/origin/main', + ...overrides + } +} + +describe('selectPreparationForCreate', () => { + it('matches the identical base before any ref probe has run', () => { + const selection = selectPreparationForCreate([candidate()], request({ canonicalBase: null })) + + expect(selection).toEqual({ + kind: 'exact', + candidate: candidate(), + canonicalBase: 'refs/remotes/origin/main' + }) + }) + + it('asks for a canonical base only when something is armed under another spelling', () => { + expect( + selectPreparationForCreate( + [candidate()], + request({ baseBranch: 'main', canonicalBase: null }) + ) + ).toEqual({ kind: 'needs-canonical-base' }) + // Nothing armed for this repo, so the create must not pay a probe to learn that. + expect( + selectPreparationForCreate([], request({ baseBranch: 'main', canonicalBase: null })) + ).toEqual({ kind: 'miss', reason: 'none_armed' }) + }) + + it('matches when the two sides spell the same ref differently', () => { + const selection = selectPreparationForCreate( + [candidate()], + request({ baseBranch: 'refs/remotes/origin/main' }) + ) + + expect(selection).toEqual({ + kind: 'exact', + candidate: candidate(), + canonicalBase: 'refs/remotes/origin/main' + }) + }) + + it('retargets a local base onto the armed remote-tracking base of the same branch', () => { + const selection = selectPreparationForCreate( + [candidate()], + request({ baseBranch: 'main', canonicalBase: 'refs/heads/main' }) + ) + + expect(selection).toEqual({ + kind: 'retarget', + candidate: candidate(), + canonicalBase: 'refs/heads/main' + }) + }) + + it('prefers the freshest armed entry when several share the family', () => { + const older = candidate({ canonicalBase: 'refs/remotes/origin/main', createdAt: 1 }) + const newer = candidate({ canonicalBase: 'refs/remotes/upstream/main', createdAt: 2 }) + + const selection = selectPreparationForCreate( + [older, newer], + request({ baseBranch: 'main', canonicalBase: 'refs/heads/main' }) + ) + + expect(selection).toMatchObject({ kind: 'retarget', candidate: newer }) + }) + + it('refuses to retarget onto a different branch', () => { + const selection = selectPreparationForCreate( + [candidate()], + request({ baseBranch: 'origin/release', canonicalBase: 'refs/remotes/origin/release' }) + ) + + expect(selection).toEqual({ kind: 'miss', reason: 'base_mismatch' }) + }) + + it('refuses to retarget onto a bare commit id, whose divergence is unbounded', () => { + const selection = selectPreparationForCreate( + [candidate()], + request({ baseBranch: '1f2e3d4c5b6a7988', canonicalBase: '1f2e3d4c5b6a7988' }) + ) + + expect(selection).toEqual({ kind: 'miss', reason: 'base_mismatch' }) + }) + + it('names the key field that disagreed', () => { + expect(selectPreparationForCreate([], request())).toEqual({ + kind: 'miss', + reason: 'none_armed' + }) + // Something is warm, just not for this repo — the shape of a size-cap eviction. + expect( + selectPreparationForCreate([candidate()], request({ repoPathKey: '/other-repo' })) + ).toEqual({ kind: 'miss', reason: 'repo_mismatch' }) + expect(selectPreparationForCreate([candidate()], request({ wslDistro: 'Ubuntu' }))).toEqual({ + kind: 'miss', + reason: 'wsl_distro_mismatch' + }) + expect( + selectPreparationForCreate([candidate()], request({ workspaceRootKey: '/other' })) + ).toEqual({ kind: 'miss', reason: 'workspace_root_mismatch' }) + }) + + it('never crosses hosts to satisfy a family retarget', () => { + const selection = selectPreparationForCreate( + [candidate({ wslDistro: 'Ubuntu' })], + request({ baseBranch: 'main', canonicalBase: 'refs/heads/main' }) + ) + + expect(selection).toEqual({ kind: 'miss', reason: 'wsl_distro_mismatch' }) + }) +}) + +describe('preparationPathKey', () => { + it('normalizes a posix path without folding case', () => { + expect(preparationPathKey('/workspace/./repo/')).toBe('/workspace/repo/') + expect(preparationPathKey('/Workspace/Repo')).toBe('/Workspace/Repo') + }) + + it('folds case for Windows drive and UNC paths, which compare case-insensitively', () => { + expect(preparationPathKey('C:\\Workspace\\Repo')).toBe('c:\\workspace\\repo') + expect(preparationPathKey('\\\\wsl.localhost\\Ubuntu\\home\\jin')).toBe( + '\\\\wsl.localhost\\ubuntu\\home\\jin' + ) + }) +}) diff --git a/src/main/worktree-create-preparation-claim.ts b/src/main/worktree-create-preparation-claim.ts new file mode 100644 index 00000000000..e329282982d --- /dev/null +++ b/src/main/worktree-create-preparation-claim.ts @@ -0,0 +1,130 @@ +import { posix, win32 } from 'node:path' +import { isWindowsAbsolutePathLike } from '../shared/cross-platform-path' +import { worktreeBaseRefFamily } from '../shared/worktree/base-ref' +import type { PreparedCheckoutMissReason } from '../shared/worktree/create-types' + +/** The subset of miss reasons this selection can produce; the rest are decided by the caller + * (sparse/existing-branch skips) or by the finalize step. */ +export type PreparationSelectionMissReason = Extract< + PreparedCheckoutMissReason, + | 'none_armed' + | 'repo_mismatch' + | 'base_mismatch' + | 'workspace_root_mismatch' + | 'wsl_distro_mismatch' +> + +export type PreparationCandidate = { + repoPathKey: string + workspaceRootKey: string + wslDistro: string + /** The base exactly as the prefetch handler armed it. */ + baseBranch: string + /** That base after `resolveWorktreeAddBaseRef`, so `main` and `refs/heads/main` compare equal. */ + canonicalBase: string + createdAt: number +} + +export type PreparationRequest = { + repoPathKey: string + workspaceRootKey: string + wslDistro: string + baseBranch: string + /** `null` until the caller has paid the ref probe. A raw-base match resolves without it, so the + * common hit spawns no git at all. */ + canonicalBase: string | null +} + +/** Case-folded on Windows, so the arming and claiming sides key on the same path. */ +export function preparationPathKey(path: string): string { + if (isWindowsAbsolutePathLike(path)) { + return win32.normalize(path).toLowerCase() + } + return posix.normalize(path) +} + +/** Keyed on the canonical base so the prefetch and the create agree when they spell the same ref + * differently; a genuinely different ref still gets its own entry. */ +export function preparationEntryKey( + repoPathKey: string, + workspaceRootKey: string, + canonicalBase: string, + wslDistro: string +): string { + return `${repoPathKey}\0${workspaceRootKey}\0${canonicalBase}\0${wslDistro}` +} + +export type PreparationSelection = + /** `canonicalBase` is echoed back so the caller can re-arm on the create's own base without + * paying the ref probe a second time. */ + | { kind: 'exact'; candidate: T; canonicalBase: string } + | { kind: 'retarget'; candidate: T; canonicalBase: string } + /** Something is armed for this repo but not under this raw base; only a resolved canonical base + * can decide between a hit and a miss. */ + | { kind: 'needs-canonical-base' } + | { kind: 'miss'; reason: PreparationSelectionMissReason } + +/** + * Picks the armed preparation a create may claim. + * + * The two sides of the pool disagree in practice — the prefetch arms `origin/main` while the + * create resolves `main`, or vice versa — and an exact-string key turns every such disagreement + * into a silent cold create. Canonicalizing catches the spelling differences; the base-family + * retarget catches the local-vs-remote-tracking ones, where finalize's existing drift reset lands + * the checkout on the requested commit for far less than a cold add plus a full materialize. + * + * The bound matters: refs outside the same branch family are rejected, because a retarget across + * unrelated history degenerates into a full checkout and wins nothing. + * + * Synchronous on purpose: the caller claims the returned entry in the same run, so two concurrent + * creates cannot both walk away with the same prepared checkout. + */ +export function selectPreparationForCreate( + candidates: readonly T[], + request: PreparationRequest +): PreparationSelection { + if (candidates.length === 0) { + return { kind: 'miss', reason: 'none_armed' } + } + const sameRepo = candidates.filter((candidate) => candidate.repoPathKey === request.repoPathKey) + if (sameRepo.length === 0) { + // Separate from `none_armed`: this is what a size-cap eviction looks like from the create side. + return { kind: 'miss', reason: 'repo_mismatch' } + } + // Distro before root: the distro decides which filesystem the root is even on. + const sameHost = sameRepo.filter((candidate) => candidate.wslDistro === request.wslDistro) + if (sameHost.length === 0) { + return { kind: 'miss', reason: 'wsl_distro_mismatch' } + } + const sameRoot = sameHost.filter( + (candidate) => candidate.workspaceRootKey === request.workspaceRootKey + ) + if (sameRoot.length === 0) { + return { kind: 'miss', reason: 'workspace_root_mismatch' } + } + + const { canonicalBase } = request + if (canonicalBase === null) { + const rawMatch = sameRoot.find((candidate) => candidate.baseBranch === request.baseBranch) + // Same spelling, so the armed entry already holds this request's canonical form. + return rawMatch + ? { kind: 'exact', candidate: rawMatch, canonicalBase: rawMatch.canonicalBase } + : { kind: 'needs-canonical-base' } + } + + const canonicalMatch = sameRoot.find((candidate) => candidate.canonicalBase === canonicalBase) + if (canonicalMatch) { + return { kind: 'exact', candidate: canonicalMatch, canonicalBase } + } + + const family = worktreeBaseRefFamily(canonicalBase) + if (family) { + const retarget = sameRoot + .filter((candidate) => worktreeBaseRefFamily(candidate.canonicalBase) === family) + .sort((left, right) => right.createdAt - left.createdAt)[0] + if (retarget) { + return { kind: 'retarget', candidate: retarget, canonicalBase } + } + } + return { kind: 'miss', reason: 'base_mismatch' } +} diff --git a/src/main/worktree-create-preparation-pool.ts b/src/main/worktree-create-preparation-pool.ts new file mode 100644 index 00000000000..26ee1c41aad --- /dev/null +++ b/src/main/worktree-create-preparation-pool.ts @@ -0,0 +1,210 @@ +import { randomUUID } from 'node:crypto' +import { mkdir } from 'node:fs/promises' +import { posix, win32 } from 'node:path' +import { isWindowsAbsolutePathLike } from '../shared/cross-platform-path' +import { + WORKTREE_CREATE_PREPARATION_DIRECTORY, + createWorktreePreparationLockReason +} from '../shared/worktree/create-preparation' +import type { AddWorktreeOptions } from './git/worktree' +import { prepareWorktreeCreateCheckout } from './git/worktree-create-preparation' +import { toHostFilesystemPath } from './host-tree-removal' +import { preparationEntryKey, preparationPathKey } from './worktree-create-preparation-claim' +import { + cleanupStalePreparations, + hasPendingStalePreparationCleanup, + resetStalePreparationCleanupForTests +} from './worktree-create-preparation-stale-cleanup' +import { + discardPreparationWithRetry, + resetPendingPreparationDiscardsForTests, + trackPreparationDiscard +} from './worktree-preparation-discard-retry' + +export const WORKTREE_CREATE_PREPARATION_TTL_MS = 5 * 60_000 +export const WORKTREE_CREATE_PREPARATION_LIMIT = 3 + +export type PreparationEntry = { + key: string + repoPath: string + repoPathKey: string + workspaceRoot: string + workspaceRootKey: string + wslDistro: string + baseBranch: string + canonicalBase: string + preparedPath: string + options: AddWorktreeOptions + createdAt: number + ready: Promise + expiration: NodeJS.Timeout +} + +export type StartPreparationArgs = { + repoPath: string + workspaceRoot: string + baseBranch: string + canonicalBase: string + options: AddWorktreeOptions +} + +const preparations = new Map() + +/** One repo on one Git host: the scope a stranded discard is retried under. */ +function preparationHostKey(repoPathKey: string, wslDistro: string): string { + return `${repoPathKey}\0${wslDistro}` +} + +/** A prepared checkout is a create that is either in flight or imminent. */ +export function hasPendingPreparations(): boolean { + return preparations.size > 0 || hasPendingStalePreparationCleanup() +} + +function pathOps(path: string): Pick { + return isWindowsAbsolutePathLike(path) ? win32 : posix +} + +async function discardEntry(entry: PreparationEntry): Promise { + // A failed checkout self-discards, but that self-discard is best-effort too, so it can strand the + // registration for the same reason the discard here can. Enrol either way. + await entry.ready.catch(() => {}) + await discardPreparationWithRetry({ + hostKey: preparationHostKey(entry.repoPathKey, entry.wslDistro), + repoPath: entry.repoPath, + preparedPath: entry.preparedPath, + options: entry.options + }) +} + +function discardEntryInBackground(entry: PreparationEntry): void { + // Tracked, not bare `void`: the test reset must be able to settle it before dropping the registry. + trackPreparationDiscard(discardEntry(entry)) +} + +function expireEntry(entry: PreparationEntry): void { + if (preparations.get(entry.key) !== entry) { + return + } + preparations.delete(entry.key) + discardEntryInBackground(entry) +} + +/** + * Frees a slot for an incoming preparation, preferring one the same workspace already owns. + * + * The cap is a disk bound — a prepared checkout is a full tree, ~200 MB of tracked content in the + * repo this was measured against — so it stays small. But flipping through the composer's base + * picker arms several preparations for one repo, and a plain oldest-first eviction let that churn + * throw away another project's warm checkout, which is a structural miss for anyone working across + * several repos. Evict the incoming workspace's own oldest entry first; only reach across + * workspaces when this one holds none. + */ +function enforcePreparationLimit( + repoPathKey: string, + workspaceRootKey: string, + wslDistro: string +): void { + while (preparations.size >= WORKTREE_CREATE_PREPARATION_LIMIT) { + const byAge = [...preparations.values()].sort((left, right) => left.createdAt - right.createdAt) + const victim = + byAge.find( + (entry) => + entry.repoPathKey === repoPathKey && + entry.workspaceRootKey === workspaceRootKey && + entry.wslDistro === wslDistro + ) ?? byAge[0] + if (!victim) { + return + } + preparations.delete(victim.key) + clearTimeout(victim.expiration) + discardEntryInBackground(victim) + } +} + +export function listPreparations(): PreparationEntry[] { + return [...preparations.values()] +} + +export function findPreparation( + repoPathKey: string, + workspaceRootKey: string, + canonicalBase: string, + wslDistro: string +): PreparationEntry | undefined { + return preparations.get( + preparationEntryKey(repoPathKey, workspaceRootKey, canonicalBase, wslDistro) + ) +} + +/** Removes an entry from the pool so no other create can claim it. Callers must run this in the + * same synchronous turn as the selection that produced `entry`. */ +export function takePreparation(entry: PreparationEntry): void { + preparations.delete(entry.key) + clearTimeout(entry.expiration) +} + +export function startPreparation({ + repoPath, + workspaceRoot, + baseBranch, + canonicalBase, + options +}: StartPreparationArgs): Promise { + const repoPathKey = preparationPathKey(repoPath) + const workspaceRootKey = preparationPathKey(workspaceRoot) + const wslDistro = options.wslDistro ?? '' + const key = preparationEntryKey(repoPathKey, workspaceRootKey, canonicalBase, wslDistro) + enforcePreparationLimit(repoPathKey, workspaceRootKey, wslDistro) + const preparationId = `${process.pid}-${randomUUID()}` + const lockReason = createWorktreePreparationLockReason(preparationId) + const preparationRoot = pathOps(workspaceRoot).join( + workspaceRoot, + WORKTREE_CREATE_PREPARATION_DIRECTORY + ) + const preparedPath = pathOps(workspaceRoot).join(preparationRoot, preparationId) + const entry = {} as PreparationEntry + const expiration = setTimeout(() => expireEntry(entry), WORKTREE_CREATE_PREPARATION_TTL_MS) + expiration.unref() + Object.assign(entry, { + key, + repoPath, + repoPathKey, + workspaceRoot, + workspaceRootKey, + wslDistro, + baseBranch, + canonicalBase, + preparedPath, + options, + createdAt: Date.now(), + expiration, + ready: (async () => { + await cleanupStalePreparations(preparationHostKey(repoPathKey, wslDistro), repoPath, options) + await mkdir(toHostFilesystemPath(preparationRoot), { recursive: true }) + // Already canonical, so the add re-resolves nothing. + await prepareWorktreeCreateCheckout(repoPath, preparedPath, canonicalBase, lockReason, options) + })() + } satisfies PreparationEntry) + preparations.set(key, entry) + void entry.ready.catch(() => { + if (preparations.get(key) === entry) { + preparations.delete(key) + clearTimeout(entry.expiration) + } + }) + return entry.ready +} + +export async function _resetPreparationPoolForTests(): Promise { + const entries = [...preparations.values()] + preparations.clear() + resetStalePreparationCleanupForTests() + await Promise.all( + entries.map(async (entry) => { + clearTimeout(entry.expiration) + await discardEntry(entry) + }) + ) + await resetPendingPreparationDiscardsForTests() +} 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..fd02b7cc0d1 --- /dev/null +++ b/src/main/worktree-create-preparation-stale-cleanup.ts @@ -0,0 +1,84 @@ +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) + } + } +} + +/** True while a crash-recovery scan is running, which means a create is in flight or imminent. */ +export function hasPendingStalePreparationCleanup(): boolean { + return staleCleanupInFlight.size > 0 +} + +export function resetStalePreparationCleanupForTests(): void { + staleCleanupInFlight.clear() +} diff --git a/src/main/worktree-create-preparation-wsl-root.test.ts b/src/main/worktree-create-preparation-wsl-root.test.ts index f0c8e664298..2c752b74a80 100644 --- a/src/main/worktree-create-preparation-wsl-root.test.ts +++ b/src/main/worktree-create-preparation-wsl-root.test.ts @@ -16,7 +16,8 @@ const mocks = vi.hoisted(() => ({ getWorktreeOptions: vi.fn(), getMirrorDistro: vi.fn(), getWslHome: vi.fn(), - getWslHomeAsync: vi.fn() + getWslHomeAsync: vi.fn(), + resolveBaseRef: vi.fn() })) vi.mock('node:fs/promises', () => ({ mkdir: mocks.mkdir })) @@ -27,6 +28,9 @@ vi.mock('./git/worktree-create-preparation', () => ({ discardPreparedWorktree: mocks.discard, unlockPreparedWorktree: mocks.unlock })) +vi.mock('./git/worktree-base-ref-probe', () => ({ + resolveLocalWorktreeBaseRef: mocks.resolveBaseRef +})) vi.mock('./project-runtime-git-options', () => ({ getLocalProjectWorktreeGitOptions: mocks.getWorktreeOptions, getWorktreeMirrorDistro: mocks.getMirrorDistro @@ -86,6 +90,9 @@ beforeEach(() => { throw new Error('the blocking wsl.exe home probe must not run while preparing') }) mocks.getWslHomeAsync.mockReset().mockResolvedValue(WSL_HOME) + mocks.resolveBaseRef + .mockReset() + .mockImplementation(async (_repoPath: string, baseRef: string) => `refs/remotes/${baseRef}`) }) afterEach(async () => { diff --git a/src/main/worktree-create-preparation.test.ts b/src/main/worktree-create-preparation.test.ts index 3e03643d6a8..06818fec422 100644 --- a/src/main/worktree-create-preparation.test.ts +++ b/src/main/worktree-create-preparation.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { Store } from './persistence' import type { Repo } from '../shared/repo-types' import { WORKTREE_CREATE_PREPARATION_DIRECTORY } from '../shared/worktree/create-preparation' +import { resolveWorktreeAddBaseRef } from '../shared/worktree/base-ref' const mocks = vi.hoisted(() => ({ mkdir: vi.fn(), @@ -12,7 +13,9 @@ const mocks = vi.hoisted(() => ({ unlock: vi.fn(), getWorktreeOptions: vi.fn(), computeWorkspaceRoot: vi.fn(), - computeWorkspaceRootAsync: vi.fn() + computeWorkspaceRootAsync: vi.fn(), + resolveBaseRef: vi.fn(), + measureDivergence: vi.fn() })) vi.mock('node:fs/promises', () => ({ mkdir: mocks.mkdir })) @@ -23,6 +26,12 @@ vi.mock('./git/worktree-create-preparation', () => ({ discardPreparedWorktree: mocks.discard, unlockPreparedWorktree: mocks.unlock })) +vi.mock('./git/worktree-base-ref-probe', () => ({ + resolveLocalWorktreeBaseRef: mocks.resolveBaseRef +})) +vi.mock('./git/worktree-base-divergence', () => ({ + measureRetargetDivergence: mocks.measureDivergence +})) vi.mock('./project-runtime-git-options', () => ({ getLocalProjectWorktreeGitOptions: mocks.getWorktreeOptions, getWorktreeMirrorDistro: () => undefined @@ -39,6 +48,7 @@ vi.mock('./ipc/worktree-logic', () => ({ import { _resetWorktreeCreatePreparationsForTests, consumePreparedWorktreeCreate, + hasPendingWorktreeCreatePreparations, prepareWorktreeCreateForRepo } from './worktree-create-preparation' @@ -47,6 +57,11 @@ function flushBackgroundWork(ms = 0): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) } +const EXISTING_REFS = new Set([ + 'refs/heads/main', + 'refs/remotes/origin/main', + 'refs/remotes/origin/release' +]) const repo = { id: 'repo-1', path: '/repo' } as Repo const store = { getSettings: () => ({}) } as unknown as Store @@ -58,6 +73,12 @@ beforeEach(() => { mocks.discard.mockReset().mockResolvedValue(undefined) mocks.unlock.mockReset().mockResolvedValue(undefined) mocks.getWorktreeOptions.mockReset().mockReturnValue({}) + mocks.measureDivergence.mockReset().mockResolvedValue('within') + mocks.resolveBaseRef + .mockReset() + .mockImplementation((_repoPath: string, baseRef: string) => + resolveWorktreeAddBaseRef(baseRef, async (candidate) => EXISTING_REFS.has(candidate)) + ) mocks.computeWorkspaceRoot.mockReset().mockImplementation(() => { throw new Error('synchronous workspace-root lookup must not run on the main thread') }) @@ -134,7 +155,7 @@ describe('worktree create preparation registry', () => { expect(mocks.prepareCheckout).toHaveBeenCalledTimes(1) }) - it('does not claim a preparation after the selected base changes', async () => { + it('does not claim a preparation after the selected base changes to another branch', async () => { await prepareWorktreeCreateForRepo(store, repo, 'origin/main') await expect( @@ -145,10 +166,185 @@ describe('worktree create preparation registry', () => { branch: 'feature/test', baseBranch: 'origin/release' }) - ).resolves.toBeNull() + ).resolves.toEqual({ status: 'miss', reason: 'base_mismatch' }) expect(mocks.finalize).not.toHaveBeenCalled() }) + it('claims across the local/remote spelling of the same base and reports the retarget', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + + // `main` has no local ref here, so the canonical forms differ and only the base family matches. + await expect( + consumePreparedWorktreeCreate({ + repoPath: repo.path, + workspaceRoot: '/workspace', + worktreePath: '/workspace/final', + branch: 'feature/test', + baseBranch: 'main' + }) + ).resolves.toEqual({ status: 'hit', retargeted: true, result: {} }) + // Finalize still receives the requested base, so it resets onto the requested commit. + expect(mocks.finalize).toHaveBeenCalledWith( + repo.path, + expect.any(String), + '/workspace/final', + 'feature/test', + 'main', + undefined, + {} + ) + }) + + it('refuses a same-family retarget whose bases have drifted too far apart', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + // An abandoned fork's `main` is the same base family but a whole-tree checkout away. + mocks.measureDivergence.mockResolvedValue('exceeded') + + await expect( + consumePreparedWorktreeCreate({ + repoPath: repo.path, + workspaceRoot: '/workspace', + worktreePath: '/workspace/final', + branch: 'feature/test', + baseBranch: 'main' + }) + ).resolves.toEqual({ status: 'miss', reason: 'retarget_too_divergent' }) + expect(mocks.finalize).not.toHaveBeenCalled() + // The preparation is left armed for the base it actually holds. + expect(mocks.discard).not.toHaveBeenCalled() + }) + + it('separates a drift check that said no from one that could not answer', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + // A timed-out or aborted walk skipped a retarget that may well have been cheap; that is a + // tuning signal, not the bound working as intended, so it must not report as excess drift. + mocks.measureDivergence.mockResolvedValue('unknown') + + await expect( + consumePreparedWorktreeCreate({ + repoPath: repo.path, + workspaceRoot: '/workspace', + worktreePath: '/workspace/final', + branch: 'feature/test', + baseBranch: 'main' + }) + ).resolves.toEqual({ status: 'miss', reason: 'retarget_unverifiable' }) + expect(mocks.finalize).not.toHaveBeenCalled() + expect(mocks.discard).not.toHaveBeenCalled() + }) + + it('does not spend a divergence walk when the base matches exactly', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + + await consumePreparedWorktreeCreate({ + repoPath: repo.path, + workspaceRoot: '/workspace', + worktreePath: '/workspace/final', + branch: 'feature/test', + baseBranch: 'origin/main' + }) + + expect(mocks.measureDivergence).not.toHaveBeenCalled() + }) + + it('claims when the two sides spell the same ref differently', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + + await expect( + consumePreparedWorktreeCreate({ + repoPath: repo.path, + workspaceRoot: '/workspace', + worktreePath: '/workspace/final', + branch: 'feature/test', + baseBranch: 'refs/remotes/origin/main' + }) + ).resolves.toEqual({ status: 'hit', retargeted: false, result: {} }) + }) + + it('never hands the same prepared checkout to two concurrent creates', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + + // `main` needs the ref probe, so the claim has to await mid-flight — the window where a + // second create could otherwise walk away with the same preparation. + const [first, second] = await Promise.all([ + consumePreparedWorktreeCreate({ + repoPath: repo.path, + workspaceRoot: '/workspace', + worktreePath: '/workspace/first', + branch: 'feature/first', + baseBranch: 'main' + }), + consumePreparedWorktreeCreate({ + repoPath: repo.path, + workspaceRoot: '/workspace', + worktreePath: '/workspace/second', + branch: 'feature/second', + baseBranch: 'main' + }) + ]) + + expect([first.status, second.status]).toContain('hit') + const preparedPaths = mocks.finalize.mock.calls.map((call) => call[1]) + expect(new Set(preparedPaths).size).toBe(preparedPaths.length) + }) + + it('reports which part of the claim key disagreed', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + + await expect( + consumePreparedWorktreeCreate({ + repoPath: repo.path, + workspaceRoot: '/other-workspace', + worktreePath: '/other-workspace/final', + branch: 'feature/test', + baseBranch: 'origin/main' + }) + ).resolves.toEqual({ status: 'miss', reason: 'workspace_root_mismatch' }) + + await expect( + consumePreparedWorktreeCreate({ + repoPath: repo.path, + workspaceRoot: '/workspace', + worktreePath: '/workspace/final', + branch: 'feature/test', + baseBranch: 'origin/main', + options: { wslDistro: 'Ubuntu' } + }) + ).resolves.toEqual({ status: 'miss', reason: 'wsl_distro_mismatch' }) + + await expect( + consumePreparedWorktreeCreate({ + repoPath: '/other-repo', + workspaceRoot: '/workspace', + worktreePath: '/workspace/final', + branch: 'feature/test', + baseBranch: 'origin/main' + }) + ).resolves.toEqual({ status: 'miss', reason: 'repo_mismatch' }) + expect(mocks.finalize).not.toHaveBeenCalled() + }) + + it("evicts a repo's own stale preparation before another repo's", async () => { + const otherRepo = { id: 'repo-2', path: '/other-repo' } as Repo + await prepareWorktreeCreateForRepo(store, otherRepo, 'origin/main') + // Fill the pool from one repo, as flipping the composer's base picker does, until the next + // arm has to evict something. + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + await prepareWorktreeCreateForRepo(store, repo, 'origin/release') + await prepareWorktreeCreateForRepo(store, repo, 'main') + + // The eviction must cost `repo` a slot, not `otherRepo` its warm checkout. + await expect( + consumePreparedWorktreeCreate({ + repoPath: otherRepo.path, + workspaceRoot: '/workspace', + worktreePath: '/workspace/other', + branch: 'feature/other', + baseBranch: 'origin/main' + }) + ).resolves.toMatchObject({ status: 'hit' }) + }) + it('routes preparation and finalization through the selected WSL runtime', async () => { const options = { wslDistro: 'Ubuntu' } mocks.getWorktreeOptions.mockReturnValue(options) @@ -166,7 +362,7 @@ describe('worktree create preparation registry', () => { expect(mocks.prepareCheckout).toHaveBeenCalledWith( repo.path, expect.any(String), - 'origin/main', + 'refs/remotes/origin/main', expect.any(String), options ) @@ -246,6 +442,76 @@ describe('worktree create preparation registry', () => { ) }) + it('cleans up and reports a finalize miss so normal add can run', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + mocks.finalize.mockRejectedValueOnce(new Error('submodules prevent worktree move')) + + await expect( + consumePreparedWorktreeCreate({ + repoPath: repo.path, + workspaceRoot: '/workspace', + worktreePath: '/workspace/final', + branch: 'feature/test', + baseBranch: 'origin/main' + }) + ).resolves.toEqual({ status: 'miss', reason: 'finalize_failed' }) + expect(mocks.mkdir).toHaveBeenCalledWith('/workspace', { recursive: true }) + expect(mocks.discard).toHaveBeenCalledTimes(1) + }) + + async function consumeOnce(name: string): Promise { + await 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 consumeOnce('only') + + // 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 consumeOnce('first') + expect(mocks.prepareCheckout).toHaveBeenCalledTimes(1) + + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + await consumeOnce('second') + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + + expect(mocks.prepareCheckout).toHaveBeenCalledTimes(3) + // The replacement is claimable, so a third create still skips the cold add. + await expect( + consumePreparedWorktreeCreate({ + repoPath: repo.path, + workspaceRoot: '/workspace', + worktreePath: '/workspace/third', + branch: 'feature/third', + baseBranch: 'origin/main' + }) + ).resolves.toEqual({ status: 'hit', retargeted: false, result: {} }) + expect(mocks.finalize).toHaveBeenCalledTimes(3) + }) + + it('does not re-arm when finalization failed', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + await consumeOnce('first') + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + mocks.prepareCheckout.mockClear() + mocks.finalize.mockRejectedValueOnce(new Error('submodules prevent worktree move')) + + await consumeOnce('second') + + expect(mocks.prepareCheckout).not.toHaveBeenCalled() + }) + it('retries a discard that failed while this process is still alive', async () => { await prepareWorktreeCreateForRepo(store, repo, 'origin/main') const leakedPath = mocks.prepareCheckout.mock.calls[0][1] as string @@ -285,10 +551,14 @@ describe('worktree create preparation registry', () => { unremovable.add(leakedHere) unremovable.add(leakedElsewhere) - // Evict both, oldest first, so each host has one recorded discard failure. - for (const base of ['origin/one', 'origin/two', 'origin/three']) { + // Evict through each host's own arming: eviction prefers the incoming workspace's oldest + // entry, so preparing for `repo` no longer reaches across and takes `otherRepo`'s. + for (const base of ['origin/one', 'origin/two']) { await prepareWorktreeCreateForRepo(store, repo, base) } + for (const base of ['origin/one', 'origin/two']) { + await prepareWorktreeCreateForRepo(store, otherRepo, base) + } await flushBackgroundWork() expect(mocks.discard).toHaveBeenCalledWith(repo.path, leakedHere, {}) expect(mocks.discard).toHaveBeenCalledWith(otherRepo.path, leakedElsewhere, {}) @@ -378,11 +648,15 @@ describe('worktree create preparation registry', () => { unremovable.add(leakedOnUbuntu) unremovable.add(leakedOnDebian) - // Evict both, oldest first, so each distro has one recorded discard failure. + // Evict through each distro's own arming: the eviction scope includes the distro, so arming + // under Ubuntu no longer reaches across and takes the Debian entry. mocks.getWorktreeOptions.mockReturnValue({ wslDistro: 'Ubuntu' }) - for (const base of ['origin/one', 'origin/two', 'origin/three']) { + for (const base of ['origin/one', 'origin/two']) { await prepareWorktreeCreateForRepo(store, repo, base) } + mocks.getWorktreeOptions.mockReturnValue({ wslDistro: 'Debian' }) + await prepareWorktreeCreateForRepo(store, repo, 'origin/one') + mocks.getWorktreeOptions.mockReturnValue({ wslDistro: 'Ubuntu' }) await flushBackgroundWork() expect(mocks.discard).toHaveBeenCalledWith(repo.path, leakedOnUbuntu, { wslDistro: 'Ubuntu' }) expect(mocks.discard).toHaveBeenCalledWith(repo.path, leakedOnDebian, { wslDistro: 'Debian' }) @@ -450,20 +724,24 @@ describe('worktree create preparation registry', () => { expect(mocks.discard).not.toHaveBeenCalledWith(repo.path, leakedPath, {}) }) - it('cleans up and returns null so normal add can run when finalization fails', async () => { - await prepareWorktreeCreateForRepo(store, repo, 'origin/main') - mocks.finalize.mockRejectedValueOnce(new Error('submodules prevent worktree move')) - - await expect( - consumePreparedWorktreeCreate({ - repoPath: repo.path, - workspaceRoot: '/workspace', - worktreePath: '/workspace/final', - branch: 'feature/test', - baseBranch: 'origin/main' + it('reports a pending create while a stale-cleanup scan is running', async () => { + let releaseListing!: () => void + mocks.listWorktreeGraph.mockReturnValueOnce( + new Promise((resolve) => { + releaseListing = () => resolve([]) }) - ).resolves.toBeNull() - expect(mocks.mkdir).toHaveBeenCalledWith('/workspace', { recursive: true }) - expect(mocks.discard).toHaveBeenCalledTimes(1) + ) + const arming = prepareWorktreeCreateForRepo(store, repo, 'origin/main') + // Anchor on the scan actually starting, not on a fixed number of microtasks: an await added + // ahead of it would otherwise make this pass vacuously rather than fail. + while (mocks.listWorktreeGraph.mock.calls.length === 0) { + await Promise.resolve() + } + + // Why: the idle gate must not start repo maintenance while crash recovery is mid-scan. + expect(hasPendingWorktreeCreatePreparations()).toBe(true) + + releaseListing() + await arming }) }) diff --git a/src/main/worktree-create-preparation.ts b/src/main/worktree-create-preparation.ts index ff7478cb46e..b13916194ca 100644 --- a/src/main/worktree-create-preparation.ts +++ b/src/main/worktree-create-preparation.ts @@ -1,53 +1,52 @@ -import { randomUUID } from 'node:crypto' import { mkdir } from 'node:fs/promises' import { posix, win32 } from 'node:path' import type { Store } from './persistence' import type { Repo } from '../shared/repo-types' import { isFolderRepo } from '../shared/repo-kind' import { isWindowsAbsolutePathLike } from '../shared/cross-platform-path' -import { - WORKTREE_CREATE_PREPARATION_DIRECTORY, - createWorktreePreparationLockReason, - isWorktreeCreatePreparation, - parseWorktreePreparationOwnerPid, - parseWorktreePreparationPathOwnerPid -} from '../shared/worktree/create-preparation' +import type { PreparedCheckoutMissReason } from '../shared/worktree/create-types' import type { AddWorktreeOptions, AddWorktreeResult } from './git/worktree' -import { listWorktreeGraph } from './git/worktree' +import { measureRetargetDivergence } from './git/worktree-base-divergence' +import { resolveLocalWorktreeBaseRef } from './git/worktree-base-ref-probe' +import { preparationPathKey, selectPreparationForCreate } from './worktree-create-preparation-claim' +import { + _resetPreparationPoolForTests, + findPreparation, + hasPendingPreparations, + listPreparations, + startPreparation, + takePreparation, + type PreparationEntry +} from './worktree-create-preparation-pool' import { discardPreparedWorktree, - finalizePreparedWorktree, - unlockPreparedWorktree, - prepareWorktreeCreateCheckout + finalizePreparedWorktree } from './git/worktree-create-preparation' import { getLocalProjectWorktreeGitOptions, getWorktreeMirrorDistro } from './project-runtime-git-options' import { computeWorkspaceRootAsync, getWorktreePathSettings } from './ipc/worktree-logic' -import { toHostFilesystemPath } from './host-tree-removal' import { - discardPreparationWithRetry, - resetPendingPreparationDiscardsForTests, - retryPendingPreparationDiscards, - trackPreparationDiscard -} from './worktree-preparation-discard-retry' + recordPreparationConsume, + resetPreparationConsumeHistoryForTests +} from './worktree-create-preparation-burst' +import { toHostFilesystemPath } from './host-tree-removal' -export const WORKTREE_CREATE_PREPARATION_TTL_MS = 5 * 60_000 -export const WORKTREE_CREATE_PREPARATION_LIMIT = 3 -const STALE_PREPARATION_CLEANUP_CONCURRENCY = 4 +export { + WORKTREE_CREATE_PREPARATION_LIMIT, + WORKTREE_CREATE_PREPARATION_TTL_MS +} from './worktree-create-preparation-pool' -type PreparationEntry = { - key: string - repoPath: string - workspaceRoot: string - preparedPath: string - options: AddWorktreeOptions - createdAt: number - ready: Promise - expiration: NodeJS.Timeout +/** A prepared checkout is a create that is either in flight or imminent. */ +export function hasPendingWorktreeCreatePreparations(): boolean { + return hasPendingPreparations() } +export type PreparedWorktreeCreateAttempt = + | { status: 'hit'; retargeted: boolean; result: AddWorktreeResult } + | { status: 'miss'; reason: PreparedCheckoutMissReason } + type ConsumePreparedWorktreeArgs = { repoPath: string workspaceRoot: string @@ -58,128 +57,16 @@ type ConsumePreparedWorktreeArgs = { options?: AddWorktreeOptions } -const preparations = new Map() -const staleCleanupInFlight = new Map>() - -function pathOps(path: string): Pick { - return isWindowsAbsolutePathLike(path) ? win32 : posix -} - -function pathKey(path: string): string { - const normalized = pathOps(path).normalize(path) - return isWindowsAbsolutePathLike(path) ? normalized.toLowerCase() : normalized -} - -function preparationKey( +function canonicalBaseRef( repoPath: string, - workspaceRoot: string, baseBranch: string, options: AddWorktreeOptions -): string { - 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 ?? ''}` -} - -async function discardEntry(entry: PreparationEntry): Promise { - // A failed checkout self-discards, but that self-discard is best-effort too, so it can strand the - // registration for the same reason the discard here can. Enrol either way. - await entry.ready.catch(() => {}) - await discardPreparationWithRetry({ - hostKey: preparationHostKey(entry.repoPath, entry.options), - repoPath: entry.repoPath, - preparedPath: entry.preparedPath, - options: entry.options - }) -} - -function discardEntryInBackground(entry: PreparationEntry): void { - // Tracked, not bare `void`: the test reset must be able to settle it before dropping the registry. - trackPreparationDiscard(discardEntry(entry)) -} - -function expireEntry(entry: PreparationEntry): void { - if (preparations.get(entry.key) !== entry) { - return - } - preparations.delete(entry.key) - discardEntryInBackground(entry) -} - -function enforcePreparationLimit(): void { - while (preparations.size >= WORKTREE_CREATE_PREPARATION_LIMIT) { - const oldest = [...preparations.values()].sort( - (left, right) => left.createdAt - right.createdAt - )[0] - if (!oldest) { - return - } - preparations.delete(oldest.key) - clearTimeout(oldest.expiration) - discardEntryInBackground(oldest) - } -} - -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) - } - } +): Promise { + return resolveLocalWorktreeBaseRef( + repoPath, + baseBranch, + options.wslDistro ? { wslDistro: options.wslDistro } : {} + ) } export async function prepareWorktreeCreateForRepo( @@ -199,91 +86,147 @@ export async function prepareWorktreeCreateForRepo( repo.path, getWorktreePathSettings(repo, store.getSettings(), getWorktreeMirrorDistro(store, repo)) ) - const key = preparationKey(repo.path, workspaceRoot, baseBranch, options) - const existing = preparations.get(key) + const canonicalBase = await canonicalBaseRef(repo.path, baseBranch, options) + const existing = findPreparation( + preparationPathKey(repo.path), + preparationPathKey(workspaceRoot), + canonicalBase, + options.wslDistro ?? '' + ) if (existing) { return existing.ready } - enforcePreparationLimit() - const preparationId = `${process.pid}-${randomUUID()}` - const lockReason = createWorktreePreparationLockReason(preparationId) - const preparedPath = pathOps(workspaceRoot).join( - workspaceRoot, - WORKTREE_CREATE_PREPARATION_DIRECTORY, - preparationId - ) - const entry = {} as PreparationEntry - const expiration = setTimeout(() => expireEntry(entry), WORKTREE_CREATE_PREPARATION_TTL_MS) - expiration.unref() - Object.assign(entry, { - key, + return startPreparation({ repoPath: repo.path, workspaceRoot, - preparedPath, - options, - createdAt: Date.now(), - expiration, - ready: (async () => { - await cleanupStalePreparations(repo.path, options) - await mkdir( - toHostFilesystemPath( - pathOps(workspaceRoot).join(workspaceRoot, WORKTREE_CREATE_PREPARATION_DIRECTORY) - ), - { recursive: true } - ) - await prepareWorktreeCreateCheckout(repo.path, preparedPath, baseBranch, lockReason, options) - })() - } satisfies PreparationEntry) - preparations.set(key, entry) - void entry.ready.catch(() => { - if (preparations.get(key) === entry) { - preparations.delete(key) - clearTimeout(entry.expiration) - } + baseBranch, + canonicalBase, + options }) - return entry.ready } +type ClaimedPreparation = + | { status: 'claimed'; entry: PreparationEntry; retargeted: boolean; canonicalBase: string } + | { status: 'miss'; reason: PreparedCheckoutMissReason } + async function claimPreparedWorktree( - repoPath: string, - workspaceRoot: string, - baseBranch: string, + args: ConsumePreparedWorktreeArgs, options: AddWorktreeOptions -): Promise { - const key = preparationKey(repoPath, workspaceRoot, baseBranch, options) - const entry = preparations.get(key) - if (!entry) { - return null +): Promise { + const request = { + repoPathKey: preparationPathKey(args.repoPath), + workspaceRootKey: preparationPathKey(args.workspaceRoot), + wslDistro: options.wslDistro ?? '', + baseBranch: args.baseBranch } - preparations.delete(key) - clearTimeout(entry.expiration) + let selection = selectPreparationForCreate(listPreparations(), { + ...request, + canonicalBase: null + }) + if (selection.kind === 'needs-canonical-base') { + // The probe is the only await here, and the pool is re-read after it, so the select-and-take + // below stays one synchronous run and no other create can hold the same entry. + const canonicalBase = await canonicalBaseRef(args.repoPath, args.baseBranch, options) + selection = selectPreparationForCreate(listPreparations(), { ...request, canonicalBase }) + } + if (selection.kind !== 'exact' && selection.kind !== 'retarget') { + return { + status: 'miss', + reason: selection.kind === 'miss' ? selection.reason : 'base_mismatch' + } + } + if (selection.kind === 'retarget') { + const candidate = selection.candidate + const { canonicalBase } = selection + const divergence = await measureRetargetDivergence( + args.repoPath, + candidate.canonicalBase, + canonicalBase, + { + ...(options.wslDistro ? { wslDistro: options.wslDistro } : {}), + // Why forward it: a cancelled create must stop these probes now, not at the deadline. + ...(options.signal ? { signal: options.signal } : {}) + } + ) + if (divergence !== 'within') { + return { + status: 'miss', + reason: divergence === 'exceeded' ? 'retarget_too_divergent' : 'retarget_unverifiable' + } + } + // Re-select after the walk: the pool may have gained an exact match or lost this entry. A + // different retarget candidate is left for the next create rather than claimed unverified. + selection = selectPreparationForCreate(listPreparations(), { ...request, canonicalBase }) + if (selection.kind === 'miss' || selection.kind === 'needs-canonical-base') { + return { status: 'miss', reason: 'base_mismatch' } + } + if (selection.kind === 'retarget' && selection.candidate !== candidate) { + return { status: 'miss', reason: 'base_mismatch' } + } + } + const entry = selection.candidate + takePreparation(entry) try { await entry.ready - return entry + return { + status: 'claimed', + entry, + retargeted: selection.kind === 'retarget', + canonicalBase: selection.canonicalBase + } } catch { - return null + return { status: 'miss', reason: 'prepare_failed' } } } +/** Replaces a just-consumed preparation, re-armed on the base the create actually used so the + * next one hits exactly — 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, + canonicalBase: 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 ( + !continuesBurst || + findPreparation(entry.repoPathKey, entry.workspaceRootKey, canonicalBase, entry.wslDistro) + ) { + return + } + void startPreparation({ + repoPath: entry.repoPath, + workspaceRoot: entry.workspaceRoot, + baseBranch, + canonicalBase, + options: 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 { +): Promise { const options = args.options ?? {} - const entry = await claimPreparedWorktree( - args.repoPath, - args.workspaceRoot, - args.baseBranch, - options - ) - if (!entry) { - return null + const claim = await claimPreparedWorktree(args, options) + if (claim.status === 'miss') { + return { status: 'miss', reason: claim.reason } } + const { entry } = claim try { - await mkdir(toHostFilesystemPath(pathOps(args.worktreePath).dirname(args.worktreePath)), { - recursive: true - }) - return await finalizePreparedWorktree( + const parentDir = isWindowsAbsolutePathLike(args.worktreePath) + ? win32.dirname(args.worktreePath) + : posix.dirname(args.worktreePath) + await mkdir(toHostFilesystemPath(parentDir), { recursive: true }) + // Finalize resolves the requested base itself and resets the prepared checkout onto that + // commit, so a retargeted claim is handed over at the requested commit or not at all. + const result = await finalizePreparedWorktree( args.repoPath, entry.preparedPath, args.worktreePath, @@ -292,25 +235,21 @@ 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, claim.canonicalBase) + return { status: 'hit', retargeted: claim.retargeted, result } } catch (error) { await discardPreparedWorktree(args.repoPath, entry.preparedPath, options).catch(() => {}) console.warn( '[worktree-create] prepared checkout could not be finalized; using normal add', error ) - return null + return { status: 'miss', reason: 'finalize_failed' } } } export async function _resetWorktreeCreatePreparationsForTests(): Promise { - const entries = [...preparations.values()] - preparations.clear() - staleCleanupInFlight.clear() - await Promise.all( - entries.map(async (entry) => { - clearTimeout(entry.expiration) - await discardEntry(entry) - }) - ) - await resetPendingPreparationDiscardsForTests() + resetPreparationConsumeHistoryForTests() + await _resetPreparationPoolForTests() } diff --git a/src/main/worktree-create-timing.ts b/src/main/worktree-create-timing.ts index 433a9ac0098..bc1e49f4842 100644 --- a/src/main/worktree-create-timing.ts +++ b/src/main/worktree-create-timing.ts @@ -1,4 +1,5 @@ import type { + PreparedCheckoutOutcome, WorktreeCreateTiming, WorktreeCreateTimingPhase } from '../shared/worktree/create-types' @@ -8,6 +9,7 @@ type TimingClock = () => number export type WorktreeCreateTimingRecorder = { time(phase: string, operation: () => Promise): Promise timeSync(phase: string, operation: () => T): T + recordPreparedCheckout(outcome: PreparedCheckoutOutcome): void finish(): WorktreeCreateTiming } @@ -37,6 +39,7 @@ export function createWorktreeCreateTimingRecorder( ): WorktreeCreateTimingRecorder { const startedAt = clock() const phases: WorktreeCreateTimingPhase[] = [] + let preparedCheckout: PreparedCheckoutOutcome | undefined const recordPhase = (phase: string, operationStartedAt: number): void => { phases.push(createPhase(phase, operationStartedAt, clock(), startedAt)) @@ -59,10 +62,14 @@ export function createWorktreeCreateTimingRecorder( recordPhase(phase, operationStartedAt) } }, + recordPreparedCheckout(outcome: PreparedCheckoutOutcome): void { + preparedCheckout = outcome + }, finish() { return { totalDurationMs: clampDuration(clock() - startedAt), - phases: [...phases] + phases: [...phases], + ...(preparedCheckout ? { preparedCheckout } : {}) } } } 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..822af96714d 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 @@ -72,6 +67,7 @@ export const gitApi = { }): Promise => ipcRenderer.invoke('git:upstreamStatus', args), fetch: (args: { worktreePath: string + worktreeId?: string connectionId?: string pushTarget?: GitPushTarget }): Promise => ipcRenderer.invoke('git:fetch', args), @@ -82,6 +78,7 @@ export const gitApi = { }): Promise => ipcRenderer.invoke('git:syncFork', args), push: (args: { worktreePath: string + worktreeId?: string publish?: boolean forceWithLease?: boolean connectionId?: string @@ -89,11 +86,13 @@ export const gitApi = { }): Promise => ipcRenderer.invoke('git:push', args), pull: (args: { worktreePath: string + worktreeId?: string connectionId?: string pushTarget?: GitPushTarget }): Promise => ipcRenderer.invoke('git:pull', args), fastForward: (args: { worktreePath: string + worktreeId?: string connectionId?: string pushTarget?: GitPushTarget }): Promise => ipcRenderer.invoke('git:fastForward', args), @@ -108,7 +107,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 +115,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 +129,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 +153,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 +196,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-api.ts b/src/preload/api/ssh-api.ts index e2b9ce9e665..af198ad1080 100644 --- a/src/preload/api/ssh-api.ts +++ b/src/preload/api/ssh-api.ts @@ -9,7 +9,8 @@ import type { SshTarget, SshTargetAddResult, SshTargetCreateInput, - SshTargetUpdateInput + SshTargetUpdateInput, + SshTerminateSessionsResult } from '../../shared/ssh-types' import type { FilesystemPathFlavor } from '../../shared/filesystem-entry-types' @@ -25,7 +26,7 @@ export type SshApi = { resolveConfigHost: (args: { alias: string }) => Promise connect: (args: { targetId: string }) => Promise disconnect: (args: { targetId: string }) => Promise - terminateSessions: (args: { targetId: string }) => Promise + terminateSessions: (args: { targetId: string }) => Promise resetRelay: (args: { targetId: string }) => Promise getState: (args: { targetId: string }) => Promise needsPassphrasePrompt: (args: { targetId: string }) => Promise diff --git a/src/preload/api/ssh-bridge.ts b/src/preload/api/ssh-bridge.ts index 2884e4ec8c1..d552fb1781d 100644 --- a/src/preload/api/ssh-bridge.ts +++ b/src/preload/api/ssh-bridge.ts @@ -10,13 +10,15 @@ import type { SshTarget, SshTargetUpdateInput, PortForwardEntry, - EnrichedDetectedPort + EnrichedDetectedPort, + SshTerminateSessionsResult } from '../../shared/ssh-types' import { admitSshConnectionStateForAuthorityReconciliation, 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'), @@ -50,7 +52,7 @@ export const sshApi = { disconnect: (args: { targetId: string }): Promise => ipcRenderer.invoke('ssh:disconnect', args), - terminateSessions: (args: { targetId: string }): Promise => + terminateSessions: (args: { targetId: string }): Promise => ipcRenderer.invoke('ssh:terminateSessions', args), resetRelay: (args: { targetId: string }): Promise => @@ -180,4 +182,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/relay/dispatcher-rpc-routing.ts b/src/relay/dispatcher-rpc-routing.ts index c30d222ff3d..164a375e1c6 100644 --- a/src/relay/dispatcher-rpc-routing.ts +++ b/src/relay/dispatcher-rpc-routing.ts @@ -3,6 +3,10 @@ import { SKILL_INSTALL_RPC_ERROR_CODE, SkillInstallFailureSchema } from '../shared/skill-install-failure' +import { + TERMINAL_UNAVAILABLE_RPC_ERROR_CODE, + TerminalUnavailableCauseSchema +} from '../shared/terminal-unavailable-cause' import { RelayErrorCode, type JsonRpcNotification, @@ -135,11 +139,16 @@ export abstract class RelayDispatcherRpcRouting extends RelayDispatcherFrameCode const message = err instanceof Error ? err.message : String(err) const errorCode = (err as { code?: unknown }).code const code = typeof errorCode === 'number' ? errorCode : -32000 - const skillFailure = + // Why an allowlist keyed on the error code: error `data` is otherwise dropped, so a + // handler cannot leak internals by attaching them. Each published shape is validated + // against its own schema before it crosses. + const structured = errorCode === SKILL_INSTALL_RPC_ERROR_CODE ? SkillInstallFailureSchema.safeParse((err as { data?: unknown }).data) - : null - const data = skillFailure?.success === true ? skillFailure.data : undefined + : errorCode === TERMINAL_UNAVAILABLE_RPC_ERROR_CODE + ? TerminalUnavailableCauseSchema.safeParse((err as { data?: unknown }).data) + : null + const data = structured?.success === true ? structured.data : undefined const accepted = this.sendResponse( client, req.id, diff --git a/src/relay/dispatcher-structured-error.test.ts b/src/relay/dispatcher-structured-error.test.ts index 0ba8e164216..3ce0a79f19f 100644 --- a/src/relay/dispatcher-structured-error.test.ts +++ b/src/relay/dispatcher-structured-error.test.ts @@ -1,6 +1,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { RelayDispatcher } from './dispatcher' import { encodeJsonRpcFrame, MessageType, type JsonRpcResponse } from './protocol' +import { + TERMINAL_UNAVAILABLE_RPC_ERROR_CODE, + type TerminalUnavailableCause +} from '../shared/terminal-unavailable-cause' function decodeResponse(frame: Buffer): JsonRpcResponse | null { if (frame[0] !== MessageType.Regular) { @@ -52,4 +56,54 @@ describe('RelayDispatcher structured errors', () => { message: 'boom' }) }) + + it('carries a terminal-unavailable cause across the wire, and rejects a malformed one', async () => { + // Why this must cross: the fault is proved on the relay at spawn time, and the only + // machinery that can repair it runs on the client. Prose cannot be acted on. + vi.useFakeTimers() + const written: Buffer[] = [] + const dispatcher = new RelayDispatcher((data) => { + written.push(Buffer.from(data)) + }) + dispatchers.push(dispatcher) + const cause: TerminalUnavailableCause = { + status: 'blocked', + reason: 'abi_mismatch', + detail: 'built for Node ABI 127, this host runs ABI 115', + repairable: true, + host: { + platform: 'linux', + arch: 'x64', + libc: 'glibc', + glibcVersion: '2.31', + nodeAbi: '115', + nodeVersion: 'v20.11.0' + } + } + dispatcher.onRequest('pty.spawn', async () => { + throw Object.assign(new Error('Remote terminals are unavailable'), { + code: TERMINAL_UNAVAILABLE_RPC_ERROR_CODE, + data: cause + }) + }) + dispatcher.onRequest('pty.spawnBogus', async () => { + throw Object.assign(new Error('Remote terminals are unavailable'), { + code: TERMINAL_UNAVAILABLE_RPC_ERROR_CODE, + data: { status: 'blocked', repairable: true } + }) + }) + + dispatcher.feed(encodeJsonRpcFrame({ jsonrpc: '2.0', id: 8, method: 'pty.spawn' }, 1, 0)) + dispatcher.feed(encodeJsonRpcFrame({ jsonrpc: '2.0', id: 9, method: 'pty.spawnBogus' }, 2, 0)) + await vi.advanceTimersByTimeAsync(0) + + const responses = written.map(decodeResponse) + expect(responses.find((message) => message?.id === 8)?.error?.data).toEqual(cause) + // A cause that does not validate is dropped entirely; a half-read cause must never + // authorize a repair. + expect(responses.find((message) => message?.id === 9)?.error).toEqual({ + code: -32000, + message: 'Remote terminals are unavailable' + }) + }) }) diff --git a/src/relay/git-handler-exec-operations.ts b/src/relay/git-handler-exec-operations.ts index 510b7d0b9ee..f7d0c320697 100644 --- a/src/relay/git-handler-exec-operations.ts +++ b/src/relay/git-handler-exec-operations.ts @@ -34,6 +34,21 @@ export class GitHandlerExecOperations extends GitHandlerOperationContext { ) } + // Why: generic git.exec blocks all `git config` writes outright (CONFIG_READ_ONLY_FLAGS), + // so a deferred fork remote's provenance marker (#17828) needs its own narrow RPC that + // only ever writes this fixed key shape, mirroring renameCurrentBranch below. + async markRemoteOrcaCreated(params: Record) { + const repoPath = params.repoPath + const remoteName = params.remoteName + if (typeof repoPath !== 'string' || typeof remoteName !== 'string' || !remoteName) { + throw new Error('Invalid remote provenance marker request.') + } + if (!/^[A-Za-z0-9._-]+$/.test(remoteName)) { + throw new Error('Invalid remote name for provenance marker.') + } + await this.git(['config', `remote.${remoteName}.orca-created`, 'true'], repoPath) + } + async renameCurrentBranch(params: Record) { return this.runWithGitReadCacheClear(async () => { const worktreePath = params.worktreePath diff --git a/src/relay/git-handler-registration.ts b/src/relay/git-handler-registration.ts index a9f11445dd1..6462327c416 100644 --- a/src/relay/git-handler-registration.ts +++ b/src/relay/git-handler-registration.ts @@ -65,6 +65,7 @@ export function registerGitHandlers( dispatcher.onRequest('git.refreshLocalBaseRefForWorktreeCreate', (p) => handlers.worktree.refreshLocalBaseRefForWorktreeCreate(p) ) + dispatcher.onRequest('git.markRemoteOrcaCreated', (p) => handlers.exec.markRemoteOrcaCreated(p)) dispatcher.onRequest('git.renameCurrentBranch', (p) => handlers.exec.renameCurrentBranch(p)) dispatcher.onRequest('git.forceDeletePreservedBranch', (p) => handlers.exec.forceDeletePreservedBranch(p) diff --git a/src/relay/git-handler-worktree-list-authority.test.ts b/src/relay/git-handler-worktree-list-authority.test.ts new file mode 100644 index 00000000000..8b8a606dbba --- /dev/null +++ b/src/relay/git-handler-worktree-list-authority.test.ts @@ -0,0 +1,96 @@ +/** + * Issue #14004: a relay-side worktree-list failure must stay a failure across the relay/provider + * boundary. Converting it to `[]` reports an unreadable catalog as an authoritative empty one, and + * downstream reconciliation uses that to authorize missing-worktree teardown. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { RelayContext } from './context' +import { GitHandler } from './git-handler' +import { + createMockDispatcher, + type MockDispatcher, + type RelayDispatcher +} from './git-handler-test-setup' + +type GitSpyTarget = { + git(args: string[], cwd: string): Promise<{ stdout: string; stderr: string }> +} + +const WORKTREE_LIST_OUTPUT = `worktree /repo +HEAD abc123 +branch refs/heads/main +` + +/** Git <2.36 rejects `worktree list -z` with a usage error, which routes the handler to the fallback lane. */ +function unsupportedZError(): Error { + return Object.assign(new Error('git usage error'), { + code: 129, + stderr: 'usage: git worktree list []\n' + }) +} + +describe('relay worktree-list authority (#14004)', () => { + let dispatcher: MockDispatcher + let handler: GitHandler + + beforeEach(() => { + dispatcher = createMockDispatcher() + handler = new GitHandler(dispatcher as unknown as RelayDispatcher, new RelayContext()) + }) + + it('rejects instead of reporting an empty catalog when the fallback listing fails', async () => { + vi.spyOn(handler as unknown as GitSpyTarget, 'git').mockImplementation((args: string[]) => + args.includes('-z') + ? Promise.reject(unsupportedZError()) + : Promise.reject( + Object.assign(new Error('fatal: not a git repository'), { code: 128, stderr: '' }) + ) + ) + + await expect( + dispatcher.callRequest('git.listWorktrees', { repoPath: '/repo' }) + ).rejects.toThrow('not a git repository') + }) + + it('rejects a timed-out fallback listing on a host whose -z support is already known absent', async () => { + const gitSpy = vi + .spyOn(handler as unknown as GitSpyTarget, 'git') + .mockImplementation((args: string[]) => + args.includes('-z') + ? Promise.reject(unsupportedZError()) + : Promise.resolve({ stdout: WORKTREE_LIST_OUTPUT, stderr: '' }) + ) + // Prime the capability cache so the probe is not repeated; later scans go straight to the fallback. + await dispatcher.callRequest('git.listWorktrees', { repoPath: '/repo' }) + + gitSpy.mockRejectedValue(Object.assign(new Error('ETIMEDOUT'), { code: 'ETIMEDOUT' })) + + await expect( + dispatcher.callRequest('git.listWorktrees', { repoPath: '/repo' }) + ).rejects.toThrow('ETIMEDOUT') + expect(gitSpy.mock.calls.at(-1)?.[0]).toEqual(['worktree', 'list', '--porcelain']) + }) + + it('republishes the catalog when a later fallback listing succeeds', async () => { + let failListing = true + vi.spyOn(handler as unknown as GitSpyTarget, 'git').mockImplementation((args: string[]) => { + if (args.includes('-z')) { + return Promise.reject(unsupportedZError()) + } + return failListing + ? Promise.reject(new Error('transient relay failure')) + : Promise.resolve({ stdout: WORKTREE_LIST_OUTPUT, stderr: '' }) + }) + + await expect( + dispatcher.callRequest('git.listWorktrees', { repoPath: '/repo' }) + ).rejects.toThrow('transient relay failure') + + failListing = false + const result = (await dispatcher.callRequest('git.listWorktrees', { + repoPath: '/repo' + })) as Record[] + expect(result).toHaveLength(1) + expect(result[0]).toMatchObject({ path: '/repo', isMainWorktree: true }) + }) +}) diff --git a/src/relay/git-handler-worktree-operations.ts b/src/relay/git-handler-worktree-operations.ts index 6689c178344..72853a342c6 100644 --- a/src/relay/git-handler-worktree-operations.ts +++ b/src/relay/git-handler-worktree-operations.ts @@ -132,19 +132,14 @@ export class GitHandlerWorktreeOperations extends GitHandlerOperationContext { }, async () => { // Why: Git <2.36 lacks worktree-list `-z`, so fall back to the newline-block parser (loses newline-in-path safety). - try { - const { stdout } = await this.git(['worktree', 'list', '--porcelain'], repoPath, { - signal: context?.signal - }) - const normalized = await this.normalizeMainWorktreePath( - repoPath, - parseWorktreeList(stdout) - ) - // Why: Git <2.31 emits no `prunable` annotation, so probe each linked worktree's existence instead of trusting stale registrations (issue #8389). - return annotatePrunableWorktreesByExistence(normalized) - } catch { - return [] - } + // Why no catch (#14004): swallowing to `[]` would report an unreadable catalog as an authoritative + // empty one, and callers use that to authorize missing-worktree teardown. Let the failure propagate. + const { stdout } = await this.git(['worktree', 'list', '--porcelain'], repoPath, { + signal: context?.signal + }) + const normalized = await this.normalizeMainWorktreePath(repoPath, parseWorktreeList(stdout)) + // Why: Git <2.31 emits no `prunable` annotation, so probe each linked worktree's existence instead of trusting stale registrations (issue #8389). + return annotatePrunableWorktreesByExistence(normalized) }, isUnsupportedWorktreeListZError ) diff --git a/src/relay/git-handler.test.ts b/src/relay/git-handler.test.ts index d8156741a3f..590b22636ba 100644 --- a/src/relay/git-handler.test.ts +++ b/src/relay/git-handler.test.ts @@ -73,6 +73,7 @@ describe('GitHandler', () => { expect(methods).toContain('git.removeWorktree') expect(methods).toContain('git.worktreeIsClean') expect(methods).toContain('git.refreshLocalBaseRefForWorktreeCreate') + expect(methods).toContain('git.markRemoteOrcaCreated') expect(methods).toContain('git.renameCurrentBranch') expect(methods).toContain('git.forceDeletePreservedBranch') expect(methods).toContain('git.exec') @@ -197,6 +198,37 @@ describe('GitHandler', () => { }) }) + describe('markRemoteOrcaCreated', () => { + it('writes the provenance marker via config, not the generic git.exec path', async () => { + gitInit(tmpDir) + execFileSync('git', ['remote', 'add', 'pr-contributor-orca', 'https://example.com/x.git'], { + cwd: tmpDir + }) + + await dispatcher.callRequest('git.markRemoteOrcaCreated', { + repoPath: tmpDir, + remoteName: 'pr-contributor-orca' + }) + + const value = execFileSync( + 'git', + ['config', '--get', 'remote.pr-contributor-orca.orca-created'], + { cwd: tmpDir, encoding: 'utf-8' } + ).trim() + expect(value).toBe('true') + }) + + it('rejects a remote name that is not a plain config-key segment', async () => { + gitInit(tmpDir) + await expect( + dispatcher.callRequest('git.markRemoteOrcaCreated', { + repoPath: tmpDir, + remoteName: 'bad name; rm -rf' + }) + ).rejects.toThrow('Invalid remote name for provenance marker.') + }) + }) + describe('renameCurrentBranch', () => { it('renames only the checked-out branch through the narrow RPC', async () => { gitInit(tmpDir) diff --git a/src/relay/node-pty-binding-survey.test.ts b/src/relay/node-pty-binding-survey.test.ts new file mode 100644 index 00000000000..b51a57fd4a8 --- /dev/null +++ b/src/relay/node-pty-binding-survey.test.ts @@ -0,0 +1,138 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import process from 'node:process' +import { afterEach, describe, expect, it } from 'vitest' +import { isFlattenedNodePtyLoaderMessage } from '../main/orcad/node-pty-loader-diagnosis' +import { + collectNodePtyUnavailableDiagnosis, + readNodeGypBuildRecord, + surveyNodePtyBinding +} from './node-pty-binding-survey' +import { formatNodePtyUnavailableMessage } from './node-pty-unavailable-diagnosis' + +const HOST = { platform: process.platform, arch: process.arch } +/** What node-pty throws once its loader has replaced the real cause with its last miss. */ +const FLATTENED = + 'Failed to load native module: pty.node, checked: build/Release, build/Debug, ' + + `prebuilds/${process.platform}-${process.arch}: Error: Cannot find module './pty.node'` + +const roots: string[] = [] + +function fixture(options: { binding?: boolean; configGypi?: string } = {}): string { + const root = mkdtempSync(join(tmpdir(), 'orca-node-pty-')) + roots.push(root) + const dir = join(root, 'node-pty') + mkdirSync(join(dir, 'lib'), { recursive: true }) + writeFileSync(join(dir, 'lib', 'index.js'), 'module.exports = {}\n') + writeFileSync(join(dir, 'lib', 'utils.js'), 'exports.loadNativeModule = () => ({})\n') + if (options.binding) { + mkdirSync(join(dir, 'build', 'Release'), { recursive: true }) + // Deliberately not a valid addon: the point is to make the dynamic loader talk. + for (const name of ['pty.node', 'conpty.node']) { + writeFileSync(join(dir, 'build', 'Release', name), 'not an addon\n') + } + } + if (options.configGypi !== undefined) { + mkdirSync(join(dir, 'build'), { recursive: true }) + writeFileSync(join(dir, 'build', 'config.gypi'), options.configGypi) + } + return dir +} + +afterEach(() => { + while (roots.length > 0) { + rmSync(roots.pop()!, { recursive: true, force: true }) + } +}) + +describe('surveyNodePtyBinding', () => { + it('finds the file node-pty itself would open, and lists where it looked when there is none', () => { + const withBinding = surveyNodePtyBinding(fixture({ binding: true }), HOST) + expect(withBinding?.bindingPath).toMatch(/build[/\\]Release[/\\](con)?pty\.node$/) + + const without = surveyNodePtyBinding(fixture(), HOST) + expect(without?.bindingPath).toBeNull() + expect(without?.searched).toHaveLength(3) + expect(without?.searched.join(' ')).toContain(`prebuilds/${process.platform}-${process.arch}`) + }) +}) + +describe('readNodeGypBuildRecord', () => { + it('reads what node-gyp configured for, past its leading comment lines', () => { + const dir = fixture({ + configGypi: + '# Do not edit. File was generated by node-gyp\'s "configure" step\n' + + '{ "variables": { "node_module_version": 127, "target_arch": "arm64" } }\n' + }) + expect(readNodeGypBuildRecord(dir)).toEqual({ nodeAbi: '127', arch: 'arm64' }) + }) + + it('answers nothing rather than guessing when there is no build record', () => { + expect(readNodeGypBuildRecord(fixture())).toEqual({ nodeAbi: null, arch: null }) + }) +}) + +describe('collectNodePtyUnavailableDiagnosis', () => { + it("recovers the dynamic loader's own words that node-pty threw away", async () => { + // The whole defect in one assertion: what reaches the relay is FLATTENED, which names + // no cause; the diagnosis must carry what the loader actually said about the file. + const diagnosis = await collectNodePtyUnavailableDiagnosis({ + nodePtyDir: fixture({ binding: true }), + error: new Error(FLATTENED) + }) + expect(diagnosis.status).toBe('blocked') + expect(diagnosis.rawError).toBeTruthy() + expect(isFlattenedNodePtyLoaderMessage(diagnosis.rawError!)).toBe(false) + expect(diagnosis.rawError).not.toBe(FLATTENED) + expect(diagnosis.rawError).toMatch(/pty\.node/) + }, 20_000) + + it("prefers node-gyp's build record over a loader message that named no fault", async () => { + const diagnosis = await collectNodePtyUnavailableDiagnosis({ + nodePtyDir: fixture({ + binding: true, + configGypi: '{ "variables": { "node_module_version": 4242, "target_arch": "x64" } }' + }), + error: new Error(FLATTENED) + }) + // A garbage binary reads differently per platform (mach-o vs ELF), so only assert the + // build record is consulted when the loader message did not name the fault itself. + if (diagnosis.reason === 'abi_mismatch') { + expect(formatNodePtyUnavailableMessage(diagnosis)).toContain( + `built for Node ABI 4242, this host runs ABI ${process.versions.modules}` + ) + } else { + expect(['arch_mismatch', 'load_failed', 'load_crashed']).toContain(diagnosis.reason) + } + }, 20_000) + + it('reports an unlocatable install as unverifiable, not as a diagnosis', async () => { + const diagnosis = await collectNodePtyUnavailableDiagnosis({ + nodePtyDir: null, + error: new Error(FLATTENED) + }) + expect(diagnosis.status).toBe('unverifiable') + const text = formatNodePtyUnavailableMessage(diagnosis) + expect(text).toContain('could not establish why') + // It still has to be reportable: the raw error is the only thing an issue can quote. + expect(text).toContain(FLATTENED) + }) + + it('probes the host toolchain only when nothing was compiled', async () => { + const diagnosis = await collectNodePtyUnavailableDiagnosis({ + nodePtyDir: fixture(), + error: new Error(FLATTENED) + }) + expect(diagnosis.survey?.bindingPath).toBeNull() + expect(['toolchain_missing', 'dependency_missing']).toContain(diagnosis.reason) + // Non-Linux hosts ship a node-pty prebuild, so a toolchain answer there would be noise. + expect(diagnosis.toolchain === null).toBe(process.platform !== 'linux') + + const compiled = await collectNodePtyUnavailableDiagnosis({ + nodePtyDir: fixture({ binding: true }), + error: new Error(FLATTENED) + }) + expect(compiled.toolchain).toBeNull() + }, 20_000) +}) diff --git a/src/relay/node-pty-binding-survey.ts b/src/relay/node-pty-binding-survey.ts new file mode 100644 index 00000000000..805527f1aad --- /dev/null +++ b/src/relay/node-pty-binding-survey.ts @@ -0,0 +1,215 @@ +/** + * Gather the evidence a node-pty spawn failure needs, on the host that failed. + * + * Three sources, because no single one is sufficient: + * + * 1. What is on disk where node-pty's loader looks, and what node-gyp recorded it was + * configured for (`build/config.gypi`). This answers "wrong ABI / wrong arch" even + * when the loader said nothing useful, and it is the only source available when the + * binding is absent entirely. + * 2. The dynamic loader's own words, recovered by dlopen'ing the file node-pty would + * have opened. node-pty's loader rethrows only its LAST attempt, so the real message + * is otherwise destroyed before the relay sees it. This runs in a CHILD process: a + * binding that aborts inside the loader would take the relay down with it, and a + * relay that dies is a reconnect loop rather than an error message. + * 3. The host's C/C++ toolchain, but only when nothing was compiled — "install + * build-essential" is the right answer for a compile that never ran, and noise for a + * binary that exists and is simply wrong. + * + * Every step is best-effort and failure-tolerant: whatever cannot be established is + * reported as unestablished rather than guessed (docs/reference/ssh-execution-boundary.md). + */ +import { existsSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { release } from 'node:os' +import process from 'node:process' +import { runProcess } from '../shared/child-process/run-process' +import { + buildToolchainProbeCommand, + parseBuildToolchainProbe, + type BuildToolchainStatus +} from '../main/ssh/build-toolchain-diagnosis' +import { detectNativeHostAbi } from '../main/orcad/native-host-abi' +import { + buildNodePtyLoadProbeScript, + readNodePtyProbeOutcome +} from '../main/orcad/node-pty-precondition' +import { + diagnoseNodePtyUnavailable, + type NodePtyBindingSurvey, + type NodePtyDiagnosisInput, + type NodePtyUnavailableDiagnosis, + type NodePtyUnavailableHost +} from './node-pty-unavailable-diagnosis' + +/** Bounded so a wedged loader delays one spawn rejection, not the relay. */ +const LOAD_PROBE_TIMEOUT_MS = 10_000 +const TOOLCHAIN_PROBE_TIMEOUT_MS = 5_000 + +/** node-pty's own search order, so the file surveyed is the file it would have opened. */ +function bindingSearchDirs(platform: NodeJS.Platform, arch: string): string[] { + return ['build/Release', 'build/Debug', `prebuilds/${platform}-${arch}`] +} + +/** Windows defers to conpty.node on builds that have ConPTY, exactly as node-pty picks it. */ +function bindingBaseName(platform: NodeJS.Platform): string { + if (platform !== 'win32') { + return 'pty' + } + return Number(release().split('.')[2]) >= 18309 ? 'conpty' : 'pty' +} + +export function surveyNodePtyBinding( + nodePtyDir: string, + host: Pick +): NodePtyBindingSurvey | null { + const name = bindingBaseName(host.platform) + const searched = bindingSearchDirs(host.platform, host.arch) + let bindingPath: string | null = null + try { + for (const dir of searched) { + for (const root of [nodePtyDir, join(nodePtyDir, 'lib')]) { + const candidate = join(root, dir, `${name}.node`) + if (existsSync(candidate)) { + bindingPath = candidate + break + } + } + if (bindingPath) { + break + } + } + } catch { + return null + } + const built = readNodeGypBuildRecord(nodePtyDir) + return { + moduleDir: nodePtyDir, + bindingPath, + searched, + builtNodeAbi: built.nodeAbi, + builtArch: built.arch + } +} + +/** + * What node-gyp configured this build for. + * + * Why this file and not the binary: `build/config.gypi` is written by `node-gyp + * configure` from the headers it downloaded, so it names the ABI and architecture the + * `.node` was compiled against without parsing ELF. It survives a build that later + * failed, which is the case where the loader has nothing to say. + */ +export function readNodeGypBuildRecord(nodePtyDir: string): { + nodeAbi: string | null + arch: string | null +} { + try { + const raw = readFileSync(join(nodePtyDir, 'build', 'config.gypi'), 'utf8') + // node-gyp prefixes the JSON with `# Do not edit…` comment lines. + const body = raw + .split('\n') + .filter((line) => !line.trim().startsWith('#')) + .join('\n') + const variables = (JSON.parse(body) as { variables?: Record }).variables + const nodeAbi = variables?.node_module_version + const arch = variables?.target_arch + return { + nodeAbi: nodeAbi === undefined || nodeAbi === null ? null : String(nodeAbi), + arch: typeof arch === 'string' && arch.length > 0 ? arch : null + } + } catch { + return { nodeAbi: null, arch: null } + } +} + +/** + * The loader's verdict on the binding, recovered out of process. + * + * Returns the pieces `diagnoseNodePtyUnavailable` reads; a probe that could not run + * answers `unverifiableBecause` rather than a cause, because it established nothing. + */ +async function probeNodePtyLoader( + nodePtyDir: string +): Promise> { + let result + try { + result = await runProcess({ + program: process.execPath, + args: ['-e', buildNodePtyLoadProbeScript(nodePtyDir)], + timeoutMs: LOAD_PROBE_TIMEOUT_MS + }) + } catch (error) { + return { + unverifiableBecause: `the node-pty load probe could not be started (${(error as Error).message})` + } + } + const outcome = readNodePtyProbeOutcome(result) + switch (outcome.kind) { + case 'loaderError': + return { loaderError: outcome.message } + case 'signalled': + return { probeSignal: outcome.signal } + case 'unanswered': + return { unverifiableBecause: outcome.detail } + // `loaded` here means the binding is fine under plain Node while the relay's own + // require failed — real, and not something the loader can explain. `noBinary` and + // `unexplained` are both better answered by the on-disk survey than by the probe. + case 'loaded': + case 'noBinary': + case 'unexplained': + return {} + } +} + +/** node-pty has no Linux prebuild, so only there does a missing toolchain explain anything. */ +async function probeRelayBuildToolchain( + platform: NodeJS.Platform +): Promise { + if (platform !== 'linux') { + return null + } + try { + const result = await runProcess({ + program: '/bin/sh', + args: ['-c', buildToolchainProbeCommand()], + timeoutMs: TOOLCHAIN_PROBE_TIMEOUT_MS + }) + return result.timedOut ? null : parseBuildToolchainProbe(result.stdout) + } catch { + return null + } +} + +function readErrorMessage(error: unknown): string | null { + if (error instanceof Error) { + return error.message + } + return typeof error === 'string' && error.length > 0 ? error : null +} + +/** + * Everything above, in the order that makes each step's cost conditional on the previous + * one's answer. Called only on the failure path, so a spawn that works pays nothing. + */ +export async function collectNodePtyUnavailableDiagnosis(options: { + nodePtyDir: string | null + error?: unknown +}): Promise { + const abi = detectNativeHostAbi() + const host: NodePtyUnavailableHost = { ...abi, nodeVersion: process.version } + const requireError = readErrorMessage(options.error) + if (!options.nodePtyDir) { + return diagnoseNodePtyUnavailable({ + host, + survey: null, + requireError, + unverifiableBecause: 'the relay could not locate its node-pty install directory' + }) + } + const survey = surveyNodePtyBinding(options.nodePtyDir, host) + const probed = survey?.bindingPath ? await probeNodePtyLoader(options.nodePtyDir) : {} + const toolchain = + survey && !survey.bindingPath ? await probeRelayBuildToolchain(host.platform) : null + return diagnoseNodePtyUnavailable({ ...probed, host, survey, requireError, toolchain }) +} diff --git a/src/relay/node-pty-unavailable-diagnosis.test.ts b/src/relay/node-pty-unavailable-diagnosis.test.ts new file mode 100644 index 00000000000..7bed6ef256e --- /dev/null +++ b/src/relay/node-pty-unavailable-diagnosis.test.ts @@ -0,0 +1,223 @@ +import { describe, expect, it } from 'vitest' +import { + diagnoseNodePtyUnavailable, + formatNodePtyUnavailableMessage, + type NodePtyBindingSurvey, + type NodePtyDiagnosisInput, + toTerminalUnavailableCause, + type NodePtyUnavailableHost +} from './node-pty-unavailable-diagnosis' +import { parseBuildToolchainProbe } from '../main/ssh/build-toolchain-diagnosis' +import { + mayRepairFromCause, + parseTerminalUnavailableCause +} from '../shared/terminal-unavailable-cause' + +const UBUNTU_2004: NodePtyUnavailableHost = { + platform: 'linux', + arch: 'x64', + libc: 'glibc', + glibcVersion: '2.31', + nodeAbi: '115', + nodeVersion: 'v20.11.0' +} + +const MODULE_DIR = '/opt/orca/relay/node_modules/node-pty' +const SEARCHED = ['build/Release', 'build/Debug', 'prebuilds/linux-x64'] + +const INSTALLED: NodePtyBindingSurvey = { + moduleDir: MODULE_DIR, + bindingPath: `${MODULE_DIR}/build/Release/pty.node`, + searched: SEARCHED, + builtNodeAbi: null, + builtArch: null +} + +const NOTHING_INSTALLED: NodePtyBindingSurvey = { ...INSTALLED, bindingPath: null } + +/** What node-pty itself throws: the real cause replaced by its LAST directory miss. */ +const FLATTENED = + 'Failed to load native module: pty.node, checked: build/Release, build/Debug, ' + + "prebuilds/linux-x64: Error: Cannot find module '../prebuilds/linux-x64//pty.node'" + +const diagnose = (overrides: Partial = {}) => + diagnoseNodePtyUnavailable({ + host: UBUNTU_2004, + survey: INSTALLED, + requireError: FLATTENED, + ...overrides + }) + +const message = (overrides: Partial = {}) => + formatNodePtyUnavailableMessage(diagnose(overrides)) + +const toolchain = (present: readonly string[]) => + parseBuildToolchainProbe([...present.map((tool) => `HAVE ${tool}`), 'PKG apt-get'].join('\n')) + +describe('diagnoseNodePtyUnavailable', () => { + it("never treats node-pty's flattened wrapper as the cause", () => { + // node-pty rethrows only its last directory miss, so acting on that text sends the + // user to install a module that is already installed. + expect( + diagnose({ survey: NOTHING_INSTALLED, toolchain: toolchain(['make', 'g++', 'python3']) }) + ).toMatchObject({ reason: 'dependency_missing' }) + expect(diagnose().reason).not.toBe('dependency_missing') + }) + + it('names the glibc the host actually has next to the one the binary needs', () => { + const verdict = diagnose({ + loaderError: + "/lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.34' not found (required by /opt/orca/node_modules/node-pty/build/Release/pty.node)" + }) + expect(verdict).toMatchObject({ status: 'blocked', reason: 'libc_floor' }) + const text = formatNodePtyUnavailableMessage(verdict) + expect(text).toContain('GLIBC_2.34') + expect(text).toContain('glibc 2.31') + // The remedy is a rebuild; offering "install build-essential" here is a wrong answer. + expect(text).not.toContain('build tools') + }) + + it('names both ABI numbers from the loader message', () => { + const text = message({ + loaderError: + 'The module was compiled against a different Node.js version using NODE_MODULE_VERSION 115. ' + + 'This version of Node.js requires NODE_MODULE_VERSION 127.' + }) + expect(text).toContain('built for Node ABI 115, this host runs ABI 127') + expect(text).toContain('v20.11.0') + }) + + it("reads the ABI mismatch off node-gyp's build record when the loader said nothing", () => { + // The case the old message could only hedge about: the binding is present, node-pty + // destroyed the loader error, and the only evidence left is what node-gyp configured. + const text = message({ + survey: { ...INSTALLED, builtNodeAbi: '127' } + }) + expect(text).toContain('built for Node ABI 127, this host runs ABI 115') + }) + + it('separates an architecture mismatch from an ABI mismatch', () => { + expect(diagnose({ loaderError: 'invalid ELF header' }).reason).toBe('arch_mismatch') + expect(message({ survey: { ...INSTALLED, builtArch: 'arm64' } })).toContain( + 'built for arm64, this host runs x64' + ) + expect( + message({ + loaderError: + "dlopen(/opt/pty.node, 0x0001): tried: '/opt/pty.node' (mach-o file, but is an " + + "incompatible architecture (have 'arm64', need 'x86_64'))" + }) + ).toContain('built for arm64, this host needs x86_64') + }) + + it('separates a missing shared library from a libc floor break', () => { + // Different remedies: install a package, versus rebuild against an older toolchain. + const verdict = diagnose({ + loaderError: 'libstdc++.so.6: cannot open shared object file: No such file or directory' + }) + expect(verdict.reason).toBe('shared_library_missing') + const text = formatNodePtyUnavailableMessage(verdict) + expect(text).toContain('libstdc++.so.6 is not installed on this host') + expect(text).toContain('Install that library') + }) + + it('offers the build-tools remedy only when it probed the toolchain and found it missing', () => { + const missing = diagnose({ + survey: NOTHING_INSTALLED, + toolchain: toolchain(['python3']) + }) + expect(missing.reason).toBe('toolchain_missing') + const text = formatNodePtyUnavailableMessage(missing) + expect(text).toContain('make and a C++ compiler are not installed') + expect(text).toContain(`checked ${SEARCHED.join(', ')} under ${MODULE_DIR}`) + expect(text).toContain('sudo apt-get install -y build-essential python3') + + // Toolchain present and nothing compiled: the install failed for another reason, and + // "install make/g++/python3" would send the user chasing tools they already have. + const present = diagnose({ + survey: NOTHING_INSTALLED, + toolchain: toolchain(['make', 'g++', 'python3']) + }) + expect(present.reason).toBe('dependency_missing') + expect(formatNodePtyUnavailableMessage(present)).not.toContain('apt-get') + }) + + it('reports a binding that killed the probe as a crash rather than a miss', () => { + const text = message({ probeSignal: 'SIGSEGV' }) + expect(text).toContain('SIGSEGV') + expect(text).toContain('incompatible with this host rather than missing') + }) + + it('quotes the loader verbatim when nothing recognizes it', () => { + const raw = 'dlopen(/opt/pty.node): unexpected relocation kind 0x9f' + const verdict = diagnose({ loaderError: raw }) + expect(verdict).toMatchObject({ status: 'blocked', reason: 'load_failed', rawError: raw }) + const text = formatNodePtyUnavailableMessage(verdict) + expect(text).toContain(`Loader error: ${raw}`) + expect(text).toContain('file an issue') + }) + + it('reports a probe that never answered as unverifiable and diagnoses nothing', () => { + // docs/reference/ssh-execution-boundary.md: loss of contact is not a verdict. + const verdict = diagnose({ + unverifiableBecause: 'the node-pty load probe did not finish in time' + }) + expect(verdict.status).toBe('unverifiable') + const text = formatNodePtyUnavailableMessage(verdict) + expect(text).toContain('could not establish why') + expect(text).toContain('not evidence node-pty is broken') + expect(text).not.toContain('Reconnect to rebuild') + expect(text).not.toContain('apt-get') + }) + + it('marks rebuildable faults repairable and everything else not', () => { + // The relay's node-pty is compiled ON the remote, so a binding that no longer matches + // the machine is fixed by recompiling there. A missing compiler or a missing library + // is not: the rebuild would need the very thing that is absent. + const repairable = (overrides: Partial) => + toTerminalUnavailableCause(diagnose(overrides)).repairable + + expect(repairable({ survey: { ...INSTALLED, builtNodeAbi: '127' } })).toBe(true) + expect(repairable({ survey: { ...INSTALLED, builtArch: 'arm64' } })).toBe(true) + expect(repairable({ loaderError: "version `GLIBC_2.34' not found" })).toBe(true) + expect(repairable({ probeSignal: 'SIGSEGV' })).toBe(true) + expect( + repairable({ survey: NOTHING_INSTALLED, toolchain: toolchain(['make', 'g++', 'python3']) }) + ).toBe(true) + + expect(repairable({ survey: NOTHING_INSTALLED, toolchain: toolchain(['python3']) })).toBe(false) + expect(repairable({ loaderError: 'libstdc++.so.6: cannot open shared object file' })).toBe( + false + ) + // Nothing was established, so nothing may be rewritten on the host (#14830). + expect(repairable({ unverifiableBecause: 'probe timed out' })).toBe(false) + }) + + it('publishes a cause that survives its own wire schema', () => { + const cause = toTerminalUnavailableCause( + diagnose({ loaderError: "version `GLIBC_2.34' not found" }) + ) + expect(parseTerminalUnavailableCause(cause)).toEqual(cause) + expect(mayRepairFromCause(cause)).toBe(true) + expect(cause.host).toMatchObject({ arch: 'x64', nodeAbi: '115', glibcVersion: '2.31' }) + + // A peer claiming repairable on an unverifiable status must not be believed. + expect(mayRepairFromCause({ ...cause, status: 'unverifiable' })).toBe(false) + expect(parseTerminalUnavailableCause({ ...cause, host: undefined })).toBeNull() + // A reason this client has never heard of must not discard the whole cause; the + // relay may name faults added after the client shipped. + expect(parseTerminalUnavailableCause({ ...cause, reason: 'invented_later' })).not.toBeNull() + }) + + it('puts the host on every message so a bug report needs no follow-up question', () => { + for (const overrides of [ + {}, + { loaderError: 'invalid ELF header' }, + { unverifiableBecause: 'probe timed out' } + ]) { + expect(message(overrides)).toContain( + 'linux/x64, glibc 2.31, Node v20.11.0 (ABI 115), prebuild slot linux-x64-glibc' + ) + } + }) +}) diff --git a/src/relay/node-pty-unavailable-diagnosis.ts b/src/relay/node-pty-unavailable-diagnosis.ts new file mode 100644 index 00000000000..590eedcc375 --- /dev/null +++ b/src/relay/node-pty-unavailable-diagnosis.ts @@ -0,0 +1,362 @@ +/** + * Why the remote host cannot spawn terminals, in terms the user can act on and check. + * + * The relay used to answer this with one hedged paragraph — "install build tools, or + * else reconnect, or else check your Node version" — because the only thing it looked at + * was that `require('node-pty')` threw. That paragraph names three different remedies for + * four different faults and lets the user verify none of them. + * + * node-pty's own loader is why the raw cause went missing: it walks build/Release, + * build/Debug and prebuilds/-, then rethrows only the LAST error. So a + * `pty.node` the dynamic loader refused arrives as `Cannot find module '../prebuilds/…'`, + * and the GLIBC/ABI/arch sentence that actually says what is wrong is discarded before + * the relay ever sees it. Recovering it needs a separate dlopen of the file the loader + * would have opened — see node-pty-binding-survey.ts. + * + * Everything here is pure so every verdict is testable from a host that is none of the + * hosts that break. `unverifiable` is a first-class outcome: a probe that did not answer + * is not a diagnosis (docs/reference/ssh-execution-boundary.md). + */ +import { GLIBC_FLOOR, nativeSlotName, type NativeHostAbi } from '../main/orcad/native-host-abi' +import { + classifyNodePtyLoaderMessage, + isFlattenedNodePtyLoaderMessage +} from '../main/orcad/node-pty-loader-diagnosis' +import { + toolchainInstallHintLines, + type BuildToolchainStatus +} from '../main/ssh/build-toolchain-diagnosis' +import type { RuntimeTerminalUnavailableReason } from '../shared/runtime-types' +import type { TerminalUnavailableCause } from '../shared/terminal-unavailable-cause' + +/** What is actually on disk where node-pty's loader looks, and what it was built for. */ +export type NodePtyBindingSurvey = { + /** The node-pty install the relay would load from. */ + moduleDir: string + /** The compiled binding the loader would open, or null when no directory holds one. */ + bindingPath: string | null + /** Directories checked, so "nothing is installed" is a statement with evidence. */ + searched: string[] + /** `node_module_version` from node-gyp's build/config.gypi, when it is readable. */ + builtNodeAbi: string | null + /** `target_arch` from node-gyp's build/config.gypi, when it is readable. */ + builtArch: string | null +} + +export type NodePtyUnavailableHost = NativeHostAbi & { nodeVersion: string } + +export type NodePtyUnavailableDiagnosis = { + /** `blocked` — proved. `unverifiable` — nothing answered, which is not evidence. */ + status: 'blocked' | 'unverifiable' + reason: RuntimeTerminalUnavailableReason + host: NodePtyUnavailableHost + /** Short phrase naming the values found. */ + detail: string + /** The loader's own words, kept verbatim so an unclassified verdict is still reportable. */ + rawError: string | null + survey: NodePtyBindingSurvey | null + toolchain: BuildToolchainStatus | null +} + +export type NodePtyDiagnosisInput = { + /** What the recovered dlopen said, when one ran. Preferred over `requireError`. */ + loaderError?: string | null + /** What `require('node-pty')`/`pty.spawn` threw. Usually flattened by node-pty. */ + requireError?: string | null + /** A load probe that was killed rather than answering. */ + probeSignal?: NodeJS.Signals | null + /** Set when the load probe never answered at all; forces `unverifiable`. */ + unverifiableBecause?: string | null + host: NodePtyUnavailableHost + survey: NodePtyBindingSurvey | null + toolchain?: BuildToolchainStatus | null +} + +/** Reasons a loader message can establish on its own, and which nothing else outranks. */ +const LOADER_NAMED_FAULTS: ReadonlySet = new Set([ + 'abi_mismatch', + 'arch_mismatch', + 'libc_floor', + 'shared_library_missing' +]) + +export function diagnoseNodePtyUnavailable( + input: NodePtyDiagnosisInput +): NodePtyUnavailableDiagnosis { + const { host, survey } = input + const toolchain = input.toolchain ?? null + // Why the require error is only a fallback: node-pty flattens the real cause away, so + // its text is evidence of "did not load", never of why. + const usableRequireError = + input.requireError && !isFlattenedNodePtyLoaderMessage(input.requireError) + ? input.requireError + : null + // Capped because a macOS dlopen error lists every path it tried; the message quotes this + // verbatim when nothing classifies it, and a toast is not a log file. + const rawError = truncate(input.loaderError ?? usableRequireError ?? input.requireError ?? null) + const base = { host, rawError, survey, toolchain } as const + + if (input.unverifiableBecause) { + return { + ...base, + status: 'unverifiable', + reason: 'unknown', + detail: input.unverifiableBecause + } + } + // Before anything the loader said: a binary that aborts inside the loader never reaches + // a catch and often prints nothing, so the signal is the only evidence there is. + if (input.probeSignal) { + return { + ...base, + status: 'blocked', + reason: 'load_crashed', + detail: `loading the binding killed the probe with ${input.probeSignal}` + } + } + + const classifiable = input.loaderError ?? usableRequireError + const classified = classifiable ? classifyNodePtyLoaderMessage(classifiable) : null + // Only a loader message that named the fault outranks the build record. `load_failed` + // and `dependency_missing` do not: the first named nothing, and the second is what + // node-pty says about a binding it never reached. + if (classified && LOADER_NAMED_FAULTS.has(classified.reason)) { + return { ...base, status: 'blocked', ...classified } + } + + // The loader said nothing usable. The binding's own build record still can: node-gyp + // records the ABI and arch it configured for, and either differing from this runtime is + // a fault the user can check without reproducing the load. + if (survey?.bindingPath) { + if (survey.builtNodeAbi && survey.builtNodeAbi !== host.nodeAbi) { + return { + ...base, + status: 'blocked', + reason: 'abi_mismatch', + detail: `built for Node ABI ${survey.builtNodeAbi}, this host runs ABI ${host.nodeAbi}` + } + } + if (survey.builtArch && survey.builtArch !== host.arch) { + return { + ...base, + status: 'blocked', + reason: 'arch_mismatch', + detail: `built for ${survey.builtArch}, this host runs ${host.arch}` + } + } + return { + ...base, + status: 'blocked', + reason: classified?.reason ?? 'load_failed', + detail: classified?.detail ?? 'the binding is present but the loader refused it' + } + } + + if (!survey) { + return { + ...base, + status: 'unverifiable', + reason: 'unknown', + detail: "the relay could not read node-pty's install directory" + } + } + // Nothing compiled anywhere. On Linux that is either a compile that never ran for want + // of a toolchain, or an install that failed for some other reason — different remedies. + if (toolchain?.toolchainMissing) { + return { + ...base, + status: 'blocked', + reason: 'toolchain_missing', + detail: `no compiled binding exists and ${missingToolSummary(toolchain)} missing` + } + } + return { + ...base, + status: 'blocked', + reason: 'dependency_missing', + detail: 'no compiled node-pty binding exists on this host' + } +} + +const RAW_ERROR_MAX = 600 + +function truncate(message: string | null): string | null { + if (message === null || message.length <= RAW_ERROR_MAX) { + return message + } + return `${message.slice(0, RAW_ERROR_MAX)}…` +} + +function missingToolSummary(toolchain: BuildToolchainStatus): string { + const present = new Set(toolchain.present) + const missing: string[] = [] + if (!present.has('make')) { + missing.push('make') + } + if (!present.has('g++') && !present.has('c++') && !present.has('clang++')) { + missing.push('a C++ compiler') + } + if (!present.has('python3') && !present.has('python')) { + missing.push('python3') + } + if (missing.length <= 1) { + return `${missing[0] ?? 'the build tools'} is` + } + return `${missing.slice(0, -1).join(', ')} and ${missing.at(-1)} are` +} + +/** + * Faults a rebuild on the host actually fixes. + * + * The relay's node-pty is compiled ON the remote by `npm install`, so a binding that is + * absent, built for another Node ABI, built for another architecture, or linked against a + * newer libc than the host provides is all one thing: the compiled artifact no longer + * matches the machine, and recompiling here produces one that does. That is different + * from the packaged desktop app, where the binary is built elsewhere and the glibc floor + * in docs/reference/linux-glibc-compatibility.md is the binding constraint. + * + * Excluded on purpose: `toolchain_missing` (no compiler to rebuild with) and + * `shared_library_missing` (the compile would need the same absent library). + */ +const REBUILD_FIXES: ReadonlySet = new Set([ + 'abi_mismatch', + 'arch_mismatch', + 'libc_floor', + 'load_crashed', + 'dependency_missing' +]) + +/** + * The machine-readable cause, for a client that can act instead of printing. + * + * `repairable` requires a proved status AND a toolchain that is not known-missing: a + * rebuild the host cannot perform is not a repair, it is a wasted `npm install` — which + * is the shape of #14830. + */ +export function toTerminalUnavailableCause( + diagnosis: NodePtyUnavailableDiagnosis +): TerminalUnavailableCause { + const { host } = diagnosis + return { + status: diagnosis.status, + reason: diagnosis.reason, + detail: diagnosis.detail.slice(0, 400), + repairable: + diagnosis.status === 'blocked' && + REBUILD_FIXES.has(diagnosis.reason) && + diagnosis.toolchain?.toolchainMissing !== true, + host: { + platform: host.platform, + arch: host.arch, + libc: host.libc, + ...(host.glibcVersion ? { glibcVersion: host.glibcVersion } : {}), + nodeAbi: host.nodeAbi, + nodeVersion: host.nodeVersion + }, + ...(diagnosis.rawError ? { rawError: diagnosis.rawError.slice(0, 1000) } : {}) + } +} + +/** `linux/x64, glibc 2.31, Node v20.11.0 (ABI 115), prebuild slot linux-x64-glibc`. */ +function formatNodePtyHostLine(host: NodePtyUnavailableHost): string { + const libc = + host.libc === 'none' ? null : `${host.libc}${host.glibcVersion ? ` ${host.glibcVersion}` : ''}` + return [ + `${host.platform}/${host.arch}`, + libc, + `Node ${host.nodeVersion} (ABI ${host.nodeAbi})`, + `prebuild slot ${nativeSlotName(host)}` + ] + .filter((part): part is string => part !== null) + .join(', ') +} + +/** + * One remedy per fault, each naming a value the user can go and check. + * + * `unverifiable` deliberately prescribes nothing: the relay proved only that it could not + * establish a cause, and dressing that up as a diagnosis is the bug this replaces. + */ +export function formatNodePtyUnavailableMessage(diagnosis: NodePtyUnavailableDiagnosis): string { + const { host } = diagnosis + // Unverifiable deliberately prescribes nothing beyond a retry: nothing was established, + // and dressing that up as a diagnosis is the bug this replaces. + const opening = + diagnosis.status === 'unverifiable' + ? `Remote terminals are unavailable, and the relay could not establish why: ${diagnosis.detail}. ` + + `That is not evidence node-pty is broken — reconnect to retry.` + : `Remote terminals are unavailable: ${remedyFor(diagnosis)}` + const lines = [opening, `Host: ${formatNodePtyHostLine(host)}.`] + // Quoted only where nothing else named the fault: elsewhere the remedy already carries + // the numbers, and a dlopen dump would bury them. + const quoteRaw = + diagnosis.status === 'unverifiable' || + diagnosis.reason === 'load_failed' || + diagnosis.reason === 'unknown' + if (diagnosis.rawError && quoteRaw) { + lines.push(`Loader error: ${diagnosis.rawError}`) + } + return lines.join('\n') +} + +function remedyFor(diagnosis: NodePtyUnavailableDiagnosis): string { + const { host, survey, toolchain } = diagnosis + switch (diagnosis.reason) { + case 'toolchain_missing': + return ( + `node-pty ships no prebuilt binary for Linux and this host has no compiled one ` + + `(${searchedPhrase(survey)}), because ${toolchain ? missingToolSummary(toolchain) : 'the build tools are'} not installed. ` + + `Install them on the remote host, then reconnect:\n` + + `${(toolchain ? toolchainInstallHintLines(toolchain) : []).join('\n')}` + ) + case 'dependency_missing': + return ( + `node-pty has no compiled binary on this host (${searchedPhrase(survey)}). ` + + `The C/C++ build tools needed to compile it are present, so reconnect to reinstall ` + + `the relay's native modules.` + ) + case 'abi_mismatch': + return ( + `the installed node-pty binding was built for a different Node ABI than the remote's ` + + `Node — ${diagnosis.detail}. Reconnect to rebuild node-pty against ${host.nodeVersion}, ` + + `or run the relay on the Node version the binding was built for.` + ) + case 'arch_mismatch': + return ( + `the installed node-pty binding does not match this host's CPU architecture — ` + + `${diagnosis.detail}. Reconnect to rebuild node-pty on the remote host; a binding ` + + `copied from a machine of another architecture can never load here.` + ) + case 'libc_floor': + return ( + `${diagnosis.detail}, which this host's C library does not provide ` + + `(${host.glibcVersion ? `glibc ${host.glibcVersion}` : 'this host reports no glibc version'}). ` + + `The binding was compiled on a newer system than this one. Reconnect to rebuild ` + + `node-pty here; Orca's own Linux floor is glibc ${GLIBC_FLOOR}.` + ) + case 'shared_library_missing': + return ( + `node-pty's native binding cannot be opened because ${diagnosis.detail}. ` + + `Install that library on the remote host, then reconnect.` + ) + case 'load_crashed': + return ( + `${diagnosis.detail}, which means the binding is incompatible with this host rather ` + + `than missing. Reconnect to rebuild the relay's native modules.` + ) + case 'load_failed': + case 'spawn_helper_missing': + case 'unknown': + return ( + `this host refused to load node-pty's native binding and the cause was not recognized. ` + + `Reconnect to rebuild the relay's native modules; if that does not help, please file an ` + + `issue quoting the loader error below.` + ) + } +} + +function searchedPhrase(survey: NodePtyBindingSurvey | null): string { + return survey && survey.searched.length > 0 + ? `checked ${survey.searched.join(', ')} under ${survey.moduleDir}` + : 'nothing was found where node-pty looks' +} diff --git a/src/relay/pty-handler-spawn-admission.test.ts b/src/relay/pty-handler-spawn-admission.test.ts index 2df29b95420..728f883d3b1 100644 --- a/src/relay/pty-handler-spawn-admission.test.ts +++ b/src/relay/pty-handler-spawn-admission.test.ts @@ -32,7 +32,7 @@ vi.mock('../main/shell-prompt-readiness-probe', () => ({ createShellPromptReadinessProbe: mockCreateShellPromptReadinessProbe })) -import { MAX_RELAY_PTY_SESSIONS, PtyHandler, formatNodePtyUnavailableMessage } from './pty-handler' +import { MAX_RELAY_PTY_SESSIONS, PtyHandler } from './pty-handler' import type { RelayDispatcher } from './dispatcher' import { beginPtyHandlerTest, @@ -222,24 +222,6 @@ describe('PtyHandler', () => { expect(mockPtySpawn).toHaveBeenCalledOnce() }) - it('hedges both causes on Linux and offers the build-tools remedy nowhere else', () => { - const linux = formatNodePtyUnavailableMessage('linux') - expect(linux).toContain('Remote terminals are unavailable') - // Conditional, not asserted: a host with build-essential can still hit an ABI/Node-version flip. - expect(linux).toMatch(/If it is missing the C\/C\+\+ build tools/) - expect(linux).toContain('python3') - expect(linux).toContain('version and architecture match the installed binding') - - // Windows/macOS ship node-pty prebuilds, so "install make/g++/python3" sends the user chasing nothing. - for (const platform of ['win32', 'darwin'] as const) { - const message = formatNodePtyUnavailableMessage(platform) - expect(message).toContain('Remote terminals are unavailable') - expect(message).not.toContain('build tools') - expect(message).not.toContain('python3') - expect(message).toMatch(/reconnect/i) - } - }) - it('normalizes a missing native binding as degraded node-pty availability', async () => { mockPtySpawn.mockImplementationOnce(() => { throw new Error( @@ -253,6 +235,30 @@ describe('PtyHandler', () => { expect(handler.activePtyCount).toBe(0) }) + it('keeps the load error it was handed instead of replacing it with guesses', async () => { + // #17830: the user got three remedies for four possible faults and could verify none. + // The relay must carry what it was actually told, and must not prescribe a toolchain + // install it never probed for. + const thrown = + 'Failed to load native module: conpty.node, checked: build/Release, prebuilds/win32-x64' + mockPtySpawn.mockImplementationOnce(() => { + throw new Error(thrown) + }) + + const message = await dispatcher.callRequest('pty.spawn', {}).then( + () => '', + (error: Error) => error.message + ) + + expect(message).toContain(thrown) + expect(message).not.toContain('install make, a C++ compiler, and python3') + // Nothing here established a cause — the relay's node-pty directory is not on disk in + // this harness — so per docs/reference/ssh-execution-boundary.md it must say so rather + // than pick a diagnosis. Every message still names the host, for the bug report. + expect(message).toContain('could not establish why') + expect(message).toMatch(/Host: linux\/\w+, .*Node v[\d.]+ \(ABI \d+\)/) + }) + it('preserves unrelated node-pty spawn failures', async () => { mockPtySpawn.mockImplementationOnce(() => { throw new Error('File not found: missing-shell.exe') diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 82f0b19b9ff..f8bd2351cf2 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -102,23 +102,16 @@ import { injectRelayFishHistoryEnv, injectRelayHistoryEnv } from './terminal-history' - -// Why: only Linux compiles node-pty (no prebuilt), so the build-tools remedy is a closable setup gap -// there and wrong advice anywhere node-pty ships one. The relay only sees an unloadable binding, never -// why — a skipped compile and a later Node/ABI flip look identical here — so Linux hedges both causes. -export function formatNodePtyUnavailableMessage(platform: NodeJS.Platform): string { - const remedy = - platform === 'linux' - ? "node-pty's native binding is not loadable on this host. If it is missing the C/C++ build tools needed to compile node-pty, install make, a C++ compiler, and python3 on the remote host, then reconnect. Otherwise reconnect to reinstall the relay's native modules, and check that the remote Node.js version and architecture match the installed binding." - : "node-pty's native binding failed to load on this host. Reconnect to reinstall the relay's native modules; if it persists, check that the remote Node.js version and architecture match the installed binding." - return `Remote terminals are unavailable: ${remedy}` -} +import { isFlattenedNodePtyLoaderMessage } from '../main/orcad/node-pty-loader-diagnosis' +import { collectNodePtyUnavailableDiagnosis } from './node-pty-binding-survey' +import { + formatNodePtyUnavailableMessage, + toTerminalUnavailableCause +} from './node-pty-unavailable-diagnosis' +import { TERMINAL_UNAVAILABLE_RPC_ERROR_CODE } from '../shared/terminal-unavailable-cause' function isMissingNodePtyNativeBinding(error: unknown): boolean { - return ( - error instanceof Error && - /Failed to load native module: (?:conpty|pty)\.node(?:,|$)/.test(error.message) - ) + return error instanceof Error && isFlattenedNodePtyLoaderMessage(error.message) } function parseSourceRecoveryRequest(value: unknown): PtySourceRecoveryRequest | undefined { @@ -474,6 +467,8 @@ export class PtyHandler { private ptyModule: typeof NodePty | null = null private ptyModuleLoadPromise: Promise | null = null private reloadPtyModuleFromDisk = false + /** The last thing `require('node-pty')` threw, kept because it is the only cause anyone has. */ + private lastPtyLoadError: unknown = null // Why: single optional slot is intentional — callers compose externally; a throw is swallowed so it can't block cleanup. private exitListener: PtyExitListener | null = null private surfaceRetiredListener: PtySurfaceRetiredListener | null = null @@ -547,27 +542,56 @@ export class PtyHandler { try { this.ptyModule = await import('node-pty') return this.ptyModule - } catch { + } catch (error) { + // Why keep it: this is the only place the load error exists. Discarding it here is + // what left the relay able to say "unavailable" and never why. + this.lastPtyLoadError = error this.reloadPtyModuleFromDisk = true } } // Why: tie module resolution to the deployed bundle dir, not cwd. - const moduleEntry = join(__dirname, 'node_modules', 'node-pty', 'lib', 'index.js') + const moduleEntry = join(this.relayNodePtyDir(), 'lib', 'index.js') if (!existsSync(moduleEntry)) { + this.lastPtyLoadError = this.lastPtyLoadError ?? new Error(`no node-pty at ${moduleEntry}`) return null } try { this.ptyModule = require(moduleEntry) as typeof NodePty return this.ptyModule - } catch { + } catch (error) { + this.lastPtyLoadError = error return null } } + /** Where the relay's own node-pty lives — the deployed bundle dir, never cwd. */ + private relayNodePtyDir(): string { + return join(__dirname, 'node_modules', 'node-pty') + } + + /** + * The rejection for a spawn that cannot happen: prose for a human, and the structured + * cause for a client that can repair the host instead of printing a paragraph. + * + * Runs the survey and out-of-process load probe only here, on the failure path, so a + * healthy relay never pays for them. + */ + private async nodePtyUnavailableError(spawnError?: unknown): Promise { + const nodePtyDir = this.relayNodePtyDir() + const diagnosis = await collectNodePtyUnavailableDiagnosis({ + nodePtyDir: existsSync(nodePtyDir) ? nodePtyDir : null, + error: spawnError ?? this.lastPtyLoadError + }) + return Object.assign(new Error(formatNodePtyUnavailableMessage(diagnosis)), { + code: TERMINAL_UNAVAILABLE_RPC_ERROR_CODE, + data: toTerminalUnavailableCause(diagnosis) + }) + } + private invalidatePtyModuleAfterBindingFailure(): void { this.ptyModule = null this.reloadPtyModuleFromDisk = true - const moduleRoot = join(__dirname, 'node_modules', 'node-pty') + const moduleRoot = this.relayNodePtyDir() for (const cachedPath of Object.keys(require.cache)) { if (isPathInsideOrEqual(moduleRoot, cachedPath)) { delete require.cache[cachedPath] @@ -1718,7 +1742,7 @@ export class PtyHandler { }> { const pty = await this.loadPty() if (!pty) { - throw new Error(formatNodePtyUnavailableMessage(process.platform)) + throw await this.nodePtyUnavailableError() } const cols = (params.cols as number) || 80 @@ -1831,7 +1855,7 @@ export class PtyHandler { // Why: Windows loads conpty.node only on first spawn, so handle that late binding failure here. if (isMissingNodePtyNativeBinding(error)) { this.invalidatePtyModuleAfterBindingFailure() - throw new Error(formatNodePtyUnavailableMessage(process.platform)) + throw await this.nodePtyUnavailableError(error) } throw error } diff --git a/src/renderer/src/app-shell/use-app-startup-hydration.ts b/src/renderer/src/app-shell/use-app-startup-hydration.ts index 584cd9a9c9a..2b5272f440d 100644 --- a/src/renderer/src/app-shell/use-app-startup-hydration.ts +++ b/src/renderer/src/app-shell/use-app-startup-hydration.ts @@ -6,7 +6,7 @@ import { reconcileHydratedWorkspaceTabModels } from './reconcile-hydrated-worksp import { useStartupActions } from './use-app-startup-actions' import { WORKTREE_REFRESH_CONCURRENCY } from '../store/slices/worktrees' import { sweepRestoredCodexPanesForStaleAccounts } from '../lib/codex-stale-pane-sweep' -import { fetchWorkspaceSessionWithRuntimeHostOwners } from '../lib/workspace-session-host-persistence' +import { fetchWorkspaceSessionWithRuntimeHostOwners } from '../lib/workspace-session-host-hydration' import { collectFolderWorkspaceKeysFromSession, collectWorktreeHydrationRepoIdsFromSession @@ -189,7 +189,9 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta timeRendererStartupSyncStep('hydrate-session-stores', () => { actions.hydrateWorkspaceSession(sessionRead.session, { ...sessionHydrationOptions, - runtimeHostIdByWorkspaceSessionKey: sessionRead.runtimeHostIdByWorkspaceSessionKey + runtimeHostIdByWorkspaceSessionKey: sessionRead.runtimeHostIdByWorkspaceSessionKey, + contestedHostWorkspaceSessions: sessionRead.contestedHostWorkspaceSessions, + contestedPrimaryHostBySessionKey: sessionRead.contestedPrimaryHostBySessionKey }) actions.hydrateTabsSession(sessionRead.session, sessionHydrationOptions) actions.hydrateEditorSession(sessionRead.session, sessionHydrationOptions) diff --git a/src/renderer/src/components/AgentStateDot.test.ts b/src/renderer/src/components/AgentStateDot.test.ts index ab84e4562d8..33642542d79 100644 --- a/src/renderer/src/components/AgentStateDot.test.ts +++ b/src/renderer/src/components/AgentStateDot.test.ts @@ -93,6 +93,15 @@ describe('AgentStateDot', () => { } ) + it('renders unverifiable as an amber dashed ring, never the done check or the spinner', () => { + const markup = renderMarkup('unverifiable') + + expect(markup).toContain('lucide-circle-dashed') + expect(markup).toContain('text-amber-500') + expect(markup).not.toContain('lucide-circle-check') + expect(markup).not.toContain('data-agent-spinner') + }) + it.each(['blocked', 'interrupted'] satisfies AgentDotState[])( 'renders %s as a red attention dot', (state) => { @@ -112,6 +121,7 @@ describe('AgentStateDot', () => { 'failed', 'done', 'idle', + 'unverifiable', 'permission' ] satisfies AgentDotState[] diff --git a/src/renderer/src/components/AgentStateDot.tsx b/src/renderer/src/components/AgentStateDot.tsx index bcb44a8e726..af8ac4efd84 100644 --- a/src/renderer/src/components/AgentStateDot.tsx +++ b/src/renderer/src/components/AgentStateDot.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { Activity, CircleCheck } from 'lucide-react' +import { Activity, CircleCheck, CircleDashed } from 'lucide-react' import { cn } from '@/lib/utils' import { AgentQuestionIcon } from '@/components/AgentQuestionIcon' import { AgentWorkingSpinner } from '@/components/AgentWorkingSpinner' @@ -30,6 +30,11 @@ export type AgentDotState = | 'failed' | 'done' | 'idle' + // Why: the pane still has a live PTY but its reporting stream has gone quiet past + // the staleness window. Distinct from 'idle' because Orca has evidence something is + // held there, and never rendered as 'done' or 'working' — it asserts nothing about + // the agent, only about what Orca last heard. + | 'unverifiable' // Why: the sidebar's title-based status flow (StatusIndicator/WorktreeCard) // collapses blocked + waiting into a single "needs attention" state. Keep // this as a distinct member so that flow can render without inventing a new @@ -56,6 +61,8 @@ export function agentStateLabel(state: AgentDotState): string { return 'Done' case 'idle': return 'Idle' + case 'unverifiable': + return 'No recent update' case 'permission': return 'Needs attention' } @@ -116,6 +123,17 @@ export const AgentStateDot = React.memo(function AgentStateDot({